230 lines
6.5 KiB
JavaScript
230 lines
6.5 KiB
JavaScript
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 };
|