Compare commits

...

18 Commits

Author SHA1 Message Date
cottongin
a7bd0650eb docs: update ecast API reference with connection roles and shard details
Add Connection Roles table documenting host, player, shard, audience,
observer, and moderator roles with their capabilities and slot impact.
Add shard client/welcome capture and passive room monitoring section.

Made-with: Cursor
2026-03-20 11:47:47 -04:00
cottongin
65036a4e1b fix: Picker WebSocket auth, event handling, and status display
- Authenticate with JWT before subscribing to session events
- Use message.type instead of message.event (matches server format)
- Handle all new shard monitor events (room.connected, lobby.*,
  game.started, game.ended, room.disconnected)
- Replace never-set 'waiting' status with 'monitoring'
- Show monitoring indicator with live player count

Made-with: Cursor
2026-03-20 11:47:19 -04:00
cottongin
336ba0e608 docs: update websocket event reference with new shard monitor events
Add room.connected, lobby.player-joined, lobby.updated, game.started,
game.ended, room.disconnected events. Clarify player-count.updated as
manual-override only. Update complete example with new event handlers.

Made-with: Cursor
2026-03-20 11:35:29 -04:00
cottongin
03f79422af fix: clean up activeShards on reconnection failure
Remove stale entries from the activeShards map when
reconnectWithBackoff exhausts all attempts or detects room closure.

Made-with: Cursor
2026-03-20 11:34:15 -04:00
cottongin
2503c3fc09 chore: remove Puppeteer and old room-monitor/player-count-checker modules
Made-with: Cursor
2026-03-20 11:29:01 -04:00
cottongin
9c9927218a feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT
Made-with: Cursor
2026-03-20 11:27:58 -04:00
cottongin
3c1d5b2224 refactor: rewire sessions routes to use ecast shard client
Made-with: Cursor
2026-03-20 11:27:54 -04:00
cottongin
1c4c8bc19c feat: add startMonitor, stopMonitor, cleanupAllShards module exports 2026-03-20 11:25:01 -04:00
cottongin
de395d3a28 feat: add reconnection logic with exponential backoff to shard client 2026-03-20 11:24:48 -04:00
cottongin
3f21299720 feat: add event broadcasting and entity update handlers to shard client
Made-with: Cursor
2026-03-20 11:19:57 -04:00
cottongin
516db57248 feat: add EcastShardClient with connection, welcome parsing, and player counting
Made-with: Cursor
2026-03-20 11:09:05 -04:00
cottongin
0fc2ddbf23 feat: add getRoomInfo to jackbox-api for full room data including host
Made-with: Cursor
2026-03-20 11:04:54 -04:00
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
cottongin
002e1d70a6 Add design doc for ecast shard monitor replacing Puppeteer audience approach
Replaces room-monitor.js (REST polling) and player-count-checker.js
(Puppeteer/CDP audience join) with a single EcastShardClient that
connects as a shard via direct WebSocket. Defines new event contract,
integration points, error handling, and reconnection strategy.

Made-with: Cursor
2026-03-20 10:42:33 -04:00
cottongin
e6198181f8 docs: reframe player counting as slot-based (not a limitation)
Occupied slots = effective player count since held reconnection slots
are unavailable to new players. connections - 1 = player count,
maxPlayers - (connections - 1) = available slots. Clean and simple.

Made-with: Cursor
2026-03-20 09:56:58 -04:00
cottongin
7b0dc5c015 docs: major corrections to ecast API docs from second round of testing
Key corrections based on testing with fresh room SCWX:
- connections count includes ALL ever-joined players, not just active ones
  (slots persist for reconnection, count never decreases)
- here field also includes disconnected players (slot reservation model)
- client/connected and client/disconnected confirmed as NOT delivered to
  player connections after extensive testing
- Jackbox has no concept of "leaving" — player disconnect is invisible
  to the API
- Added reconnection URL format (secret + id query params)
- Added error code 2027 (REST/WebSocket state divergence)
- Added ws-lifecycle-test.js for systematic protocol testing

Made-with: Cursor
2026-03-20 09:51:24 -04:00
cottongin
af5e8cbd94 docs: comprehensive Jackbox ecast API reverse engineering
Adds complete documentation of the ecast platform covering:
- REST API (8 endpoints including newly discovered /connections, /info, /status)
- WebSocket protocol (connection, message format, 40+ opcodes)
- Entity model (room, player, audience, textDescriptions)
- Game lifecycle (lobby → start → gameplay → end)
- Player/room management answers (counting, join/leave detection, etc.)

Also adds scripts/ws-probe.js utility for direct WebSocket probing.

Made-with: Cursor
2026-03-20 09:39:17 -04:00
cottongin
e5ba43bcbb Add design doc for Jackbox ecast API reverse engineering
Defines scope, methodology (REST probing + WebSocket interception),
and documentation structure for comprehensive API documentation effort.

