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
397 lines
14 KiB
JavaScript
397 lines
14 KiB
JavaScript
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
|
|
};
|