From c5ffe23404a6293c7ab3e137f745b96e3a45a812 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 3 May 2026 00:23:32 -0400 Subject: [PATCH] fix: detect game start for Pack 7+ titles that don't use state: "Gameplay" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pack 7 games (Quiplash 3, Blather Round) transition room state from "Lobby" to "Logo" instead of "Gameplay". Even newer titles (Doominate, Big Survey) have no room entity at all — the only signals are room/lock and room/exit opcodes. - parseRoomEntity: detect game started as any non-Lobby state - handleMessage: add room/lock and room/exit opcode handling - handleRoomLock: emit game.started as fallback for room-entity-less games - handleRoomExit: emit game.ended on explicit room close - Tests: 6 new tests covering Logo state, room/lock, room/exit Verified against live rooms: quiplash3, blanky-blank, you-ruined-it, bigsurvey. Co-authored-by: Cursor --- backend/utils/ecast-shard-client.js | 46 +++++++++++- frontend/src/config/branding.js | 2 +- tests/api/ecast-shard-client.test.js | 105 +++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js index 6709ae5..8476712 100644 --- a/backend/utils/ecast-shard-client.js +++ b/backend/utils/ecast-shard-client.js @@ -36,7 +36,7 @@ class EcastShardClient { lobbyState: roomVal.lobbyState ?? null, gameCanStart: !!roomVal.gameCanStart, gameIsStarting: !!roomVal.gameIsStarting, - gameStarted: roomVal.state === 'Gameplay', + gameStarted: roomVal.state != null && roomVal.state !== 'Lobby', gameFinished: !!roomVal.gameFinished, }; } @@ -213,6 +213,12 @@ class EcastShardClient { break; case 'client/disconnected': break; + case 'room/lock': + this.handleRoomLock(); + break; + case 'room/exit': + this.handleRoomExit(message.result); + break; case 'error': this.handleError(message.result); break; @@ -363,6 +369,44 @@ class EcastShardClient { } } + handleRoomLock() { + if (!this.gameStarted) { + console.log(`[Shard Monitor] Room ${this.roomCode} locked (game starting)`); + this.gameStarted = true; + this.gameState = this.gameState || 'Gameplay'; + this.onEvent('game.started', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + playerCount: this.playerCount, + players: [...this.playerNames], + maxPlayers: this.maxPlayers, + }); + } + } + + handleRoomExit() { + if (this.gameFinished) return; + console.log(`[Shard Monitor] Room ${this.roomCode} exited`); + this.gameFinished = true; + this.onEvent('game.ended', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + playerCount: this.playerCount, + players: [...this.playerNames], + }); + this.onEvent('room.disconnected', { + sessionId: this.sessionId, + gameId: this.gameId, + roomCode: this.roomCode, + reason: 'room_closed', + finalPlayerCount: this.playerCount, + }); + activeShards.delete(`${this.sessionId}-${this.gameId}`); + this.disconnect(); + } + handleError(result) { console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`); if (result?.code === 2027) { diff --git a/frontend/src/config/branding.js b/frontend/src/config/branding.js index c5f6d46..9bd7a7e 100644 --- a/frontend/src/config/branding.js +++ b/frontend/src/config/branding.js @@ -2,7 +2,7 @@ export const branding = { app: { name: 'HSO Jackbox Game Picker', shortName: 'Jackbox Game Picker', - version: '0.6.5 - Fish Tank Edition', + version: '0.7.0 - Fixed For Real Edition', description: 'Spicing up Hyper Spaceout game nights!', }, meta: { diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js index 115ee15..cda4b3d 100644 --- a/tests/api/ecast-shard-client.test.js +++ b/tests/api/ecast-shard-client.test.js @@ -50,6 +50,15 @@ describe('EcastShardClient', () => { expect(result.gameStarted).toBe(true); }); + test('detects game started from non-Lobby state (Pack 7 Logo)', () => { + const roomVal = { state: 'Logo', locale: 'en', platformId: 'PS4' }; + const result = EcastShardClient.parseRoomEntity(roomVal); + expect(result.gameStarted).toBe(true); + expect(result.gameState).toBe('Logo'); + expect(result.lobbyState).toBeNull(); + expect(result.gameFinished).toBe(false); + }); + test('detects game finished', () => { const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true }; const result = EcastShardClient.parseRoomEntity(roomVal); @@ -272,6 +281,26 @@ describe('EcastShardClient', () => { expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']); }); + test('broadcasts game.started on state transition to Logo (Pack 7)', () => { + client.lobbyState = 'Countdown'; + client.gameState = 'Lobby'; + client.gameStarted = false; + client.playerCount = 4; + client.playerNames = ['A', 'B', 'C', 'D']; + + client.handleEntityUpdate({ + key: 'room', + val: { state: 'Logo', locale: 'en', platformId: 'PS4' }, + version: 14, from: 1, + }); + + const startEvents = events.filter(e => e.type === 'game.started'); + expect(startEvents).toHaveLength(1); + expect(startEvents[0].data.playerCount).toBe(4); + expect(client.gameState).toBe('Logo'); + expect(client.gameStarted).toBe(true); + }); + test('does not broadcast game.started if already started', () => { client.gameStarted = true; client.gameState = 'Gameplay'; @@ -573,4 +602,80 @@ describe('EcastShardClient', () => { expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true); }); }); + + describe('handleRoomLock', () => { + test('emits game.started when game has not yet started', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, + gameId: 5, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.gameStarted = false; + client.playerCount = 4; + client.playerNames = ['A', 'B', 'C', 'D']; + + client.handleRoomLock(); + + expect(client.gameStarted).toBe(true); + const startEvents = events.filter(e => e.type === 'game.started'); + expect(startEvents).toHaveLength(1); + expect(startEvents[0].data.playerCount).toBe(4); + }); + + test('does not emit game.started if game already started', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, + gameId: 5, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.gameStarted = true; + + client.handleRoomLock(); + + expect(events.filter(e => e.type === 'game.started')).toHaveLength(0); + }); + }); + + describe('handleRoomExit', () => { + test('emits game.ended and room.disconnected on room exit', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, + gameId: 5, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.playerCount = 3; + client.playerNames = ['X', 'Y', 'Z']; + + client.handleRoomExit(); + + expect(client.gameFinished).toBe(true); + expect(events.some(e => e.type === 'game.ended')).toBe(true); + expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true); + }); + + test('does not emit events if game already finished', () => { + const events = []; + const client = new EcastShardClient({ + sessionId: 1, + gameId: 5, + roomCode: 'TEST', + maxPlayers: 8, + onEvent: (type, data) => events.push({ type, data }), + }); + client.gameFinished = true; + + client.handleRoomExit(); + + expect(events).toHaveLength(0); + }); + }); });