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.appTag).toBeNull(); 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'); }); }); describe('event broadcasting', () => { let events; let client; beforeEach(() => { events = []; client = new EcastShardClient({ sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: (type, data) => events.push({ type, data }), }); }); describe('handleWelcome broadcasts room.connected', () => { test('broadcasts room.connected with initial state', () => { client.appTag = 'drawful2international'; client.handleWelcome({ id: 7, secret: 'abc', 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' } } }, } }); expect(events).toHaveLength(1); expect(events[0].type).toBe('room.connected'); expect(events[0].data.playerCount).toBe(1); expect(events[0].data.players).toEqual(['Alice']); expect(events[0].data.lobbyState).toBe('CanStart'); }); }); describe('handleEntityUpdate broadcasts events', () => { test('broadcasts lobby.updated on lobbyState change', () => { client.lobbyState = 'WaitingForMore'; client.gameState = 'Lobby'; client.handleEntityUpdate({ key: 'room', val: { state: 'Lobby', lobbyState: 'CanStart', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 2, from: 1, }); expect(events).toHaveLength(1); expect(events[0].type).toBe('lobby.updated'); expect(events[0].data.lobbyState).toBe('CanStart'); }); test('broadcasts game.started on state transition to Gameplay', () => { client.lobbyState = 'Countdown'; client.gameState = 'Lobby'; client.gameStarted = false; client.playerCount = 4; client.playerNames = ['A', 'B', 'C', 'D']; client.handleEntityUpdate({ key: 'room', val: { state: 'Gameplay', lobbyState: 'Countdown', gameCanStart: true, gameIsStarting: true, gameFinished: false }, version: 5, from: 1, }); const startEvents = events.filter(e => e.type === 'game.started'); expect(startEvents).toHaveLength(1); expect(startEvents[0].data.playerCount).toBe(4); expect(startEvents[0].data.players).toEqual(['A', 'B', 'C', 'D']); }); test('does not broadcast game.started if already started', () => { client.gameStarted = true; client.gameState = 'Gameplay'; client.handleEntityUpdate({ key: 'room', val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: false }, version: 10, from: 1, }); expect(events.filter(e => e.type === 'game.started')).toHaveLength(0); }); test('broadcasts game.ended on gameFinished transition', () => { client.gameStarted = true; client.gameState = 'Gameplay'; client.gameFinished = false; client.playerCount = 3; client.playerNames = ['X', 'Y', 'Z']; client.handleEntityUpdate({ key: 'room', val: { state: 'Gameplay', lobbyState: '', gameCanStart: true, gameIsStarting: false, gameFinished: true }, version: 20, from: 1, }); const endEvents = events.filter(e => e.type === 'game.ended'); expect(endEvents).toHaveLength(1); expect(endEvents[0].data.playerCount).toBe(3); }); test('broadcasts lobby.player-joined from textDescriptions', () => { client.playerNames = ['Alice']; client.playerCount = 1; client.handleEntityUpdate({ key: 'textDescriptions', val: { latestDescriptions: [ { category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' } ] }, version: 3, from: 1, }); expect(events).toHaveLength(1); expect(events[0].type).toBe('lobby.player-joined'); expect(events[0].data.playerName).toBe('Bob'); expect(events[0].data.playerCount).toBe(2); expect(events[0].data.players).toEqual(['Alice', 'Bob']); }); test('does not broadcast duplicate player join', () => { client.playerNames = ['Alice', 'Bob']; client.playerCount = 2; client.handleEntityUpdate({ key: 'textDescriptions', val: { latestDescriptions: [ { category: 'TEXT_DESCRIPTION_PLAYER_JOINED', text: 'Bob joined.' } ] }, version: 4, from: 1, }); expect(events).toHaveLength(0); }); }); describe('handleClientConnected', () => { test('broadcasts lobby.player-joined for new player connection', () => { client.playerNames = ['Alice']; client.playerCount = 1; client.handleClientConnected({ id: 3, roles: { player: { name: 'Charlie' } }, }); expect(events).toHaveLength(1); expect(events[0].type).toBe('lobby.player-joined'); expect(events[0].data.playerName).toBe('Charlie'); expect(events[0].data.playerCount).toBe(2); }); test('ignores non-player connections', () => { client.handleClientConnected({ id: 5, roles: { shard: {} }, }); expect(events).toHaveLength(0); }); test('ignores duplicate player connection', () => { client.playerNames = ['Alice']; client.playerCount = 1; client.handleClientConnected({ id: 2, roles: { player: { name: 'Alice' } }, }); expect(events).toHaveLength(0); }); }); }); describe('buildReconnectUrl', () => { test('uses stored secret and id', () => { const client = new EcastShardClient({ sessionId: 1, gameId: 1, roomCode: 'TEST', maxPlayers: 8, onEvent: () => {}, }); client.secret = 'abc-123'; client.shardId = 5; client.host = 'ecast-prod-use2.jackboxgames.com'; const url = client.buildReconnectUrl(); expect(url).toContain('secret=abc-123'); expect(url).toContain('id=5'); expect(url).toContain('role=shard'); expect(url).toContain('ecast-prod-use2.jackboxgames.com'); }); }); describe('handleError with code 2027', () => { test('marks game as finished and emits events on room-closed error', () => { const events = []; const client = new EcastShardClient({ sessionId: 1, gameId: 5, roomCode: 'TEST', maxPlayers: 8, onEvent: (type, data) => events.push({ type, data }), }); client.playerCount = 4; client.playerNames = ['A', 'B', 'C', 'D']; client.handleError({ code: 2027, msg: 'the room has already been closed' }); 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); }); }); });