Files
jackboxpartypack-gamepicker/backend/utils/player-count-checker.js
cottongin 4747aa9632 Fix game.started not firing when Jackbox room is full
The waitForGameStart() function checked roomStatus.full before
roomStatus.locked, causing it to short-circuit when a room was full
but the game hadn't started yet. This meant game.started was never
broadcast and watchGameAsAudience() was never called for full games.

Now only locked=true triggers game start detection. When full=true
but locked=false, the poller continues until the game actually starts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 22:46:16 -05:00

578 lines
20 KiB
JavaScript

const puppeteer = require('puppeteer');
const db = require('../database');
const { getWebSocketManager } = require('./websocket-manager');
// Store active check jobs
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
* 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; // Track whether we've confirmed audience join
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`);
// Broadcast audience.joined event via WebSocket
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`);
// Verify it matches start count if we had one
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;
// Update immediately with final count
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) {
// Check for 'start' action - this is authoritative
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)`);
}
// Update UI with authoritative start count
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
}
continue; // Skip to next analytic
}
// If we already have start count, we don't need to keep counting
if (startPlayerCount !== null) {
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) {
seenPlayerCounts.add(analytic.value);
// Clamp to maxPlayers to avoid cumulative stats inflating count
const clampedValue = Math.min(analytic.value, maxPlayers);
// Update best guess (highest count seen so far, clamped to 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)`);
}
}
// Update UI with current best guess
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
// Check every 5 seconds if we should still be watching
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') {
if (process.env.DEBUG) {
console.log(`[Audience] Stopping watch - game status changed`);
}
clearInterval(checkInterval);
gameEnded = true;
if (browser) await browser.close();
return;
}
// Check if game ended
if (gameEnded) {
clearInterval(checkInterval);
if (browser) await browser.close();
return;
}
// Check if room still exists
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);
// Store the interval so we can clean it up
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 we had a best guess, use it; otherwise fail
if (bestPlayerCount !== null) {
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
} else {
updatePlayerCount(sessionId, gameId, null, 'failed');
}
}
}
/**
* 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(`[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) {
try {
db.prepare(`
UPDATE session_games
SET player_count = ?, player_count_check_status = ?
WHERE session_id = ? AND id = ?
`).run(playerCount, status, sessionId, gameId);
// Broadcast via WebSocket
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 checking player count for a game
* Strategy:
* 1. Wait 10 seconds for initial room setup
* 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) {
const checkKey = `${sessionId}-${gameId}`;
// If already checking, don't start again
if (activeChecks.has(checkKey)) {
console.log(`[Player Count] Already checking ${checkKey}`);
return;
}
// Check if already completed (but allow retrying failed checks)
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 retrying a failed check, reset the status
if (game && game.player_count_check_status === 'failed') {
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
}
console.log(`[Player Count] Starting check for game ${gameId} with room code ${roomCode}`);
// Update status to waiting
db.prepare(`
UPDATE session_games
SET player_count_check_status = 'waiting'
WHERE session_id = ? AND id = ?
`).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, {
sessionId,
gameId,
roomCode,
initialTimeout,
interval: null,
watchInterval: null,
browser: null
});
}
/**
* 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.initialTimeout) {
clearTimeout(check.initialTimeout);
}
if (check.interval) {
clearInterval(check.interval);
}
if (check.watchInterval) {
clearInterval(check.watchInterval);
}
if (check.browser) {
try {
await check.browser.close();
} catch (e) {
// Ignore errors closing browser
}
}
activeChecks.delete(checkKey);
// Update status to stopped if not already completed or failed
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 [checkKey, check] of activeChecks.entries()) {
if (check.initialTimeout) {
clearTimeout(check.initialTimeout);
}
if (check.interval) {
clearInterval(check.interval);
}
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
};