424 lines
12 KiB
JavaScript
424 lines
12 KiB
JavaScript
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 };
|