Some Jackbox games (e.g. Trivia Murder Party 2) do not send client/connected events to shard connections and lack textDescriptions, leaving the player count stuck at 0 if the shard connects before players join. Fix by opening a lightweight probe shard every 20s to read the fresh here map. Also fix bc:room entity lookup in handleWelcome and a WebSocket close handler race condition. Made-with: Cursor
577 lines
19 KiB
JavaScript
577 lines
19 KiB
JavaScript
const { EcastShardClient } = require('../../backend/utils/ecast-shard-client');
|
|
|
|
describe('EcastShardClient', () => {
|
|
describe('parsePlayersFromHere', () => {
|
|
test('counts only player roles, excludes host and shard', () => {
|
|
const here = {
|
|
'1': { id: 1, roles: { host: {} } },
|
|
'2': { id: 2, roles: { player: { name: 'Alice' } } },
|
|
'3': { id: 3, roles: { player: { name: 'Bob' } } },
|
|
'5': { id: 5, roles: { shard: {} } },
|
|
};
|
|
const result = EcastShardClient.parsePlayersFromHere(here);
|
|
expect(result.playerCount).toBe(2);
|
|
expect(result.playerNames).toEqual(['Alice', 'Bob']);
|
|
});
|
|
|
|
test('returns zero for empty here or host-only', () => {
|
|
const here = { '1': { id: 1, roles: { host: {} } } };
|
|
const result = EcastShardClient.parsePlayersFromHere(here);
|
|
expect(result.playerCount).toBe(0);
|
|
expect(result.playerNames).toEqual([]);
|
|
});
|
|
|
|
test('handles null or undefined here', () => {
|
|
expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0);
|
|
expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('parseRoomEntity', () => {
|
|
test('extracts lobby state from room entity val', () => {
|
|
const roomVal = {
|
|
state: 'Lobby',
|
|
lobbyState: 'CanStart',
|
|
gameCanStart: true,
|
|
gameIsStarting: false,
|
|
gameFinished: false,
|
|
};
|
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
|
expect(result.gameState).toBe('Lobby');
|
|
expect(result.lobbyState).toBe('CanStart');
|
|
expect(result.gameCanStart).toBe(true);
|
|
expect(result.gameStarted).toBe(false);
|
|
expect(result.gameFinished).toBe(false);
|
|
});
|
|
|
|
test('detects game started from Gameplay state', () => {
|
|
const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false };
|
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
|
expect(result.gameStarted).toBe(true);
|
|
});
|
|
|
|
test('detects game finished', () => {
|
|
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
|
const result = EcastShardClient.parseRoomEntity(roomVal);
|
|
expect(result.gameFinished).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parsePlayerJoinFromTextDescriptions', () => {
|
|
test('extracts player name from join description', () => {
|
|
const val = {
|
|
latestDescriptions: [
|
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' }
|
|
]
|
|
};
|
|
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
|
expect(result).toEqual([{ name: 'Charlie', isVIP: false }]);
|
|
});
|
|
|
|
test('extracts VIP join', () => {
|
|
const val = {
|
|
latestDescriptions: [
|
|
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' }
|
|
]
|
|
};
|
|
const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val);
|
|
expect(result).toEqual([{ name: 'Alice', isVIP: true }]);
|
|
});
|
|
|
|
test('returns empty array for no joins', () => {
|
|
const val = { latestDescriptions: [] };
|
|
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]);
|
|
});
|
|
|
|
test('handles null/undefined val', () => {
|
|
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(null)).toEqual([]);
|
|
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(undefined)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
test('initializes with correct defaults', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
expect(client.sessionId).toBe(1);
|
|
expect(client.gameId).toBe(5);
|
|
expect(client.roomCode).toBe('TEST');
|
|
expect(client.maxPlayers).toBe(8);
|
|
expect(client.playerCount).toBe(0);
|
|
expect(client.playerNames).toEqual([]);
|
|
expect(client.gameStarted).toBe(false);
|
|
expect(client.gameFinished).toBe(false);
|
|
expect(client.appTag).toBeNull();
|
|
expect(client.ws).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('handleWelcome', () => {
|
|
test('parses welcome message and sets internal state', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
|
|
client.handleWelcome({
|
|
id: 7,
|
|
secret: 'abc-123',
|
|
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' } } },
|
|
'3': { id: 3, roles: { player: { name: 'Bob' } } },
|
|
}
|
|
});
|
|
|
|
expect(client.shardId).toBe(7);
|
|
expect(client.secret).toBe('abc-123');
|
|
expect(client.playerCount).toBe(2);
|
|
expect(client.playerNames).toEqual(['Alice', 'Bob']);
|
|
expect(client.gameState).toBe('Lobby');
|
|
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', () => {
|
|
test('updates room state on room entity update', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
client.gameState = 'Lobby';
|
|
client.lobbyState = 'WaitingForMore';
|
|
|
|
client.handleEntityUpdate({
|
|
key: 'room',
|
|
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
|
|
version: 5,
|
|
from: 1
|
|
});
|
|
|
|
expect(client.gameState).toBe('Gameplay');
|
|
expect(client.gameStarted).toBe(true);
|
|
});
|
|
|
|
test('handles bc:room key as room update', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
|
|
client.handleEntityUpdate({
|
|
key: 'bc:room',
|
|
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
|
|
version: 1,
|
|
from: 1
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildReconnectUrl', () => {
|
|
test('uses stored secret and id', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1,
|
|
gameId: 1,
|
|
roomCode: 'TEST',
|
|
maxPlayers: 8,
|
|
onEvent: () => {},
|
|
});
|
|
client.secret = 'abc-123';
|
|
client.shardId = 5;
|
|
client.host = 'ecast-prod-use2.jackboxgames.com';
|
|
|
|
const url = client.buildReconnectUrl();
|
|
expect(url).toContain('secret=abc-123');
|
|
expect(url).toContain('id=5');
|
|
expect(url).toContain('role=shard');
|
|
expect(url).toContain('ecast-prod-use2.jackboxgames.com');
|
|
});
|
|
});
|
|
|
|
describe('getSnapshot', () => {
|
|
test('returns correct shape with current state', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'LSBN', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
client.appTag = 'drawful2international';
|
|
client.playerCount = 3;
|
|
client.playerNames = ['Alice', 'Bob', 'Charlie'];
|
|
client.lobbyState = 'CanStart';
|
|
client.gameState = 'Lobby';
|
|
client.gameStarted = false;
|
|
client.gameFinished = false;
|
|
|
|
const snapshot = client.getSnapshot();
|
|
|
|
expect(snapshot).toEqual({
|
|
sessionId: 1,
|
|
gameId: 5,
|
|
roomCode: 'LSBN',
|
|
appTag: 'drawful2international',
|
|
maxPlayers: 8,
|
|
playerCount: 3,
|
|
players: ['Alice', 'Bob', 'Charlie'],
|
|
lobbyState: 'CanStart',
|
|
gameState: 'Lobby',
|
|
gameStarted: false,
|
|
gameFinished: false,
|
|
monitoring: true,
|
|
});
|
|
});
|
|
|
|
test('returns a defensive copy of playerNames', () => {
|
|
const client = new EcastShardClient({
|
|
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
|
|
});
|
|
client.playerNames = ['Alice'];
|
|
|
|
const snapshot = client.getSnapshot();
|
|
snapshot.players.push('Mutated');
|
|
|
|
expect(client.playerNames).toEqual(['Alice']);
|
|
});
|
|
});
|
|
|
|
describe('startStatusBroadcast / stopStatusBroadcast', () => {
|
|
beforeEach(() => jest.useFakeTimers());
|
|
afterEach(() => jest.useRealTimers());
|
|
|
|
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';
|
|
|
|
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', 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', 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,
|
|
secret: 'abc',
|
|
reconnect: false,
|
|
entities: {},
|
|
here: {},
|
|
});
|
|
|
|
jest.advanceTimersByTime(20000);
|
|
await Promise.resolve();
|
|
const statusEvents = events.filter(e => e.type === 'game.status');
|
|
expect(statusEvents).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('module exports', () => {
|
|
const { startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot } = require('../../backend/utils/ecast-shard-client');
|
|
|
|
test('startMonitor is exported', () => {
|
|
expect(typeof startMonitor).toBe('function');
|
|
});
|
|
|
|
test('stopMonitor is exported', () => {
|
|
expect(typeof stopMonitor).toBe('function');
|
|
});
|
|
|
|
test('cleanupAllShards is exported', () => {
|
|
expect(typeof cleanupAllShards).toBe('function');
|
|
});
|
|
|
|
test('getMonitorSnapshot is exported', () => {
|
|
expect(typeof getMonitorSnapshot).toBe('function');
|
|
});
|
|
|
|
test('getMonitorSnapshot returns null when no shard active', () => {
|
|
const snapshot = getMonitorSnapshot(999, 999);
|
|
expect(snapshot).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('handleError with code 2027', () => {
|
|
test('marks game as finished and emits events on room-closed error', () => {
|
|
const events = [];
|
|
const client = new EcastShardClient({
|
|
sessionId: 1,
|
|
gameId: 5,
|
|
roomCode: 'TEST',
|
|
maxPlayers: 8,
|
|
onEvent: (type, data) => events.push({ type, data }),
|
|
});
|
|
client.playerCount = 4;
|
|
client.playerNames = ['A', 'B', 'C', 'D'];
|
|
|
|
client.handleError({ code: 2027, msg: 'the room has already been closed' });
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|