Compare commits
18 Commits
35617268e9
...
a7bd0650eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7bd0650eb
|
||
|
|
65036a4e1b
|
||
|
|
336ba0e608
|
||
|
|
03f79422af
|
||
|
|
2503c3fc09
|
||
|
|
9c9927218a
|
||
|
|
3c1d5b2224
|
||
|
|
1c4c8bc19c | ||
|
|
de395d3a28 | ||
|
|
3f21299720
|
||
|
|
516db57248
|
||
|
|
0fc2ddbf23
|
||
|
|
7712ebeb04
|
||
|
|
002e1d70a6
|
||
|
|
e6198181f8
|
||
|
|
7b0dc5c015
|
||
|
|
af5e8cbd94
|
||
|
|
e5ba43bcbb
|
974
backend/package-lock.json
generated
974
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"puppeteer": "^24.0.0",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,8 +4,7 @@ const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { triggerWebhook } = require('../utils/webhooks');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -394,7 +393,7 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
// Automatically start room monitoring if room code was provided
|
||||
if (room_code) {
|
||||
try {
|
||||
startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||
startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||
} catch (error) {
|
||||
console.error('Error starting room monitor:', error);
|
||||
}
|
||||
@@ -617,8 +616,7 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
||||
// Stop room monitor and player count check if game is no longer playing
|
||||
if (status !== 'playing') {
|
||||
try {
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
stopMonitor(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping room monitor/player count check:', error);
|
||||
}
|
||||
@@ -637,8 +635,7 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
||||
|
||||
// Stop room monitor and player count check before deleting
|
||||
try {
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
stopMonitor(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping room monitor/player count check:', error);
|
||||
}
|
||||
@@ -863,7 +860,7 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (
|
||||
}
|
||||
|
||||
// Start room monitoring (will hand off to player count check when game starts)
|
||||
startRoomMonitor(sessionId, gameId, game.room_code, game.max_players);
|
||||
startMonitor(sessionId, gameId, game.room_code, game.max_players);
|
||||
|
||||
res.json({
|
||||
message: 'Room monitor started',
|
||||
@@ -880,8 +877,7 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Stop both room monitor and player count check
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
stopMonitor(sessionId, gameId);
|
||||
|
||||
res.json({
|
||||
message: 'Room monitor and player count check stopped',
|
||||
|
||||
@@ -4,6 +4,7 @@ const http = require('http');
|
||||
const cors = require('cors');
|
||||
const { bootstrapGames } = require('./bootstrap');
|
||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
@@ -53,6 +54,15 @@ if (require.main === module) {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await cleanupAllShards();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
module.exports = { app, server };
|
||||
|
||||
572
backend/utils/ecast-shard-client.js
Normal file
572
backend/utils/ecast-shard-client.js
Normal file
@@ -0,0 +1,572 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('./websocket-manager');
|
||||
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,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
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,
|
||||
});
|
||||
activeShards.delete(`${this.sessionId}-${this.gameId}`);
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeShards = new Map();
|
||||
|
||||
function broadcastAndPersist(sessionId, gameId) {
|
||||
return (eventType, eventData) => {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent(eventType, eventData, parseInt(sessionId, 10));
|
||||
}
|
||||
|
||||
if (['room.connected', 'lobby.player-joined', 'game.started', 'game.ended'].includes(eventType)) {
|
||||
const status = eventType === 'game.ended' ? 'completed' : 'monitoring';
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count = ?, player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
||||
).run(eventData.playerCount ?? null, status, sessionId, gameId);
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'room.disconnected') {
|
||||
const reason = eventData.reason;
|
||||
const status =
|
||||
reason === 'room_closed' ? 'completed' : reason === 'manually_stopped' ? 'stopped' : 'failed';
|
||||
try {
|
||||
const game = db
|
||||
.prepare('SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?')
|
||||
.get(sessionId, gameId);
|
||||
if (game && game.player_count_check_status !== 'completed') {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
status,
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
|
||||
if (activeShards.has(monitorKey)) {
|
||||
console.log(`[Shard Monitor] Already monitoring ${monitorKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Shard Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
|
||||
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
if (!roomInfo.exists) {
|
||||
console.log(`[Shard Monitor] Room ${roomCode} not found`);
|
||||
const onEvent = broadcastAndPersist(sessionId, gameId);
|
||||
onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
reason: 'room_not_found',
|
||||
finalPlayerCount: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const onEvent = broadcastAndPersist(sessionId, gameId);
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
'monitoring',
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Shard Monitor] DB update failed:', e.message);
|
||||
}
|
||||
|
||||
const client = new EcastShardClient({
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
maxPlayers: roomInfo.maxPlayers || maxPlayers,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
activeShards.set(monitorKey, client);
|
||||
|
||||
try {
|
||||
await client.connect(roomInfo);
|
||||
} catch (e) {
|
||||
console.error(`[Shard Monitor] Failed to connect to room ${roomCode}:`, e.message);
|
||||
activeShards.delete(monitorKey);
|
||||
onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
reason: 'connection_failed',
|
||||
finalPlayerCount: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function stopMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const client = activeShards.get(monitorKey);
|
||||
|
||||
if (client) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
activeShards.delete(monitorKey);
|
||||
|
||||
const game = db
|
||||
.prepare('SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?')
|
||||
.get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare('UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?').run(
|
||||
'stopped',
|
||||
sessionId,
|
||||
gameId
|
||||
);
|
||||
}
|
||||
|
||||
client.onEvent('room.disconnected', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode: client.roomCode,
|
||||
reason: 'manually_stopped',
|
||||
finalPlayerCount: client.playerCount,
|
||||
});
|
||||
|
||||
console.log(`[Shard Monitor] Stopped monitor for ${monitorKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAllShards() {
|
||||
for (const [, client] of activeShards) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
}
|
||||
activeShards.clear();
|
||||
console.log('[Shard Monitor] Cleaned up all active shards');
|
||||
}
|
||||
|
||||
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
|
||||
@@ -39,4 +39,37 @@ async function checkRoomStatus(roomCode) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRoomStatus };
|
||||
async function getRoomInfo(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`[Jackbox API] Room ${roomCode}: HTTP ${response.status}`);
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const body = data.body || data;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
host: body.host,
|
||||
audienceHost: body.audienceHost,
|
||||
appTag: body.appTag,
|
||||
appId: body.appId,
|
||||
code: body.code,
|
||||
locked: body.locked || false,
|
||||
full: body.full || false,
|
||||
maxPlayers: body.maxPlayers || 8,
|
||||
minPlayers: body.minPlayers || 0,
|
||||
audienceEnabled: body.audienceEnabled || false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRoomStatus, getRoomInfo };
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('./websocket-manager');
|
||||
const { checkRoomStatus } = require('./jackbox-api');
|
||||
|
||||
// Store active check jobs
|
||||
const activeChecks = new Map();
|
||||
|
||||
/**
|
||||
* Watch a game from start to finish as audience member
|
||||
* Collects analytics throughout the entire game lifecycle
|
||||
*/
|
||||
async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
||||
let browser;
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
|
||||
try {
|
||||
console.log(`[Player Count] Opening audience connection for ${checkKey} (max: ${maxPlayers})`);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
// Track all player counts we've seen
|
||||
const seenPlayerCounts = new Set();
|
||||
let bestPlayerCount = null;
|
||||
let startPlayerCount = null; // Authoritative count from 'start' action
|
||||
let gameEnded = false;
|
||||
let audienceJoined = false;
|
||||
let frameCount = 0;
|
||||
|
||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketFrameReceived', ({ response }) => {
|
||||
if (response.payloadData && !gameEnded) {
|
||||
frameCount++;
|
||||
try {
|
||||
const data = JSON.parse(response.payloadData);
|
||||
|
||||
if (process.env.DEBUG && frameCount % 10 === 0) {
|
||||
console.log(`[Frame ${frameCount}] opcode: ${data.opcode}`);
|
||||
}
|
||||
|
||||
// Check for bc:room with player count data
|
||||
let roomVal = null;
|
||||
|
||||
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
|
||||
roomVal = data.result.entities['bc:room'][1]?.val;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] Found bc:room in client/welcome`);
|
||||
}
|
||||
|
||||
// First client/welcome means Jackbox accepted our audience join
|
||||
if (!audienceJoined) {
|
||||
audienceJoined = true;
|
||||
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('audience.joined', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
|
||||
roomVal = data.result.val;
|
||||
}
|
||||
|
||||
if (roomVal) {
|
||||
// Check if game has ended
|
||||
if (roomVal.gameResults?.players) {
|
||||
const finalCount = roomVal.gameResults.players.length;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
|
||||
|
||||
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
|
||||
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
|
||||
} else if (startPlayerCount !== null) {
|
||||
console.log(`[Frame ${frameCount}] ✓ Verified: Start count matches final count (${finalCount})`);
|
||||
}
|
||||
}
|
||||
bestPlayerCount = finalCount;
|
||||
gameEnded = true;
|
||||
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract player counts from analytics (game in progress)
|
||||
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
|
||||
for (const analytic of roomVal.analytics) {
|
||||
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
|
||||
if (startPlayerCount === null) {
|
||||
startPlayerCount = analytic.value;
|
||||
bestPlayerCount = analytic.value;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
|
||||
}
|
||||
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startPlayerCount !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
|
||||
seenPlayerCounts.add(analytic.value);
|
||||
|
||||
const clampedValue = Math.min(analytic.value, maxPlayers);
|
||||
|
||||
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
|
||||
bestPlayerCount = clampedValue;
|
||||
if (process.env.DEBUG) {
|
||||
if (analytic.value > maxPlayers) {
|
||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (clamped to ${clampedValue})`);
|
||||
} else {
|
||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
|
||||
}
|
||||
}
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if room is no longer locked (game ended another way)
|
||||
if (roomVal.locked === false && bestPlayerCount !== null) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] Room unlocked, game likely ended. Final count: ${bestPlayerCount}`);
|
||||
}
|
||||
gameEnded = true;
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG && frameCount % 50 === 0) {
|
||||
console.log(`[Frame ${frameCount}] Parse error:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate and join audience
|
||||
if (process.env.DEBUG) console.log('[Audience] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('CountBot', { delay: 30 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Waiting for JOIN AUDIENCE button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
}, { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Clicking JOIN AUDIENCE...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
if (btn) btn.click();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
|
||||
|
||||
// Keep watching until game ends or we're told to stop
|
||||
const checkInterval = setInterval(async () => {
|
||||
const game = db.prepare(`
|
||||
SELECT status, player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped') {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Audience] Stopping watch - game status changed`);
|
||||
}
|
||||
clearInterval(checkInterval);
|
||||
gameEnded = true;
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameEnded) {
|
||||
clearInterval(checkInterval);
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
if (!roomStatus.exists) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Audience] Room no longer exists - game ended`);
|
||||
}
|
||||
gameEnded = true;
|
||||
clearInterval(checkInterval);
|
||||
if (bestPlayerCount !== null) {
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
} else {
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
}
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
const check = activeChecks.get(checkKey);
|
||||
if (check) {
|
||||
check.watchInterval = checkInterval;
|
||||
check.browser = browser;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Audience] Error watching game:', error.message);
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
if (bestPlayerCount !== null) {
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
} else {
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player count in database and broadcast via WebSocket
|
||||
*/
|
||||
function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count = ?, player_count_check_status = ?
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(playerCount, status, sessionId, gameId);
|
||||
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('player-count.updated', {
|
||||
sessionId,
|
||||
gameId,
|
||||
playerCount,
|
||||
status
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Updated game ${gameId}: ${playerCount} players (${status})`);
|
||||
} catch (error) {
|
||||
console.error('[Player Count] Failed to update database:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start player count checking for a game.
|
||||
* Called by room-monitor once the game is confirmed started (room locked).
|
||||
* Goes straight to joining the audience — no polling needed.
|
||||
*/
|
||||
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
|
||||
if (activeChecks.has(checkKey)) {
|
||||
console.log(`[Player Count] Already checking ${checkKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const game = db.prepare(`
|
||||
SELECT player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status === 'completed') {
|
||||
console.log(`[Player Count] Check already completed for ${checkKey}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (game && game.player_count_check_status === 'failed') {
|
||||
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Starting audience watch for game ${gameId} (room ${roomCode}, max ${maxPlayers})`);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count_check_status = 'checking'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(sessionId, gameId);
|
||||
|
||||
activeChecks.set(checkKey, {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
watchInterval: null,
|
||||
browser: null
|
||||
});
|
||||
|
||||
await watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop checking player count for a game
|
||||
*/
|
||||
async function stopPlayerCountCheck(sessionId, gameId) {
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
const check = activeChecks.get(checkKey);
|
||||
|
||||
if (check) {
|
||||
if (check.watchInterval) {
|
||||
clearInterval(check.watchInterval);
|
||||
}
|
||||
if (check.browser) {
|
||||
try {
|
||||
await check.browser.close();
|
||||
} catch (e) {
|
||||
// Ignore errors closing browser
|
||||
}
|
||||
}
|
||||
activeChecks.delete(checkKey);
|
||||
|
||||
const game = db.prepare(`
|
||||
SELECT player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count_check_status = 'stopped'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(sessionId, gameId);
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Stopped check for ${checkKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all active checks (for graceful shutdown)
|
||||
*/
|
||||
async function cleanupAllChecks() {
|
||||
for (const [, check] of activeChecks.entries()) {
|
||||
if (check.watchInterval) {
|
||||
clearInterval(check.watchInterval);
|
||||
}
|
||||
if (check.browser) {
|
||||
try {
|
||||
await check.browser.close();
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
activeChecks.clear();
|
||||
console.log('[Player Count] Cleaned up all active checks');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPlayerCountCheck,
|
||||
stopPlayerCountCheck,
|
||||
cleanupAllChecks
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('./websocket-manager');
|
||||
const { checkRoomStatus } = require('./jackbox-api');
|
||||
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
|
||||
// Active room monitors, keyed by "{sessionId}-{gameId}"
|
||||
const activeMonitors = new Map();
|
||||
|
||||
/**
|
||||
* Broadcast game.started event when room becomes locked
|
||||
*/
|
||||
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.started', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
maxPlayers
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
console.log(`[Room Monitor] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
|
||||
} catch (error) {
|
||||
console.error('[Room Monitor] Failed to broadcast game.started:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring a Jackbox room for game start (locked state).
|
||||
*
|
||||
* Polls the Jackbox REST API every 10 seconds. When the room becomes
|
||||
* locked, broadcasts a game.started WebSocket event and then hands off
|
||||
* to the player-count-checker to join as audience.
|
||||
*/
|
||||
async function startRoomMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
|
||||
if (activeMonitors.has(monitorKey)) {
|
||||
console.log(`[Room Monitor] Already monitoring ${monitorKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Room Monitor] Starting monitor for room ${roomCode} (${monitorKey})`);
|
||||
|
||||
const onGameStarted = (realMaxPlayers) => {
|
||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
// Lazy require breaks the circular dependency with player-count-checker
|
||||
const { startPlayerCountCheck } = require('./player-count-checker');
|
||||
console.log(`[Room Monitor] Room ${roomCode} locked — handing off to player count checker`);
|
||||
startPlayerCountCheck(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
};
|
||||
|
||||
const pollRoom = async () => {
|
||||
const game = db.prepare(`
|
||||
SELECT status FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game || game.status === 'skipped' || game.status === 'played') {
|
||||
console.log(`[Room Monitor] Stopping — game status changed for ${monitorKey}`);
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
|
||||
if (!roomStatus.exists) {
|
||||
console.log(`[Room Monitor] Room ${roomCode} does not exist — stopping`);
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomStatus.locked) {
|
||||
stopRoomMonitor(sessionId, gameId);
|
||||
onGameStarted(roomStatus.maxPlayers);
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomStatus.full) {
|
||||
console.log(`[Room Monitor] Room ${roomCode} is full but not locked yet — waiting`);
|
||||
} else {
|
||||
console.log(`[Room Monitor] Room ${roomCode} lobby still open — waiting`);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then every POLL_INTERVAL_MS
|
||||
activeMonitors.set(monitorKey, {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
interval: null
|
||||
});
|
||||
|
||||
await pollRoom();
|
||||
|
||||
// If the monitor was already stopped (room locked or gone on first check), bail
|
||||
if (!activeMonitors.has(monitorKey)) return;
|
||||
|
||||
const interval = setInterval(() => pollRoom(), POLL_INTERVAL_MS);
|
||||
const monitor = activeMonitors.get(monitorKey);
|
||||
if (monitor) monitor.interval = interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a room
|
||||
*/
|
||||
function stopRoomMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const monitor = activeMonitors.get(monitorKey);
|
||||
|
||||
if (monitor) {
|
||||
if (monitor.interval) clearInterval(monitor.interval);
|
||||
activeMonitors.delete(monitorKey);
|
||||
console.log(`[Room Monitor] Stopped monitor for ${monitorKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all active monitors (for graceful shutdown)
|
||||
*/
|
||||
function cleanupAllMonitors() {
|
||||
for (const [, monitor] of activeMonitors.entries()) {
|
||||
if (monitor.interval) clearInterval(monitor.interval);
|
||||
}
|
||||
activeMonitors.clear();
|
||||
console.log('[Room Monitor] Cleaned up all active monitors');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startRoomMonitor,
|
||||
stopRoomMonitor,
|
||||
cleanupAllMonitors
|
||||
};
|
||||
@@ -5,7 +5,8 @@
|
||||
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
|
||||
|
||||
- Receive notifications when sessions start, end, or when games are added
|
||||
- Track player counts as they are updated
|
||||
- Monitor Jackbox room state in real-time (lobby, player joins, game start/end)
|
||||
- Track player counts automatically via shard connection
|
||||
- Receive live vote updates (upvotes/downvotes) as viewers vote
|
||||
- Avoid polling REST endpoints for session state changes
|
||||
|
||||
@@ -129,7 +130,13 @@ Must be authenticated.
|
||||
| `session.started` | New session created (broadcast to all authenticated clients) |
|
||||
| `game.added` | Game added to a session (broadcast to subscribers) |
|
||||
| `session.ended` | Session closed (broadcast to subscribers) |
|
||||
| `player-count.updated` | Player count changed (broadcast to subscribers) |
|
||||
| `room.connected` | Shard connected to Jackbox room (broadcast to subscribers) |
|
||||
| `lobby.player-joined` | Player joined the Jackbox lobby (broadcast to subscribers) |
|
||||
| `lobby.updated` | Lobby state changed (broadcast to subscribers) |
|
||||
| `game.started` | Game transitioned to Gameplay (broadcast to subscribers) |
|
||||
| `game.ended` | Game finished (broadcast to subscribers) |
|
||||
| `room.disconnected` | Shard lost connection to Jackbox room (broadcast to subscribers) |
|
||||
| `player-count.updated` | Manual player count override (broadcast to subscribers) |
|
||||
| `vote.received` | Live vote recorded (broadcast to subscribers) |
|
||||
|
||||
---
|
||||
@@ -204,10 +211,117 @@ All server-sent events use this envelope:
|
||||
}
|
||||
```
|
||||
|
||||
### room.connected
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Shard WebSocket successfully connecting to a Jackbox room (after `POST .../start-player-check` or adding a game with a room code)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"appTag": "drawful2international",
|
||||
"maxPlayers": 8,
|
||||
"playerCount": 2,
|
||||
"players": ["Alice", "Bob"],
|
||||
"lobbyState": "CanStart",
|
||||
"gameState": "Lobby"
|
||||
}
|
||||
```
|
||||
|
||||
### lobby.player-joined
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** A new player joining the Jackbox room lobby (detected via `textDescriptions` entity updates or `client/connected` messages)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerName": "Charlie",
|
||||
"playerCount": 3,
|
||||
"players": ["Alice", "Bob", "Charlie"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
```
|
||||
|
||||
### lobby.updated
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Lobby state change in the Jackbox room (e.g., enough players to start, countdown started)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"lobbyState": "Countdown",
|
||||
"gameCanStart": true,
|
||||
"gameIsStarting": true,
|
||||
"playerCount": 4
|
||||
}
|
||||
```
|
||||
|
||||
### game.started
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Jackbox game transitioning from Lobby to Gameplay state
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
```
|
||||
|
||||
### game.ended
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Jackbox game finishing (`gameFinished: true`) or room closing
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"]
|
||||
}
|
||||
```
|
||||
|
||||
### room.disconnected
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** Shard losing connection to the Jackbox room (room closed, connection failed, manually stopped)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
{
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"reason": "room_closed",
|
||||
"finalPlayerCount": 4
|
||||
}
|
||||
```
|
||||
|
||||
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
|
||||
|
||||
### player-count.updated
|
||||
|
||||
- **Broadcast to:** Clients subscribed to the session
|
||||
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count`
|
||||
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count` (manual override only)
|
||||
|
||||
**Data:**
|
||||
```json
|
||||
@@ -398,6 +512,30 @@ ws.onmessage = (event) => {
|
||||
subscribedSessions.delete(msg.data.session.id);
|
||||
break;
|
||||
|
||||
case 'room.connected':
|
||||
console.log('Room connected:', msg.data.roomCode, '- players:', msg.data.players.join(', '));
|
||||
break;
|
||||
|
||||
case 'lobby.player-joined':
|
||||
console.log('Player joined:', msg.data.playerName, '- count:', msg.data.playerCount);
|
||||
break;
|
||||
|
||||
case 'lobby.updated':
|
||||
console.log('Lobby:', msg.data.lobbyState);
|
||||
break;
|
||||
|
||||
case 'game.started':
|
||||
console.log('Game started with', msg.data.playerCount, 'players');
|
||||
break;
|
||||
|
||||
case 'game.ended':
|
||||
console.log('Game ended with', msg.data.playerCount, 'players');
|
||||
break;
|
||||
|
||||
case 'room.disconnected':
|
||||
console.log('Room disconnected:', msg.data.reason);
|
||||
break;
|
||||
|
||||
case 'player-count.updated':
|
||||
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
||||
break;
|
||||
|
||||
1046
docs/jackbox-ecast-api.md
Normal file
1046
docs/jackbox-ecast-api.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
# Jackbox Ecast API Reverse Engineering — Design
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Goal:** Produce comprehensive documentation of the Jackbox ecast API (REST + WebSocket) through live traffic analysis.
|
||||
**Deliverable:** `docs/jackbox-ecast-api.md`
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
1. **Ecast REST API** — All discoverable endpoints at `ecast.jackboxgames.com`, including undocumented paths, multiple API versions, and different HTTP methods.
|
||||
|
||||
2. **Ecast WebSocket Protocol** — Full bidirectional message protocol between `jackbox.tv` and ecast servers: connection handshake, all opcodes/message types, entity model, and state transitions through the complete game lifecycle.
|
||||
|
||||
3. **Player & Room Management** — Join/leave detection, real-time player count, max players, lobby lock/game start signals, game completion signals, player and game stats/results.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Kosmi integration (chrome extension)
|
||||
- This application's own API
|
||||
- Game-specific mechanics (drawing, voting within Drawful 2) unless they reveal useful metadata
|
||||
|
||||
## Approach
|
||||
|
||||
**Primary: REST probing + browser console WebSocket interception (Approach A)**
|
||||
**Fallback: Puppeteer + CDP automated capture script (Approach B)**
|
||||
|
||||
### Phase 1 — REST API Probing
|
||||
|
||||
- Probe known endpoint `GET /api/v2/rooms/{code}` and document full response
|
||||
- Discover new paths: `/players`, `/audience`, `/state`, etc.
|
||||
- Try API versions `v1`, `v3`
|
||||
- Check for discovery endpoints (`/api`, `/swagger`, `/openapi`, `/health`)
|
||||
- Try different HTTP methods (POST, PUT, OPTIONS)
|
||||
- Document all response schemas, headers, status codes
|
||||
|
||||
### Phase 2 — WebSocket Interception
|
||||
|
||||
- Navigate to `jackbox.tv` in browser
|
||||
- Inject WebSocket interceptor (monkey-patch `WebSocket`) before joining room
|
||||
- Join as player — capture full connection lifecycle
|
||||
- Join as audience — capture audience-specific messages
|
||||
- If injection races with connection, reload page after patching
|
||||
|
||||
### Phase 3 — Game Lifecycle Capture
|
||||
|
||||
Walk through entire Drawful 2 lifecycle with interceptor running:
|
||||
- Lobby: join/leave messages, player list updates
|
||||
- Game start: lock signal, round info
|
||||
- Gameplay: state transitions, metadata
|
||||
- Game end: results, stats, disconnection sequence
|
||||
- Multiple players to observe multi-player messages
|
||||
|
||||
### Fallback (Approach B)
|
||||
|
||||
If browser console interception is unreliable, write a Node.js script using Puppeteer + CDP `Network.webSocketFrame*` events for automated structured capture.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
The deliverable (`docs/jackbox-ecast-api.md`) will contain:
|
||||
|
||||
1. **Overview** — What ecast is, base URLs, architecture
|
||||
2. **REST API Reference** — Each endpoint with method, URL, params, response schema, notes
|
||||
3. **WebSocket Protocol Reference** — Connection details, message format, message catalog (all opcodes), entity model
|
||||
4. **Game Lifecycle** — Sequence diagram of message flow from room creation through completion
|
||||
5. **Player & Room Management** — Answers to specific questions (join/leave detection, player count, max players, lock/start, completion, stats) with supporting evidence
|
||||
6. **Appendix: Raw Captures** — Sanitized example payloads
|
||||
345
docs/plans/2026-03-20-shard-monitor-design.md
Normal file
345
docs/plans/2026-03-20-shard-monitor-design.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Ecast Shard Monitor — Design Document
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
**Replaces:** `room-monitor.js` (REST polling for lock) + `player-count-checker.js` (Puppeteer audience join)
|
||||
|
||||
## Problem
|
||||
|
||||
The current player count approach launches a headless Chrome instance via Puppeteer, navigates to `jackbox.tv`, joins as an audience member through the UI, and sniffs WebSocket frames via CDP. This is fragile, resource-heavy, and occupies an audience slot. The room monitor is a separate module that polls the REST API until the room locks, then hands off to the Puppeteer checker. Two modules, two connection strategies, a circular dependency workaround.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace both modules with a single `EcastShardClient` that connects to the Jackbox ecast server as a **shard** via a direct Node.js WebSocket. The shard role:
|
||||
|
||||
- Gets the full `here` map (authoritative player list with names and roles)
|
||||
- Receives real-time entity updates (room state, player joins, game end)
|
||||
- Can query entities via `object/get`
|
||||
- Does NOT count toward `maxPlayers` or trigger `full: true`
|
||||
- Does NOT require a browser
|
||||
|
||||
One REST call upfront validates the room and retrieves the `host` field needed for the WebSocket URL. After that, the shard connection handles everything.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```
|
||||
Room code registered
|
||||
│
|
||||
▼
|
||||
REST: GET /rooms/{code} ──── 404 ──→ Mark failed, stop
|
||||
│
|
||||
│ (get host, maxPlayers, locked, appTag)
|
||||
▼
|
||||
WSS: Connect as shard
|
||||
wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json
|
||||
│
|
||||
▼
|
||||
client/welcome received
|
||||
├── Parse `here` → initial player count (filter for `player` roles)
|
||||
├── Parse `entities.room` → lobby state, gameCanStart, etc.
|
||||
├── Store `secret` + `id` for reconnection
|
||||
└── Broadcast initial state to our clients
|
||||
│
|
||||
▼
|
||||
┌─── Event loop (listening for server messages) ───┐
|
||||
│ │
|
||||
│ `object` (key: textDescriptions) │
|
||||
│ → Parse latestDescriptions for player joins │
|
||||
│ → Broadcast `lobby.player-joined` to clients │
|
||||
│ │
|
||||
│ `object` (key: room) │
|
||||
│ → Detect state transitions: │
|
||||
│ lobbyState changes → broadcast lobby updates │
|
||||
│ state: "Gameplay" → broadcast `game.started` │
|
||||
│ gameFinished: true → broadcast `game.ended` │
|
||||
│ gameResults → extract final player count │
|
||||
│ │
|
||||
│ `client/connected` (if delivered to shards) │
|
||||
│ → Update here map, recount players │
|
||||
│ │
|
||||
│ WebSocket close/error │
|
||||
│ → REST check: room exists? │
|
||||
│ Yes → reconnect with secret/id │
|
||||
│ No → game ended, finalize │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Internal State
|
||||
|
||||
| Field | Type | Source |
|
||||
|-------|------|--------|
|
||||
| `playerCount` | number | `here` map filtered for `player` roles |
|
||||
| `playerNames` | string[] | `here` map player role `name` fields |
|
||||
| `lobbyState` | string | `room` entity `lobbyState` |
|
||||
| `gameState` | string | `room` entity `state` (`"Lobby"`, `"Gameplay"`) |
|
||||
| `gameStarted` | boolean | Derived from `state === "Gameplay"` |
|
||||
| `gameFinished` | boolean | `room` entity `gameFinished` |
|
||||
| `maxPlayers` | number | REST response + `room` entity |
|
||||
| `secret` / `id` | string/number | `client/welcome` for reconnection |
|
||||
|
||||
### Player Counting
|
||||
|
||||
The `here` map from `client/welcome` is the authoritative source. It lists all registered connections with their roles. Count entries where `roles` contains `player`. The shard itself is excluded (it has `roles: {shard: {}}`). The host (ID 1, `roles: {host: {}}`) is also excluded. Since Jackbox holds slots for disconnected players, `here` always reflects the true occupied slot count.
|
||||
|
||||
For subsequent joins after connect, `textDescriptions` entity updates provide join notifications. Since shards have `here` visibility, `client/connected` messages may also be delivered — both paths are handled, with `here` as source of truth.
|
||||
|
||||
## WebSocket Events (Game Picker → Connected Clients)
|
||||
|
||||
### `room.connected`
|
||||
|
||||
Shard successfully connected to the Jackbox room. Sent once on initial connect. Replaces the old `audience.joined` event.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "room.connected",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"appTag": "drawful2international",
|
||||
"maxPlayers": 8,
|
||||
"playerCount": 2,
|
||||
"players": ["Alice", "Bob"],
|
||||
"lobbyState": "CanStart",
|
||||
"gameState": "Lobby"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `lobby.player-joined`
|
||||
|
||||
A new player joined the lobby.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lobby.player-joined",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerName": "Charlie",
|
||||
"playerCount": 3,
|
||||
"players": ["Alice", "Bob", "Charlie"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `lobby.updated`
|
||||
|
||||
Lobby state changed (enough players to start, countdown started, etc.).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "lobby.updated",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"lobbyState": "Countdown",
|
||||
"gameCanStart": true,
|
||||
"gameIsStarting": true,
|
||||
"playerCount": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `game.started`
|
||||
|
||||
The game transitioned from Lobby to Gameplay.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "game.started",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"],
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `game.ended`
|
||||
|
||||
The game finished.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "game.ended",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"playerCount": 4,
|
||||
"players": ["Alice", "Bob", "Charlie", "Diana"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `room.disconnected`
|
||||
|
||||
Shard lost connection to the Jackbox room.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "room.disconnected",
|
||||
"timestamp": "...",
|
||||
"data": {
|
||||
"sessionId": 1,
|
||||
"gameId": 5,
|
||||
"roomCode": "LSBN",
|
||||
"reason": "room_closed",
|
||||
"finalPlayerCount": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Possible `reason` values: `room_closed`, `room_not_found`, `connection_failed`, `role_rejected`, `manually_stopped`.
|
||||
|
||||
### Dropped Events
|
||||
|
||||
| Old event | Replacement |
|
||||
|-----------|-------------|
|
||||
| `audience.joined` | `room.connected` (richer payload) |
|
||||
| `player-count.updated` (automated) | `lobby.player-joined`, `game.started`, `game.ended` carry `playerCount` |
|
||||
|
||||
The manual `PATCH .../player-count` endpoint keeps broadcasting `player-count.updated` for its specific use case.
|
||||
|
||||
### DB Persistence
|
||||
|
||||
The `session_games` table columns `player_count` and `player_count_check_status` continue to be updated:
|
||||
|
||||
- `player_count` — updated on each join and at game end
|
||||
- `player_count_check_status` — `'monitoring'` (shard connected), `'completed'` (game ended with count), `'failed'` (couldn't connect), `'stopped'` (manual stop)
|
||||
|
||||
The old `'checking'` status becomes `'monitoring'`.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Files Deleted
|
||||
|
||||
- `backend/utils/player-count-checker.js` — Puppeteer audience approach
|
||||
- `backend/utils/room-monitor.js` — REST polling for lock state
|
||||
|
||||
### Files Created
|
||||
|
||||
- `backend/utils/ecast-shard-client.js` — `EcastShardClient` class + module exports: `startMonitor`, `stopMonitor`, `cleanupAllShards`
|
||||
|
||||
### Files Modified
|
||||
|
||||
**`backend/utils/jackbox-api.js`** — Add `getRoomInfo(roomCode)` returning the full room response including `host`, `appTag`, `audienceEnabled`.
|
||||
|
||||
**`backend/routes/sessions.js`** — Replace imports:
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
|
||||
// New
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
```
|
||||
|
||||
All call sites change from two-function calls to one:
|
||||
|
||||
| Route | Old | New |
|
||||
|-------|-----|-----|
|
||||
| `POST /:id/games` (with room_code) | `startRoomMonitor(...)` | `startMonitor(...)` |
|
||||
| `PATCH .../status` (away from playing) | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
| `DELETE .../games/:gameId` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
| `POST .../start-player-check` | `startRoomMonitor(...)` | `startMonitor(...)` |
|
||||
| `POST .../stop-player-check` | `stopRoomMonitor(...) + stopPlayerCountCheck(...)` | `stopMonitor(...)` |
|
||||
|
||||
Endpoint paths stay the same for backwards compatibility.
|
||||
|
||||
**`backend/server.js`** — Wire `cleanupAllShards()` into `SIGTERM`/`SIGINT` handlers.
|
||||
|
||||
## Error Handling and Reconnection
|
||||
|
||||
### Connection Failures
|
||||
|
||||
1. **REST validation fails** (room not found, network error): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'room_not_found'` or `'connection_failed'`. No automatic retry.
|
||||
|
||||
2. **Shard WebSocket fails to connect**: Retry up to 3 times with exponential backoff (2s, 4s, 8s). On exhaustion, set status `'failed'`, broadcast `room.disconnected` with `reason: 'connection_failed'`.
|
||||
|
||||
3. **Ecast rejects the shard role** (error opcode received): Set status `'failed'`, broadcast `room.disconnected` with `reason: 'role_rejected'`. No retry.
|
||||
|
||||
### Mid-Session Disconnections
|
||||
|
||||
4. **WebSocket closes unexpectedly**: REST check `GET /rooms/{code}`:
|
||||
- Room exists → reconnect with stored `secret`/`id` (up to 3 attempts, exponential backoff). Transparent to clients on success.
|
||||
- Room gone → finalize with last known count, status `'completed'`, broadcast `game.ended` + `room.disconnected`.
|
||||
|
||||
5. **Ecast error 2027 "room already closed"**: Same as room-gone path.
|
||||
|
||||
### Manual Stop
|
||||
|
||||
6. **`stop-player-check` called or game status changes**: Close WebSocket gracefully, set status `'stopped'` (unless already `'completed'`), broadcast `room.disconnected` with `reason: 'manually_stopped'`.
|
||||
|
||||
### Server Shutdown
|
||||
|
||||
7. **`SIGTERM`/`SIGINT`**: `cleanupAllShards()` closes all WebSocket connections. No DB updates on shutdown.
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
startMonitor()
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
┌────────│ not_started│
|
||||
│ └───────────┘
|
||||
│ │
|
||||
REST fails REST succeeds
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────┐ ┌────────────┐
|
||||
│ failed │ │ monitoring │◄──── reconnect success
|
||||
└────────┘ └─────┬──────┘
|
||||
▲ │
|
||||
│ ┌────┴─────┬──────────────┐
|
||||
reconnect │ │ │
|
||||
exhausted game ends WS drops manual stop
|
||||
│ │ │ │
|
||||
│ ▼ ▼ ▼
|
||||
│ ┌──────────┐ REST check ┌─────────┐
|
||||
│ │ completed │ │ │ stopped │
|
||||
│ └──────────┘ │ └─────────┘
|
||||
│ │
|
||||
└──── room gone? ────┘
|
||||
│
|
||||
room exists?
|
||||
│
|
||||
reconnect...
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
| Concern | Value | Rationale |
|
||||
|---------|-------|-----------|
|
||||
| WebSocket connect timeout | 10s | Ecast servers respond fast |
|
||||
| Reconnect backoff | 2s, 4s, 8s | Three attempts, ~14s total |
|
||||
| Max reconnect attempts | 3 | Fail fast, user can retry manually |
|
||||
| WebSocket inactivity timeout | None | Shard connections receive periodic `shard/sync` CRDT messages |
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Added:** `ws` (Node.js WebSocket library) — already a dependency (used by `websocket-manager.js`).
|
||||
|
||||
**Removed:** `puppeteer` — no longer needed for room monitoring.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Renaming REST endpoint paths (`start-player-check` / `stop-player-check`) — kept for backwards compatibility
|
||||
- Auto-starting monitoring when room code is set via `PATCH .../room-code` — kept as manual trigger only
|
||||
- Frontend `Picker.jsx` changes — tracked separately (existing bugs: `message.event` vs `message.type`, subscribe without auth, `'waiting'` status that's never set)
|
||||
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
722
docs/plans/2026-03-20-shard-monitor-implementation.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Ecast Shard Monitor Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the Puppeteer-based audience join and REST-polling room monitor with a single WebSocket shard client that monitors Jackbox rooms in real-time.
|
||||
|
||||
**Architecture:** A new `EcastShardClient` class connects as a shard to the Jackbox ecast server via the `ws` library. One REST call validates the room and gets the `host` field. The shard connection then handles lobby monitoring, player counting, game start/end detection, and reconnection. The module exports `startMonitor`, `stopMonitor`, and `cleanupAllShards` as drop-in replacements for the old two-module API.
|
||||
|
||||
**Tech Stack:** Node.js, `ws` library (already installed), ecast WebSocket protocol (`ecast-v0`), Jest for tests.
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-20-shard-monitor-design.md`
|
||||
|
||||
**Ecast API reference:** `docs/jackbox-ecast-api.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend `jackbox-api.js` with `getRoomInfo`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/jackbox-api.js`
|
||||
- Test: `tests/api/jackbox-api.test.js` (create)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create `tests/api/jackbox-api.test.js`:
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo } = require('../../backend/utils/jackbox-api');
|
||||
|
||||
describe('getRoomInfo', () => {
|
||||
test('is exported as a function', () => {
|
||||
expect(typeof getRoomInfo).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `getRoomInfo` is not exported.
|
||||
|
||||
**Step 3: Implement `getRoomInfo`**
|
||||
|
||||
In `backend/utils/jackbox-api.js`, add a new function that calls `GET /api/v2/rooms/{code}` and returns the full room body including `host`, `appTag`, `audienceEnabled`, `maxPlayers`, `locked`, `full`. On failure, return `{ exists: false }`.
|
||||
|
||||
The existing `checkRoomStatus` stays for now (other code may still reference it during migration).
|
||||
|
||||
```javascript
|
||||
async function getRoomInfo(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`${JACKBOX_API_BASE}/rooms/${roomCode}`, {
|
||||
headers: DEFAULT_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const body = data.body || data;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
host: body.host,
|
||||
audienceHost: body.audienceHost,
|
||||
appTag: body.appTag,
|
||||
appId: body.appId,
|
||||
code: body.code,
|
||||
locked: body.locked || false,
|
||||
full: body.full || false,
|
||||
maxPlayers: body.maxPlayers || 8,
|
||||
minPlayers: body.minPlayers || 0,
|
||||
audienceEnabled: body.audienceEnabled || false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[Jackbox API] Error getting room info for ${roomCode}:`, e.message);
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Export it alongside `checkRoomStatus`:
|
||||
|
||||
```javascript
|
||||
module.exports = { checkRoomStatus, getRoomInfo };
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/jackbox-api.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/jackbox-api.js tests/api/jackbox-api.test.js
|
||||
git commit -m "feat: add getRoomInfo to jackbox-api for full room data including host"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `EcastShardClient` — connection and welcome handling
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/utils/ecast-shard-client.js`
|
||||
- Test: `tests/api/ecast-shard-client.test.js` (create)
|
||||
|
||||
This task builds the core class with: constructor, `connect()`, `client/welcome` parsing, `here` map player counting, and the `disconnect()` method. No event broadcasting yet — that's Task 3.
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Create `tests/api/ecast-shard-client.test.js`. Since we can't connect to real Jackbox servers in tests, test the pure logic: `here` map parsing, player counting, entity parsing. Export these as static/utility methods on the class for testability.
|
||||
|
||||
```javascript
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — module does not exist.
|
||||
|
||||
**Step 3: Implement EcastShardClient**
|
||||
|
||||
Create `backend/utils/ecast-shard-client.js` with:
|
||||
|
||||
1. **Static utility methods** (`parsePlayersFromHere`, `parseRoomEntity`, `parsePlayerJoinFromTextDescriptions`) — pure functions, tested above.
|
||||
2. **Constructor** — takes `{ sessionId, gameId, roomCode, maxPlayers }`, initializes internal state.
|
||||
3. **`connect(roomInfo)`** — accepts the result of `getRoomInfo()`. Opens a WebSocket to `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&userId=gamepicker-{sessionId}&format=json` with `Sec-WebSocket-Protocol: ecast-v0` and `Origin: https://jackbox.tv`.
|
||||
4. **`handleMessage(data)`** — dispatcher that routes `client/welcome`, `object`, `error`, `client/connected`, `client/disconnected` to handler methods.
|
||||
5. **`handleWelcome(result)`** — parses `here`, `entities.room`, stores `secret`/`id`.
|
||||
6. **`disconnect()`** — closes the WebSocket gracefully.
|
||||
7. **Internal state:** `playerCount`, `playerNames`, `lobbyState`, `gameState`, `gameStarted`, `gameFinished`, `maxPlayers`, `secret`, `id`, `ws`.
|
||||
|
||||
Do NOT add broadcasting or reconnection yet — those are Tasks 3 and 4.
|
||||
|
||||
Key implementation details for the WebSocket connection:
|
||||
|
||||
```javascript
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// In connect():
|
||||
this.ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { 'Origin': 'https://jackbox.tv' },
|
||||
handshakeTimeout: 10000,
|
||||
});
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS (all 8 tests)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add EcastShardClient with connection, welcome parsing, and player counting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add event broadcasting and entity update handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
This task wires up the WebSocket message handlers to broadcast events via `WebSocketManager` and update the `session_games` DB row.
|
||||
|
||||
**Step 1: Write failing tests for entity update handlers**
|
||||
|
||||
Add tests to `tests/api/ecast-shard-client.test.js`:
|
||||
|
||||
```javascript
|
||||
describe('handleRoomUpdate', () => {
|
||||
test('detects game start transition', () => {
|
||||
// Create client instance, set initial state to Lobby
|
||||
// Call handleRoomUpdate with Gameplay state
|
||||
// Verify gameStarted flipped and handler would broadcast
|
||||
});
|
||||
|
||||
test('detects game end transition', () => {
|
||||
// Create client, set gameStarted = true
|
||||
// Call handleRoomUpdate with gameFinished: true
|
||||
// Verify gameFinished flipped
|
||||
});
|
||||
|
||||
test('detects lobby state change', () => {
|
||||
// Create client, set lobbyState to WaitingForMore
|
||||
// Call handleRoomUpdate with CanStart
|
||||
// Verify lobbyState updated
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Since broadcasting and DB writes involve external dependencies, use a test approach where the client accepts a `broadcaster` callback in its constructor options. The callback receives `(eventType, data)`. This makes the class testable without mocking the WebSocketManager singleton.
|
||||
|
||||
Constructor signature becomes:
|
||||
|
||||
```javascript
|
||||
constructor({ sessionId, gameId, roomCode, maxPlayers, onEvent })
|
||||
```
|
||||
|
||||
Where `onEvent` is `(eventType, eventData) => void`. The module-level `startMonitor` function provides a default `onEvent` that calls `wsManager.broadcastEvent(...)` and writes to the DB.
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL on new tests.
|
||||
|
||||
**Step 3: Implement entity update handlers**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleRoomUpdate(roomVal)`** — called when an `object` message arrives with `key: "room"` (or `key: "bc:room"` for some games). Compares new state against stored state. Broadcasts:
|
||||
- `lobby.updated` if `lobbyState` changed
|
||||
- `game.started` if `state` changed to `"Gameplay"` and `gameStarted` was false
|
||||
- `game.ended` if `gameFinished` changed to true
|
||||
- Updates `playerCount` in DB via `updatePlayerCount()` on game start and end.
|
||||
|
||||
- **`handleTextDescriptionsUpdate(val)`** — called when `object` with `key: "textDescriptions"` arrives. Uses `parsePlayerJoinFromTextDescriptions` to detect joins. Broadcasts `lobby.player-joined` for each new join. Updates internal `playerNames` list.
|
||||
|
||||
- **`handleClientConnected(result)`** — if shards receive `client/connected`, update internal `here` tracking and recount players. Broadcast `lobby.player-joined` if the new connection is a player.
|
||||
|
||||
- **`updatePlayerCount(count, status)`** — writes to `session_games` and calls `this.onEvent('player-count.updated', ...)` for DB-triggered updates.
|
||||
|
||||
Add the module-level `startMonitor` function:
|
||||
|
||||
```javascript
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
if (activeShards.has(monitorKey)) return;
|
||||
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
if (!roomInfo.exists) {
|
||||
// set failed status in DB, broadcast room.disconnected
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EcastShardClient({
|
||||
sessionId, gameId, roomCode,
|
||||
maxPlayers: roomInfo.maxPlayers || maxPlayers,
|
||||
onEvent: (type, data) => {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) wsManager.broadcastEvent(type, data, parseInt(sessionId));
|
||||
}
|
||||
});
|
||||
|
||||
activeShards.set(monitorKey, client);
|
||||
await client.connect(roomInfo);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add event broadcasting and entity update handlers to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add reconnection logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```javascript
|
||||
describe('reconnection state machine', () => {
|
||||
test('buildReconnectUrl 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');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL — `buildReconnectUrl` doesn't exist.
|
||||
|
||||
**Step 3: Implement reconnection**
|
||||
|
||||
Add to `EcastShardClient`:
|
||||
|
||||
- **`handleClose(code, reason)`** — called on WebSocket `close` event. If `gameFinished` or `manuallyStopped`, do nothing. Otherwise, call `attemptReconnect()`.
|
||||
- **`attemptReconnect()`** — calls `getRoomInfo(roomCode)`. If room gone, finalize. If room exists, try `reconnectWithBackoff()`.
|
||||
- **`reconnectWithBackoff()`** — attempts up to 3 reconnections with 2s/4s/8s delays. Uses `buildReconnectUrl()` with stored `secret`/`id`. On success, resumes message handling transparently. On failure, set status `'failed'`, broadcast `room.disconnected`.
|
||||
- **`buildReconnectUrl()`** — constructs `wss://{host}/api/v2/rooms/{code}/play?role=shard&name=GamePicker&format=json&secret={secret}&id={id}`.
|
||||
- **`handleError(err)`** — logs the error, defers to `handleClose` for reconnection decisions.
|
||||
|
||||
Also handle ecast error opcode 2027 ("room already closed") in `handleMessage` — treat as game-ended.
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add reconnection logic with exponential backoff to shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add module exports (`startMonitor`, `stopMonitor`, `cleanupAllShards`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/utils/ecast-shard-client.js`
|
||||
- Modify: `tests/api/ecast-shard-client.test.js`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```javascript
|
||||
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
describe('module exports', () => {
|
||||
test('startMonitor is exported', () => {
|
||||
expect(typeof startMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('stopMonitor is exported', () => {
|
||||
expect(typeof stopMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('cleanupAllShards is exported', () => {
|
||||
expect(typeof cleanupAllShards).toBe('function');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: FAIL if not yet exported.
|
||||
|
||||
**Step 3: Finalize module exports**
|
||||
|
||||
Ensure these are all exported from `backend/utils/ecast-shard-client.js`:
|
||||
|
||||
```javascript
|
||||
const activeShards = new Map();
|
||||
|
||||
async function startMonitor(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
// ... (implemented in Task 3)
|
||||
}
|
||||
|
||||
async function stopMonitor(sessionId, gameId) {
|
||||
const monitorKey = `${sessionId}-${gameId}`;
|
||||
const client = activeShards.get(monitorKey);
|
||||
if (client) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
activeShards.delete(monitorKey);
|
||||
|
||||
// Update DB status unless already completed
|
||||
const game = db.prepare(
|
||||
'SELECT player_count_check_status FROM session_games WHERE session_id = ? AND id = ?'
|
||||
).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare(
|
||||
'UPDATE session_games SET player_count_check_status = ? WHERE session_id = ? AND id = ?'
|
||||
).run('stopped', sessionId, gameId);
|
||||
}
|
||||
|
||||
client.onEvent('room.disconnected', {
|
||||
sessionId, gameId, roomCode: client.roomCode,
|
||||
reason: 'manually_stopped',
|
||||
finalPlayerCount: client.playerCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAllShards() {
|
||||
for (const [, client] of activeShards) {
|
||||
client.manuallyStopped = true;
|
||||
client.disconnect();
|
||||
}
|
||||
activeShards.clear();
|
||||
console.log('[Shard Monitor] Cleaned up all active shards');
|
||||
}
|
||||
|
||||
module.exports = { EcastShardClient, startMonitor, stopMonitor, cleanupAllShards };
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/ecast-shard-client.test.js --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/utils/ecast-shard-client.js tests/api/ecast-shard-client.test.js
|
||||
git commit -m "feat: add startMonitor, stopMonitor, cleanupAllShards module exports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Rewire `sessions.js` routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/routes/sessions.js` (lines 7–8 imports, lines 394–401, 617–624, 638–644, 844–875, 877–893)
|
||||
- Test: `tests/api/regression-sessions.test.js` (verify existing tests still pass)
|
||||
|
||||
**Step 1: Run existing session tests as baseline**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (capture baseline)
|
||||
|
||||
**Step 2: Replace imports**
|
||||
|
||||
In `backend/routes/sessions.js`, replace lines 7–8:
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||
|
||||
// New
|
||||
const { startMonitor, stopMonitor } = require('../utils/ecast-shard-client');
|
||||
```
|
||||
|
||||
**Step 3: Replace call sites**
|
||||
|
||||
Search and replace across the file. There are 5 call sites:
|
||||
|
||||
1. **Line ~397** (`POST /:id/games`): `startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)` → `startMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players)`
|
||||
|
||||
2. **Lines ~620–621** (`PATCH .../status`): Replace both `stopRoomMonitor(sessionId, gameId)` and `stopPlayerCountCheck(sessionId, gameId)` with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
3. **Lines ~640–641** (`DELETE .../games/:gameId`): Same — replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
4. **Line ~866** (`POST .../start-player-check`): `startRoomMonitor(...)` → `startMonitor(...)`
|
||||
|
||||
5. **Lines ~883–884** (`POST .../stop-player-check`): Replace two stop calls with single `stopMonitor(sessionId, gameId)`
|
||||
|
||||
**Step 4: Run tests to verify nothing broke**
|
||||
|
||||
Run: `cd backend && npx jest ../tests/api/regression-sessions.test.js --verbose --forceExit`
|
||||
Expected: PASS (same as baseline — these tests don't exercise actual Jackbox connections)
|
||||
|
||||
Also run the full test suite:
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/routes/sessions.js
|
||||
git commit -m "refactor: rewire sessions routes to use ecast shard client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire graceful shutdown in `server.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/server.js`
|
||||
|
||||
**Step 1: Add shutdown handler**
|
||||
|
||||
In `backend/server.js`, import `cleanupAllShards` and add signal handlers inside the `if (require.main === module)` block:
|
||||
|
||||
```javascript
|
||||
const { cleanupAllShards } = require('./utils/ecast-shard-client');
|
||||
|
||||
// Inside the if (require.main === module) block, after server.listen:
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down gracefully...');
|
||||
await cleanupAllShards();
|
||||
server.close(() => process.exit(0));
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
```
|
||||
|
||||
**Step 2: Verify server still starts**
|
||||
|
||||
Run: `cd backend && timeout 5 node server.js || true`
|
||||
Expected: Server starts, prints port message, exits on timeout.
|
||||
|
||||
**Step 3: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/server.js
|
||||
git commit -m "feat: wire graceful shutdown for shard connections on SIGTERM/SIGINT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Delete old files and remove Puppeteer dependency
|
||||
|
||||
**Files:**
|
||||
- Delete: `backend/utils/player-count-checker.js`
|
||||
- Delete: `backend/utils/room-monitor.js`
|
||||
- Modify: `backend/package.json` (remove `puppeteer` from dependencies)
|
||||
|
||||
**Step 1: Verify no remaining imports of old modules**
|
||||
|
||||
Search the codebase for any remaining `require('./player-count-checker')`, `require('./room-monitor')`, `require('../utils/player-count-checker')`, `require('../utils/room-monitor')`. After Task 6, `sessions.js` should be the only file that imported them and it now imports from `ecast-shard-client`. The old `room-monitor.js` had a lazy require of `player-count-checker` which is going away with it.
|
||||
|
||||
If any other files reference these modules, update them first.
|
||||
|
||||
**Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
rm backend/utils/player-count-checker.js backend/utils/room-monitor.js
|
||||
```
|
||||
|
||||
**Step 3: Remove Puppeteer dependency**
|
||||
|
||||
```bash
|
||||
cd backend && npm uninstall puppeteer
|
||||
```
|
||||
|
||||
**Step 4: Run full test suite**
|
||||
|
||||
Run: `cd backend && npx jest --config ../jest.config.js --runInBand --verbose --forceExit`
|
||||
Expected: PASS — no test should depend on the deleted files.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: remove Puppeteer and old room-monitor/player-count-checker modules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Update WebSocket documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/api/websocket.md`
|
||||
|
||||
**Step 1: Read current websocket.md**
|
||||
|
||||
Read `docs/api/websocket.md` and identify the server-to-client event table.
|
||||
|
||||
**Step 2: Update the event table**
|
||||
|
||||
Replace the old events with the new contract:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `room.connected` | Shard connected to Jackbox room (replaces `audience.joined`) |
|
||||
| `lobby.player-joined` | A player joined the lobby |
|
||||
| `lobby.updated` | Lobby state changed |
|
||||
| `game.started` | Game transitioned to Gameplay |
|
||||
| `game.ended` | Game finished |
|
||||
| `room.disconnected` | Shard lost connection to room |
|
||||
| `game.added` | New game added to session (unchanged) |
|
||||
| `session.started` | Session created (unchanged) |
|
||||
| `session.ended` | Session closed (unchanged) |
|
||||
| `vote.received` | Vote recorded (unchanged) |
|
||||
| `player-count.updated` | Manual player count override (unchanged) |
|
||||
|
||||
Add payload examples for each new event (from design doc).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/api/websocket.md
|
||||
git commit -m "docs: update websocket event reference with new shard monitor events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Smoke test with a real Jackbox room (manual)
|
||||
|
||||
This task is manual verification — not automated.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Start the backend: `cd backend && npm run dev`
|
||||
2. Create a session via API, add a game with a room code from an active Jackbox game
|
||||
3. Watch backend logs for `[Shard Monitor]` messages:
|
||||
- REST room info fetched
|
||||
- WebSocket connected as shard
|
||||
- `client/welcome` parsed, player count reported
|
||||
- Player join detected when someone joins the lobby
|
||||
- Game start detected when the game begins
|
||||
- Game end detected when the game finishes
|
||||
4. Connect a WebSocket client to `/api/sessions/live`, authenticate, subscribe to the session, and verify events arrive:
|
||||
- `room.connected`
|
||||
- `lobby.player-joined`
|
||||
- `game.started`
|
||||
- `game.ended`
|
||||
- `room.disconnected`
|
||||
5. Test `stop-player-check` endpoint — verify shard disconnects cleanly
|
||||
6. Test reconnection — kill and restart the backend mid-game, call `start-player-check` again
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
|------|-------------|-------|
|
||||
| 1 | `getRoomInfo` in jackbox-api | `jackbox-api.js`, test |
|
||||
| 2 | `EcastShardClient` core + parsing | `ecast-shard-client.js`, test |
|
||||
| 3 | Event broadcasting + entity handlers | `ecast-shard-client.js`, test |
|
||||
| 4 | Reconnection logic | `ecast-shard-client.js`, test |
|
||||
| 5 | Module exports | `ecast-shard-client.js`, test |
|
||||
| 6 | Rewire sessions routes | `sessions.js` |
|
||||
| 7 | Graceful shutdown | `server.js` |
|
||||
| 8 | Delete old files + remove Puppeteer | `player-count-checker.js`, `room-monitor.js`, `package.json` |
|
||||
| 9 | Update docs | `websocket.md` |
|
||||
| 10 | Manual smoke test | — |
|
||||
@@ -8,7 +8,7 @@ import { formatLocalTime } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
|
||||
function Picker() {
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
const { isAuthenticated, loading: authLoading, token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
@@ -977,8 +977,10 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
return () => clearInterval(interval);
|
||||
}, [loadGames]);
|
||||
|
||||
// Setup WebSocket connection for real-time player count updates
|
||||
// Setup WebSocket connection for real-time session updates
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
|
||||
|
||||
@@ -986,22 +988,33 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WebSocket] Connected for player count updates');
|
||||
// Subscribe to session events
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
sessionId: parseInt(sessionId)
|
||||
}));
|
||||
console.log('[WebSocket] Connected, authenticating...');
|
||||
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
// Handle player count updates
|
||||
if (message.event === 'player-count.updated') {
|
||||
console.log('[WebSocket] Player count updated:', message.data);
|
||||
// Reload games to get updated player counts
|
||||
if (message.type === 'auth_success') {
|
||||
console.log('[WebSocket] Authenticated, subscribing to session', sessionId);
|
||||
ws.send(JSON.stringify({ type: 'subscribe', sessionId: parseInt(sessionId) }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reloadEvents = [
|
||||
'room.connected',
|
||||
'lobby.player-joined',
|
||||
'lobby.updated',
|
||||
'game.started',
|
||||
'game.ended',
|
||||
'room.disconnected',
|
||||
'player-count.updated',
|
||||
'game.added',
|
||||
];
|
||||
|
||||
if (reloadEvents.includes(message.type)) {
|
||||
console.log(`[WebSocket] ${message.type}:`, message.data);
|
||||
loadGames();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1027,7 +1040,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to connect:', error);
|
||||
}
|
||||
}, [sessionId, loadGames]);
|
||||
}, [sessionId, token, loadGames]);
|
||||
|
||||
const handleUpdateStatus = async (gameId, newStatus) => {
|
||||
try {
|
||||
@@ -1303,14 +1316,14 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
{/* Player Count Display */}
|
||||
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
|
||||
<div className="flex items-center gap-1">
|
||||
{game.player_count_check_status === 'waiting' && (
|
||||
{game.player_count_check_status === 'monitoring' && !game.player_count && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||
⏳ Waiting...
|
||||
📡 Monitoring...
|
||||
</span>
|
||||
)}
|
||||
{game.player_count_check_status === 'checking' && (
|
||||
{(game.player_count_check_status === 'checking' || (game.player_count_check_status === 'monitoring' && game.player_count)) && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
|
||||
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
|
||||
📡 {game.player_count ? `${game.player_count} players` : 'Monitoring...'}
|
||||
</span>
|
||||
)}
|
||||
{game.player_count_check_status === 'completed' && game.player_count && (
|
||||
@@ -1406,7 +1419,7 @@ function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame })
|
||||
</>
|
||||
)}
|
||||
{/* Stop button for active checks */}
|
||||
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
|
||||
{isAuthenticated && (game.player_count_check_status === 'monitoring' || game.player_count_check_status === 'checking') && (
|
||||
<button
|
||||
onClick={() => handleStopPlayerCountCheck(game.id)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
|
||||
157
scripts/ws-lifecycle-test.js
Normal file
157
scripts/ws-lifecycle-test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
const WebSocket = require('ws');
|
||||
const https = require('https');
|
||||
|
||||
const ROOM = process.argv[2] || 'SCWX';
|
||||
const HOST = 'ecast-prod-use2.jackboxgames.com';
|
||||
|
||||
function ts() { return new Date().toISOString().slice(11, 23); }
|
||||
function log(tag, ...args) { console.log(`[${ts()}][${tag}]`, ...args); }
|
||||
|
||||
function connect(name, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let params = `role=player&name=${encodeURIComponent(name)}&format=json`;
|
||||
if (opts.secret) {
|
||||
params += `&secret=${opts.secret}`;
|
||||
params += `&id=${opts.id}`;
|
||||
} else {
|
||||
params += `&userId=${name}-${Date.now()}`;
|
||||
}
|
||||
const url = `wss://${HOST}/api/v2/rooms/${ROOM}/play?${params}`;
|
||||
log(name, 'Connecting:', url);
|
||||
|
||||
const ws = new WebSocket(url, ['ecast-v0'], {
|
||||
headers: { 'Origin': 'https://jackbox.tv' }
|
||||
});
|
||||
|
||||
const allMsgs = [];
|
||||
ws.on('message', (raw) => {
|
||||
const m = JSON.parse(raw.toString());
|
||||
allMsgs.push(m);
|
||||
|
||||
if (m.opcode === 'client/welcome') {
|
||||
const r = m.result;
|
||||
const hereList = r.here ? Object.entries(r.here).map(([k, v]) => {
|
||||
const role = Object.keys(v.roles)[0];
|
||||
const detail = v.roles.player ? `(${v.roles.player.name})` : '';
|
||||
return `${k}:${role}${detail}`;
|
||||
}).join(', ') : 'null';
|
||||
log(name, `WELCOME id=${r.id} reconnect=${r.reconnect} secret=${r.secret} here=[${hereList}]`);
|
||||
resolve({ ws, id: r.id, secret: r.secret, msgs: allMsgs, name });
|
||||
} else if (m.opcode === 'client/connected') {
|
||||
const r = m.result;
|
||||
log(name, `*** CLIENT/CONNECTED id=${r.id} userId=${r.userId} name=${r.name} role=${JSON.stringify(r.roles || r.role)}`);
|
||||
} else if (m.opcode === 'client/disconnected') {
|
||||
const r = m.result;
|
||||
log(name, `*** CLIENT/DISCONNECTED id=${r.id} role=${JSON.stringify(r.roles || r.role)}`);
|
||||
} else if (m.opcode === 'client/kicked') {
|
||||
log(name, `*** CLIENT/KICKED:`, JSON.stringify(m.result));
|
||||
} else if (m.opcode === 'error') {
|
||||
log(name, `ERROR code=${m.result.code}: ${m.result.msg}`);
|
||||
reject(new Error(m.result.msg));
|
||||
} else if (m.opcode === 'object') {
|
||||
const r = m.result;
|
||||
if (r.key === 'room') {
|
||||
log(name, `ROOM state=${r.val?.state} lobbyState=${r.val?.lobbyState} gameCanStart=${r.val?.gameCanStart} gameFinished=${r.val?.gameFinished} v${r.version}`);
|
||||
} else if (r.key === 'textDescriptions') {
|
||||
const latest = r.val?.latestDescriptions?.[0];
|
||||
if (latest) log(name, `TEXT: "${latest.text}" (${latest.category})`);
|
||||
} else if (r.key?.startsWith('player:')) {
|
||||
log(name, `PLAYER ${r.key} state=${r.val?.state || 'init'} v${r.version}`);
|
||||
} else {
|
||||
log(name, `ENTITY ${r.key} v${r.version}`);
|
||||
}
|
||||
} else if (m.opcode === 'ok') {
|
||||
log(name, `OK seq response`);
|
||||
} else if (m.opcode === 'room/get-audience') {
|
||||
log(name, `AUDIENCE connections=${m.result?.connections}`);
|
||||
} else {
|
||||
log(name, `OTHER op=${m.opcode}`, JSON.stringify(m.result).slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
log(name, `CLOSED code=${code} reason=${reason.toString()}`);
|
||||
});
|
||||
ws.on('error', (e) => {
|
||||
log(name, `WS_ERROR: ${e.message}`);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function main() {
|
||||
log('TEST', `=== Phase 1: Connect P1 to ${ROOM} ===`);
|
||||
const p1 = await connect('P1');
|
||||
log('TEST', `P1 connected as id=${p1.id}, secret=${p1.secret}`);
|
||||
|
||||
log('TEST', `=== Phase 2: Check connections ===`);
|
||||
const connBefore = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||
log('TEST', `Connections: ${JSON.stringify(connBefore)}`);
|
||||
|
||||
await wait(2000);
|
||||
|
||||
log('TEST', `=== Phase 3: Connect P2 (watch P1 for client/connected) ===`);
|
||||
const p2 = await connect('P2');
|
||||
log('TEST', `P2 connected as id=${p2.id}, secret=${p2.secret}`);
|
||||
|
||||
const connAfterJoin = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||
log('TEST', `Connections after P2 join: ${JSON.stringify(connAfterJoin)}`);
|
||||
|
||||
await wait(2000);
|
||||
|
||||
log('TEST', `=== Phase 4: P2 gracefully disconnects (watch P1 for client/disconnected) ===`);
|
||||
p2.ws.close(1000, 'test-disconnect');
|
||||
await wait(3000);
|
||||
|
||||
const connAfterLeave = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||
log('TEST', `Connections after P2 leave: ${JSON.stringify(connAfterLeave)}`);
|
||||
|
||||
log('TEST', `=== Phase 5: Reconnect P2 using secret ===`);
|
||||
const p2r = await connect('P2-RECONNECT', { secret: p2.secret, id: p2.id });
|
||||
log('TEST', `P2 reconnected as id=${p2r.id}, reconnect should be true`);
|
||||
|
||||
const connAfterReconnect = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||
log('TEST', `Connections after P2 reconnect: ${JSON.stringify(connAfterReconnect)}`);
|
||||
|
||||
await wait(2000);
|
||||
|
||||
log('TEST', `=== Phase 6: Connect P3 (reach 3 players for CanStart) ===`);
|
||||
const p3 = await connect('P3');
|
||||
log('TEST', `P3 connected as id=${p3.id}`);
|
||||
|
||||
await wait(2000);
|
||||
|
||||
log('TEST', `=== Phase 7: Query audience count ===`);
|
||||
p1.ws.send(JSON.stringify({ seq: 100, opcode: 'room/get-audience', params: {} }));
|
||||
await wait(1000);
|
||||
|
||||
log('TEST', `=== Phase 8: All messages received by P1 ===`);
|
||||
const p1Opcodes = p1.msgs.map(m => `pc:${m.pc} ${m.opcode}${m.result?.key ? ':' + m.result.key : ''}`);
|
||||
log('TEST', `P1 received ${p1.msgs.length} messages: ${p1Opcodes.join(', ')}`);
|
||||
|
||||
log('TEST', `=== Cleanup ===`);
|
||||
p1.ws.close(1000);
|
||||
p2r.ws.close(1000);
|
||||
p3.ws.close(1000);
|
||||
await wait(1000);
|
||||
|
||||
const connFinal = await fetchJSON(`https://ecast.jackboxgames.com/api/v2/rooms/${ROOM}/connections`);
|
||||
log('TEST', `Final connections: ${JSON.stringify(connFinal)}`);
|
||||
log('TEST', `=== DONE ===`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function fetchJSON(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
let d = '';
|
||||
res.on('data', c => d += c);
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
||||
109
scripts/ws-probe.js
Normal file
109
scripts/ws-probe.js
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
const WebSocket = require('ws');
|
||||
const https = require('https');
|
||||
|
||||
const ROOM_CODE = process.argv[2] || 'LSBN';
|
||||
const PLAYER_NAME = process.argv[3] || 'PROBE_WS';
|
||||
const ROLE = process.argv[4] || 'player'; // 'player' or 'audience'
|
||||
const USER_ID = `probe-${Date.now()}`;
|
||||
|
||||
function getRoomInfo(code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${code}`, res => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.ok) resolve(json.body);
|
||||
else reject(new Error(json.error || 'Room not found'));
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[${ts()}] Fetching room info for ${ROOM_CODE}...`);
|
||||
const room = await getRoomInfo(ROOM_CODE);
|
||||
console.log(`[${ts()}] Room: ${room.appTag}, host: ${room.host}, locked: ${room.locked}`);
|
||||
|
||||
let wsUrl;
|
||||
if (ROLE === 'audience') {
|
||||
wsUrl = `wss://${room.audienceHost}/api/v2/audience/${ROOM_CODE}/play`;
|
||||
} else {
|
||||
wsUrl = `wss://${room.host}/api/v2/rooms/${ROOM_CODE}/play?role=${ROLE}&name=${encodeURIComponent(PLAYER_NAME)}&userId=${USER_ID}&format=json`;
|
||||
}
|
||||
|
||||
console.log(`[${ts()}] Connecting: ${wsUrl}`);
|
||||
|
||||
const ws = new WebSocket(wsUrl, ['ecast-v0'], {
|
||||
headers: {
|
||||
'Origin': 'https://jackbox.tv',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
let msgCount = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`[${ts()}] CONNECTED`);
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
msgCount++;
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
const summary = summarize(msg);
|
||||
console.log(`[${ts()}] RECV #${msgCount} | pc:${msg.pc} | opcode:${msg.opcode} | ${summary}`);
|
||||
if (process.env.VERBOSE === 'true') {
|
||||
console.log(JSON.stringify(msg, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[${ts()}] RECV #${msgCount} | raw: ${raw.toString().slice(0, 200)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log(`[${ts()}] CLOSED code=${code} reason=${reason}`);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error(`[${ts()}] ERROR: ${err.message}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log(`\n[${ts()}] Closing (${msgCount} messages received)`);
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
function summarize(msg) {
|
||||
if (msg.opcode === 'client/welcome') {
|
||||
const r = msg.result || {};
|
||||
const hereIds = r.here ? Object.keys(r.here) : [];
|
||||
const entityKeys = r.entities ? Object.keys(r.entities) : [];
|
||||
return `id=${r.id} name=${r.name} reconnect=${r.reconnect} here=[${hereIds}] entities=[${entityKeys}]`;
|
||||
}
|
||||
if (msg.opcode === 'object') {
|
||||
const r = msg.result || {};
|
||||
const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null';
|
||||
return `key=${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`;
|
||||
}
|
||||
if (msg.opcode === 'client/connected') {
|
||||
const r = msg.result || {};
|
||||
return `id=${r.id} userId=${r.userId} name=${r.name} role=${r.role}`;
|
||||
}
|
||||
if (msg.opcode === 'client/disconnected') {
|
||||
const r = msg.result || {};
|
||||
return `id=${r.id} role=${r.role}`;
|
||||
}
|
||||
return JSON.stringify(msg.result || msg).slice(0, 150);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
415
tests/api/ecast-shard-client.test.js
Normal file
415
tests/api/ecast-shard-client.test.js
Normal file
@@ -0,0 +1,415 @@
|
||||
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('module exports', () => {
|
||||
const { startMonitor, stopMonitor, cleanupAllShards } = require('../../backend/utils/ecast-shard-client');
|
||||
|
||||
test('startMonitor is exported', () => {
|
||||
expect(typeof startMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('stopMonitor is exported', () => {
|
||||
expect(typeof stopMonitor).toBe('function');
|
||||
});
|
||||
|
||||
test('cleanupAllShards is exported', () => {
|
||||
expect(typeof cleanupAllShards).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
11
tests/api/jackbox-api.test.js
Normal file
11
tests/api/jackbox-api.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { getRoomInfo, checkRoomStatus } = require('../../backend/utils/jackbox-api');
|
||||
|
||||
describe('jackbox-api exports', () => {
|
||||
test('getRoomInfo is exported as a function', () => {
|
||||
expect(typeof getRoomInfo).toBe('function');
|
||||
});
|
||||
|
||||
test('checkRoomStatus is still exported', () => {
|
||||
expect(typeof checkRoomStatus).toBe('function');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user