Made-with: Cursor
2026-03-20 09:18:55 -04:00
18 changed files with 3696 additions and 1508 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,6 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"puppeteer": "^24.0.0",
"ws": "^8.14.0"
},
"devDependencies": {

View File

@@ -4,8 +4,7 @@ const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager');
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
const router = express.Router();
@@ -394,7 +393,7 @@ router.post('/:id/games', authenticateToken, (req, res) => {
// Automatically start room monitoring if room code was provided
if (room_code) {
try {
startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
} catch (error) {
console.error('Error starting room monitor:', error);
}
@@ -617,8 +616,7 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
// Stop room monitor and player count check if game is no longer playing
if (status !== 'playing') {
try {
stopRoomMonitor(sessionId, gameId);
stopPlayerCountCheck(sessionId, gameId);
stopMonitor(sessionId, gameId);
} catch (error) {
console.error('Error stopping room monitor/player count check:', error);
}
@@ -637,8 +635,7 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
// Stop room monitor and player count check before deleting
try {
stopRoomMonitor(sessionId, gameId);
stopPlayerCountCheck(sessionId, gameId);
stopMonitor(sessionId, gameId);
} catch (error) {
console.error('Error stopping room monitor/player count check:', error);
}
@@ -863,7 +860,7 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (
}
// Start room monitoring (will hand off to player count check when game starts)
startRoomMonitor(sessionId, gameId, game.room_code, game.max_players);
startMonitor(sessionId, gameId, game.room_code, game.max_players);
res.json({
message: 'Room monitor started',
@@ -880,8 +877,7 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r
const { sessionId, gameId } = req.params;
// Stop both room monitor and player count check
stopRoomMonitor(sessionId, gameId);
stopPlayerCountCheck(sessionId, gameId);
stopMonitor(sessionId, gameId);
res.json({
message: 'Room monitor and player count check stopped',

View File

@@ -4,6 +4,7 @@ const http = require('http');
const cors = require('cors');
const { bootstrapGames } = require('./bootstrap');
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
const { cleanupAllShards } = require('./utils/ecast-shard-client');
const app = express();
const PORT = process.env.PORT || 5000;
@@ -53,6 +54,15 @@ if (require.main === module) {
console.log(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
});
const shutdown = async () => {
console.log('Shutting down gracefully...');
await cleanupAllShards();
server.close(() => process.exit(0));
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
module.exports = { app, server };

View File

@@ -0,0 +1,572 @@
const WebSocket = require('ws');
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
const { getRoomInfo } = require('./jackbox-api');
class EcastShardClient {
static parsePlayersFromHere(here) {
if (here == null || typeof here !== 'object') {
return { playerCount: 0, playerNames: [] };
}
const names = [];
const keys = Object.keys(here).sort((a, b) => Number(a) - Number(b));
for (const key of keys) {
const conn = here[key];
if (conn?.roles?.player) {
names.push(conn.roles.player.name ?? '');
}
}
return { playerCount: names.length, playerNames: names };
}
static parseRoomEntity(roomVal) {
if (roomVal == null || typeof roomVal !== 'object') {
return {
gameState: null,
lobbyState: null,
gameCanStart: false,
gameIsStarting: false,
gameStarted: false,
gameFinished: false,
};
}
return {
gameState: roomVal.state ?? null,
lobbyState: roomVal.lobbyState ?? null,
gameCanStart: !!roomVal.gameCanStart,
gameIsStarting: !!roomVal.gameIsStarting,
gameStarted: roomVal.state === 'Gameplay',
gameFinished: !!roomVal.gameFinished,
};
}
static parsePlayerJoinFromTextDescriptions(val) {
if (val == null || typeof val !== 'object') {
return [];
}
const latest = val.latestDescriptions;
if (!Array.isArray(latest)) {
return [];
}
const out = [];
for (const desc of latest) {
if (!desc || typeof desc !== 'object') continue;
const { category, text } = desc;
if (category !== 'TEXT_DESCRIPTION_PLAYER_JOINED' && category !== 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP') {
continue;
}
if (typeof text !== 'string') continue;
const joinedIdx = text.indexOf(' joined');
if (joinedIdx === -1) continue;
const before = text.slice(0, joinedIdx).trim();
const name = before.split(/\s+/)[0] || before;
out.push({
name,
isVIP: category === 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP',
});
}
return out;
}
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent }) {
this.sessionId = sessionId;
this.gameId = gameId;
this.roomCode = roomCode;
this.maxPlayers = maxPlayers;
this.onEvent = onEvent || (() => {});
this.ws = null;
this.shardId = null;
this.secret = null;
this.host = null;
this.playerCount = 0;
this.playerNames = [];
this.lobbyState = null;
this.gameState = null;
this.gameStarted = false;
this.gameFinished = false;
this.manuallyStopped = false;
this.seq = 0;
this.appTag = null;
this.reconnecting = false;
}
buildReconnectUrl() {
return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`;
}
handleMessage(message) {
switch (message.opcode) {
case 'client/welcome':
this.handleWelcome(message.result);
break;
case 'object':
this.handleEntityUpdate(message.result);
break;
case 'client/connected':
this.handleClientConnected(message.result);
break;
case 'client/disconnected':
break;
case 'error':
this.handleError(message.result);
break;
default:
break;
}
}
handleWelcome(result) {
this.shardId = result.id;
this.secret = result.secret;
const { playerCount, playerNames } = EcastShardClient.parsePlayersFromHere(result.here);
this.playerCount = playerCount;
this.playerNames = playerNames;
if (result.entities?.room) {
const roomEntity = result.entities.room;
const roomVal = Array.isArray(roomEntity) ? roomEntity[1]?.val : roomEntity.val;
if (roomVal) {
const roomState = EcastShardClient.parseRoomEntity(roomVal);
this.lobbyState = roomState.lobbyState;
this.gameState = roomState.gameState;
this.gameStarted = roomState.gameStarted;
this.gameFinished = roomState.gameFinished;
}
}
console.log(
`[Shard Monitor] Welcome: id=${this.shardId}, players=${this.playerCount} [${this.playerNames.join(', ')}], state=${this.gameState}, lobby=${this.lobbyState}`
);
this.onEvent('room.connected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
appTag: this.appTag,
maxPlayers: this.maxPlayers,
playerCount: this.playerCount,
players: [...this.playerNames],
lobbyState: this.lobbyState,
gameState: this.gameState,
});
}
handleEntityUpdate(result) {
if (!result?.key) return;
if (result.key === 'room' || result.key === 'bc:room') {
if (result.val) {
const prevLobbyState = this.lobbyState;
const prevGameStarted = this.gameStarted;
const prevGameFinished = this.gameFinished;
const roomState = EcastShardClient.parseRoomEntity(result.val);
this.lobbyState = roomState.lobbyState;
this.gameState = roomState.gameState;
this.gameStarted = roomState.gameStarted;
this.gameFinished = roomState.gameFinished;
if (this.lobbyState !== prevLobbyState && !this.gameStarted) {
this.onEvent('lobby.updated', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
lobbyState: this.lobbyState,
gameCanStart: roomState.gameCanStart,
gameIsStarting: roomState.gameIsStarting,
playerCount: this.playerCount,
});
}
if (this.gameStarted && !prevGameStarted) {
this.onEvent('game.started', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
if (this.gameFinished && !prevGameFinished) {
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
}
}
}
if (result.key === 'textDescriptions') {
if (result.val) {
const joins = EcastShardClient.parsePlayerJoinFromTextDescriptions(result.val);
for (const join of joins) {
if (!this.playerNames.includes(join.name)) {
this.playerNames.push(join.name);
this.playerCount = this.playerNames.length;
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: join.name,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
}
}
}
}
handleClientConnected(result) {
if (!result) return;
if (result.roles?.player) {
const name = result.roles.player.name ?? '';
if (!this.playerNames.includes(name)) {
this.playerNames.push(name);
this.playerCount = this.playerNames.length;
this.onEvent('lobby.player-joined', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerName: name,
playerCount: this.playerCount,
players: [...this.playerNames],
maxPlayers: this.maxPlayers,
});
}
}
}
handleError(result) {
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
if (result?.code === 2027) {
this.gameFinished = true;
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
this.disconnect();
}
}
_openWebSocket(url) {
return new Promise((resolve, reject) => {
let welcomeTimeoutId = null;
const cleanupWelcomeTimeout = () => {
if (welcomeTimeoutId != null) {
clearTimeout(welcomeTimeoutId);
welcomeTimeoutId = null;
}
};
this.ws = new WebSocket(url, ['ecast-v0'], {
headers: { Origin: 'https://jackbox.tv' },
handshakeTimeout: 10000,
});
this.ws.on('open', () => {
console.log(`[Shard Monitor] Connected to room ${this.roomCode}`);
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
if (message.opcode === 'client/welcome') {
cleanupWelcomeTimeout();
resolve();
}
} catch (e) {
console.error('[Shard Monitor] Failed to parse message:', e.message);
}
});
this.ws.on('error', (err) => {
cleanupWelcomeTimeout();
console.error(`[Shard Monitor] WebSocket error for room ${this.roomCode}:`, err.message);
reject(err);
});
this.ws.on('close', (code, reason) => {
console.log(`[Shard Monitor] Disconnected from room ${this.roomCode} (code: ${code})`);
this.ws = null;
if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) {
void this.reconnectWithBackoff();
}
});
welcomeTimeoutId = setTimeout(() => {
welcomeTimeoutId = null;
if (!this.shardId) {
reject(new Error('Timeout waiting for client/welcome'));
this.disconnect();
}
}, 15000);
});
}
async connect(roomInfo, reconnectUrl) {
this.disconnect();
this.shardId = null;
this.secret = null;
this.host = roomInfo.host;
this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers;
this.appTag = roomInfo.appTag;
const url =
reconnectUrl ||
`wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`;
return this._openWebSocket(url);
}
async reconnect() {
const url = this.buildReconnectUrl();
this.disconnect();
this.shardId = null;
return this._openWebSocket(url);
}
async reconnectWithBackoff() {
if (this.reconnecting || this.manuallyStopped || this.gameFinished) {
return false;
}
this.reconnecting = true;
const delays = [2000, 4000, 8000];
try {
for (let i = 0; i < delays.length; i++) {
await new Promise((r) => setTimeout(r, delays[i]));
const roomInfo = await getRoomInfo(this.roomCode);
if (!roomInfo.exists) {
this.gameFinished = true;
this.onEvent('game.ended', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
playerCount: this.playerCount,
players: [...this.playerNames],
});
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'room_closed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
return false;
}
try {
await this.reconnect();
console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`);
return true;
} catch (e) {
console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message);
}
}
this.onEvent('room.disconnected', {
sessionId: this.sessionId,
gameId: this.gameId,
roomCode: this.roomCode,
reason: 'connection_failed',
finalPlayerCount: this.playerCount,
});
activeShards.delete(`${this.sessionId}-${this.gameId}`);
return false;
} finally {
this.reconnecting = false;
}
}
disconnect() {
if (this.ws) {
try {
this.ws.close(1000, 'Monitor stopped');
} catch (e) {
// Ignore close errors
}
this.ws = null;
}
}
sendMessage(opcode, params = {}) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.seq++;
this.ws.send(JSON.stringify({ seq: this.seq, opcode, params }));
}
}
}
const activeShards = new Map();
function broadcastAndPersist(sessionId, gameId) {
return (eventType, eventData) => {
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent(eventType, eventData, parseInt(sessionId, 10));
}
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
const status = eventType === 'game.ended' ? 'completed' : 'monitoring';
try {
db.prepare(
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
).run(eventData.playerCount ?? null, status, sessionId, gameId);
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
}
if (eventType === 'room.disconnected') {
const reason = eventData.reason;
const status =
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
try {
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') {
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
status,
sessionId,
gameId
);
}
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
}
};
}
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
const monitorKey = `${sessionId}-${gameId}`;
if (activeShards.has(monitorKey)) {
console.log(`[Shard Monitor] Already monitoring ${monitorKey}`);
return;
}
console.log(`[Shard Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
const roomInfo = await getRoomInfo(roomCode);
if (!roomInfo.exists) {
console.log(`[Shard Monitor] Room ${roomCode} not found`);
const onEvent = broadcastAndPersist(sessionId, gameId);
onEvent('room.disconnected', {
sessionId,
gameId,
roomCode,
reason: 'room_not_found',
finalPlayerCount: null,
});
return;
}
const onEvent = broadcastAndPersist(sessionId, gameId);
try {
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
'monitoring',
sessionId,
gameId
);
} catch (e) {
console.error('[Shard Monitor] DB update failed:', e.message);
}
const client = new EcastShardClient({
sessionId,
gameId,
roomCode,
maxPlayers: roomInfo.maxPlayers || maxPlayers,
onEvent,
});
activeShards.set(monitorKey, client);
try {
await client.connect(roomInfo);
} catch (e) {
console.error(`[Shard Monitor] Failed to connect to room ${roomCode}:`, e.message);
activeShards.delete(monitorKey);
onEvent('room.disconnected', {
sessionId,
gameId,
roomCode,
reason: 'connection_failed',
finalPlayerCount: null,
});
}
}
async function stopMonitor(sessionId, gameId) {
const monitorKey = `${sessionId}-${gameId}`;
const client = activeShards.get(monitorKey);
if (client) {
client.manuallyStopped = true;
client.disconnect();
activeShards.delete(monitorKey);
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,
});
console.log(`[Shard Monitor] Stopped monitor for ${monitorKey}`);
}
}
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 };

View File

@@ -39,4 +39,37 @@ async function checkRoomStatus(roomCode) {
}
}
module.exports = { checkRoomStatus };
async function getRoomInfo(roomCode) {
try {
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
headers: DEFAULT_HEADERS
});
if (!response.ok) {
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
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 };
}
}
module.exports = { checkRoomStatus, getRoomInfo };

View File

@@ -1,396 +0,0 @@
const puppeteer = require('puppeteer');
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
const { checkRoomStatus } = require('./jackbox-api');
// Store active check jobs
const activeChecks = new Map();
/**
* Watch a game from start to finish as audience member
* Collects analytics throughout the entire game lifecycle
*/
async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
let browser;
const checkKey = `${sessionId}-${gameId}`;
try {
console.log(`[Player Count] Opening audience connection for ${checkKey} (max: ${maxPlayers})`);
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu'
]
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
// Track all player counts we've seen
const seenPlayerCounts = new Set();
let bestPlayerCount = null;
let startPlayerCount = null; // Authoritative count from 'start' action
let gameEnded = false;
let audienceJoined = false;
let frameCount = 0;
// Enable CDP and listen for WebSocket frames BEFORE navigating
const client = await page.target().createCDPSession();
await client.send('Network.enable');
client.on('Network.webSocketFrameReceived', ({ response }) => {
if (response.payloadData && !gameEnded) {
frameCount++;
try {
const data = JSON.parse(response.payloadData);
if (process.env.DEBUG && frameCount % 10 === 0) {
console.log(`[Frame ${frameCount}] opcode: ${data.opcode}`);
}
// Check for bc:room with player count data
let roomVal = null;
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
roomVal = data.result.entities['bc:room'][1]?.val;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] Found bc:room in client/welcome`);
}
// First client/welcome means Jackbox accepted our audience join
if (!audienceJoined) {
audienceJoined = true;
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('audience.joined', {
sessionId,
gameId,
roomCode
}, parseInt(sessionId));
}
}
}
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
roomVal = data.result.val;
}
if (roomVal) {
// Check if game has ended
if (roomVal.gameResults?.players) {
const finalCount = roomVal.gameResults.players.length;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
} else if (startPlayerCount !== null) {
console.log(`[Frame ${frameCount}] ✓ Verified: Start count matches final count (${finalCount})`);
}
}
bestPlayerCount = finalCount;
gameEnded = true;
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
return;
}
// Extract player counts from analytics (game in progress)
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
for (const analytic of roomVal.analytics) {
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
if (startPlayerCount === null) {
startPlayerCount = analytic.value;
bestPlayerCount = analytic.value;
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
}
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
}
continue;
}
if (startPlayerCount !== null) {
continue;
}
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
seenPlayerCounts.add(analytic.value);
const clampedValue = Math.min(analytic.value, maxPlayers);
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
bestPlayerCount = clampedValue;
if (process.env.DEBUG) {
if (analytic.value > maxPlayers) {
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (clamped to ${clampedValue})`);
} else {
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
}
}
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
}
}
}
}
// Check if room is no longer locked (game ended another way)
if (roomVal.locked === false && bestPlayerCount !== null) {
if (process.env.DEBUG) {
console.log(`[Frame ${frameCount}] Room unlocked, game likely ended. Final count: ${bestPlayerCount}`);
}
gameEnded = true;
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
return;
}
}
} catch (e) {
if (process.env.DEBUG && frameCount % 50 === 0) {
console.log(`[Frame ${frameCount}] Parse error:`, e.message);
}
}
}
});
// Navigate and join audience
if (process.env.DEBUG) console.log('[Audience] Navigating to jackbox.tv...');
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
if (process.env.DEBUG) console.log('[Audience] Waiting for form...');
await page.waitForSelector('input#roomcode', { timeout: 10000 });
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
if (process.env.DEBUG) console.log('[Audience] Typing room code:', roomCode);
const roomInput = await page.$('input#roomcode');
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
await new Promise(resolve => setTimeout(resolve, 2000));
if (process.env.DEBUG) console.log('[Audience] Typing name...');
const nameInput = await page.$('input#username');
await nameInput.type('CountBot', { delay: 30 });
if (process.env.DEBUG) console.log('[Audience] Waiting for JOIN AUDIENCE button...');
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
}, { timeout: 10000 });
if (process.env.DEBUG) console.log('[Audience] Clicking JOIN AUDIENCE...');
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
if (btn) btn.click();
});
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
// Keep watching until game ends or we're told to stop
const checkInterval = setInterval(async () => {
const game = db.prepare(`
SELECT status, player_count_check_status
FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped') {
if (process.env.DEBUG) {
console.log(`[Audience] Stopping watch - game status changed`);
}
clearInterval(checkInterval);
gameEnded = true;
if (browser) await browser.close();
return;
}
if (gameEnded) {
clearInterval(checkInterval);
if (browser) await browser.close();
return;
}
const roomStatus = await checkRoomStatus(roomCode);
if (!roomStatus.exists) {
if (process.env.DEBUG) {
console.log(`[Audience] Room no longer exists - game ended`);
}
gameEnded = true;
clearInterval(checkInterval);
if (bestPlayerCount !== null) {
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
} else {
updatePlayerCount(sessionId, gameId, null, 'failed');
}
if (browser) await browser.close();
return;
}
}, 5000);
const check = activeChecks.get(checkKey);
if (check) {
check.watchInterval = checkInterval;
check.browser = browser;
}
} catch (error) {
console.error('[Audience] Error watching game:', error.message);
if (browser) {
await browser.close();
}
if (bestPlayerCount !== null) {
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
} else {
updatePlayerCount(sessionId, gameId, null, 'failed');
}
}
}
/**
* Update player count in database and broadcast via WebSocket
*/
function updatePlayerCount(sessionId, gameId, playerCount, status) {
try {
db.prepare(`
UPDATE session_games
SET player_count = ?, player_count_check_status = ?
WHERE session_id = ? AND id = ?
`).run(playerCount, status, sessionId, gameId);
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('player-count.updated', {
sessionId,
gameId,
playerCount,
status
}, parseInt(sessionId));
}
console.log(`[Player Count] Updated game ${gameId}: ${playerCount} players (${status})`);
} catch (error) {
console.error('[Player Count] Failed to update database:', error.message);
}
}
/**
* Start player count checking for a game.
* Called by room-monitor once the game is confirmed started (room locked).
* Goes straight to joining the audience — no polling needed.
*/
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
const checkKey = `${sessionId}-${gameId}`;
if (activeChecks.has(checkKey)) {
console.log(`[Player Count] Already checking ${checkKey}`);
return;
}
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') {
console.log(`[Player Count] Check already completed for ${checkKey}, skipping`);
return;
}
if (game && game.player_count_check_status === 'failed') {
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
}
console.log(`[Player Count] Starting audience watch for game ${gameId} (room ${roomCode}, max ${maxPlayers})`);
db.prepare(`
UPDATE session_games
SET player_count_check_status = 'checking'
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
activeChecks.set(checkKey, {
sessionId,
gameId,
roomCode,
watchInterval: null,
browser: null
});
await watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers);
}
/**
* Stop checking player count for a game
*/
async function stopPlayerCountCheck(sessionId, gameId) {
const checkKey = `${sessionId}-${gameId}`;
const check = activeChecks.get(checkKey);
if (check) {
if (check.watchInterval) {
clearInterval(check.watchInterval);
}
if (check.browser) {
try {
await check.browser.close();
} catch (e) {
// Ignore errors closing browser
}
}
activeChecks.delete(checkKey);
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 = 'stopped'
WHERE session_id = ? AND id = ?
`).run(sessionId, gameId);
}
console.log(`[Player Count] Stopped check for ${checkKey}`);
}
}
/**
* Clean up all active checks (for graceful shutdown)
*/
async function cleanupAllChecks() {
for (const [, check] of activeChecks.entries()) {
if (check.watchInterval) {
clearInterval(check.watchInterval);
}
if (check.browser) {
try {
await check.browser.close();
} catch (e) {
// Ignore errors
}
}
}
activeChecks.clear();
console.log('[Player Count] Cleaned up all active checks');
}
module.exports = {
startPlayerCountCheck,
stopPlayerCountCheck,
cleanupAllChecks
};

