diff --git a/docs/plans/2026-03-20-shard-monitor-implementation.md b/docs/plans/2026-03-20-shard-monitor-implementation.md new file mode 100644 index 0000000..3b30612 --- /dev/null +++ b/docs/plans/2026-03-20-shard-monitor-implementation.md @@ -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 7–8 imports, lines 394–401, 617–624, 638–644, 844–875, 877–893) +- 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 7–8: + +```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 ~620–621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)` + +3. **Lines ~640–641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)` + +4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)` → `startMonitor(...)` + +5. **Lines ~883–884** (`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 | — |