diff --git a/backend/Dockerfile b/backend/Dockerfile index 9c4e435..b5bab68 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,8 +2,20 @@ FROM node:18-alpine WORKDIR /app -# Install wget for healthcheck -RUN apk add --no-cache wget +# Install Chromium, fonts, and dependencies for Puppeteer +RUN apk add --no-cache \ + wget \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + font-noto-emoji + +# Tell Puppeteer to use the installed Chromium +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true # Copy package files COPY package*.json ./ diff --git a/backend/database.js b/backend/database.js index 4abcedd..bbdb8f3 100644 --- a/backend/database.js +++ b/backend/database.js @@ -84,6 +84,20 @@ function initializeDatabase() { // Column already exists, ignore error } + // Add player_count column if it doesn't exist (for existing databases) + try { + db.exec(`ALTER TABLE session_games ADD COLUMN player_count INTEGER`); + } catch (err) { + // Column already exists, ignore error + } + + // Add player_count_check_status column if it doesn't exist (for existing databases) + try { + db.exec(`ALTER TABLE session_games ADD COLUMN player_count_check_status TEXT DEFAULT 'not_started'`); + } catch (err) { + // Column already exists, ignore error + } + // Add favor_bias column to games if it doesn't exist try { db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); diff --git a/backend/package.json b/backend/package.json index 56fff2e..a4631af 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,8 @@ "dotenv": "^16.3.1", "csv-parse": "^5.5.3", "csv-stringify": "^6.4.5", - "ws": "^8.14.0" + "ws": "^8.14.0", + "puppeteer": "^24.0.0" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 7499463..47e6acb 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -4,6 +4,7 @@ const { authenticateToken } = require('../middleware/auth'); const db = require('../database'); const { triggerWebhook } = require('../utils/webhooks'); const { getWebSocketManager } = require('../utils/websocket-manager'); +const { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker'); const router = express.Router(); @@ -355,6 +356,16 @@ router.post('/:id/games', authenticateToken, (req, res) => { console.error('Error triggering notifications:', error); } + // Automatically start player count check if room code was provided + if (room_code) { + try { + startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players); + } catch (error) { + console.error('Error starting player count check:', error); + // Don't fail the request if player count check fails + } + } + res.status(201).json(sessionGame); } catch (error) { res.status(500).json({ error: error.message }); @@ -569,6 +580,15 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) = return res.status(404).json({ error: 'Session game not found' }); } + // Stop player count check if game is no longer playing + if (status !== 'playing') { + try { + stopPlayerCountCheck(sessionId, gameId); + } catch (error) { + console.error('Error stopping player count check:', error); + } + } + res.json({ message: 'Status updated successfully', status }); } catch (error) { res.status(500).json({ error: error.message }); @@ -580,6 +600,13 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => { try { const { sessionId, gameId } = req.params; + // Stop player count check before deleting + try { + stopPlayerCountCheck(sessionId, gameId); + } catch (error) { + console.error('Error stopping player count check:', error); + } + const result = db.prepare(` DELETE FROM session_games WHERE session_id = ? AND id = ? @@ -778,5 +805,101 @@ router.get('/:id/export', authenticateToken, (req, res) => { } }); +// Start player count check for a session game (admin only) +router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => { + try { + const { sessionId, gameId } = req.params; + + // Get the game to verify it exists and has a room code + const game = db.prepare(` + SELECT sg.*, g.max_players + FROM session_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.session_id = ? AND sg.id = ? + `).get(sessionId, gameId); + + if (!game) { + return res.status(404).json({ error: 'Session game not found' }); + } + + if (!game.room_code) { + return res.status(400).json({ error: 'Game does not have a room code' }); + } + + // Start the check + startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players); + + res.json({ + message: 'Player count check started', + status: 'waiting' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Stop player count check for a session game (admin only) +router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (req, res) => { + try { + const { sessionId, gameId } = req.params; + + // Stop the check + stopPlayerCountCheck(sessionId, gameId); + + res.json({ + message: 'Player count check stopped', + status: 'stopped' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Manually update player count for a session game (admin only) +router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req, res) => { + try { + const { sessionId, gameId } = req.params; + const { player_count } = req.body; + + if (player_count === undefined || player_count === null) { + return res.status(400).json({ error: 'player_count is required' }); + } + + const count = parseInt(player_count); + if (isNaN(count) || count < 0) { + return res.status(400).json({ error: 'player_count must be a positive number' }); + } + + // Update the player count + const result = db.prepare(` + UPDATE session_games + SET player_count = ?, player_count_check_status = 'completed' + WHERE session_id = ? AND id = ? + `).run(count, sessionId, gameId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Session game not found' }); + } + + // Broadcast via WebSocket + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('player-count.updated', { + sessionId, + gameId, + playerCount: count, + status: 'completed' + }, parseInt(sessionId)); + } + + res.json({ + message: 'Player count updated successfully', + player_count: count + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/utils/player-count-checker.js b/backend/utils/player-count-checker.js new file mode 100644 index 0000000..93e743e --- /dev/null +++ b/backend/utils/player-count-checker.js @@ -0,0 +1,541 @@ +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 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`); + } + } + + 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'); + } + } +} + +/** + * 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 + * New strategy: + * 1. Wait 30 seconds + * 2. Check if game is locked - if not, wait another 30 seconds + * 3. Once locked, join audience and watch entire game + * 4. Update UI as we learn more + * 5. 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 full, we know the count immediately + if (roomStatus.full) { + console.log(`[Player Count] Room is FULL - ${roomStatus.maxPlayers} players`); + updatePlayerCount(sessionId, gameId, roomStatus.maxPlayers, 'completed'); + 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 both status and real maxPlayers from Jackbox + return { ready: true, maxPlayers: roomStatus.maxPlayers }; + } + + // Not ready yet + console.log(`[Player Count] Room not ready yet (lobby still open)`); + return null; + }; + + // Wait 30 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 30s for ${checkKey}`); + const result = await waitForGameStart(); + + if (result && result.ready === true) { + // Game is locked, start watching with REAL maxPlayers from Jackbox + const realMaxPlayers = result.maxPlayers; + 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, check every 30 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 and start watching with REAL maxPlayers + clearInterval(checkInterval); + const check = activeChecks.get(checkKey); + if (check) check.interval = null; + const realMaxPlayers = result.maxPlayers; + 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); + } + }, 30000); // Check every 30 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); + } + }, 30000); // Wait 30 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 +}; diff --git a/docker-compose.yml b/docker-compose.yml index 7696cf0..93d53ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - DB_PATH=/app/data/jackbox.db - JWT_SECRET=${JWT_SECRET:-change-me-in-production} - ADMIN_KEY=${ADMIN_KEY:-admin123} + - DEBUG=false volumes: - jackbox-data:/app/data - ./games-list.csv:/app/games-list.csv:ro diff --git a/frontend/src/config/branding.js b/frontend/src/config/branding.js index 0316663..47f31d2 100644 --- a/frontend/src/config/branding.js +++ b/frontend/src/config/branding.js @@ -2,7 +2,7 @@ export const branding = { app: { name: 'HSO Jackbox Game Picker', shortName: 'Jackbox Game Picker', - version: '0.4.2 - Safari Walkabout Edition', + version: '0.5.0 - Safari Walkabout Edition', description: 'Spicing up Hyper Spaceout game nights!', }, meta: { diff --git a/frontend/src/pages/Picker.jsx b/frontend/src/pages/Picker.jsx index 10b915a..80d2dad 100644 --- a/frontend/src/pages/Picker.jsx +++ b/frontend/src/pages/Picker.jsx @@ -14,6 +14,7 @@ function Picker() { const [activeSession, setActiveSession] = useState(null); const [allGames, setAllGames] = useState([]); const [selectedGame, setSelectedGame] = useState(null); + const [playingGame, setPlayingGame] = useState(null); // Currently playing game const [loading, setLoading] = useState(true); const [picking, setPicking] = useState(false); const [error, setError] = useState(''); @@ -29,6 +30,9 @@ function Picker() { // Manual game selection const [showManualSelect, setShowManualSelect] = useState(false); const [manualGameId, setManualGameId] = useState(''); + const [manualSearchQuery, setManualSearchQuery] = useState(''); + const [showManualDropdown, setShowManualDropdown] = useState(false); + const [filteredManualGames, setFilteredManualGames] = useState([]); // Game pool viewer const [showGamePool, setShowGamePool] = useState(false); @@ -87,6 +91,21 @@ function Picker() { // Load all games for manual selection const gamesResponse = await api.get('/games'); setAllGames(gamesResponse.data); + + // Load currently playing game if session exists + if (session && session.id) { + try { + const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`); + const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing'); + if (playingGameEntry) { + setPlayingGame(playingGameEntry); + } else { + setPlayingGame(null); + } + } catch (err) { + console.error('Failed to load playing game', err); + } + } } catch (err) { setError('Failed to load session data'); } finally { @@ -116,6 +135,18 @@ function Picker() { return () => clearInterval(interval); }, [isAuthenticated, authLoading, checkActiveSession]); + // Close manual game dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (showManualDropdown && !event.target.closest('.manual-search-container')) { + setShowManualDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showManualDropdown]); + const handleCreateSession = async () => { try { const newSession = await api.post('/sessions', {}); @@ -214,22 +245,29 @@ function Picker() { const { type, game, gameId } = pendingGameAction; if (type === 'accept' || type === 'version') { - await api.post(`/sessions/${activeSession.id}/games`, { + const response = await api.post(`/sessions/${activeSession.id}/games`, { game_id: gameId || game.id, manually_added: false, room_code: roomCode }); - setSelectedGame(null); + // Set the newly added game as playing + setPlayingGame(response.data); } else if (type === 'manual') { - await api.post(`/sessions/${activeSession.id}/games`, { + const response = await api.post(`/sessions/${activeSession.id}/games`, { game_id: gameId, manually_added: true, room_code: roomCode }); setManualGameId(''); setShowManualSelect(false); + // Set the newly added game as playing + setPlayingGame(response.data); } + // Close all modals and clear selected game after adding to session + setSelectedGame(null); + setShowGamePool(false); + // Trigger games list refresh setGamesUpdateTrigger(prev => prev + 1); setError(''); @@ -257,8 +295,43 @@ function Picker() { game: game }); setShowRoomCodeModal(true); + + // Reset search + setManualSearchQuery(''); + setShowManualDropdown(false); + setManualGameId(''); }; + // Handle manual search input with filtering + const handleManualSearchChange = useCallback((e) => { + const query = e.target.value; + setManualSearchQuery(query); + + if (query.trim().length === 0) { + setFilteredManualGames([]); + setShowManualDropdown(false); + setManualGameId(''); + return; + } + + // Filter games by query (non-blocking) + const lowerQuery = query.toLowerCase(); + const filtered = allGames.filter(game => + game.title.toLowerCase().includes(lowerQuery) || + game.pack_name.toLowerCase().includes(lowerQuery) + ).slice(0, 50); // Limit to 50 results for performance + + setFilteredManualGames(filtered); + setShowManualDropdown(filtered.length > 0); + }, [allGames]); + + // Handle selecting a game from the dropdown + const handleSelectManualGame = useCallback((game) => { + setManualGameId(game.id.toString()); + setManualSearchQuery(`${game.title} (${game.pack_name})`); + setShowManualDropdown(false); + }, []); + const handleSelectVersion = async (gameId) => { if (!activeSession) return; @@ -614,9 +687,75 @@ function Picker() { )} - {selectedGame && ( -
+ {/* Currently Playing Game Card */} + {playingGame && ( +
+
+ + 🎮 Playing Now + +

+ {playingGame.title} +

+

{playingGame.pack_name}

+ +
+
+ Players: + + {playingGame.min_players}-{playingGame.max_players} + +
+
+ Length: + + {playingGame.length_minutes ? `${playingGame.length_minutes} min` : 'Unknown'} + +
+
+ Type: + + {playingGame.game_type || 'N/A'} + +
+
+ Room Code: + + {playingGame.room_code || 'N/A'} + +
+
+ +
+ + +
+
+ )} + + {/* Selected Game Card (from dice roll) */} + {selectedGame && ( +
+ {/* Close/Dismiss Button */} + +

{selectedGame.title}

{selectedGame.pack_name}

@@ -682,6 +821,13 @@ function Picker() { > 🎲 Re-roll +
{/* Other Versions Suggestion */} @@ -727,18 +873,47 @@ function Picker() { Manual Game Selection
- +
+ { + if (filteredManualGames.length > 0) { + setShowManualDropdown(true); + } + }} + placeholder="Type to search games..." + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400" + /> + + {/* Autocomplete dropdown - above on mobile, below on desktop */} + {showManualDropdown && filteredManualGames.length > 0 && ( +
+ {filteredManualGames.map((game) => ( + + ))} +
+ )} + + {/* No results message - above on mobile, below on desktop */} + {manualSearchQuery.trim() && filteredManualGames.length === 0 && !showManualDropdown && ( +
+ No games found matching "{manualSearchQuery}" +
+ )} +
); } -function SessionInfo({ sessionId, onGamesUpdate }) { +function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) { const { isAuthenticated } = useAuth(); const [games, setGames] = useState([]); const [loading, setLoading] = useState(true); @@ -766,6 +946,11 @@ function SessionInfo({ sessionId, onGamesUpdate }) { const [showPopularity, setShowPopularity] = useState(true); const [editingRoomCode, setEditingRoomCode] = useState(null); const [newRoomCode, setNewRoomCode] = useState(''); + const [showRepeatRoomCodeModal, setShowRepeatRoomCodeModal] = useState(false); + const [repeatGameData, setRepeatGameData] = useState(null); + const [wsConnection, setWsConnection] = useState(null); + const [editingPlayerCount, setEditingPlayerCount] = useState(null); + const [newPlayerCount, setNewPlayerCount] = useState(''); const loadGames = useCallback(async () => { try { @@ -792,9 +977,65 @@ function SessionInfo({ sessionId, onGamesUpdate }) { return () => clearInterval(interval); }, [loadGames]); + // Setup WebSocket connection for real-time player count updates + useEffect(() => { + 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`; + + try { + 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) + })); + }; + + 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 + loadGames(); + } + } catch (error) { + console.error('[WebSocket] Error parsing message:', error); + } + }; + + ws.onerror = (error) => { + console.error('[WebSocket] Error:', error); + }; + + ws.onclose = () => { + console.log('[WebSocket] Disconnected'); + }; + + setWsConnection(ws); + + return () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + } catch (error) { + console.error('[WebSocket] Failed to connect:', error); + } + }, [sessionId, loadGames]); + const handleUpdateStatus = async (gameId, newStatus) => { try { await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus }); + // If we're changing the playing game's status, clear it from the playing card + if (playingGame && playingGame.id === gameId && newStatus !== 'playing') { + setPlayingGame(null); + } loadGames(); // Reload to get updated statuses } catch (err) { console.error('Failed to update game status', err); @@ -816,6 +1057,10 @@ function SessionInfo({ sessionId, onGamesUpdate }) { const handleRemoveGame = async (gameId) => { try { await api.delete(`/sessions/${sessionId}/games/${gameId}`); + // If we're removing the playing game, clear it from the playing card + if (playingGame && playingGame.id === gameId) { + setPlayingGame(null); + } setConfirmingRemove(null); loadGames(); // Reload after deletion } catch (err) { @@ -857,6 +1102,84 @@ function SessionInfo({ sessionId, onGamesUpdate }) { setNewRoomCode(''); }; + const handleRepeatGame = (game) => { + // Store the game data and open the room code modal + setRepeatGameData(game); + setShowRepeatRoomCodeModal(true); + }; + + const handleRepeatRoomCodeConfirm = async (roomCode) => { + if (!repeatGameData) return; + + try { + const response = await api.post(`/sessions/${sessionId}/games`, { + game_id: repeatGameData.game_id, + manually_added: false, + room_code: roomCode + }); + // Set the newly added game as playing + setPlayingGame(response.data); + setShowRepeatRoomCodeModal(false); + setRepeatGameData(null); + loadGames(); // Reload to show the new game + } catch (err) { + console.error('Failed to repeat game', err); + } + }; + + const handleRepeatRoomCodeCancel = () => { + setShowRepeatRoomCodeModal(false); + setRepeatGameData(null); + }; + + const handleStopPlayerCountCheck = async (gameId) => { + try { + await api.post(`/sessions/${sessionId}/games/${gameId}/stop-player-check`); + loadGames(); // Reload to show updated status + } catch (err) { + console.error('Failed to stop player count check', err); + } + }; + + const handleRetryPlayerCount = async (gameId, roomCode) => { + if (!roomCode) return; + + try { + await api.post(`/sessions/${sessionId}/games/${gameId}/start-player-check`); + loadGames(); // Reload to show checking status + } catch (err) { + console.error('Failed to start player count check', err); + } + }; + + const handleEditPlayerCount = (gameId, currentCount) => { + setEditingPlayerCount(gameId); + setNewPlayerCount(currentCount?.toString() || ''); + }; + + const handleSavePlayerCount = async (gameId) => { + const count = parseInt(newPlayerCount); + if (isNaN(count) || count < 0) { + return; + } + + try { + await api.patch(`/sessions/${sessionId}/games/${gameId}/player-count`, { + player_count: count + }); + setEditingPlayerCount(null); + setNewPlayerCount(''); + loadGames(); // Reload to show updated count + } catch (err) { + console.error('Failed to update player count', err); + } + }; + + const handleCancelEditPlayerCount = () => { + setEditingPlayerCount(null); + setNewPlayerCount(''); + }; + const getStatusBadge = (status) => { if (status === 'playing') { return ( @@ -876,21 +1199,30 @@ function SessionInfo({ sessionId, onGamesUpdate }) { }; return ( -
-
-

- Games Played This Session ({games.length}) -

- -
+ <> + {/* Room Code Modal for Repeat Game */} + + +
+
+

+ Games Played This Session ({games.length}) +

+ +
{loading ? (

Loading...

) : games.length === 0 ? ( @@ -919,15 +1251,15 @@ function SessionInfo({ sessionId, onGamesUpdate }) { : 'text-gray-700 dark:text-gray-200' }`}> {displayNumber}. {game.title} - + {getStatusBadge(game.status)} {game.manually_added === 1 && ( Manual - + )} {game.room_code && ( -
+
{editingRoomCode === game.id ? (
✓ - + +
) : ( <> @@ -964,10 +1296,127 @@ function SessionInfo({ sessionId, onGamesUpdate }) { title="Edit room code" > ✏️ - + )} )} + {/* Player Count Display */} + {game.player_count_check_status && game.player_count_check_status !== 'not_started' && ( +
+ {game.player_count_check_status === 'waiting' && ( + + ⏳ Waiting... + + )} + {game.player_count_check_status === 'checking' && ( + + 🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'} + + )} + {game.player_count_check_status === 'completed' && game.player_count && ( + <> + {editingPlayerCount === game.id ? ( +
+ setNewPlayerCount(e.target.value)} + className="w-12 px-2 py-1 text-xs text-center border border-green-400 dark:border-green-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-green-500" + min="0" + autoFocus + /> + + +
+ ) : ( + <> + + ✓ {game.player_count} players + + {isAuthenticated && ( + + )} + + )} + + )} + {game.player_count_check_status === 'failed' && ( + <> + {editingPlayerCount === game.id ? ( +
+ setNewPlayerCount(e.target.value)} + className="w-12 px-2 py-1 text-xs text-center border border-orange-400 dark:border-orange-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-orange-500" + min="0" + autoFocus + /> + + +
+ ) : ( + <> + + {isAuthenticated && ( + + )} + + )} + + )} + {/* Stop button for active checks */} + {isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && ( + + )} +
+ )}
)} {showPopularity && ( @@ -991,6 +1440,13 @@ function SessionInfo({ sessionId, onGamesUpdate }) { {/* Action buttons for admins */} {isAuthenticated && (
+ {game.status !== 'playing' && (
)} -
+
+ ); }