feat: add periodic game.status broadcast and live status REST endpoint

Add 20-second game.status WebSocket heartbeat from active shard monitors
containing full game state, and GET /status-live REST endpoint for on-demand
polling. Fix missing token destructuring in SessionInfo causing crash.
Relax frontend polling from 3s to 60s since WebSocket events now cover
real-time updates. Bump version to 0.6.0.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-20 21:05:19 -04:00
parent a7bd0650eb
commit 34637d6d2c
8 changed files with 328 additions and 9 deletions

View File

@@ -376,8 +376,121 @@ describe('EcastShardClient', () => {
});
});
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());
test('broadcasts game.status every 20 seconds', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.playerCount = 2;
client.playerNames = ['A', 'B'];
client.gameState = 'Lobby';
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
expect(events).toHaveLength(1);
expect(events[0].type).toBe('game.status');
expect(events[0].data.monitoring).toBe(true);
jest.advanceTimersByTime(20000);
expect(events).toHaveLength(2);
client.stopStatusBroadcast();
jest.advanceTimersByTime(40000);
expect(events).toHaveLength(2);
});
test('disconnect stops the status broadcast', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.startStatusBroadcast();
jest.advanceTimersByTime(20000);
expect(events).toHaveLength(1);
client.disconnect();
jest.advanceTimersByTime(40000);
expect(events).toHaveLength(1);
});
test('handleWelcome starts status broadcast', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.handleWelcome({
id: 7,
secret: 'abc',
reconnect: false,
entities: {},
here: {},
});
jest.advanceTimersByTime(20000);
const statusEvents = events.filter(e => e.type === 'game.status');
expect(statusEvents).toHaveLength(1);
});
});
describe('module exports', () => {
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
const { startMonitor, stopMonitor, cleanupAllShards, getMonitorSnapshot } = require('../../backend/utils/ecast-shard-client');
test('startMonitor is exported', () => {
expect(typeof startMonitor).toBe('function');
@@ -390,6 +503,15 @@ describe('EcastShardClient', () => {
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', () => {