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