fix: detect game start for Pack 7+ titles that don't use state: "Gameplay"

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 <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-03 00:23:32 -04:00
parent 2964cee291
commit c5ffe23404
3 changed files with 151 additions and 2 deletions

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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);
});
});
});