feat: add event broadcasting and entity update handlers to shard client
Made-with: Cursor
This commit is contained in:
@@ -84,6 +84,7 @@ class EcastShardClient {
|
|||||||
this.gameFinished = false;
|
this.gameFinished = false;
|
||||||
this.manuallyStopped = false;
|
this.manuallyStopped = false;
|
||||||
this.seq = 0;
|
this.seq = 0;
|
||||||
|
this.appTag = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(message) {
|
handleMessage(message) {
|
||||||
@@ -95,6 +96,7 @@ class EcastShardClient {
|
|||||||
this.handleEntityUpdate(message.result);
|
this.handleEntityUpdate(message.result);
|
||||||
break;
|
break;
|
||||||
case 'client/connected':
|
case 'client/connected':
|
||||||
|
this.handleClientConnected(message.result);
|
||||||
break;
|
break;
|
||||||
case 'client/disconnected':
|
case 'client/disconnected':
|
||||||
break;
|
break;
|
||||||
@@ -129,6 +131,18 @@ class EcastShardClient {
|
|||||||
console.log(
|
console.log(
|
||||||
`[Shard Monitor] Welcome: id=${this.shardId}, players=${this.playerCount} [${this.playerNames.join(', ')}], state=${this.gameState}, lobby=${this.lobbyState}`
|
`[Shard Monitor] Welcome: id=${this.shardId}, players=${this.playerCount} [${this.playerNames.join(', ')}], state=${this.gameState}, lobby=${this.lobbyState}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.onEvent('room.connected', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
appTag: this.appTag,
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
lobbyState: this.lobbyState,
|
||||||
|
gameState: this.gameState,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEntityUpdate(result) {
|
handleEntityUpdate(result) {
|
||||||
@@ -136,11 +150,91 @@ class EcastShardClient {
|
|||||||
|
|
||||||
if (result.key === 'room' || result.key === 'bc:room') {
|
if (result.key === 'room' || result.key === 'bc:room') {
|
||||||
if (result.val) {
|
if (result.val) {
|
||||||
|
const prevLobbyState = this.lobbyState;
|
||||||
|
const prevGameStarted = this.gameStarted;
|
||||||
|
const prevGameFinished = this.gameFinished;
|
||||||
|
|
||||||
const roomState = EcastShardClient.parseRoomEntity(result.val);
|
const roomState = EcastShardClient.parseRoomEntity(result.val);
|
||||||
this.lobbyState = roomState.lobbyState;
|
this.lobbyState = roomState.lobbyState;
|
||||||
this.gameState = roomState.gameState;
|
this.gameState = roomState.gameState;
|
||||||
this.gameStarted = roomState.gameStarted;
|
this.gameStarted = roomState.gameStarted;
|
||||||
this.gameFinished = roomState.gameFinished;
|
this.gameFinished = roomState.gameFinished;
|
||||||
|
|
||||||
|
if (this.lobbyState !== prevLobbyState && !this.gameStarted) {
|
||||||
|
this.onEvent('lobby.updated', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
lobbyState: this.lobbyState,
|
||||||
|
gameCanStart: roomState.gameCanStart,
|
||||||
|
gameIsStarting: roomState.gameIsStarting,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gameStarted && !prevGameStarted) {
|
||||||
|
this.onEvent('game.started', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gameFinished && !prevGameFinished) {
|
||||||
|
this.onEvent('game.ended', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.key === 'textDescriptions') {
|
||||||
|
if (result.val) {
|
||||||
|
const joins = EcastShardClient.parsePlayerJoinFromTextDescriptions(result.val);
|
||||||
|
for (const join of joins) {
|
||||||
|
if (!this.playerNames.includes(join.name)) {
|
||||||
|
this.playerNames.push(join.name);
|
||||||
|
this.playerCount = this.playerNames.length;
|
||||||
|
|
||||||
|
this.onEvent('lobby.player-joined', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerName: join.name,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClientConnected(result) {
|
||||||
|
if (!result) return;
|
||||||
|
if (result.roles?.player) {
|
||||||
|
const name = result.roles.player.name ?? '';
|
||||||
|
if (!this.playerNames.includes(name)) {
|
||||||
|
this.playerNames.push(name);
|
||||||
|
this.playerCount = this.playerNames.length;
|
||||||
|
|
||||||
|
this.onEvent('lobby.player-joined', {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
gameId: this.gameId,
|
||||||
|
roomCode: this.roomCode,
|
||||||
|
playerName: name,
|
||||||
|
playerCount: this.playerCount,
|
||||||
|
players: [...this.playerNames],
|
||||||
|
maxPlayers: this.maxPlayers,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +244,10 @@ class EcastShardClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async connect(roomInfo) {
|
async connect(roomInfo) {
|
||||||
|
this.disconnect();
|
||||||
this.host = roomInfo.host;
|
this.host = roomInfo.host;
|
||||||
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
|
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
|
||||||
|
this.appTag = roomInfo.appTag;
|
||||||
|
|
||||||
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
|
const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ describe('EcastShardClient', () => {
|
|||||||
expect(client.playerNames).toEqual([]);
|
expect(client.playerNames).toEqual([]);
|
||||||
expect(client.gameStarted).toBe(false);
|
expect(client.gameStarted).toBe(false);
|
||||||
expect(client.gameFinished).toBe(false);
|
expect(client.gameFinished).toBe(false);
|
||||||
|
expect(client.appTag).toBeNull();
|
||||||
expect(client.ws).toBeNull();
|
expect(client.ws).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -170,4 +171,187 @@ describe('EcastShardClient', () => {
|
|||||||
expect(client.lobbyState).toBe('CanStart');
|
expect(client.lobbyState).toBe('CanStart');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('event broadcasting', () => {
|
||||||
|
let events;
|
||||||
|
let client;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
events = [];
|
||||||
|
client = new EcastShardClient({
|
||||||
|
sessionId: 1,
|
||||||
|
gameId: 5,
|
||||||
|
roomCode: 'TEST',
|
||||||
|
maxPlayers: 8,
|
||||||
|
onEvent: (type, data) => events.push({ type, data }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleWelcome broadcasts room.connected', () => {
|
||||||
|
test('broadcasts room.connected with initial state', () => {
|
||||||
|
client.appTag = 'drawful2international';
|
||||||
|
client.handleWelcome({
|
||||||
|
id: 7,
|
||||||
|
secret: 'abc',
|
||||||
|
reconnect: false,
|
||||||
|
entities: {
|
||||||
|
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
|
||||||
|
},
|
||||||
|
here: {
|
||||||
|
'1': { id: 1, roles: { host: {} } },
|
||||||
|
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('room.connected');
|
||||||
|
expect(events[0].data.playerCount).toBe(1);
|
||||||
|
expect(events[0].data.players).toEqual(['Alice']);
|
||||||
|
expect(events[0].data.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleEntityUpdate broadcasts events', () => {
|
||||||
|
test('broadcasts lobby.updated on lobbyState change', () => {
|
||||||
|
client.lobbyState = 'WaitingForMore';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
||||||
|
version: 2, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.updated');
|
||||||
|
expect(events[0].data.lobbyState).toBe('CanStart');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.started on state transition to Gameplay', () => {
|
||||||
|
client.lobbyState = 'Countdown';
|
||||||
|
client.gameState = 'Lobby';
|
||||||
|
client.gameStarted = false;
|
||||||
|
client.playerCount = 4;
|
||||||
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
|
||||||
|
version: 5, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEvents = events.filter(e => e.type === 'game.started');
|
||||||
|
expect(startEvents).toHaveLength(1);
|
||||||
|
expect(startEvents[0].data.playerCount).toBe(4);
|
||||||
|
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast game.started if already started', () => {
|
||||||
|
client.gameStarted = true;
|
||||||
|
client.gameState = 'Gameplay';
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
||||||
|
version: 10, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts game.ended on gameFinished transition', () => {
|
||||||
|
client.gameStarted = true;
|
||||||
|
client.gameState = 'Gameplay';
|
||||||
|
client.gameFinished = false;
|
||||||
|
client.playerCount = 3;
|
||||||
|
client.playerNames = ['X', 'Y', 'Z'];
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'room',
|
||||||
|
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true },
|
||||||
|
version: 20, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endEvents = events.filter(e => e.type === 'game.ended');
|
||||||
|
expect(endEvents).toHaveLength(1);
|
||||||
|
expect(endEvents[0].data.playerCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broadcasts lobby.player-joined from textDescriptions', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'textDescriptions',
|
||||||
|
val: {
|
||||||
|
latestDescriptions: [
|
||||||
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
version: 3, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.player-joined');
|
||||||
|
expect(events[0].data.playerName).toBe('Bob');
|
||||||
|
expect(events[0].data.playerCount).toBe(2);
|
||||||
|
expect(events[0].data.players).toEqual(['Alice', 'Bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast duplicate player join', () => {
|
||||||
|
client.playerNames = ['Alice', 'Bob'];
|
||||||
|
client.playerCount = 2;
|
||||||
|
|
||||||
|
client.handleEntityUpdate({
|
||||||
|
key: 'textDescriptions',
|
||||||
|
val: {
|
||||||
|
latestDescriptions: [
|
||||||
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
version: 4, from: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleClientConnected', () => {
|
||||||
|
test('broadcasts lobby.player-joined for new player connection', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 3,
|
||||||
|
roles: { player: { name: 'Charlie' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe('lobby.player-joined');
|
||||||
|
expect(events[0].data.playerName).toBe('Charlie');
|
||||||
|
expect(events[0].data.playerCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores non-player connections', () => {
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 5,
|
||||||
|
roles: { shard: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores duplicate player connection', () => {
|
||||||
|
client.playerNames = ['Alice'];
|
||||||
|
client.playerCount = 1;
|
||||||
|
|
||||||
|
client.handleClientConnected({
|
||||||
|
id: 2,
|
||||||
|
roles: { player: { name: 'Alice' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user