Compare commits
3 Commits
91b7de3bb7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195448644a
|
||
|
|
c5ffe23404
|
||
|
|
2964cee291
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,6 +21,7 @@ frontend/public/manifest.json
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
logs/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
> [!IMPORTANT]
|
||||
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
|
||||
|
||||
# Jackbox Party Pack Game Picker
|
||||
|
||||
A full-stack web application that helps groups pick games to play from various Jackbox Party Packs. Features include random game selection with weighted filters, session tracking, game management, popularity scoring through chat log imports and live voting, and Jackbox lobby integration.
|
||||
|
||||
@@ -36,7 +36,7 @@ class EcastShardClient {
|
||||
lobbyState: roomVal.lobbyState ?? null,
|
||||
gameCanStart: !!roomVal.gameCanStart,
|
||||
gameIsStarting: !!roomVal.gameIsStarting,
|
||||
gameStarted: roomVal.state === 'Gameplay',
|
||||
gameStarted: roomVal.state != null && roomVal.state !== 'Lobby',
|
||||
gameFinished: !!roomVal.gameFinished,
|
||||
};
|
||||
}
|
||||
@@ -213,6 +213,12 @@ class EcastShardClient {
|
||||
break;
|
||||
case 'client/disconnected':
|
||||
break;
|
||||
case 'room/lock':
|
||||
this.handleRoomLock();
|
||||
break;
|
||||
case 'room/exit':
|
||||
this.handleRoomExit(message.result);
|
||||
break;
|
||||
case 'error':
|
||||
this.handleError(message.result);
|
||||
break;
|
||||
@@ -363,6 +369,44 @@ class EcastShardClient {
|
||||
}
|
||||
}
|
||||
|
||||
handleRoomLock() {
|
||||
if (!this.gameStarted) {
|
||||
console.log(`[Shard Monitor] Room ${this.roomCode} locked (game starting)`);
|
||||
this.gameStarted = true;
|
||||
this.gameState = this.gameState || 'Gameplay';
|
||||
this.onEvent('game.started', {
|
||||
sessionId: this.sessionId,
|
||||
gameId: this.gameId,
|
||||
roomCode: this.roomCode,
|
||||
playerCount: this.playerCount,
|
||||
players: [...this.playerNames],
|
||||
maxPlayers: this.maxPlayers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleRoomExit() {
|
||||
if (this.gameFinished) return;
|
||||
console.log(`[Shard Monitor] Room ${this.roomCode} exited`);
|
||||
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}`);
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
handleError(result) {
|
||||
console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`);
|
||||
if (result?.code === 2027) {
|
||||
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.6.5 - Fish Tank Edition',
|
||||
version: '0.7.0 - Fixed For Real Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -50,6 +50,15 @@ describe('EcastShardClient', () => {
|
||||
expect(result.gameStarted).toBe(true);
|
||||
});
|
||||
|
||||
test('detects game started from non-Lobby state (Pack 7 Logo)', () => {
|
||||
const roomVal = { state: 'Logo', locale: 'en', platformId: 'PS4' };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
expect(result.gameStarted).toBe(true);
|
||||
expect(result.gameState).toBe('Logo');
|
||||
expect(result.lobbyState).toBeNull();
|
||||
expect(result.gameFinished).toBe(false);
|
||||
});
|
||||
|
||||
test('detects game finished', () => {
|
||||
const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true };
|
||||
const result = EcastShardClient.parseRoomEntity(roomVal);
|
||||
@@ -272,6 +281,26 @@ describe('EcastShardClient', () => {
|
||||
expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
test('broadcasts game.started on state transition to Logo (Pack 7)', () => {
|
||||
client.lobbyState = 'Countdown';
|
||||
client.gameState = 'Lobby';
|
||||
client.gameStarted = false;
|
||||
client.playerCount = 4;
|
||||
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||
|
||||
client.handleEntityUpdate({
|
||||
key: 'room',
|
||||
val: { state: 'Logo', locale: 'en', platformId: 'PS4' },
|
||||
version: 14, from: 1,
|
||||
});
|
||||
|
||||
const startEvents = events.filter(e => e.type === 'game.started');
|
||||
expect(startEvents).toHaveLength(1);
|
||||
expect(startEvents[0].data.playerCount).toBe(4);
|
||||
expect(client.gameState).toBe('Logo');
|
||||
expect(client.gameStarted).toBe(true);
|
||||
});
|
||||
|
||||
test('does not broadcast game.started if already started', () => {
|
||||
client.gameStarted = true;
|
||||
client.gameState = 'Gameplay';
|
||||
@@ -573,4 +602,80 @@ describe('EcastShardClient', () => {
|
||||
expect(events.some(e => e.type === 'room.disconnected' && e.data.reason === 'room_closed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRoomLock', () => {
|
||||
test('emits game.started when game has not yet started', () => {
|
||||
const events = [];
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1,
|
||||
gameId: 5,
|
||||
roomCode: 'TEST',
|
||||
maxPlayers: 8,
|
||||
onEvent: (type, data) => events.push({ type, data }),
|
||||
});
|
||||
client.gameStarted = false;
|
||||
client.playerCount = 4;
|
||||
client.playerNames = ['A', 'B', 'C', 'D'];
|
||||
|
||||
client.handleRoomLock();
|
||||
|
||||
expect(client.gameStarted).toBe(true);
|
||||
const startEvents = events.filter(e => e.type === 'game.started');
|
||||
expect(startEvents).toHaveLength(1);
|
||||
expect(startEvents[0].data.playerCount).toBe(4);
|
||||
});
|
||||
|
||||
test('does not emit game.started if game already started', () => {
|
||||
const events = [];
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1,
|
||||
gameId: 5,
|
||||
roomCode: 'TEST',
|
||||
maxPlayers: 8,
|
||||
onEvent: (type, data) => events.push({ type, data }),
|
||||
});
|
||||
client.gameStarted = true;
|
||||
|
||||
client.handleRoomLock();
|
||||
|
||||
expect(events.filter(e => e.type === 'game.started')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRoomExit', () => {
|
||||
test('emits game.ended and room.disconnected on room exit', () => {
|
||||
const events = [];
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1,
|
||||
gameId: 5,
|
||||
roomCode: 'TEST',
|
||||
maxPlayers: 8,
|
||||
onEvent: (type, data) => events.push({ type, data }),
|
||||
});
|
||||
client.playerCount = 3;
|
||||
client.playerNames = ['X', 'Y', 'Z'];
|
||||
|
||||
client.handleRoomExit();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('does not emit events if game already finished', () => {
|
||||
const events = [];
|
||||
const client = new EcastShardClient({
|
||||
sessionId: 1,
|
||||
gameId: 5,
|
||||
roomCode: 'TEST',
|
||||
maxPlayers: 8,
|
||||
onEvent: (type, data) => events.push({ type, data }),
|
||||
});
|
||||
client.gameFinished = true;
|
||||
|
||||
client.handleRoomExit();
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
492
tools/jackbox-logger.js
Normal file
492
tools/jackbox-logger.js
Normal file
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let WebSocket;
|
||||
try {
|
||||
WebSocket = require('ws');
|
||||
} catch (_) {
|
||||
try {
|
||||
WebSocket = require('../backend/node_modules/ws');
|
||||
} catch (_2) {
|
||||
console.error('Error: WebSocket library (ws) not found.');
|
||||
console.error('Run: cd backend && npm install');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const C = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
||||
console.log(`
|
||||
${C.bold}Jackbox API Logger${C.reset}
|
||||
Connects to a Jackbox game room and logs all WebSocket events.
|
||||
|
||||
${C.bold}Usage:${C.reset}
|
||||
node tools/jackbox-logger.js <ROOM_CODE> [options]
|
||||
|
||||
${C.bold}Options:${C.reset}
|
||||
--role <shard|audience|player> Connection role (default: shard)
|
||||
--name <name> Display name (default: JBLogger)
|
||||
--no-file Skip writing to log file
|
||||
--verbose Print full JSON to console
|
||||
--help, -h Show this help
|
||||
|
||||
${C.bold}Examples:${C.reset}
|
||||
node tools/jackbox-logger.js ABCD
|
||||
node tools/jackbox-logger.js ABCD --role audience
|
||||
node tools/jackbox-logger.js ABCD --verbose --no-file
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const opts = {
|
||||
roomCode: null,
|
||||
role: 'shard',
|
||||
name: 'JBLogger',
|
||||
noFile: false,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--role') {
|
||||
opts.role = args[++i];
|
||||
} else if (arg === '--name') {
|
||||
opts.name = args[++i];
|
||||
} else if (arg === '--no-file') {
|
||||
opts.noFile = true;
|
||||
} else if (arg === '--verbose') {
|
||||
opts.verbose = true;
|
||||
} else if (!arg.startsWith('-') && !opts.roomCode) {
|
||||
opts.roomCode = arg.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.roomCode) {
|
||||
console.error(`${C.red}Error: room code is required${C.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['shard', 'audience', 'player'].includes(opts.role)) {
|
||||
console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REST: fetch room info
|
||||
// ---------------------------------------------------------------------------
|
||||
function getRoomInfo(roomCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timestamp
|
||||
// ---------------------------------------------------------------------------
|
||||
function ts() {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js
|
||||
// ---------------------------------------------------------------------------
|
||||
function summarize(msg) {
|
||||
const r = msg.result || {};
|
||||
|
||||
switch (msg.opcode) {
|
||||
case 'client/welcome': {
|
||||
const hereEntries = r.here ? Object.entries(r.here) : [];
|
||||
const players = hereEntries
|
||||
.filter(([, v]) => v.roles?.player)
|
||||
.map(([id, v]) => `${v.roles.player.name}(${id})`);
|
||||
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||||
return (
|
||||
`id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` +
|
||||
` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` +
|
||||
` entities: [${entityKeys.join(', ')}]`
|
||||
);
|
||||
}
|
||||
|
||||
case 'object': {
|
||||
if (r.key === 'room' || r.key === 'bc:room') {
|
||||
const v = r.val || {};
|
||||
return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`;
|
||||
}
|
||||
if (r.key === 'textDescriptions') {
|
||||
const latest = r.val?.latestDescriptions;
|
||||
if (Array.isArray(latest) && latest.length > 0) {
|
||||
const last = latest[latest.length - 1];
|
||||
return `TEXT "${last.text}" (${last.category}) v${r.version}`;
|
||||
}
|
||||
return `textDescriptions v${r.version}`;
|
||||
}
|
||||
if (r.key?.startsWith('player:')) {
|
||||
const v = r.val || {};
|
||||
return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`;
|
||||
}
|
||||
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||||
return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||||
}
|
||||
|
||||
case 'client/connected': {
|
||||
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||
const playerName = r.roles?.player?.name || r.name || '';
|
||||
return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`;
|
||||
}
|
||||
|
||||
case 'client/disconnected': {
|
||||
const roleName = r.roles ? Object.keys(r.roles)[0] : r.role;
|
||||
return `id=${r.id} role=${roleName}`;
|
||||
}
|
||||
|
||||
case 'client/kicked':
|
||||
return JSON.stringify(r).slice(0, 200);
|
||||
|
||||
case 'room/lock':
|
||||
return 'room locked (game starting)';
|
||||
|
||||
case 'room/exit':
|
||||
return 'room closed';
|
||||
|
||||
case 'room/get-audience':
|
||||
return `audience connections=${r.connections}`;
|
||||
|
||||
case 'error':
|
||||
return `code=${r.code}: ${r.msg}`;
|
||||
|
||||
case 'ok':
|
||||
return `seq response`;
|
||||
|
||||
default:
|
||||
return JSON.stringify(r).slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function opcodeColor(opcode) {
|
||||
if (opcode === 'client/welcome') return C.green;
|
||||
if (opcode === 'error') return C.red;
|
||||
if (opcode === 'client/connected') return C.cyan;
|
||||
if (opcode === 'client/disconnected') return C.yellow;
|
||||
if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta;
|
||||
if (opcode === 'object') return C.white;
|
||||
return C.dim;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File logger
|
||||
// ---------------------------------------------------------------------------
|
||||
class FileLogger {
|
||||
constructor(roomCode) {
|
||||
const logsDir = path.join(__dirname, '..', 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`);
|
||||
this.stream = fs.createWriteStream(this.filePath, { flags: 'a' });
|
||||
}
|
||||
|
||||
write(entry) {
|
||||
this.stream.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stream.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main logger
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
const { roomCode, role, name, noFile, verbose } = opts;
|
||||
|
||||
console.log(`${C.bold}Jackbox API Logger${C.reset}`);
|
||||
console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`);
|
||||
console.log();
|
||||
|
||||
// Fetch room info
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`);
|
||||
let roomInfo;
|
||||
try {
|
||||
roomInfo = await getRoomInfo(roomCode);
|
||||
} catch (e) {
|
||||
console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`);
|
||||
console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`);
|
||||
console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`);
|
||||
console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`);
|
||||
console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`);
|
||||
console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`);
|
||||
console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`);
|
||||
console.log();
|
||||
|
||||
// File logger
|
||||
let fileLogger = null;
|
||||
if (!noFile) {
|
||||
fileLogger = new FileLogger(roomCode);
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`);
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'meta',
|
||||
type: 'session_start',
|
||||
roomCode,
|
||||
role,
|
||||
name,
|
||||
host: roomInfo.host,
|
||||
appTag: roomInfo.appTag,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
audienceEnabled: roomInfo.audienceEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
// State for reconnection
|
||||
let shardId = null;
|
||||
let secret = null;
|
||||
let msgCount = 0;
|
||||
let reconnecting = false;
|
||||
let manuallyStopped = false;
|
||||
const startTime = Date.now();
|
||||
|
||||
function buildWsUrl(reconnect) {
|
||||
if (role === 'audience') {
|
||||
return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`;
|
||||
}
|
||||
const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`;
|
||||
if (reconnect && secret && shardId) {
|
||||
return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`;
|
||||
}
|
||||
return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`;
|
||||
}
|
||||
|
||||
function connect(isReconnect) {
|
||||
const url = buildWsUrl(isReconnect);
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`);
|
||||
|
||||
const ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: {
|
||||
Origin: 'https://jackbox.tv',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
handshakeTimeout: 10000,
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`);
|
||||
console.log();
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
msgCount++;
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
// Capture credentials from welcome for reconnection
|
||||
if (msg.opcode === 'client/welcome' && msg.result) {
|
||||
shardId = msg.result.id;
|
||||
secret = msg.result.secret;
|
||||
}
|
||||
|
||||
// Console output
|
||||
const color = opcodeColor(msg.opcode);
|
||||
const summary = summarize(msg);
|
||||
console.log(
|
||||
`${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}`
|
||||
);
|
||||
if (verbose) {
|
||||
console.log(JSON.stringify(msg, null, 2));
|
||||
}
|
||||
|
||||
// File output
|
||||
if (fileLogger) {
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'recv',
|
||||
msgNum: msgCount,
|
||||
pc: msg.pc,
|
||||
opcode: msg.opcode,
|
||||
raw: msg,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`);
|
||||
if (fileLogger) {
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'recv',
|
||||
msgNum: msgCount,
|
||||
parseError: true,
|
||||
raw: raw.toString().slice(0, 2000),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`);
|
||||
if (fileLogger) {
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'meta',
|
||||
type: 'disconnected',
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
}
|
||||
if (!manuallyStopped && secret != null) {
|
||||
reconnectWithBackoff(ws);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`);
|
||||
if (fileLogger) {
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'meta',
|
||||
type: 'error',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// SIGINT handler
|
||||
const onSigint = () => {
|
||||
manuallyStopped = true;
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log();
|
||||
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||
if (fileLogger) {
|
||||
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'meta',
|
||||
type: 'session_end',
|
||||
msgCount,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
fileLogger.close();
|
||||
}
|
||||
console.log();
|
||||
try {
|
||||
ws.close(1000, 'Logger stopped');
|
||||
} catch (_) {}
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
};
|
||||
|
||||
process.removeAllListeners('SIGINT');
|
||||
process.on('SIGINT', onSigint);
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function reconnectWithBackoff() {
|
||||
if (reconnecting || manuallyStopped) return;
|
||||
reconnecting = true;
|
||||
const delays = [2000, 4000, 8000];
|
||||
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`);
|
||||
await new Promise((r) => setTimeout(r, delays[i]));
|
||||
|
||||
if (manuallyStopped) {
|
||||
reconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const freshRoom = await getRoomInfo(roomCode);
|
||||
if (!freshRoom) {
|
||||
console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`);
|
||||
reconnecting = false;
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
roomInfo.host = freshRoom.host;
|
||||
connect(true);
|
||||
reconnecting = false;
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`);
|
||||
reconnecting = false;
|
||||
shutdown();
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log();
|
||||
console.log(`${C.bold}--- Session Summary ---${C.reset}`);
|
||||
console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`);
|
||||
console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`);
|
||||
if (fileLogger) {
|
||||
console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`);
|
||||
fileLogger.write({
|
||||
ts: new Date().toISOString(),
|
||||
direction: 'meta',
|
||||
type: 'session_end',
|
||||
msgCount,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
fileLogger.close();
|
||||
}
|
||||
console.log();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
connect(false);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`${C.red}Fatal: ${e.message}${C.reset}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user