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
158 lines
6.2 KiB
JavaScript
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); });
|