View File

@@ -1,135 +0,0 @@
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
const { checkRoomStatus } = require('./jackbox-api');
const POLL_INTERVAL_MS = 10000;
// Active room monitors, keyed by "{sessionId}-{gameId}"
const activeMonitors = new Map();
/**
* Broadcast game.started event when room becomes locked
*/
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
try {
const wsManager = getWebSocketManager();
if (wsManager) {
wsManager.broadcastEvent('game.started', {
sessionId,
gameId,
roomCode,
maxPlayers
}, parseInt(sessionId));
}
console.log(`[Room Monitor] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
} catch (error) {
console.error('[Room Monitor] Failed to broadcast game.started:', error.message);
}
}
/**
* Start monitoring a Jackbox room for game start (locked state).
*
* Polls the Jackbox REST API every 10 seconds. When the room becomes
* locked, broadcasts a game.started WebSocket event and then hands off
* to the player-count-checker to join as audience.
*/
async function startRoomMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
const monitorKey = `${sessionId}-${gameId}`;
if (activeMonitors.has(monitorKey)) {
console.log(`[Room Monitor] Already monitoring ${monitorKey}`);
return;
}
console.log(`[Room Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
const onGameStarted = (realMaxPlayers) => {
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
// Lazy require breaks the circular dependency with player-count-checker
const { startPlayerCountCheck } = require('./player-count-checker');
console.log(`[Room Monitor] Room ${roomCode} locked — handing off to player count checker`);
startPlayerCountCheck(sessionId, gameId, roomCode, realMaxPlayers);
};
const pollRoom = async () => {
const game = db.prepare(`
SELECT status FROM session_games
WHERE session_id = ? AND id = ?
`).get(sessionId, gameId);
if (!game || game.status === 'skipped' || game.status === 'played') {
console.log(`[Room Monitor] Stopping — game status changed for ${monitorKey}`);
stopRoomMonitor(sessionId, gameId);
return;
}
const roomStatus = await checkRoomStatus(roomCode);
if (!roomStatus.exists) {
console.log(`[Room Monitor] Room ${roomCode} does not exist — stopping`);
stopRoomMonitor(sessionId, gameId);
return;
}
if (roomStatus.locked) {
stopRoomMonitor(sessionId, gameId);
onGameStarted(roomStatus.maxPlayers);
return;
}
if (roomStatus.full) {
console.log(`[Room Monitor] Room ${roomCode} is full but not locked yet — waiting`);
} else {
console.log(`[Room Monitor] Room ${roomCode} lobby still open — waiting`);
}
};
// Poll immediately, then every POLL_INTERVAL_MS
activeMonitors.set(monitorKey, {
sessionId,
gameId,
roomCode,
interval: null
});
await pollRoom();
// If the monitor was already stopped (room locked or gone on first check), bail
if (!activeMonitors.has(monitorKey)) return;
const interval = setInterval(() => pollRoom(), POLL_INTERVAL_MS);
const monitor = activeMonitors.get(monitorKey);
if (monitor) monitor.interval = interval;
}
/**
* Stop monitoring a room
*/
function stopRoomMonitor(sessionId, gameId) {
const monitorKey = `${sessionId}-${gameId}`;
const monitor = activeMonitors.get(monitorKey);
if (monitor) {
if (monitor.interval) clearInterval(monitor.interval);
activeMonitors.delete(monitorKey);
console.log(`[Room Monitor] Stopped monitor for ${monitorKey}`);
}
}
/**
* Clean up all active monitors (for graceful shutdown)
*/
function cleanupAllMonitors() {
for (const [, monitor] of activeMonitors.entries()) {
if (monitor.interval) clearInterval(monitor.interval);
}
activeMonitors.clear();
console.log('[Room Monitor] Cleaned up all active monitors');
}
module.exports = {
startRoomMonitor,
stopRoomMonitor,
cleanupAllMonitors
};

View File

@@ -5,7 +5,8 @@
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
- Receive notifications when sessions start, end, or when games are added
- Track player counts as they are updated
- Monitor Jackbox room state in real-time (lobby, player joins, game start/end)
- Track player counts automatically via shard connection
- Receive live vote updates (upvotes/downvotes) as viewers vote
- Avoid polling REST endpoints for session state changes
@@ -129,7 +130,13 @@ Must be authenticated.
| `session.started` | New session created (broadcast to all authenticated clients) |
| `game.added` | Game added to a session (broadcast to subscribers) |
| `session.ended` | Session closed (broadcast to subscribers) |
| `player-count.updated` | Player count changed (broadcast to subscribers) |
| `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) |
| `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) |
| `lobby.updated` | Lobby state changed (broadcast to subscribers) |
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
| `game.ended` | Game finished (broadcast to subscribers) |
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
| `vote.received` | Live vote recorded (broadcast to subscribers) |
---
@@ -204,10 +211,117 @@ All server-sent events use this envelope:
}
```
### room.connected
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 2,
"players": ["Alice", "Bob"],
"lobbyState": "CanStart",
"gameState": "Lobby"
}
```
### lobby.player-joined
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerName": "Charlie",
"playerCount": 3,
"players": ["Alice", "Bob", "Charlie"],
"maxPlayers": 8
}
```
### lobby.updated
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"lobbyState": "Countdown",
"gameCanStart": true,
"gameIsStarting": true,
"playerCount": 4
}
```
### game.started
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"maxPlayers": 8
}
```
### game.ended
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"]
}
```
### room.disconnected
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped)
**Data:**
```json
{
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"reason": "room_closed",
"finalPlayerCount": 4
}
```
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
### player-count.updated
- **Broadcast to:** Clients subscribed to the session
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count`
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)
**Data:**
```json
@@ -398,6 +512,30 @@ ws.onmessage = (event) => {
subscribedSessions.delete(msg.data.session.id);
break;
case 'room.connected':
console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', '));
break;
case 'lobby.player-joined':
console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount);
break;
case 'lobby.updated':
console.log('Lobby:', msg.data.lobbyState);
break;
case 'game.started':
console.log('Game started with', msg.data.playerCount, 'players');
break;
case 'game.ended':
console.log('Game ended with', msg.data.playerCount, 'players');
break;
case 'room.disconnected':
console.log('Room disconnected:', msg.data.reason);
break;
case 'player-count.updated':
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
break;

