feat: add EcastShardClient with connection, welcome parsing, and player counting
Made-with: Cursor
This commit is contained in:
229
backend/utils/ecast-shard-client.js
Normal file
229
backend/utils/ecast-shard-client.js
Normal file
@@ -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 };
|
||||
@@ -46,6 +46,7 @@ async function getRoomInfo(roomCode) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
|
||||
173
tests/api/ecast-shard-client.test.js
Normal file
173
tests/api/ecast-shard-client.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user