From 3f21299720cb58b707a75bf3e80b78e89a723c78 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 11:19:57 -0400 Subject: [PATCH] feat: add event broadcasting and entity update handlers to shard client Made-with: Cursor --- backend/utils/ecast-shard-client.js | 96 ++++++++++++++ tests/api/ecast-shard-client.test.js | 184 +++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js index 31899b7..9829707 100644 --- a/backend/utils/ecast-shard-client.js +++ b/backend/utils/ecast-shard-client.js @@ -84,6 +84,7 @@ class EcastShardClient { this.gameFinished = false; this.manuallyStopped = false; this.seq = 0; + this.appTag = null; } handleMessage(message) { @@ -95,6 +96,7 @@ class EcastShardClient { this.handleEntityUpdate(message.result); break; case 'client/connected': + this.handleClientConnected(message.result); break; case 'client/disconnected': break; @@ -129,6 +131,18 @@ class EcastShardClient { console.log( `[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) { @@ -136,11 +150,91 @@ class EcastShardClient { if (result.key === 'room' || result.key === 'bc:room') { if (result.val) { + const prevLobbyState = this.lobbyState; + const prevGameStarted = this.gameStarted; + const prevGameFinished = this.gameFinished; + const roomState = EcastShardClient.parseRoomEntity(result.val); this.lobbyState = roomState.lobbyState; this.gameState = roomState.gameState; this.gameStarted = roomState.gameStarted; 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) { + this.disconnect(); this.host = roomInfo.host; 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`; diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js index 459d858..7515765 100644 --- a/tests/api/ecast-shard-client.test.js +++ b/tests/api/ecast-shard-client.test.js @@ -102,6 +102,7 @@ describe('EcastShardClient', () => { expect(client.playerNames).toEqual([]); expect(client.gameStarted).toBe(false); expect(client.gameFinished).toBe(false); + expect(client.appTag).toBeNull(); expect(client.ws).toBeNull(); }); }); @@ -170,4 +171,187 @@ describe('EcastShardClient', () => { 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); + }); + }); + }); });