# 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 | — |