1046
docs/jackbox-ecast-api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
# Jackbox Ecast API Reverse Engineering — Design
**Date:** 2026-03-20
**Goal:** Produce comprehensive documentation of the Jackbox ecast API (REST + WebSocket) through live traffic analysis.
**Deliverable:** `docs/jackbox-ecast-api.md`
## Scope
### In Scope
1. **Ecast REST API** — All discoverable endpoints at `ecast.jackboxgames.com`, including undocumented paths, multiple API versions, and different HTTP methods.
2. **Ecast WebSocket Protocol** — Full bidirectional message protocol between `jackbox.tv` and ecast servers: connection handshake, all opcodes/message types, entity model, and state transitions through the complete game lifecycle.
3. **Player & Room Management** — Join/leave detection, real-time player count, max players, lobby lock/game start signals, game completion signals, player and game stats/results.
### Out of Scope
- Kosmi integration (chrome extension)
- This application's own API
- Game-specific mechanics (drawing, voting within Drawful 2) unless they reveal useful metadata
## Approach
**Primary: REST probing + browser console WebSocket interception (Approach A)**
**Fallback: Puppeteer + CDP automated capture script (Approach B)**
### Phase 1 — REST API Probing
- Probe known endpoint `GET /api/v2/rooms/{code}` and document full response
- Discover new paths: `/players`, `/audience`, `/state`, etc.
- Try API versions `v1`, `v3`
- Check for discovery endpoints (`/api`, `/swagger`, `/openapi`, `/health`)
- Try different HTTP methods (POST, PUT, OPTIONS)
- Document all response schemas, headers, status codes
### Phase 2 — WebSocket Interception
- Navigate to `jackbox.tv` in browser
- Inject WebSocket interceptor (monkey-patch `WebSocket`) before joining room
- Join as player — capture full connection lifecycle
- Join as audience — capture audience-specific messages
- If injection races with connection, reload page after patching
### Phase 3 — Game Lifecycle Capture
Walk through entire Drawful 2 lifecycle with interceptor running:
- Lobby: join/leave messages, player list updates
- Game start: lock signal, round info
- Gameplay: state transitions, metadata
- Game end: results, stats, disconnection sequence
- Multiple players to observe multi-player messages
### Fallback (Approach B)
If browser console interception is unreliable, write a Node.js script using Puppeteer + CDP `Network.webSocketFrame*` events for automated structured capture.
## Documentation Structure
The deliverable (`docs/jackbox-ecast-api.md`) will contain:
1. **Overview** — What ecast is, base URLs, architecture
2. **REST API Reference** — Each endpoint with method, URL, params, response schema, notes
3. **WebSocket Protocol Reference** — Connection details, message format, message catalog (all opcodes), entity model
4. **Game Lifecycle** — Sequence diagram of message flow from room creation through completion
5. **Player & Room Management** — Answers to specific questions (join/leave detection, player count, max players, lock/start, completion, stats) with supporting evidence
6. **Appendix: Raw Captures** — Sanitized example payloads

