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
This commit is contained in:
cottongin
2026-03-20 10:53:19 -04:00
parent 002e1d70a6
commit 7712ebeb04

View File

@@ -0,0 +1,722 @@
# 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 | — |