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 };