View File

@@ -0,0 +1,345 @@
# Ecast Shard Monitor — Design Document
**Date:** 2026-03-20
**Status:** Approved
**Replaces:** `room-monitor.js` (REST polling for lock) + `player-count-checker.js` (Puppeteer audience join)
## Problem
The current player count approach launches a headless Chrome instance via Puppeteer, navigates to `jackbox.tv`, joins as an audience member through the UI, and sniffs WebSocket frames via CDP. This is fragile, resource-heavy, and occupies an audience slot. The room monitor is a separate module that polls the REST API until the room locks, then hands off to the Puppeteer checker. Two modules, two connection strategies, a circular dependency workaround.
## Solution
Replace both modules with a single `EcastShardClient` that connects to the Jackbox ecast server as a **shard** via a direct Node.js WebSocket. The shard role:
- Gets the full `here` map (authoritative player list with names and roles)
- Receives real-time entity updates (room state, player joins, game end)
- Can query entities via `object/get`
- Does NOT count toward `maxPlayers` or trigger `full: true`
- Does NOT require a browser
One REST call upfront validates the room and retrieves the `host` field needed for the WebSocket URL. After that, the shard connection handles everything.
## Architecture
### Lifecycle
```
Room code registered
REST: GET /rooms/{code} ──── 404 ──→ Mark failed, stop
│ (get host, maxPlayers, locked, appTag)
WSS: Connect as shard
wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json
client/welcome received
├── Parse `here` → initial player count (filter for `player` roles)
├── Parse `entities.room` → lobby state, gameCanStart, etc.
├── Store `secret` + `id` for reconnection
└── Broadcast initial state to our clients
┌─── Event loop (listening for server messages) ───┐
│ │
│ `object` (key: textDescriptions) │
│ → Parse latestDescriptions for player joins │
│ → Broadcast `lobby.player-joined` to clients │
│ │
│ `object` (key: room) │
│ → Detect state transitions: │
│ lobbyState changes → broadcast lobby updates │
│ state: "Gameplay" → broadcast `game.started` │
│ gameFinished: true → broadcast `game.ended` │
│ gameResults → extract final player count │
│ │
│ `client/connected` (if delivered to shards) │
│ → Update here map, recount players │
│ │
│ WebSocket close/error │
│ → REST check: room exists? │
│ Yes → reconnect with secret/id │
│ No → game ended, finalize │
└────────────────────────────────────────────────────┘
```
### Internal State
| Field | Type | Source |
|-------|------|--------|
| `playerCount` | number | `here` map filtered for `player` roles |
| `playerNames` | string[] | `here` map player role `name` fields |
| `lobbyState` | string | `room` entity `lobbyState` |
| `gameState` | string | `room` entity `state` (`"Lobby"`, `"Gameplay"`) |
| `gameStarted` | boolean | Derived from `state === "Gameplay"` |
| `gameFinished` | boolean | `room` entity `gameFinished` |
| `maxPlayers` | number | REST response + `room` entity |
| `secret` / `id` | string/number | `client/welcome` for reconnection |
### Player Counting
The `here` map from `client/welcome` is the authoritative source. It lists all registered connections with their roles. Count entries where `roles` contains `player`. The shard itself is excluded (it has `roles: {shard: {}}`). The host (ID 1, `roles: {host: {}}`) is also excluded. Since Jackbox holds slots for disconnected players, `here` always reflects the true occupied slot count.
For subsequent joins after connect, `textDescriptions` entity updates provide join notifications. Since shards have `here` visibility, `client/connected` messages may also be delivered — both paths are handled, with `here` as source of truth.
## WebSocket Events (Game Picker → Connected Clients)
### `room.connected`
Shard successfully connected to the Jackbox room. Sent once on initial connect. Replaces the old `audience.joined` event.
```json
{
"type": "room.connected",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"appTag": "drawful2international",
"maxPlayers": 8,
"playerCount": 2,
"players": ["Alice", "Bob"],
"lobbyState": "CanStart",
"gameState": "Lobby"
}
}
```
### `lobby.player-joined`
A new player joined the lobby.
```json
{
"type": "lobby.player-joined",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerName": "Charlie",
"playerCount": 3,
"players": ["Alice", "Bob", "Charlie"],
"maxPlayers": 8
}
}
```
### `lobby.updated`
Lobby state changed (enough players to start, countdown started, etc.).
```json
{
"type": "lobby.updated",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"lobbyState": "Countdown",
"gameCanStart": true,
"gameIsStarting": true,
"playerCount": 4
}
}
```
### `game.started`
The game transitioned from Lobby to Gameplay.
```json
{
"type": "game.started",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"],
"maxPlayers": 8
}
}
```
### `game.ended`
The game finished.
```json
{
"type": "game.ended",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"playerCount": 4,
"players": ["Alice", "Bob", "Charlie", "Diana"]
}
}
```
### `room.disconnected`
Shard lost connection to the Jackbox room.
```json
{
"type": "room.disconnected",
"timestamp": "...",
"data": {
"sessionId": 1,
"gameId": 5,
"roomCode": "LSBN",
"reason": "room_closed",
"finalPlayerCount": 4
}
}
```
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
### Dropped Events
| Old event | Replacement |
|-----------|-------------|
| `audience.joined` | `room.connected` (richer payload) |
| `player-count.updated` (automated) | `lobby.player-joined`, `game.started`, `game.ended` carry `playerCount` |
The manual `PATCH .../player-count` endpoint keeps broadcasting `player-count.updated` for its specific use case.
### DB Persistence
The `session_games` table columns `player_count` and `player_count_check_status` continue to be updated:
- `player_count` — updated on each join and at game end
- `player_count_check_status``'monitoring'` (shard connected), `'completed'` (game ended with count), `'failed'` (couldn't connect), `'stopped'` (manual stop)
The old `'checking'` status becomes `'monitoring'`.
## Integration Points
### Files Deleted
- `backend/utils/player-count-checker.js` — Puppeteer audience approach
- `backend/utils/room-monitor.js` — REST polling for lock state
### Files Created
- `backend/utils/ecast-shard-client.js``EcastShardClient` class + module exports: `startMonitor`, `stopMonitor`, `cleanupAllShards`
### Files Modified
**`backend/utils/jackbox-api.js`** — Add `getRoomInfo(roomCode)` returning the full room response including `host`, `appTag`, `audienceEnabled`.
**`backend/routes/sessions.js`** — Replace imports:
```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');
```
All call sites change from two-function calls to one:
| Route | Old | New |
|-------|-----|-----|
| `POST /:id/games` (with room_code) | `startRoomMonitor(...)` | `startMonitor(...)` |
| `PATCH .../status` (away from playing) | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
| `DELETE .../games/:gameId` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
| `POST .../start-player-check` | `startRoomMonitor(...)` | `startMonitor(...)` |
| `POST .../stop-player-check` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
Endpoint paths stay the same for backwards compatibility.
**`backend/server.js`** — Wire `cleanupAllShards()` into `SIGTERM`/`SIGINT` handlers.
## Error Handling and Reconnection
### Connection Failures
1. **REST validation fails** (room not found, network error): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'room_not_found'` or `'connection_failed'`. No automatic retry.
2. **Shard WebSocket fails to connect**: Retry up to 3 times with exponential backoff (2s, 4s, 8s). On exhaustion, set status `'failed'`, broadcast `room.disconnected` with `reason: 'connection_failed'`.
3. **Ecast rejects the shard role** (error opcode received): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'role_rejected'`. No retry.
### Mid-Session Disconnections
4. **WebSocket closes unexpectedly**: REST check `GET /rooms/{code}`:
- Room exists → reconnect with stored `secret`/`id` (up to 3 attempts, exponential backoff). Transparent to clients on success.
- Room gone → finalize with last known count, status `'completed'`, broadcast `game.ended` + `room.disconnected`.
5. **Ecast error 2027 "room already closed"**: Same as room-gone path.
### Manual Stop
6. **`stop-player-check` called or game status changes**: Close WebSocket gracefully, set status `'stopped'` (unless already `'completed'`), broadcast `room.disconnected` with `reason: 'manually_stopped'`.
### Server Shutdown
7. **`SIGTERM`/`SIGINT`**: `cleanupAllShards()` closes all WebSocket connections. No DB updates on shutdown.
### State Machine
```
startMonitor()
┌───────────┐
┌────────│ not_started│
│ └───────────┘
│ │
REST fails REST succeeds
│ │
▼ ▼
┌────────┐ ┌────────────┐
│ failed │ │ monitoring │◄──── reconnect success
└────────┘ └─────┬──────┘
▲ │
│ ┌────┴─────┬──────────────┐
reconnect │ │ │
exhausted game ends WS drops manual stop
│ │ │ │
│ ▼ ▼ ▼
│ ┌──────────┐ REST check ┌─────────┐
│ │ completed │ │ │ stopped │
│ └──────────┘ │ └─────────┘
│ │
└──── room gone? ────┘
room exists?
reconnect...
```
### Timeouts
| Concern | Value | Rationale |
|---------|-------|-----------|
| WebSocket connect timeout | 10s | Ecast servers respond fast |
| Reconnect backoff | 2s, 4s, 8s | Three attempts, ~14s total |
| Max reconnect attempts | 3 | Fail fast, user can retry manually |
| WebSocket inactivity timeout | None | Shard connections receive periodic `shard/sync` CRDT messages |
## Dependencies
**Added:** `ws` (Node.js WebSocket library) — already a dependency (used by `websocket-manager.js`).
**Removed:** `puppeteer` — no longer needed for room monitoring.
## Non-Goals
- Renaming REST endpoint paths (`start-player-check` / `stop-player-check`) — kept for backwards compatibility
- Auto-starting monitoring when room code is set via `PATCH .../room-code` — kept as manual trigger only
- Frontend `Picker.jsx` changes — tracked separately (existing bugs: `message.event` vs `message.type`, subscribe without auth, `'waiting'` status that's never set)

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

View File

@@ -8,7 +8,7 @@ import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
function Picker() {
const { isAuthenticated, loading: authLoading } = useAuth();
const { isAuthenticated, loading: authLoading, token } = useAuth();
const navigate = useNavigate();
const [activeSession, setActiveSession] = useState(null);
@@ -977,8 +977,10 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
return () => clearInterval(interval);
}, [loadGames]);
// Setup WebSocket connection for real-time player count updates
// Setup WebSocket connection for real-time session updates
useEffect(() => {
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
@@ -986,22 +988,33 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WebSocket] Connected for player count updates');
// Subscribe to session events
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: parseInt(sessionId)
}));
console.log('[WebSocket] Connected, authenticating...');
ws.send(JSON.stringify({ type: 'auth', token }));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Handle player count updates
if (message.event === 'player-count.updated') {
console.log('[WebSocket] Player count updated:', message.data);
// Reload games to get updated player counts
if (message.type === 'auth_success') {
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
return;
}
const reloadEvents = [
'room.connected',
'lobby.player-joined',
'lobby.updated',
'game.started',
'game.ended',
'room.disconnected',
'player-count.updated',
'game.added',
];
if (reloadEvents.includes(message.type)) {
console.log(`[WebSocket] ${message.type}:`, message.data);
loadGames();
}
} catch (error) {
@@ -1027,7 +1040,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
}
}, [sessionId, loadGames]);
}, [sessionId, token, loadGames]);
const handleUpdateStatus = async (gameId, newStatus) => {
try {
@@ -1303,14 +1316,14 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
{/* Player Count Display */}
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
<div className="flex items-center gap-1">
{game.player_count_check_status === 'waiting' && (
{game.player_count_check_status === 'monitoring' && !game.player_count && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Waiting...
📡 Monitoring...
</span>
)}
{game.player_count_check_status === 'checking' && (
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
</span>
)}
{game.player_count_check_status === 'completed' && game.player_count && (
@@ -1406,7 +1419,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
</>
)}
{/* Stop button for active checks */}
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
<button
onClick={() => handleStopPlayerCountCheck(game.id)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
const WebSocket = require('ws');
const https = require('https');
const ROOM = process.argv[2] || 'SCWX';
const HOST = 'ecast-prod-use2.jackboxgames.com';
function ts() { return new Date().toISOString().slice(11, 23); }
function log(tag, ...args) { console.log(`[${ts()}][${tag}]`, ...args); }
function connect(name, opts = {}) {
return new Promise((resolve, reject) => {
let params = `role=player&name=${encodeURIComponent(name)}&format=json`;
if (opts.secret) {
params += `&secret=${opts.secret}`;
params += `&id=${opts.id}`;
} else {
params += `&userId=${name}-${Date.now()}`;
}
const url = `wss://${HOST}/api/v2/rooms/${ROOM}/play?${params}`;
log(name, 'Connecting:', url);
const ws = new WebSocket(url, ['ecast-v0'], {
headers: { 'Origin': 'https://jackbox.tv' }
});
const allMsgs = [];
ws.on('message', (raw) => {
const m = JSON.parse(raw.toString());
allMsgs.push(m);
if (m.opcode === 'client/welcome') {
const r = m.result;
const hereList = r.here ? Object.entries(r.here).map(([k, v]) => {
const role = Object.keys(v.roles)[0];
const detail = v.roles.player ? `(${v.roles.player.name})` : '';
return `${k}:${role}${detail}`;
}).join(', ') : 'null';
log(name, `WELCOME id=${r.id} reconnect=${r.reconnect} secret=${r.secret} here=[${hereList}]`);
resolve({ ws, id: r.id, secret: r.secret, msgs: allMsgs, name });
} else if (m.opcode === 'client/connected') {
const r = m.result;
log(name, `*** CLIENT/CONNECTED id=${r.id} userId=${r.userId} name=${r.name} role=${JSON.stringify(r.roles || r.role)}`);
} else if (m.opcode === 'client/disconnected') {
const r = m.result;
log(name, `*** CLIENT/DISCONNECTED id=${r.id} role=${JSON.stringify(r.roles || r.role)}`);
} else if (m.opcode === 'client/kicked') {
log(name, `*** CLIENT/KICKED:`, JSON.stringify(m.result));
} else if (m.opcode === 'error') {
log(name, `ERROR code=${m.result.code}: ${m.result.msg}`);
reject(new Error(m.result.msg));
} else if (m.opcode === 'object') {
const r = m.result;
if (r.key === 'room') {
log(name, `ROOM state=${r.val?.state} lobbyState=${r.val?.lobbyState} gameCanStart=${r.val?.gameCanStart} gameFinished=${r.val?.gameFinished} v${r.version}`);
} else if (r.key === 'textDescriptions') {
const latest = r.val?.latestDescriptions?.[0];
if (latest) log(name, `TEXT: "${latest.text}" (${latest.category})`);
} else if (r.key?.startsWith('player:')) {
log(name, `PLAYER ${r.key} state=${r.val?.state || 'init'} v${r.version}`);
} else {
log(name, `ENTITY ${r.key} v${r.version}`);
}
} else if (m.opcode === 'ok') {
log(name, `OK seq response`);
} else if (m.opcode === 'room/get-audience') {
log(name, `AUDIENCE connections=${m.result?.connections}`);
} else {
log(name, `OTHER op=${m.opcode}`, JSON.stringify(m.result).slice(0, 200));
}
});
ws.on('close', (code, reason) => {
log(name, `CLOSED code=${code} reason=${reason.toString()}`);
});
ws.on('error', (e) => {
log(name, `WS_ERROR: ${e.message}`);
reject(e);
});
});
}
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
async function main() {
log('TEST', `=== Phase 1: Connect P1 to ${ROOM} ===`);
const p1 = await connect('P1');
log('TEST', `P1 connected as id=${p1.id}, secret=${p1.secret}`);
log('TEST', `=== Phase 2: Check connections ===`);
const connBefore = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections: ${JSON.stringify(connBefore)}`);
await wait(2000);
log('TEST', `=== Phase 3: Connect P2 (watch P1 for client/connected) ===`);
const p2 = await connect('P2');
log('TEST', `P2 connected as id=${p2.id}, secret=${p2.secret}`);
const connAfterJoin = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 join: ${JSON.stringify(connAfterJoin)}`);
await wait(2000);
log('TEST', `=== Phase 4: P2 gracefully disconnects (watch P1 for client/disconnected) ===`);
p2.ws.close(1000, 'test-disconnect');
await wait(3000);
const connAfterLeave = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 leave: ${JSON.stringify(connAfterLeave)}`);
log('TEST', `=== Phase 5: Reconnect P2 using secret ===`);
const p2r = await connect('P2-RECONNECT', { secret: p2.secret, id: p2.id });
log('TEST', `P2 reconnected as id=${p2r.id}, reconnect should be true`);
const connAfterReconnect = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Connections after P2 reconnect: ${JSON.stringify(connAfterReconnect)}`);
await wait(2000);
log('TEST', `=== Phase 6: Connect P3 (reach 3 players for CanStart) ===`);
const p3 = await connect('P3');
log('TEST', `P3 connected as id=${p3.id}`);
await wait(2000);
log('TEST', `=== Phase 7: Query audience count ===`);
p1.ws.send(JSON.stringify({ seq: 100, opcode: 'room/get-audience', params: {} }));
await wait(1000);
log('TEST', `=== Phase 8: All messages received by P1 ===`);
const p1Opcodes = p1.msgs.map(m => `pc:${m.pc} ${m.opcode}${m.result?.key ? ':' + m.result.key : ''}`);
log('TEST', `P1 received ${p1.msgs.length} messages: ${p1Opcodes.join(', ')}`);
log('TEST', `=== Cleanup ===`);
p1.ws.close(1000);
p2r.ws.close(1000);
p3.ws.close(1000);
await wait(1000);
const connFinal = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
log('TEST', `Final connections: ${JSON.stringify(connFinal)}`);
log('TEST', `=== DONE ===`);
process.exit(0);
}
function fetchJSON(url) {
return new Promise((resolve, reject) => {
https.get(url, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
}).on('error', reject);
});
}
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });

