const WebSocket = require('ws'); const { getRoomInfo } = require('./jackbox-api'); 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; this.appTag = null; this.reconnecting = false; } buildReconnectUrl() { return `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&format=json&secret=${this.secret}&id=${this.shardId}`; } handleMessage(message) { switch (message.opcode) { case 'client/welcome': this.handleWelcome(message.result); break; case 'object': this.handleEntityUpdate(message.result); break; case 'client/connected': this.handleClientConnected(message.result); 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}` ); this.onEvent('room.connected', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, appTag: this.appTag, maxPlayers: this.maxPlayers, playerCount: this.playerCount, players: [...this.playerNames], lobbyState: this.lobbyState, gameState: this.gameState, }); } handleEntityUpdate(result) { if (!result?.key) return; if (result.key === 'room' || result.key === 'bc:room') { if (result.val) { const prevLobbyState = this.lobbyState; const prevGameStarted = this.gameStarted; const prevGameFinished = this.gameFinished; const roomState = EcastShardClient.parseRoomEntity(result.val); this.lobbyState = roomState.lobbyState; this.gameState = roomState.gameState; this.gameStarted = roomState.gameStarted; this.gameFinished = roomState.gameFinished; if (this.lobbyState !== prevLobbyState && !this.gameStarted) { this.onEvent('lobby.updated', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, lobbyState: this.lobbyState, gameCanStart: roomState.gameCanStart, gameIsStarting: roomState.gameIsStarting, playerCount: this.playerCount, }); } if (this.gameStarted && !prevGameStarted) { this.onEvent('game.started', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, playerCount: this.playerCount, players: [...this.playerNames], maxPlayers: this.maxPlayers, }); } if (this.gameFinished && !prevGameFinished) { this.onEvent('game.ended', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, playerCount: this.playerCount, players: [...this.playerNames], }); } } } if (result.key === 'textDescriptions') { if (result.val) { const joins = EcastShardClient.parsePlayerJoinFromTextDescriptions(result.val); for (const join of joins) { if (!this.playerNames.includes(join.name)) { this.playerNames.push(join.name); this.playerCount = this.playerNames.length; this.onEvent('lobby.player-joined', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, playerName: join.name, playerCount: this.playerCount, players: [...this.playerNames], maxPlayers: this.maxPlayers, }); } } } } } handleClientConnected(result) { if (!result) return; if (result.roles?.player) { const name = result.roles.player.name ?? ''; if (!this.playerNames.includes(name)) { this.playerNames.push(name); this.playerCount = this.playerNames.length; this.onEvent('lobby.player-joined', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, playerName: name, playerCount: this.playerCount, players: [...this.playerNames], maxPlayers: this.maxPlayers, }); } } } handleError(result) { console.error(`[Shard Monitor] Ecast error ${result?.code}: ${result?.msg}`); if (result?.code === 2027) { 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, }); this.disconnect(); } } _openWebSocket(url) { 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})`); this.ws = null; if (!this.manuallyStopped && !this.gameFinished && this.secret != null && this.host != null) { void this.reconnectWithBackoff(); } }); welcomeTimeoutId = setTimeout(() => { welcomeTimeoutId = null; if (!this.shardId) { reject(new Error('Timeout waiting for client/welcome')); this.disconnect(); } }, 15000); }); } async connect(roomInfo, reconnectUrl) { this.disconnect(); this.shardId = null; this.secret = null; this.host = roomInfo.host; this.maxPlayers = roomInfo.maxPlayers || this.maxPlayers; this.appTag = roomInfo.appTag; const url = reconnectUrl || `wss://${this.host}/api/v2/rooms/${this.roomCode}/play?role=shard&name=GamePicker&userId=gamepicker-${this.sessionId}&format=json`; return this._openWebSocket(url); } async reconnect() { const url = this.buildReconnectUrl(); this.disconnect(); this.shardId = null; return this._openWebSocket(url); } async reconnectWithBackoff() { if (this.reconnecting || this.manuallyStopped || this.gameFinished) { return false; } this.reconnecting = true; const delays = [2000, 4000, 8000]; try { for (let i = 0; i < delays.length; i++) { await new Promise((r) => setTimeout(r, delays[i])); const roomInfo = await getRoomInfo(this.roomCode); if (!roomInfo.exists) { 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, }); return false; } try { await this.reconnect(); console.log(`[Shard Monitor] Reconnected to room ${this.roomCode} (attempt ${i + 1})`); return true; } catch (e) { console.error(`[Shard Monitor] Reconnect attempt ${i + 1} failed:`, e.message); } } this.onEvent('room.disconnected', { sessionId: this.sessionId, gameId: this.gameId, roomCode: this.roomCode, reason: 'connection_failed', finalPlayerCount: this.playerCount, }); return false; } finally { this.reconnecting = false; } } 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 };