Files
jackboxpartypack-gamepicker/scripts/ws-lifecycle-test.js
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

158 lines
6.2 KiB
JavaScript

#!/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); });