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:
@@ -36,7 +36,7 @@ class EcastShardClient {
|
|||||||
lobbyState: roomVal.lobbyState ?? null,
|
lobbyState: roomVal.lobbyState ?? null,
|
||||||
gameCanStart: !!roomVal.gameCanStart,
|
gameCanStart: !!roomVal.gameCanStart,
|
||||||
gameIsStarting: !!roomVal.gameIsStarting,
|
gameIsStarting: !!roomVal.gameIsStarting,
|
||||||
gameStarted: roomVal.state === 'Gameplay',
|
gameStarted: roomVal.state != null && roomVal.state !== 'Lobby',
|
||||||
gameFinished: !!roomVal.gameFinished,
|
gameFinished: !!roomVal.gameFinished,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,12 @@ class EcastShardClient {
|
|||||||
break;
|
break;
|
||||||
case 'client/disconnected':
|
case 'client/disconnected':
|
||||||
break;
|
break;
|
||||||
|
case 'room/lock':
|
||||||
|
this.handleRoomLock();
|
||||||
|
break;
|
||||||
|
case 'room/exit':
|
||||||
|
this.handleRoomExit(message.result);
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
this.handleError(message.result);
|
this.handleError(message.result);
|
||||||
break;
|
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) {
|
handleError(result) {
|
||||||
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
||||||
if (result?.code === 2027) {
|
if (result?.code === 2027) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: '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!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ describe('EcastShardClient', () => {
|
|||||||
expect(result.gameStarted).toBe(true);
|
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', () => {
|
test('detects game finished', () => {
|
||||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||||
@@ -272,6 +281,26 @@ describe('EcastShardClient', () => {
|
|||||||
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
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', () => {
|
test('does not broadcast game.started if already started', () => {
|
||||||
client.gameStarted = true;
|
client.gameStarted = true;
|
||||||
client.gameState = 'Gameplay';
|
client.gameState = 'Gameplay';
|
||||||
@@ -573,4 +602,80 @@ describe('EcastShardClient', () => {
|
|||||||
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user