109
scripts/ws-probe.js Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
const WebSocket = require('ws');
const https = require('https');
const ROOM_CODE = process.argv[2] || 'LSBN';
const PLAYER_NAME = process.argv[3] || 'PROBE_WS';
const ROLE = process.argv[4] || 'player'; // 'player' or 'audience'
const USER_ID = `probe-${Date.now()}`;
function getRoomInfo(code) {
return new Promise((resolve, reject) => {
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${code}`, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.ok) resolve(json.body);
else reject(new Error(json.error || 'Room not found'));
} catch (e) { reject(e); }
});
}).on('error', reject);
});
}
function ts() {
return new Date().toISOString().slice(11, 23);
}
async function main() {
console.log(`[${ts()}] Fetching room info for ${ROOM_CODE}...`);
const room = await getRoomInfo(ROOM_CODE);
console.log(`[${ts()}] Room: ${room.appTag}, host: ${room.host}, locked: ${room.locked}`);
let wsUrl;
if (ROLE === 'audience') {
wsUrl = `wss://${room.audienceHost}/api/v2/audience/${ROOM_CODE}/play`;
} else {
wsUrl = `wss://${room.host}/api/v2/rooms/${ROOM_CODE}/play?role=${ROLE}&name=${encodeURIComponent(PLAYER_NAME)}&userId=${USER_ID}&format=json`;
}
console.log(`[${ts()}] Connecting: ${wsUrl}`);
const ws = new WebSocket(wsUrl, ['ecast-v0'], {
headers: {
'Origin': 'https://jackbox.tv',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
});
let msgCount = 0;
ws.on('open', () => {
console.log(`[${ts()}] CONNECTED`);
});
ws.on('message', (raw) => {
msgCount++;
try {
const msg = JSON.parse(raw.toString());
const summary = summarize(msg);
console.log(`[${ts()}] RECV #${msgCount} | pc:${msg.pc} | opcode:${msg.opcode} | ${summary}`);
if (process.env.VERBOSE === 'true') {
console.log(JSON.stringify(msg, null, 2));
}
} catch (e) {
console.log(`[${ts()}] RECV #${msgCount} | raw: ${raw.toString().slice(0, 200)}`);
}
});
ws.on('close', (code, reason) => {
console.log(`[${ts()}] CLOSED code=${code} reason=${reason}`);
process.exit(0);
});
ws.on('error', (err) => {
console.error(`[${ts()}] ERROR: ${err.message}`);
});
process.on('SIGINT', () => {
console.log(`\n[${ts()}] Closing (${msgCount} messages received)`);
ws.close();
});
}
function summarize(msg) {
if (msg.opcode === 'client/welcome') {
const r = msg.result || {};
const hereIds = r.here ? Object.keys(r.here) : [];
const entityKeys = r.entities ? Object.keys(r.entities) : [];
return `id=${r.id} name=${r.name} reconnect=${r.reconnect} here=[${hereIds}] entities=[${entityKeys}]`;
}
if (msg.opcode === 'object') {
const r = msg.result || {};
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
return `key=${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
}
if (msg.opcode === 'client/connected') {
const r = msg.result || {};
return `id=${r.id} userId=${r.userId} name=${r.name} role=${r.role}`;
}
if (msg.opcode === 'client/disconnected') {
const r = msg.result || {};
return `id=${r.id} role=${r.role}`;
}
return JSON.stringify(msg.result || msg).slice(0, 150);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,415 @@
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([]);
});
test('handles null/undefined val', () => {
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(null)).toEqual([]);
expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(undefined)).toEqual([]);
});
});
describe('constructor', () => {
test('initializes with correct defaults', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
expect(client.sessionId).toBe(1);
expect(client.gameId).toBe(5);
expect(client.roomCode).toBe('TEST');
expect(client.maxPlayers).toBe(8);
expect(client.playerCount).toBe(0);
expect(client.playerNames).toEqual([]);
expect(client.gameStarted).toBe(false);
expect(client.gameFinished).toBe(false);
expect(client.appTag).toBeNull();
expect(client.ws).toBeNull();
});
});
describe('handleWelcome', () => {
test('parses welcome message and sets internal state', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.handleWelcome({
id: 7,
secret: 'abc-123',
reconnect: false,
entities: {
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
},
here: {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
'3': { id: 3, roles: { player: { name: 'Bob' } } },
}
});
expect(client.shardId).toBe(7);
expect(client.secret).toBe('abc-123');
expect(client.playerCount).toBe(2);
expect(client.playerNames).toEqual(['Alice', 'Bob']);
expect(client.gameState).toBe('Lobby');
expect(client.lobbyState).toBe('CanStart');
expect(client.gameStarted).toBe(false);
});
});
describe('handleEntityUpdate', () => {
test('updates room state on room entity update', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.gameState = 'Lobby';
client.lobbyState = 'WaitingForMore';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
version: 5,
from: 1
});
expect(client.gameState).toBe('Gameplay');
expect(client.gameStarted).toBe(true);
});
test('handles bc:room key as room update', () => {
const client = new EcastShardClient({
sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}
});
client.handleEntityUpdate({
key: 'bc:room',
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 1,
from: 1
});
expect(client.lobbyState).toBe('CanStart');
});
});
describe('event broadcasting', () => {
let events;
let client;
beforeEach(() => {
events = [];
client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
});
describe('handleWelcome broadcasts room.connected', () => {
test('broadcasts room.connected with initial state', () => {
client.appTag = 'drawful2international';
client.handleWelcome({
id: 7,
secret: 'abc',
reconnect: false,
entities: {
room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }]
},
here: {
'1': { id: 1, roles: { host: {} } },
'2': { id: 2, roles: { player: { name: 'Alice' } } },
}
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('room.connected');
expect(events[0].data.playerCount).toBe(1);
expect(events[0].data.players).toEqual(['Alice']);
expect(events[0].data.lobbyState).toBe('CanStart');
});
});
describe('handleEntityUpdate broadcasts events', () => {
test('broadcasts lobby.updated on lobbyState change', () => {
client.lobbyState = 'WaitingForMore';
client.gameState = 'Lobby';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 2, from: 1,
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.updated');
expect(events[0].data.lobbyState).toBe('CanStart');
});
test('broadcasts game.started on state transition to Gameplay', () => {
client.lobbyState = 'Countdown';
client.gameState = 'Lobby';
client.gameStarted = false;
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false },
version: 5, from: 1,
});
const startEvents = events.filter(e => e.type === 'game.started');
expect(startEvents).toHaveLength(1);
expect(startEvents[0].data.playerCount).toBe(4);
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
});
test('does not broadcast game.started if already started', () => {
client.gameStarted = true;
client.gameState = 'Gameplay';
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: false },
version: 10, from: 1,
});
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
});
test('broadcasts game.ended on gameFinished transition', () => {
client.gameStarted = true;
client.gameState = 'Gameplay';
client.gameFinished = false;
client.playerCount = 3;
client.playerNames = ['X', 'Y', 'Z'];
client.handleEntityUpdate({
key: 'room',
val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true },
version: 20, from: 1,
});
const endEvents = events.filter(e => e.type === 'game.ended');
expect(endEvents).toHaveLength(1);
expect(endEvents[0].data.playerCount).toBe(3);
});
test('broadcasts lobby.player-joined from textDescriptions', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleEntityUpdate({
key: 'textDescriptions',
val: {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
]
},
version: 3, from: 1,
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.player-joined');
expect(events[0].data.playerName).toBe('Bob');
expect(events[0].data.playerCount).toBe(2);
expect(events[0].data.players).toEqual(['Alice', 'Bob']);
});
test('does not broadcast duplicate player join', () => {
client.playerNames = ['Alice', 'Bob'];
client.playerCount = 2;
client.handleEntityUpdate({
key: 'textDescriptions',
val: {
latestDescriptions: [
{ category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' }
]
},
version: 4, from: 1,
});
expect(events).toHaveLength(0);
});
});
describe('handleClientConnected', () => {
test('broadcasts lobby.player-joined for new player connection', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleClientConnected({
id: 3,
roles: { player: { name: 'Charlie' } },
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('lobby.player-joined');
expect(events[0].data.playerName).toBe('Charlie');
expect(events[0].data.playerCount).toBe(2);
});
test('ignores non-player connections', () => {
client.handleClientConnected({
id: 5,
roles: { shard: {} },
});
expect(events).toHaveLength(0);
});
test('ignores duplicate player connection', () => {
client.playerNames = ['Alice'];
client.playerCount = 1;
client.handleClientConnected({
id: 2,
roles: { player: { name: 'Alice' } },
});
expect(events).toHaveLength(0);
});
});
});
describe('buildReconnectUrl', () => {
test('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');
});
});
describe('module exports', () => {
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
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');
});
});
describe('handleError with code 2027', () => {
test('marks game as finished and emits events on room-closed error', () => {
const events = [];
const client = new EcastShardClient({
sessionId: 1,
gameId: 5,
roomCode: 'TEST',
maxPlayers: 8,
onEvent: (type, data) => events.push({ type, data }),
});
client.playerCount = 4;
client.playerNames = ['A', 'B', 'C', 'D'];
client.handleError({ code: 2027, msg: 'the room has already been closed' });
expect(client.gameFinished).toBe(true);
expect(events.some(e => e.type === 'game.ended')).toBe(true);
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
});
});
});

View File

@@ -0,0 +1,11 @@
const { getRoomInfo, checkRoomStatus } = require('../../backend/utils/jackbox-api');
describe('jackbox-api exports', () => {
test('getRoomInfo is exported as a function', () => {
expect(typeof getRoomInfo).toBe('function');
});
test('checkRoomStatus is still exported', () => {
expect(typeof checkRoomStatus).toBe('function');
});
});