Files
jackboxpartypack-gamepicker/docs/plans/2026-03-20-shard-monitor-implementation.md
cottongin 7712ebeb04 Add implementation plan for ecast shard monitor
10-task TDD plan covering: jackbox-api extension, EcastShardClient
class, event broadcasting, reconnection, route rewiring, graceful
shutdown, old module removal, doc updates, and manual smoke test.

Made-with: Cursor
2026-03-20 10:53:19 -04:00

723 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ecast Shard Monitor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.
**Architecture:** A new `EcastShardClient` class connects as a shard to the Jackbox ecast server via the `ws` library. One REST call validates the room and gets the `host` field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports `startMonitor`, `stopMonitor`, and `cleanupAllShards` as drop-in replacements for the old two-module API.
**Tech Stack:** Node.js, `ws` library (already installed), ecast WebSocket protocol (`ecast-v0`), Jest for tests.
**Design doc:** `docs/plans/2026-03-20-shard-monitor-design.md`
**Ecast API reference:** `docs/jackbox-ecast-api.md`
---
### Task 1: Extend `jackbox-api.js` with `getRoomInfo`
**Files:**
- Modify: `backend/utils/jackbox-api.js`
- Test: `tests/api/jackbox-api.test.js` (create)
**Step 1: Write the failing test**
Create `tests/api/jackbox-api.test.js`:
```javascript
const { getRoomInfo } = require('../../backend/utils/jackbox-api');
describe('getRoomInfo', () => {
test('is exported as a function', () => {
expect(typeof getRoomInfo).toBe('function');
});
});
```
**Step 2: Run test to verify it fails**
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
Expected: FAIL — `getRoomInfo` is not exported.
**Step 3: Implement `getRoomInfo`**
In `backend/utils/jackbox-api.js`, add a new function that calls `GET /api/v2/rooms/{code}` and returns the full room body including `host`, `appTag`, `audienceEnabled`, `maxPlayers`, `locked`, `full`. On failure, return `{ exists: false }`.
The existing `checkRoomStatus` stays for now (other code may still reference it during migration).
```javascript
async function getRoomInfo(roomCode) {
try {
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
headers: DEFAULT_HEADERS
});
if (!response.ok) {
return { exists: false };
}
const data = await response.json();
const body = data.body || data;
return {
exists: true,
host: body.host,
audienceHost: body.audienceHost,
appTag: body.appTag,
appId: body.appId,
code: body.code,
locked: body.locked || false,
full: body.full || false,
maxPlayers: body.maxPlayers || 8,
minPlayers: body.minPlayers || 0,
audienceEnabled: body.audienceEnabled || false,
};
} catch (e) {
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
return { exists: false };
}
}
```
Export it alongside `checkRoomStatus`:
```javascript
module.exports = { checkRoomStatus, getRoomInfo };
```
**Step 4: Run test to verify it passes**
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"
```
---
### Task 2: Create `EcastShardClient` — connection and welcome handling
**Files:**
- Create: `backend/utils/ecast-shard-client.js`
- Test: `tests/api/ecast-shard-client.test.js` (create)
This task builds the core class with: constructor, `connect()`, `client/welcome` parsing, `here` map player counting, and the `disconnect()` method. No event broadcasting yet — that's Task 3.
**Step 1: Write failing tests**
Create `tests/api/ecast-shard-client.test.js`. Since we can't connect to real Jackbox servers in tests, test the pure logic: `here` map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.
```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([]);
});
});
});
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL — module does not exist.
**Step 3: Implement EcastShardClient**
Create `backend/utils/ecast-shard-client.js` with:
1. **Static utility methods** (`parsePlayersFromHere`, `parseRoomEntity`, `parsePlayerJoinFromTextDescriptions`) — pure functions, tested above.
2. **Constructor** — takes `{ sessionId, gameId, roomCode, maxPlayers }`, initializes internal state.
3. **`connect(roomInfo)`** — accepts the result of `getRoomInfo()`. Opens a WebSocket to `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json` with `Sec-WebSocket-Protocol: ecast-v0` and `Origin: https://jackbox.tv`.
4. **`handleMessage(data)`** — dispatcher that routes `client/welcome`, `object`, `error`, `client/connected`, `client/disconnected` to handler methods.
5. **`handleWelcome(result)`** — parses `here`, `entities.room`, stores `secret`/`id`.
6. **`disconnect()`** — closes the WebSocket gracefully.
7. **Internal state:** `playerCount`, `playerNames`, `lobbyState`, `gameState`, `gameStarted`, `gameFinished`, `maxPlayers`, `secret`, `id`, `ws`.
Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.
Key implementation details for the WebSocket connection:
```javascript
const WebSocket = require('ws');
// In connect():
this.ws = new WebSocket(url, ['ecast-v0'], {
headers: { 'Origin': 'https://jackbox.tv' },
handshakeTimeout: 10000,
});
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS (all 8 tests)
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"
```
---
### Task 3: Add event broadcasting and entity update handling
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
This task wires up the WebSocket message handlers to broadcast events via `WebSocketManager` and update the `session_games` DB row.
**Step 1: Write failing tests for entity update handlers**
Add tests to `tests/api/ecast-shard-client.test.js`:
```javascript
describe('handleRoomUpdate', () => {
test('detects game start transition', () => {
// Create client instance, set initial state to Lobby
// Call handleRoomUpdate with Gameplay state
// Verify gameStarted flipped and handler would broadcast
});
test('detects game end transition', () => {
// Create client, set gameStarted = true
// Call handleRoomUpdate with gameFinished: true
// Verify gameFinished flipped
});
test('detects lobby state change', () => {
// Create client, set lobbyState to WaitingForMore
// Call handleRoomUpdate with CanStart
// Verify lobbyState updated
});
});
```
Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a `broadcaster` callback in its constructor options. The callback receives `(eventType, data)`. This makes the class testable without mocking the WebSocketManager singleton.
Constructor signature becomes:
```javascript
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })
```
Where `onEvent` is `(eventType, eventData) => void`. The module-level `startMonitor` function provides a default `onEvent` that calls `wsManager.broadcastEvent(...)` and writes to the DB.
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL on new tests.
**Step 3: Implement entity update handlers**
Add to `EcastShardClient`:
- **`handleRoomUpdate(roomVal)`** — called when an `object` message arrives with `key: "room"` (or `key: "bc:room"` for some games). Compares new state against stored state. Broadcasts:
- `lobby.updated` if `lobbyState` changed
- `game.started` if `state` changed to `"Gameplay"` and `gameStarted` was false
- `game.ended` if `gameFinished` changed to true
- Updates `playerCount` in DB via `updatePlayerCount()` on game start and end.
- **`handleTextDescriptionsUpdate(val)`** — called when `object` with `key: "textDescriptions"` arrives. Uses `parsePlayerJoinFromTextDescriptions` to detect joins. Broadcasts `lobby.player-joined` for each new join. Updates internal `playerNames` list.
- **`handleClientConnected(result)`** — if shards receive `client/connected`, update internal `here` tracking and recount players. Broadcast `lobby.player-joined` if the new connection is a player.
- **`updatePlayerCount(count, status)`** — writes to `session_games` and calls `this.onEvent('player-count.updated', ...)` for DB-triggered updates.
Add the module-level `startMonitor` function:
```javascript
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
const monitorKey = `${sessionId}-${gameId}`;
if (activeShards.has(monitorKey)) return;
const roomInfo = await getRoomInfo(roomCode);
if (!roomInfo.exists) {
// set failed status in DB, broadcast room.disconnected
return;
}
const client = new EcastShardClient({
sessionId, gameId, roomCode,
maxPlayers: roomInfo.maxPlayers || maxPlayers,
onEvent: (type, data) => {
const wsManager = getWebSocketManager();
if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
}
});
activeShards.set(monitorKey, client);
await client.connect(roomInfo);
}
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add event broadcasting and entity update handlers to shard client"
```
---
### Task 4: Add reconnection logic
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
**Step 1: Write failing test**
```javascript
describe('reconnection state machine', () => {
test('buildReconnectUrl 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');
});
});
```
**Step 2: Run test to verify it fails**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL — `buildReconnectUrl` doesn't exist.
**Step 3: Implement reconnection**
Add to `EcastShardClient`:
- **`handleClose(code, reason)`** — called on WebSocket `close` event. If `gameFinished` or `manuallyStopped`, do nothing. Otherwise, call `attemptReconnect()`.
- **`attemptReconnect()`** — calls `getRoomInfo(roomCode)`. If room gone, finalize. If room exists, try `reconnectWithBackoff()`.
- **`reconnectWithBackoff()`** — attempts up to 3 reconnections with 2s/4s/8s delays. Uses `buildReconnectUrl()` with stored `secret`/`id`. On success, resumes message handling transparently. On failure, set status `'failed'`, broadcast `room.disconnected`.
- **`buildReconnectUrl()`** — constructs `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}`.
- **`handleError(err)`** — logs the error, defers to `handleClose` for reconnection decisions.
Also handle ecast error opcode 2027 ("room already closed") in `handleMessage` — treat as game-ended.
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add reconnection logic with exponential backoff to shard client"
```
---
### Task 5: Add module exports (`startMonitor`, `stopMonitor`, `cleanupAllShards`)
**Files:**
- Modify: `backend/utils/ecast-shard-client.js`
- Modify: `tests/api/ecast-shard-client.test.js`
**Step 1: Write failing tests**
```javascript
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
describe('module exports', () => {
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');
});
});
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: FAIL if not yet exported.
**Step 3: Finalize module exports**
Ensure these are all exported from `backend/utils/ecast-shard-client.js`:
```javascript
const activeShards = new Map();
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
// ... (implemented in Task 3)
}
async function stopMonitor(sessionId, gameId) {
const monitorKey = `${sessionId}-${gameId}`;
const client = activeShards.get(monitorKey);
if (client) {
client.manuallyStopped = true;
client.disconnect();
activeShards.delete(monitorKey);
// Update DB status unless already completed
const game = db.prepare(
'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
).get(sessionId, gameId);
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
db.prepare(
'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
).run('stopped', sessionId, gameId);
}
client.onEvent('room.disconnected', {
sessionId, gameId, roomCode: client.roomCode,
reason: 'manually_stopped',
finalPlayerCount: client.playerCount
});
}
}
async function cleanupAllShards() {
for (const [, client] of activeShards) {
client.manuallyStopped = true;
client.disconnect();
}
activeShards.clear();
console.log('[Shard Monitor] Cleaned up all active shards');
}
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"
```
---
### Task 6: Rewire `sessions.js` routes
**Files:**
- Modify: `backend/routes/sessions.js` (lines 78 imports, lines 394401, 617624, 638644, 844875, 877893)
- Test: `tests/api/regression-sessions.test.js` (verify existing tests still pass)
**Step 1: Run existing session tests as baseline**
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
Expected: PASS (capture baseline)
**Step 2: Replace imports**
In `backend/routes/sessions.js`, replace lines 78:
```javascript
// Old
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
// New
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
```
**Step 3: Replace call sites**
Search and replace across the file. There are 5 call sites:
1. **Line ~397** (`POST /:id/games`): `startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)``startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)`
2. **Lines ~620621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)`
3. **Lines ~640641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)`
4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)``startMonitor(...)`
5. **Lines ~883884** (`POST .../stop-player-check`): Replace two stop calls with single `stopMonitor(sessionId, gameId)`
**Step 4: Run tests to verify nothing broke**
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)
Also run the full test suite:
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/routes/sessions.js
git commit -m "refactor: rewire sessions routes to use ecast shard client"
```
---
### Task 7: Wire graceful shutdown in `server.js`
**Files:**
- Modify: `backend/server.js`
**Step 1: Add shutdown handler**
In `backend/server.js`, import `cleanupAllShards` and add signal handlers inside the `if (require.main === module)` block:
```javascript
const { cleanupAllShards } = require('./utils/ecast-shard-client');
// Inside the if (require.main === module) block, after server.listen:
const shutdown = async () => {
console.log('Shutting down gracefully...');
await cleanupAllShards();
server.close(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```
**Step 2: Verify server still starts**
Run: `cd backend && timeout 5 node server.js || true`
Expected: Server starts, prints port message, exits on timeout.
**Step 3: Run full test suite**
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS
**Step 4: Commit**
```bash
git add backend/server.js
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"
```
---
### Task 8: Delete old files and remove Puppeteer dependency
**Files:**
- Delete: `backend/utils/player-count-checker.js`
- Delete: `backend/utils/room-monitor.js`
- Modify: `backend/package.json` (remove `puppeteer` from dependencies)
**Step 1: Verify no remaining imports of old modules**
Search the codebase for any remaining `require('./player-count-checker')`, `require('./room-monitor')`, `require('../utils/player-count-checker')`, `require('../utils/room-monitor')`. After Task 6, `sessions.js` should be the only file that imported them and it now imports from `ecast-shard-client`. The old `room-monitor.js` had a lazy require of `player-count-checker` which is going away with it.
If any other files reference these modules, update them first.
**Step 2: Delete the files**
```bash
rm backend/utils/player-count-checker.js backend/utils/room-monitor.js
```
**Step 3: Remove Puppeteer dependency**
```bash
cd backend && npm uninstall puppeteer
```
**Step 4: Run full test suite**
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
Expected: PASS — no test should depend on the deleted files.
**Step 5: Commit**
```bash
git add -A
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"
```
---
### Task 9: Update WebSocket documentation
**Files:**
- Modify: `docs/api/websocket.md`
**Step 1: Read current websocket.md**
Read `docs/api/websocket.md` and identify the server-to-client event table.
**Step 2: Update the event table**
Replace the old events with the new contract:
| Event | Description |
|-------|-------------|
| `room.connected` | Shard connected to Jackbox room (replaces `audience.joined`) |
| `lobby.player-joined` | A player joined the lobby |
| `lobby.updated` | Lobby state changed |
| `game.started` | Game transitioned to Gameplay |
| `game.ended` | Game finished |
| `room.disconnected` | Shard lost connection to room |
| `game.added` | New game added to session (unchanged) |
| `session.started` | Session created (unchanged) |
| `session.ended` | Session closed (unchanged) |
| `vote.received` | Vote recorded (unchanged) |
| `player-count.updated` | Manual player count override (unchanged) |
Add payload examples for each new event (from design doc).
**Step 3: Commit**
```bash
git add docs/api/websocket.md
git commit -m "docs: update websocket event reference with new shard monitor events"
```
---
### Task 10: Smoke test with a real Jackbox room (manual)
This task is manual verification — not automated.
**Steps:**
1. Start the backend: `cd backend && npm run dev`
2. Create a session via API, add a game with a room code from an active Jackbox game
3. Watch backend logs for `[Shard Monitor]` messages:
- REST room info fetched
- WebSocket connected as shard
- `client/welcome` parsed, player count reported
- Player join detected when someone joins the lobby
- Game start detected when the game begins
- Game end detected when the game finishes
4. Connect a WebSocket client to `/api/sessions/live`, authenticate, subscribe to the session, and verify events arrive:
- `room.connected`
- `lobby.player-joined`
- `game.started`
- `game.ended`
- `room.disconnected`
5. Test `stop-player-check` endpoint — verify shard disconnects cleanly
6. Test reconnection — kill and restart the backend mid-game, call `start-player-check` again
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | `getRoomInfo` in jackbox-api | `jackbox-api.js`, test |
| 2 | `EcastShardClient` core + parsing | `ecast-shard-client.js`, test |
| 3 | Event broadcasting + entity handlers | `ecast-shard-client.js`, test |
| 4 | Reconnection logic | `ecast-shard-client.js`, test |
| 5 | Module exports | `ecast-shard-client.js`, test |
| 6 | Rewire sessions routes | `sessions.js` |
| 7 | Graceful shutdown | `server.js` |
| 8 | Delete old files + remove Puppeteer | `player-count-checker.js`, `room-monitor.js`, `package.json` |
| 9 | Update docs | `websocket.md` |
| 10 | Manual smoke test | — |