const WebSocket = require('ws'); 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; } handleMessage(message) { switch (message.opcode) { case 'client/welcome': this.handleWelcome(message.result); break; case 'object': this.handleEntityUpdate(message.result); break; case 'client/connected': 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}` ); } handleEntityUpdate(result) { if (!result?.key) return; if (result.key === 'room' || result.key === 'bc:room') { if (result.val) { const roomState = EcastShardClient.parseRoomEntity(result.val); this.lobbyState = roomState.lobbyState; this.gameState = roomState.gameState; this.gameStarted = roomState.gameStarted; this.gameFinished = roomState.gameFinished; } } } handleError(result) { console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`); } async connect(roomInfo) { this.host = roomInfo.host; this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers; const url = `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`; 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})`); }); welcomeTimeoutId = setTimeout(() => { welcomeTimeoutId = null; if (!this.shardId) { reject(new Error('Timeout waiting for client/welcome')); this.disconnect(); } }, 15000); }); } 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 })); } } } module.exports = { EcastShardClient };