done
This commit is contained in:
@@ -2,8 +2,20 @@ FROM node:18-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install wget for healthcheck
|
# Install Chromium, fonts, and dependencies for Puppeteer
|
||||||
RUN apk add --no-cache wget
|
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 files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -84,6 +84,20 @@ function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// 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
|
// Add favor_bias column to games if it doesn't exist
|
||||||
try {
|
try {
|
||||||
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"csv-parse": "^5.5.3",
|
"csv-parse": "^5.5.3",
|
||||||
"csv-stringify": "^6.4.5",
|
"csv-stringify": "^6.4.5",
|
||||||
"ws": "^8.14.0"
|
"ws": "^8.14.0",
|
||||||
|
"puppeteer": "^24.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -355,6 +356,16 @@ 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
|
||||||
|
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);
|
res.status(201).json(sessionGame);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
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' });
|
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 });
|
res.json({ message: 'Status updated successfully', status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -580,6 +600,13 @@ 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
|
||||||
|
try {
|
||||||
|
stopPlayerCountCheck(sessionId, gameId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping player count check:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
DELETE FROM session_games
|
DELETE FROM session_games
|
||||||
WHERE session_id = ? AND id = ?
|
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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
541
backend/utils/player-count-checker.js
Normal file
541
backend/utils/player-count-checker.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
- DB_PATH=/app/data/jackbox.db
|
- DB_PATH=/app/data/jackbox.db
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
||||||
|
- DEBUG=false
|
||||||
volumes:
|
volumes:
|
||||||
- jackbox-data:/app/data
|
- jackbox-data:/app/data
|
||||||
- ./games-list.csv:/app/games-list.csv:ro
|
- ./games-list.csv:/app/games-list.csv:ro
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const branding = {
|
|||||||
app: {
|
app: {
|
||||||
name: 'HSO Jackbox Game Picker',
|
name: 'HSO Jackbox Game Picker',
|
||||||
shortName: '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!',
|
description: 'Spicing up Hyper Spaceout game nights!',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function Picker() {
|
|||||||
const [activeSession, setActiveSession] = useState(null);
|
const [activeSession, setActiveSession] = useState(null);
|
||||||
const [allGames, setAllGames] = useState([]);
|
const [allGames, setAllGames] = useState([]);
|
||||||
const [selectedGame, setSelectedGame] = useState(null);
|
const [selectedGame, setSelectedGame] = useState(null);
|
||||||
|
const [playingGame, setPlayingGame] = useState(null); // Currently playing game
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -29,6 +30,9 @@ function Picker() {
|
|||||||
// Manual game selection
|
// Manual game selection
|
||||||
const [showManualSelect, setShowManualSelect] = useState(false);
|
const [showManualSelect, setShowManualSelect] = useState(false);
|
||||||
const [manualGameId, setManualGameId] = useState('');
|
const [manualGameId, setManualGameId] = useState('');
|
||||||
|
const [manualSearchQuery, setManualSearchQuery] = useState('');
|
||||||
|
const [showManualDropdown, setShowManualDropdown] = useState(false);
|
||||||
|
const [filteredManualGames, setFilteredManualGames] = useState([]);
|
||||||
|
|
||||||
// Game pool viewer
|
// Game pool viewer
|
||||||
const [showGamePool, setShowGamePool] = useState(false);
|
const [showGamePool, setShowGamePool] = useState(false);
|
||||||
@@ -87,6 +91,21 @@ function Picker() {
|
|||||||
// Load all games for manual selection
|
// Load all games for manual selection
|
||||||
const gamesResponse = await api.get('/games');
|
const gamesResponse = await api.get('/games');
|
||||||
setAllGames(gamesResponse.data);
|
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) {
|
} catch (err) {
|
||||||
setError('Failed to load session data');
|
setError('Failed to load session data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,6 +135,18 @@ function Picker() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isAuthenticated, authLoading, checkActiveSession]);
|
}, [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 () => {
|
const handleCreateSession = async () => {
|
||||||
try {
|
try {
|
||||||
const newSession = await api.post('/sessions', {});
|
const newSession = await api.post('/sessions', {});
|
||||||
@@ -214,22 +245,29 @@ function Picker() {
|
|||||||
const { type, game, gameId } = pendingGameAction;
|
const { type, game, gameId } = pendingGameAction;
|
||||||
|
|
||||||
if (type === 'accept' || type === 'version') {
|
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,
|
game_id: gameId || game.id,
|
||||||
manually_added: false,
|
manually_added: false,
|
||||||
room_code: roomCode
|
room_code: roomCode
|
||||||
});
|
});
|
||||||
setSelectedGame(null);
|
// Set the newly added game as playing
|
||||||
|
setPlayingGame(response.data);
|
||||||
} else if (type === 'manual') {
|
} else if (type === 'manual') {
|
||||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
const response = await api.post(`/sessions/${activeSession.id}/games`, {
|
||||||
game_id: gameId,
|
game_id: gameId,
|
||||||
manually_added: true,
|
manually_added: true,
|
||||||
room_code: roomCode
|
room_code: roomCode
|
||||||
});
|
});
|
||||||
setManualGameId('');
|
setManualGameId('');
|
||||||
setShowManualSelect(false);
|
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
|
// Trigger games list refresh
|
||||||
setGamesUpdateTrigger(prev => prev + 1);
|
setGamesUpdateTrigger(prev => prev + 1);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -257,8 +295,43 @@ function Picker() {
|
|||||||
game: game
|
game: game
|
||||||
});
|
});
|
||||||
setShowRoomCodeModal(true);
|
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) => {
|
const handleSelectVersion = async (gameId) => {
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
@@ -614,9 +687,75 @@ function Picker() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedGame && (
|
{/* Currently Playing Game Card */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
|
{playingGame && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm bg-green-600 dark:bg-green-700 text-white px-3 py-1 rounded-full font-semibold">
|
||||||
|
🎮 Playing Now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
|
||||||
|
{playingGame.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{playingGame.pack_name}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Players:</span>
|
||||||
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{playingGame.min_players}-{playingGame.max_players}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Length:</span>
|
||||||
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{playingGame.length_minutes ? `${playingGame.length_minutes} min` : 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Type:</span>
|
||||||
|
<span className="ml-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{playingGame.game_type || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700 dark:text-gray-300">Room Code:</span>
|
||||||
|
<span className="ml-2 text-gray-600 dark:text-gray-400 font-mono font-bold">
|
||||||
|
{playingGame.room_code || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="flex-1 bg-green-600 dark:bg-green-700 text-white py-3 rounded-lg opacity-70 cursor-not-allowed font-semibold"
|
||||||
|
>
|
||||||
|
✓ Playing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePickGame}
|
||||||
|
className="flex-1 bg-yellow-600 dark:bg-yellow-700 text-white py-3 rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 transition font-semibold"
|
||||||
|
>
|
||||||
|
🎲 Pick New Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Game Card (from dice roll) */}
|
||||||
|
{selectedGame && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative">
|
||||||
|
{/* Close/Dismiss Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedGame(null)}
|
||||||
|
className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100 pr-8">
|
||||||
{selectedGame.title}
|
{selectedGame.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
|
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
|
||||||
@@ -682,6 +821,13 @@ function Picker() {
|
|||||||
>
|
>
|
||||||
🎲 Re-roll
|
🎲 Re-roll
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedGame(null)}
|
||||||
|
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Other Versions Suggestion */}
|
{/* Other Versions Suggestion */}
|
||||||
@@ -727,18 +873,47 @@ function Picker() {
|
|||||||
Manual Game Selection
|
Manual Game Selection
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||||
<select
|
<div className="flex-1 relative manual-search-container">
|
||||||
value={manualGameId}
|
<input
|
||||||
onChange={(e) => setManualGameId(e.target.value)}
|
type="text"
|
||||||
className="flex-1 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"
|
value={manualSearchQuery}
|
||||||
>
|
onChange={handleManualSearchChange}
|
||||||
<option value="">Select a game...</option>
|
onFocus={() => {
|
||||||
{allGames.map((game) => (
|
if (filteredManualGames.length > 0) {
|
||||||
<option key={game.id} value={game.id}>
|
setShowManualDropdown(true);
|
||||||
{game.title} ({game.pack_name})
|
}
|
||||||
</option>
|
}}
|
||||||
))}
|
placeholder="Type to search games..."
|
||||||
</select>
|
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 && (
|
||||||
|
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
{filteredManualGames.map((game) => (
|
||||||
|
<button
|
||||||
|
key={game.id}
|
||||||
|
onClick={() => handleSelectManualGame(game)}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-600 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
{game.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
|
{game.pack_name} • {game.min_players}-{game.max_players} players
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message - above on mobile, below on desktop */}
|
||||||
|
{manualSearchQuery.trim() && filteredManualGames.length === 0 && !showManualDropdown && (
|
||||||
|
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg px-4 py-3 text-gray-600 dark:text-gray-400 text-sm">
|
||||||
|
No games found matching "{manualSearchQuery}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddManualGame}
|
onClick={handleAddManualGame}
|
||||||
disabled={!manualGameId}
|
disabled={!manualGameId}
|
||||||
@@ -751,14 +926,19 @@ function Picker() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Session info and games */}
|
{/* Session info and games */}
|
||||||
<SessionInfo sessionId={activeSession.id} onGamesUpdate={gamesUpdateTrigger} />
|
<SessionInfo
|
||||||
|
sessionId={activeSession.id}
|
||||||
|
onGamesUpdate={gamesUpdateTrigger}
|
||||||
|
playingGame={playingGame}
|
||||||
|
setPlayingGame={setPlayingGame}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionInfo({ sessionId, onGamesUpdate }) {
|
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const [games, setGames] = useState([]);
|
const [games, setGames] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -766,6 +946,11 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
const [showPopularity, setShowPopularity] = useState(true);
|
const [showPopularity, setShowPopularity] = useState(true);
|
||||||
const [editingRoomCode, setEditingRoomCode] = useState(null);
|
const [editingRoomCode, setEditingRoomCode] = useState(null);
|
||||||
const [newRoomCode, setNewRoomCode] = useState('');
|
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 () => {
|
const loadGames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -792,9 +977,65 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadGames]);
|
}, [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) => {
|
const handleUpdateStatus = async (gameId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
|
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
|
loadGames(); // Reload to get updated statuses
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update game status', err);
|
console.error('Failed to update game status', err);
|
||||||
@@ -816,6 +1057,10 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
const handleRemoveGame = async (gameId) => {
|
const handleRemoveGame = async (gameId) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/sessions/${sessionId}/games/${gameId}`);
|
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);
|
setConfirmingRemove(null);
|
||||||
loadGames(); // Reload after deletion
|
loadGames(); // Reload after deletion
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -857,6 +1102,84 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
setNewRoomCode('');
|
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) => {
|
const getStatusBadge = (status) => {
|
||||||
if (status === 'playing') {
|
if (status === 'playing') {
|
||||||
return (
|
return (
|
||||||
@@ -876,21 +1199,30 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
<>
|
||||||
<div className="flex justify-between items-center mb-4">
|
{/* Room Code Modal for Repeat Game */}
|
||||||
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
|
<RoomCodeModal
|
||||||
Games Played This Session ({games.length})
|
isOpen={showRepeatRoomCodeModal}
|
||||||
</h3>
|
onConfirm={handleRepeatRoomCodeConfirm}
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
onCancel={handleRepeatRoomCodeCancel}
|
||||||
<input
|
gameName={repeatGameData?.title}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={showPopularity}
|
|
||||||
onChange={(e) => setShowPopularity(e.target.checked)}
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
|
||||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
|
<div className="flex justify-between items-center mb-4">
|
||||||
/>
|
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
|
||||||
<span className="whitespace-nowrap">Show Popularity</span>
|
Games Played This Session ({games.length})
|
||||||
</label>
|
</h3>
|
||||||
</div>
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPopularity}
|
||||||
|
onChange={(e) => setShowPopularity(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap">Show Popularity</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
@@ -919,15 +1251,15 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
: 'text-gray-700 dark:text-gray-200'
|
: 'text-gray-700 dark:text-gray-200'
|
||||||
}`}>
|
}`}>
|
||||||
{displayNumber}. {game.title}
|
{displayNumber}. {game.title}
|
||||||
</span>
|
</span>
|
||||||
{getStatusBadge(game.status)}
|
{getStatusBadge(game.status)}
|
||||||
{game.manually_added === 1 && (
|
{game.manually_added === 1 && (
|
||||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
<span className="text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
Manual
|
Manual
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{game.room_code && (
|
{game.room_code && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{editingRoomCode === game.id ? (
|
{editingRoomCode === game.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -944,13 +1276,13 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelEditRoomCode}
|
onClick={handleCancelEditRoomCode}
|
||||||
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
|
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -964,10 +1296,127 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
title="Edit room code"
|
title="Edit room code"
|
||||||
>
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Player Count Display */}
|
||||||
|
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{game.player_count_check_status === 'waiting' && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
|
||||||
|
⏳ Waiting...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.player_count_check_status === 'checking' && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
|
||||||
|
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.player_count_check_status === 'completed' && game.player_count && (
|
||||||
|
<>
|
||||||
|
{editingPlayerCount === game.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPlayerCount}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSavePlayerCount(game.id)}
|
||||||
|
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
|
||||||
|
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditPlayerCount}
|
||||||
|
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
|
||||||
|
✓ {game.player_count} players
|
||||||
|
</span>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditPlayerCount(game.id, game.player_count)}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400"
|
||||||
|
title="Edit player count"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{game.player_count_check_status === 'failed' && (
|
||||||
|
<>
|
||||||
|
{editingPlayerCount === game.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPlayerCount}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSavePlayerCount(game.id)}
|
||||||
|
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
|
||||||
|
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditPlayerCount}
|
||||||
|
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetryPlayerCount(game.id, game.room_code)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 px-2 py-1 rounded hover:bg-orange-200 dark:hover:bg-orange-800 transition cursor-pointer"
|
||||||
|
title="Click to retry detection"
|
||||||
|
>
|
||||||
|
❓ Unknown
|
||||||
|
</button>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditPlayerCount(game.id, null)}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400"
|
||||||
|
title="Set player count manually"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Stop button for active checks */}
|
||||||
|
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStopPlayerCountCheck(game.id)}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
title="Stop checking player count"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showPopularity && (
|
{showPopularity && (
|
||||||
@@ -991,6 +1440,13 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
{/* Action buttons for admins */}
|
{/* Action buttons for admins */}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRepeatGame(game)}
|
||||||
|
className="text-xs px-3 py-1 bg-purple-600 dark:bg-purple-700 text-white rounded hover:bg-purple-700 dark:hover:bg-purple-800 transition"
|
||||||
|
title="Play this game again"
|
||||||
|
>
|
||||||
|
🔁 Repeat
|
||||||
|
</button>
|
||||||
{game.status !== 'playing' && (
|
{game.status !== 'playing' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateStatus(game.id, 'playing')}
|
onClick={() => handleUpdateStatus(game.id, 'playing')}
|
||||||
@@ -1032,7 +1488,8 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user