diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js index afe87a5..e3c68ab 100644 --- a/backend/utils/ecast-shard-client.js +++ b/backend/utils/ecast-shard-client.js @@ -113,10 +113,70 @@ class EcastShardClient { startStatusBroadcast() { this.stopStatusBroadcast(); this.statusInterval = setInterval(() => { - this.onEvent('game.status', this.getSnapshot()); + this._refreshPlayerCount().finally(() => { + this.onEvent('game.status', this.getSnapshot()); + }); }, 20000); } + _refreshPlayerCount() { + if (!this.host || this.gameFinished || this.manuallyStopped) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePickerProbe&format=json`; + let resolved = false; + const done = () => { if (!resolved) { resolved = true; resolve(); } }; + + try { + const probe = new WebSocket(url, ['ecast-v0'], { + headers: { Origin: 'https://jackbox.tv' }, + handshakeTimeout: 8000, + }); + + const timeout = setTimeout(() => { + try { probe.close(); } catch (_) {} + done(); + }, 10000); + + probe.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.opcode === 'client/welcome') { + const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(msg.result.here); + if (playerCount > this.playerCount || playerNames.length !== this.playerNames.length) { + this.playerCount = playerCount; + this.playerNames = playerNames; + this.onEvent('lobby.player-joined', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + playerName: playerNames[playerNames.length - 1] || '', + playerCount, + players: [...playerNames], + maxPlayers: this.maxPlayers, + }); + } else if (playerCount !== this.playerCount) { + this.playerCount = playerCount; + this.playerNames = playerNames; + } + } else if (msg.opcode === 'error' && msg.result?.code === 2027) { + this.gameFinished = true; + } + } catch (_) {} + clearTimeout(timeout); + try { probe.close(); } catch (_) {} + done(); + }); + + probe.on('error', () => { clearTimeout(timeout); done(); }); + probe.on('close', () => { clearTimeout(timeout); done(); }); + } catch (_) { + done(); + } + }); + } + stopStatusBroadcast() { if (this.statusInterval) { clearInterval(this.statusInterval); @@ -157,8 +217,8 @@ class EcastShardClient { this.playerCount = playerCount; this.playerNames = playerNames; - if (result.entities?.room) { - const roomEntity = result.entities.room; + const roomEntity = result.entities?.room || result.entities?.['bc:room']; + if (roomEntity) { const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val; if (roomVal) { const roomState = EcastShardClient.parseRoomEntity(roomVal); @@ -343,11 +403,14 @@ class EcastShardClient { reject(err); }); + const thisWs = this.ws; this.ws.on('close', (code, reason) => { console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`); - this.ws = null; - if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) { - void this.reconnectWithBackoff(); + if (this.ws === thisWs) { + this.ws = null; + if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) { + void this.reconnectWithBackoff(); + } } }); diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js index c3d30a2..115ee15 100644 --- a/tests/api/ecast-shard-client.test.js +++ b/tests/api/ecast-shard-client.test.js @@ -135,6 +135,32 @@ describe('EcastShardClient', () => { expect(client.lobbyState).toBe('CanStart'); expect(client.gameStarted).toBe(false); }); + + test('parses bc:room entity when room key is absent', () => { + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {} + }); + + client.handleWelcome({ + id: 5, + secret: 'xyz-789', + reconnect: false, + entities: { + 'bc:room': ['object', { key: 'bc:room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }], + audience: ['crdt/pn-counter', [], { locked: false }], + }, + here: { + '1': { id: 1, roles: { host: {} } }, + '3': { id: 3, roles: { player: { name: 'HÂM' } } }, + '4': { id: 4, roles: { player: { name: 'FGHFGHY' } } }, + } + }); + + expect(client.playerCount).toBe(2); + expect(client.playerNames).toEqual(['HÂM', 'FGHFGHY']); + expect(client.gameState).toBe('Lobby'); + expect(client.lobbyState).toBe('CanStart'); + }); }); describe('handleEntityUpdate', () => { @@ -424,12 +450,17 @@ describe('EcastShardClient', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); - test('broadcasts game.status every 20 seconds', () => { + function stubRefresh(client) { + client._refreshPlayerCount = () => Promise.resolve(); + } + + test('broadcasts game.status every 20 seconds', async () => { const events = []; const client = new EcastShardClient({ sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: (type, data) => events.push({ type, data }), }); + stubRefresh(client); client.playerCount = 2; client.playerNames = ['A', 'B']; client.gameState = 'Lobby'; @@ -437,43 +468,50 @@ describe('EcastShardClient', () => { client.startStatusBroadcast(); jest.advanceTimersByTime(20000); + await Promise.resolve(); expect(events).toHaveLength(1); expect(events[0].type).toBe('game.status'); expect(events[0].data.monitoring).toBe(true); jest.advanceTimersByTime(20000); + await Promise.resolve(); expect(events).toHaveLength(2); client.stopStatusBroadcast(); jest.advanceTimersByTime(40000); + await Promise.resolve(); expect(events).toHaveLength(2); }); - test('disconnect stops the status broadcast', () => { + test('disconnect stops the status broadcast', async () => { const events = []; const client = new EcastShardClient({ sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: (type, data) => events.push({ type, data }), }); + stubRefresh(client); client.startStatusBroadcast(); jest.advanceTimersByTime(20000); + await Promise.resolve(); expect(events).toHaveLength(1); client.disconnect(); jest.advanceTimersByTime(40000); + await Promise.resolve(); expect(events).toHaveLength(1); }); - test('handleWelcome starts status broadcast', () => { + test('handleWelcome starts status broadcast', async () => { const events = []; const client = new EcastShardClient({ sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: (type, data) => events.push({ type, data }), }); + stubRefresh(client); client.handleWelcome({ id: 7, @@ -484,6 +522,7 @@ describe('EcastShardClient', () => { }); jest.advanceTimersByTime(20000); + await Promise.resolve(); const statusEvents = events.filter(e => e.type === 'game.status'); expect(statusEvents).toHaveLength(1); });