Decouple room monitoring from player count, fix Jackbox API fetch
Extracts checkRoomStatus into shared jackbox-api.js with proper User-Agent header (bare fetch was silently rejected by Jackbox API) and always-on error logging (previously gated behind DEBUG flag). Splits room-start detection (room-monitor.js) from audience-based player counting (player-count-checker.js) to eliminate circular dependency and allow immediate game.started detection. Room monitor now polls immediately instead of waiting 10 seconds for first check. Made-with: Cursor
This commit is contained in:
@@ -4,7 +4,8 @@ const { authenticateToken } = require('../middleware/auth');
|
|||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
const { triggerWebhook } = require('../utils/webhooks');
|
const { triggerWebhook } = require('../utils/webhooks');
|
||||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||||
const { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker');
|
const { stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||||
|
const { startRoomMonitor, stopRoomMonitor } = require('../utils/room-monitor');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -356,13 +357,12 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
|||||||
console.error('Error triggering notifications:', error);
|
console.error('Error triggering notifications:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically start player count check if room code was provided
|
// Automatically start room monitoring if room code was provided
|
||||||
if (room_code) {
|
if (room_code) {
|
||||||
try {
|
try {
|
||||||
startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
startRoomMonitor(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting player count check:', error);
|
console.error('Error starting room monitor:', error);
|
||||||
// Don't fail the request if player count check fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,12 +580,13 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
|||||||
return res.status(404).json({ error: 'Session game not found' });
|
return res.status(404).json({ error: 'Session game not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop player count check if game is no longer playing
|
// Stop room monitor and player count check if game is no longer playing
|
||||||
if (status !== 'playing') {
|
if (status !== 'playing') {
|
||||||
try {
|
try {
|
||||||
|
stopRoomMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
stopPlayerCountCheck(sessionId, gameId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping player count check:', error);
|
console.error('Error stopping room monitor/player count check:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,11 +601,12 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { sessionId, gameId } = req.params;
|
const { sessionId, gameId } = req.params;
|
||||||
|
|
||||||
// Stop player count check before deleting
|
// Stop room monitor and player count check before deleting
|
||||||
try {
|
try {
|
||||||
|
stopRoomMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
stopPlayerCountCheck(sessionId, gameId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping player count check:', error);
|
console.error('Error stopping room monitor/player count check:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
@@ -826,12 +828,12 @@ router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (
|
|||||||
return res.status(400).json({ error: 'Game does not have a room code' });
|
return res.status(400).json({ error: 'Game does not have a room code' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the check
|
// Start room monitoring (will hand off to player count check when game starts)
|
||||||
startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players);
|
startRoomMonitor(sessionId, gameId, game.room_code, game.max_players);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Player count check started',
|
message: 'Room monitor started',
|
||||||
status: 'waiting'
|
status: 'monitoring'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -843,11 +845,12 @@ router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (r
|
|||||||
try {
|
try {
|
||||||
const { sessionId, gameId } = req.params;
|
const { sessionId, gameId } = req.params;
|
||||||
|
|
||||||
// Stop the check
|
// Stop both room monitor and player count check
|
||||||
|
stopRoomMonitor(sessionId, gameId);
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
stopPlayerCountCheck(sessionId, gameId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Player count check stopped',
|
message: 'Room monitor and player count check stopped',
|
||||||
status: 'stopped'
|
status: 'stopped'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
42
backend/utils/jackbox-api.js
Normal file
42
backend/utils/jackbox-api.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const JACKBOX_API_BASE = 'https://ecast.jackboxgames.com/api/v2';
|
||||||
|
|
||||||
|
const DEFAULT_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; GamePicker/1.0)'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check room status via the Jackbox ecast REST API.
|
||||||
|
* Shared by room-monitor (polling for lock) and player-count-checker (room existence).
|
||||||
|
*/
|
||||||
|
async function checkRoomStatus(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 roomData = data.body || data;
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log('[Jackbox API] Room data:', JSON.stringify(roomData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
locked: roomData.locked || false,
|
||||||
|
full: roomData.full || false,
|
||||||
|
maxPlayers: roomData.maxPlayers || 8,
|
||||||
|
minPlayers: roomData.minPlayers || 0
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Jackbox API] Error checking room ${roomCode}:`, e.message);
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { checkRoomStatus };
|
||||||
@@ -1,40 +1,11 @@
|
|||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
const { getWebSocketManager } = require('./websocket-manager');
|
const { getWebSocketManager } = require('./websocket-manager');
|
||||||
|
const { checkRoomStatus } = require('./jackbox-api');
|
||||||
|
|
||||||
// Store active check jobs
|
// Store active check jobs
|
||||||
const activeChecks = new Map();
|
const activeChecks = new Map();
|
||||||
|
|
||||||
/**
|
|
||||||
* Check room status via Jackbox API
|
|
||||||
*/
|
|
||||||
async function checkRoomStatus(roomCode) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const roomData = data.body || data;
|
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.log('[API] Room data:', JSON.stringify(roomData, null, 2));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
locked: roomData.locked || false,
|
|
||||||
full: roomData.full || false,
|
|
||||||
maxPlayers: roomData.maxPlayers || 8,
|
|
||||||
minPlayers: roomData.minPlayers || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { exists: false };
|
|
||||||
} catch (e) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error('[API] Error checking room:', e.message);
|
|
||||||
}
|
|
||||||
return { exists: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch a game from start to finish as audience member
|
* Watch a game from start to finish as audience member
|
||||||
* Collects analytics throughout the entire game lifecycle
|
* Collects analytics throughout the entire game lifecycle
|
||||||
@@ -67,7 +38,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
let bestPlayerCount = null;
|
let bestPlayerCount = null;
|
||||||
let startPlayerCount = null; // Authoritative count from 'start' action
|
let startPlayerCount = null; // Authoritative count from 'start' action
|
||||||
let gameEnded = false;
|
let gameEnded = false;
|
||||||
let audienceJoined = false; // Track whether we've confirmed audience join
|
let audienceJoined = false;
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
||||||
@@ -98,7 +69,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
audienceJoined = true;
|
audienceJoined = true;
|
||||||
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
|
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
|
||||||
|
|
||||||
// Broadcast audience.joined event via WebSocket
|
|
||||||
const wsManager = getWebSocketManager();
|
const wsManager = getWebSocketManager();
|
||||||
if (wsManager) {
|
if (wsManager) {
|
||||||
wsManager.broadcastEvent('audience.joined', {
|
wsManager.broadcastEvent('audience.joined', {
|
||||||
@@ -121,7 +91,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
|
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
|
||||||
|
|
||||||
// Verify it matches start count if we had one
|
|
||||||
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
|
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
|
||||||
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
|
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
|
||||||
} else if (startPlayerCount !== null) {
|
} else if (startPlayerCount !== null) {
|
||||||
@@ -130,7 +99,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
}
|
}
|
||||||
bestPlayerCount = finalCount;
|
bestPlayerCount = finalCount;
|
||||||
gameEnded = true;
|
gameEnded = true;
|
||||||
// Update immediately with final count
|
|
||||||
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
|
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,7 +106,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
// Extract player counts from analytics (game in progress)
|
// Extract player counts from analytics (game in progress)
|
||||||
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
|
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
|
||||||
for (const analytic of roomVal.analytics) {
|
for (const analytic of roomVal.analytics) {
|
||||||
// Check for 'start' action - this is authoritative
|
|
||||||
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
|
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
|
||||||
if (startPlayerCount === null) {
|
if (startPlayerCount === null) {
|
||||||
startPlayerCount = analytic.value;
|
startPlayerCount = analytic.value;
|
||||||
@@ -146,25 +113,20 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
|
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
|
||||||
}
|
}
|
||||||
// Update UI with authoritative start count
|
|
||||||
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
|
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
|
||||||
}
|
}
|
||||||
continue; // Skip to next analytic
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we already have start count, we don't need to keep counting
|
|
||||||
if (startPlayerCount !== null) {
|
if (startPlayerCount !== null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, look for any numeric value that could be a player count
|
|
||||||
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
|
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
|
||||||
seenPlayerCounts.add(analytic.value);
|
seenPlayerCounts.add(analytic.value);
|
||||||
|
|
||||||
// Clamp to maxPlayers to avoid cumulative stats inflating count
|
|
||||||
const clampedValue = Math.min(analytic.value, maxPlayers);
|
const clampedValue = Math.min(analytic.value, maxPlayers);
|
||||||
|
|
||||||
// Update best guess (highest count seen so far, clamped to maxPlayers)
|
|
||||||
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
|
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
|
||||||
bestPlayerCount = clampedValue;
|
bestPlayerCount = clampedValue;
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
@@ -174,7 +136,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
|
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update UI with current best guess
|
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
|
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,9 +198,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
|
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
|
// Keep watching until game ends or we're told to stop
|
||||||
// Check every 5 seconds if we should still be watching
|
|
||||||
const checkInterval = setInterval(async () => {
|
const checkInterval = setInterval(async () => {
|
||||||
// Check if we should stop
|
|
||||||
const game = db.prepare(`
|
const game = db.prepare(`
|
||||||
SELECT status, player_count_check_status
|
SELECT status, player_count_check_status
|
||||||
FROM session_games
|
FROM session_games
|
||||||
@@ -256,14 +215,12 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if game ended
|
|
||||||
if (gameEnded) {
|
if (gameEnded) {
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
if (browser) await browser.close();
|
if (browser) await browser.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if room still exists
|
|
||||||
const roomStatus = await checkRoomStatus(roomCode);
|
const roomStatus = await checkRoomStatus(roomCode);
|
||||||
if (!roomStatus.exists) {
|
if (!roomStatus.exists) {
|
||||||
if (process.env.DEBUG) {
|
if (process.env.DEBUG) {
|
||||||
@@ -281,7 +238,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Store the interval so we can clean it up
|
|
||||||
const check = activeChecks.get(checkKey);
|
const check = activeChecks.get(checkKey);
|
||||||
if (check) {
|
if (check) {
|
||||||
check.watchInterval = checkInterval;
|
check.watchInterval = checkInterval;
|
||||||
@@ -293,7 +249,6 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
// If we had a best guess, use it; otherwise fail
|
|
||||||
if (bestPlayerCount !== null) {
|
if (bestPlayerCount !== null) {
|
||||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||||
} else {
|
} else {
|
||||||
@@ -303,27 +258,7 @@ async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast game.started event when room becomes locked
|
* Update player count in database and broadcast via WebSocket
|
||||||
*/
|
|
||||||
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
|
|
||||||
try {
|
|
||||||
const wsManager = getWebSocketManager();
|
|
||||||
if (wsManager) {
|
|
||||||
wsManager.broadcastEvent('game.started', {
|
|
||||||
sessionId,
|
|
||||||
gameId,
|
|
||||||
roomCode,
|
|
||||||
maxPlayers
|
|
||||||
}, parseInt(sessionId));
|
|
||||||
}
|
|
||||||
console.log(`[Player Count] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Player Count] Failed to broadcast game.started:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update player count in database
|
|
||||||
*/
|
*/
|
||||||
function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
||||||
try {
|
try {
|
||||||
@@ -333,7 +268,6 @@ function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
|||||||
WHERE session_id = ? AND id = ?
|
WHERE session_id = ? AND id = ?
|
||||||
`).run(playerCount, status, sessionId, gameId);
|
`).run(playerCount, status, sessionId, gameId);
|
||||||
|
|
||||||
// Broadcast via WebSocket
|
|
||||||
const wsManager = getWebSocketManager();
|
const wsManager = getWebSocketManager();
|
||||||
if (wsManager) {
|
if (wsManager) {
|
||||||
wsManager.broadcastEvent('player-count.updated', {
|
wsManager.broadcastEvent('player-count.updated', {
|
||||||
@@ -351,25 +285,18 @@ function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start checking player count for a game
|
* Start player count checking for a game.
|
||||||
* Strategy:
|
* Called by room-monitor once the game is confirmed started (room locked).
|
||||||
* 1. Wait 10 seconds for initial room setup
|
* Goes straight to joining the audience — no polling needed.
|
||||||
* 2. Poll every 10 seconds until game is locked (started)
|
|
||||||
* 3. Broadcast game.started event when locked detected
|
|
||||||
* 4. Join audience and watch entire game
|
|
||||||
* 5. Update UI as we learn more
|
|
||||||
* 6. Finalize when game ends
|
|
||||||
*/
|
*/
|
||||||
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
|
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||||
const checkKey = `${sessionId}-${gameId}`;
|
const checkKey = `${sessionId}-${gameId}`;
|
||||||
|
|
||||||
// If already checking, don't start again
|
|
||||||
if (activeChecks.has(checkKey)) {
|
if (activeChecks.has(checkKey)) {
|
||||||
console.log(`[Player Count] Already checking ${checkKey}`);
|
console.log(`[Player Count] Already checking ${checkKey}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already completed (but allow retrying failed checks)
|
|
||||||
const game = db.prepare(`
|
const game = db.prepare(`
|
||||||
SELECT player_count_check_status
|
SELECT player_count_check_status
|
||||||
FROM session_games
|
FROM session_games
|
||||||
@@ -381,122 +308,27 @@ async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If retrying a failed check, reset the status
|
|
||||||
if (game && game.player_count_check_status === 'failed') {
|
if (game && game.player_count_check_status === 'failed') {
|
||||||
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
|
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Player Count] Starting check for game ${gameId} with room code ${roomCode}`);
|
console.log(`[Player Count] Starting audience watch for game ${gameId} (room ${roomCode}, max ${maxPlayers})`);
|
||||||
|
|
||||||
// Update status to waiting
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE session_games
|
UPDATE session_games
|
||||||
SET player_count_check_status = 'waiting'
|
SET player_count_check_status = 'checking'
|
||||||
WHERE session_id = ? AND id = ?
|
WHERE session_id = ? AND id = ?
|
||||||
`).run(sessionId, gameId);
|
`).run(sessionId, gameId);
|
||||||
|
|
||||||
// Function to check if game is ready (locked)
|
|
||||||
const waitForGameStart = async () => {
|
|
||||||
const roomStatus = await checkRoomStatus(roomCode);
|
|
||||||
|
|
||||||
if (!roomStatus.exists) {
|
|
||||||
console.log(`[Player Count] Room ${roomCode} does not exist`);
|
|
||||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If locked, game has started - ready to watch
|
|
||||||
if (roomStatus.locked) {
|
|
||||||
console.log(`[Player Count] Room is LOCKED - game in progress, starting watch`);
|
|
||||||
return { ready: true, maxPlayers: roomStatus.maxPlayers };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log if full but not yet started
|
|
||||||
if (roomStatus.full) {
|
|
||||||
console.log(`[Player Count] Room is FULL but not locked yet - waiting for game start`);
|
|
||||||
} else {
|
|
||||||
console.log(`[Player Count] Room not ready yet (lobby still open)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // Not ready, keep polling
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wait 10 seconds before first check
|
|
||||||
const initialTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
// Update status to checking
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE session_games
|
|
||||||
SET player_count_check_status = 'checking'
|
|
||||||
WHERE session_id = ? AND id = ?
|
|
||||||
`).run(sessionId, gameId);
|
|
||||||
|
|
||||||
console.log(`[Player Count] Initial check after 10s for ${checkKey}`);
|
|
||||||
const result = await waitForGameStart();
|
|
||||||
|
|
||||||
if (result && result.ready === true) {
|
|
||||||
// Game is locked, broadcast game.started and start watching
|
|
||||||
const realMaxPlayers = result.maxPlayers;
|
|
||||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
|
|
||||||
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
} else if (result === null) {
|
|
||||||
// Not ready yet, poll every 10 seconds
|
|
||||||
const checkInterval = setInterval(async () => {
|
|
||||||
// Check if we should stop
|
|
||||||
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' || game.player_count_check_status === 'completed') {
|
|
||||||
console.log(`[Player Count] Stopping check for ${checkKey} - game status changed`);
|
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await waitForGameStart();
|
|
||||||
if (result && result.ready === true) {
|
|
||||||
// Game is now locked, stop interval, broadcast game.started, and start watching
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
const check = activeChecks.get(checkKey);
|
|
||||||
if (check) check.interval = null;
|
|
||||||
const realMaxPlayers = result.maxPlayers;
|
|
||||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
|
|
||||||
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
|
|
||||||
} else if (result === false) {
|
|
||||||
// Check failed or completed, stop
|
|
||||||
clearInterval(checkInterval);
|
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
}
|
|
||||||
}, 10000); // Poll every 10 seconds
|
|
||||||
|
|
||||||
// Store the interval
|
|
||||||
const check = activeChecks.get(checkKey);
|
|
||||||
if (check) check.interval = checkInterval;
|
|
||||||
}
|
|
||||||
// If ready === false, check already stopped/completed
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[Player Count] Error starting check for ${checkKey}:`, error.message);
|
|
||||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
|
||||||
stopPlayerCountCheck(sessionId, gameId);
|
|
||||||
}
|
|
||||||
}, 10000); // Wait 10 seconds before first check
|
|
||||||
|
|
||||||
// Store the check references
|
|
||||||
activeChecks.set(checkKey, {
|
activeChecks.set(checkKey, {
|
||||||
sessionId,
|
sessionId,
|
||||||
gameId,
|
gameId,
|
||||||
roomCode,
|
roomCode,
|
||||||
initialTimeout,
|
|
||||||
interval: null,
|
|
||||||
watchInterval: null,
|
watchInterval: null,
|
||||||
browser: null
|
browser: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -507,12 +339,6 @@ async function stopPlayerCountCheck(sessionId, gameId) {
|
|||||||
const check = activeChecks.get(checkKey);
|
const check = activeChecks.get(checkKey);
|
||||||
|
|
||||||
if (check) {
|
if (check) {
|
||||||
if (check.initialTimeout) {
|
|
||||||
clearTimeout(check.initialTimeout);
|
|
||||||
}
|
|
||||||
if (check.interval) {
|
|
||||||
clearInterval(check.interval);
|
|
||||||
}
|
|
||||||
if (check.watchInterval) {
|
if (check.watchInterval) {
|
||||||
clearInterval(check.watchInterval);
|
clearInterval(check.watchInterval);
|
||||||
}
|
}
|
||||||
@@ -525,7 +351,6 @@ async function stopPlayerCountCheck(sessionId, gameId) {
|
|||||||
}
|
}
|
||||||
activeChecks.delete(checkKey);
|
activeChecks.delete(checkKey);
|
||||||
|
|
||||||
// Update status to stopped if not already completed or failed
|
|
||||||
const game = db.prepare(`
|
const game = db.prepare(`
|
||||||
SELECT player_count_check_status
|
SELECT player_count_check_status
|
||||||
FROM session_games
|
FROM session_games
|
||||||
@@ -548,13 +373,7 @@ async function stopPlayerCountCheck(sessionId, gameId) {
|
|||||||
* Clean up all active checks (for graceful shutdown)
|
* Clean up all active checks (for graceful shutdown)
|
||||||
*/
|
*/
|
||||||
async function cleanupAllChecks() {
|
async function cleanupAllChecks() {
|
||||||
for (const [checkKey, check] of activeChecks.entries()) {
|
for (const [, check] of activeChecks.entries()) {
|
||||||
if (check.initialTimeout) {
|
|
||||||
clearTimeout(check.initialTimeout);
|
|
||||||
}
|
|
||||||
if (check.interval) {
|
|
||||||
clearInterval(check.interval);
|
|
||||||
}
|
|
||||||
if (check.watchInterval) {
|
if (check.watchInterval) {
|
||||||
clearInterval(check.watchInterval);
|
clearInterval(check.watchInterval);
|
||||||
}
|
}
|
||||||
|
|||||||
135
backend/utils/room-monitor.js
Normal file
135
backend/utils/room-monitor.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
@@ -693,12 +693,24 @@ curl -X GET "http://localhost:5000/api/webhooks/1/logs" \
|
|||||||
- `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`.
|
- `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`.
|
||||||
- `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required).
|
- `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required).
|
||||||
- `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session.
|
- `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session.
|
||||||
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
|
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds via the room monitor. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
|
||||||
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member. Sent to clients subscribed to that session. This confirms the room code is valid and the game is being monitored.
|
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member for player count tracking. Sent to clients subscribed to that session.
|
||||||
- `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session.
|
- `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session.
|
||||||
|
|
||||||
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added` and `session.ended` events for it.
|
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added` and `session.ended` events for it.
|
||||||
|
|
||||||
|
### Event Lifecycle (for a game with room code)
|
||||||
|
|
||||||
|
When a game is added with a room code, events fire in this order:
|
||||||
|
|
||||||
|
1. **`game.added`** — Game added to the session (immediate).
|
||||||
|
2. **`game.started`** — Jackbox room becomes locked, gameplay has begun. Detected by a room monitor that polls the Jackbox REST API every 10 seconds. This is independent of the player count system.
|
||||||
|
3. **`audience.joined`** — The player count bot successfully joined the Jackbox room as an audience member (seconds after `game.started`).
|
||||||
|
4. **`player-count.updated`** (status: `checking`) — Player count data received from the game's WebSocket traffic (ongoing).
|
||||||
|
5. **`player-count.updated`** (status: `completed`) — Game ended, final player count confirmed.
|
||||||
|
|
||||||
|
Room monitoring and player counting are separate systems. The room monitor (`room-monitor.js`) handles steps 1-2 and then hands off to the player count checker (`player-count-checker.js`) for steps 3-5.
|
||||||
|
|
||||||
More events may be added in the future (e.g., `vote.recorded`).
|
More events may be added in the future (e.g., `vote.recorded`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user