From 516db572480cbc8bebe3a27c52c5790e8e219ad8 Mon Sep 17 00:00:00 2001 From: cottongin Date: Fri, 20 Mar 2026 11:09:05 -0400 Subject: [PATCH] feat: add EcastShardClient with connection, welcome parsing, and player counting Made-with: Cursor --- backend/utils/ecast-shard-client.js | 229 +++++++++++++++++++++++++++ backend/utils/jackbox-api.js | 1 + tests/api/ecast-shard-client.test.js | 173 ++++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 backend/utils/ecast-shard-client.js create mode 100644 tests/api/ecast-shard-client.test.js diff --git a/backend/utils/ecast-shard-client.js b/backend/utils/ecast-shard-client.js new file mode 100644 index 0000000..31899b7 --- /dev/null +++ b/backend/utils/ecast-shard-client.js @@ -0,0 +1,229 @@ +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 }; diff --git a/backend/utils/jackbox-api.js b/backend/utils/jackbox-api.js index e15ee25..2d7fa16 100644 --- a/backend/utils/jackbox-api.js +++ b/backend/utils/jackbox-api.js @@ -46,6 +46,7 @@ async function getRoomInfo(roomCode) { }); if (!response.ok) { + console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`); return { exists: false }; } diff --git a/tests/api/ecast-shard-client.test.js b/tests/api/ecast-shard-client.test.js new file mode 100644 index 0000000..459d858 --- /dev/null +++ b/tests/api/ecast-shard-client.test.js @@ -0,0 +1,173 @@ +const { EcastShardClient } = require('../../backend/utils/ecast-shard-client'); + +describe('EcastShardClient', () => { + describe('parsePlayersFromHere', () => { + test('counts only player roles, excludes host and shard', () => { + const here = { + '1': { id: 1, roles: { host: {} } }, + '2': { id: 2, roles: { player: { name: 'Alice' } } }, + '3': { id: 3, roles: { player: { name: 'Bob' } } }, + '5': { id: 5, roles: { shard: {} } }, + }; + const result = EcastShardClient.parsePlayersFromHere(here); + expect(result.playerCount).toBe(2); + expect(result.playerNames).toEqual(['Alice', 'Bob']); + }); + + test('returns zero for empty here or host-only', () => { + const here = { '1': { id: 1, roles: { host: {} } } }; + const result = EcastShardClient.parsePlayersFromHere(here); + expect(result.playerCount).toBe(0); + expect(result.playerNames).toEqual([]); + }); + + test('handles null or undefined here', () => { + expect(EcastShardClient.parsePlayersFromHere(null).playerCount).toBe(0); + expect(EcastShardClient.parsePlayersFromHere(undefined).playerCount).toBe(0); + }); + }); + + describe('parseRoomEntity', () => { + test('extracts lobby state from room entity val', () => { + const roomVal = { + state: 'Lobby', + lobbyState: 'CanStart', + gameCanStart: true, + gameIsStarting: false, + gameFinished: false, + }; + const result = EcastShardClient.parseRoomEntity(roomVal); + expect(result.gameState).toBe('Lobby'); + expect(result.lobbyState).toBe('CanStart'); + expect(result.gameCanStart).toBe(true); + expect(result.gameStarted).toBe(false); + expect(result.gameFinished).toBe(false); + }); + + test('detects game started from Gameplay state', () => { + const roomVal = { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: false, gameFinished: false }; + const result = EcastShardClient.parseRoomEntity(roomVal); + expect(result.gameStarted).toBe(true); + }); + + test('detects game finished', () => { + const roomVal = { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true }; + const result = EcastShardClient.parseRoomEntity(roomVal); + expect(result.gameFinished).toBe(true); + }); + }); + + describe('parsePlayerJoinFromTextDescriptions', () => { + test('extracts player name from join description', () => { + const val = { + latestDescriptions: [ + { category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Charlie joined.' } + ] + }; + const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val); + expect(result).toEqual([{ name: 'Charlie', isVIP: false }]); + }); + + test('extracts VIP join', () => { + const val = { + latestDescriptions: [ + { category: 'TEXT_DESCRIPTION_PLAYER_JOINED_VIP', text: 'Alice joined and is the VIP.' } + ] + }; + const result = EcastShardClient.parsePlayerJoinFromTextDescriptions(val); + expect(result).toEqual([{ name: 'Alice', isVIP: true }]); + }); + + test('returns empty array for no joins', () => { + const val = { latestDescriptions: [] }; + expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(val)).toEqual([]); + }); + + test('handles null/undefined val', () => { + expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(null)).toEqual([]); + expect(EcastShardClient.parsePlayerJoinFromTextDescriptions(undefined)).toEqual([]); + }); + }); + + describe('constructor', () => { + test('initializes with correct defaults', () => { + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {} + }); + expect(client.sessionId).toBe(1); + expect(client.gameId).toBe(5); + expect(client.roomCode).toBe('TEST'); + expect(client.maxPlayers).toBe(8); + expect(client.playerCount).toBe(0); + expect(client.playerNames).toEqual([]); + expect(client.gameStarted).toBe(false); + expect(client.gameFinished).toBe(false); + expect(client.ws).toBeNull(); + }); + }); + + describe('handleWelcome', () => { + test('parses welcome message and sets internal state', () => { + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {} + }); + + client.handleWelcome({ + id: 7, + secret: 'abc-123', + reconnect: false, + entities: { + room: ['object', { key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 0, from: 1 }, { locked: false }] + }, + here: { + '1': { id: 1, roles: { host: {} } }, + '2': { id: 2, roles: { player: { name: 'Alice' } } }, + '3': { id: 3, roles: { player: { name: 'Bob' } } }, + } + }); + + expect(client.shardId).toBe(7); + expect(client.secret).toBe('abc-123'); + expect(client.playerCount).toBe(2); + expect(client.playerNames).toEqual(['Alice', 'Bob']); + expect(client.gameState).toBe('Lobby'); + expect(client.lobbyState).toBe('CanStart'); + expect(client.gameStarted).toBe(false); + }); + }); + + describe('handleEntityUpdate', () => { + test('updates room state on room entity update', () => { + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {} + }); + client.gameState = 'Lobby'; + client.lobbyState = 'WaitingForMore'; + + client.handleEntityUpdate({ + key: 'room', + val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false }, + version: 5, + from: 1 + }); + + expect(client.gameState).toBe('Gameplay'); + expect(client.gameStarted).toBe(true); + }); + + test('handles bc:room key as room update', () => { + const client = new EcastShardClient({ + sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {} + }); + + client.handleEntityUpdate({ + key: 'bc:room', + val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, + version: 1, + from: 1 + }); + + expect(client.lobbyState).toBe('CanStart'); + }); + }); +});