require('dotenv').config(); const crypto = require('crypto'); const express = require('express'); const path = require('path'); const fs = require('fs'); const cors = require('cors'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const rateLimit = require('express-rate-limit'); const passport = require('./auth/google'); const authMiddleware = require('./middleware/auth'); const db = require('./db'); const { getUserRank, upsertLeaderboardEntry } = require('./services/leaderboard'); const { calculateElo, DEFAULT_ELO } = require('./services/elo'); const { generateStage } = require('./services/questions'); const VALID_GRADES = new Set(['G3', 'G6', 'G9']); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'too_many_attempts' }, skipSuccessfulRequests: true, }); const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, message: { error: 'too_many_attempts' }, }); const authLimiter = rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: 'too_many_attempts' }, }); const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function issueJwt(user) { return jwt.sign( { userId: user.id, email: user.email, name: user.displayName, picture: user.avatarUrl ?? null }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '7d' }, ); } async function generateUniqueUsernameFromEmail(email) { const base = (email.split('@')[0].replace(/\./g, '_').replace(/[^a-zA-Z0-9_]/g, '').slice(0, 15).toLowerCase()) || 'user'; if (!await db.user.findFirst({ where: { username: base } })) return base; for (let i = 0; i < 20; i++) { const candidate = `${base}_${String(Math.floor(1000 + Math.random() * 9000))}`; if (!await db.user.findFirst({ where: { username: candidate } })) return candidate; } return `${base}_${Date.now()}`; } const STAGE_SIZE = 11; const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions // ── In-memory matchmaking ────────────────────────────────────── const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } } const playerGames = {}; // { userId: gameId } const activeGames = {}; // { gameId: { ... } } function generateGameId() { return crypto.randomBytes(8).toString('hex'); } function computeGameResult(game) { const players = Object.keys(game.players); const p0 = game.players[players[0]]; const p1 = game.players[players[1]]; if (p0.finished && p1.finished) { if (p0.timeSpentMs === p1.timeSpentMs) return players[Math.random() < 0.5 ? 0 : 1]; return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1]; } if (p0.finished) return players[0]; if (p1.finished) return players[1]; return null; } async function completeGame(game, forcedWinnerId = null) { if (game.completing || game.status !== 'active') return; game.completing = true; if (game.abandonTimeout) { clearTimeout(game.abandonTimeout); game.abandonTimeout = null; } const players = Object.keys(game.players); const winnerId = forcedWinnerId ?? computeGameResult(game); game.winner = winnerId; game.completedAt = Date.now(); // No winner (e.g. both disconnected) — skip ELO, just clean up if (!winnerId) { for (const uid of players) delete playerGames[uid]; game.status = 'completed'; setTimeout(() => { delete activeGames[game.id]; }, 30000); return; } // Snapshot both ELOs before any writes, then write atomically-as-possible try { const eloSnapshot = {}; for (const uid of players) { const entry = await db.leaderboardEntry.findUnique({ where: { userId_grade: { userId: uid, grade: game.grade } }, }); eloSnapshot[uid] = entry?.elo ?? DEFAULT_ELO; } const loserId = players.find(id => id !== winnerId); const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]); for (const userId of players) { const p = game.players[userId]; const isWinner = userId === winnerId; const change = isWinner ? winnerChange : loserChange; const newElo = eloSnapshot[userId] + change; await upsertLeaderboardEntry(userId, game.grade, { elo: newElo, bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined, correctAnswers: p.correctAnswers, }); p.eloChange = change; p.newElo = newElo; delete playerGames[userId]; } // Record per-player match results for dashboard history await Promise.all(players.map(userId => { const p = game.players[userId]; return db.matchResult.create({ data: { userId, grade: game.grade, won: userId === winnerId, eloChange: p.eloChange, eloAfter: p.newElo, timeSpentMs: p.timeSpentMs ?? null, }, }); })); } catch (err) { console.error('[completeGame] DB error during ELO write:', err); // Clean up playerGames refs even on failure so players aren't stuck for (const uid of players) delete playerGames[uid]; } // Set completed only after ELO is written so polls see final eloChange game.status = 'completed'; setTimeout(() => { delete activeGames[game.id]; }, 30000); } // Cleanup stale queue entries (30s no heartbeat) and old/ghost games every 30s setInterval(() => { const now = Date.now(); for (const [userId, q] of Object.entries(queue)) { if (now - q.lastSeen > 30000) delete queue[userId]; } for (const [id, game] of Object.entries(activeGames)) { if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) { delete activeGames[id]; continue; } // Ghost game: all players have been silent for 60+ seconds — purge without ELO changes if (game.status === 'active' && !game.completing) { const allInactive = Object.values(game.players).every( p => p.lastSeen && now - p.lastSeen > 60000, ); if (allInactive) { console.log(`[cleanup] purging ghost game ${id} — all players inactive >60s`); for (const uid of Object.keys(game.players)) delete playerGames[uid]; delete activeGames[id]; } } } }, 30000); const app = express(); app.use(express.json({ limit: '3mb' })); app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true })); app.use(passport.initialize()); const UPLOADS_DIR = path.join(__dirname, '..', 'uploads'); if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); app.use('/uploads', express.static(UPLOADS_DIR)); app.get('/auth/google', authLimiter, passport.authenticate('google', { scope: ['profile', 'email'], session: false, })); app.get('/auth/google/callback', authLimiter, (req, res, next) => { passport.authenticate('google', { session: false }, (err, result) => { if (err || !result) { console.error('[auth/google/callback] failure:', err); return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`); } res.redirect(`${process.env.FRONTEND_URL}/dashboard?token=${result.token}`); })(req, res, next); }); app.post('/auth/register', registerLimiter, async (req, res) => { const { email, password, username } = req.body; if (!email || !EMAIL_RE.test(email)) return res.status(400).json({ error: 'invalid_email' }); if (!password || password.length < 8) return res.status(400).json({ error: 'password_too_short' }); let finalUsername = username ? username.trim() : null; if (finalUsername) { if (!USERNAME_RE.test(finalUsername)) return res.status(400).json({ error: 'invalid_username' }); finalUsername = finalUsername.toLowerCase(); } try { if (await db.user.findUnique({ where: { email } })) return res.status(409).json({ error: 'email_taken' }); if (!finalUsername) finalUsername = await generateUniqueUsernameFromEmail(email); if (await db.user.findFirst({ where: { username: finalUsername } })) return res.status(409).json({ error: 'username_taken' }); const passwordHash = await bcrypt.hash(password, 12); const user = await db.user.create({ data: { email, displayName: finalUsername, username: finalUsername, passwordHash, avatarUrl: null }, }); res.json({ token: issueJwt(user) }); } catch (err) { console.error('[auth/register]', err); if (err.code === 'P2002') { return res.status(409).json({ error: err.meta?.target?.includes('email') ? 'email_taken' : 'username_taken' }); } res.status(500).json({ error: 'internal' }); } }); app.post('/auth/login', loginLimiter, async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'missing_fields' }); try { const user = await db.user.findUnique({ where: { email } }); if (!user) return res.status(401).json({ error: 'invalid_credentials' }); if (!user.passwordHash) return res.status(401).json({ error: 'use_google' }); if (!await bcrypt.compare(password, user.passwordHash)) return res.status(401).json({ error: 'invalid_credentials' }); res.json({ token: issueJwt(user) }); } catch (err) { console.error('[auth/login]', err); res.status(500).json({ error: 'internal' }); } }); app.get('/auth/me', authMiddleware, async (req, res) => { try { const user = await db.user.findUnique({ where: { id: req.user.userId } }); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ id: user.id, displayName: user.displayName, avatarUrl: user.avatarUrl, email: user.email, username: user.username, }); } catch (err) { res.status(500).json({ error: 'Internal server error' }); } }); app.get('/leaderboard', async (req, res) => { const { grade, type = 'time' } = req.query; if (!VALID_GRADES.has(grade)) { return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); } const byElo = type === 'elo'; try { const entries = await db.leaderboardEntry.findMany({ where: byElo ? { grade, gamesPlayed: { gt: 0 } } : { grade, bestTime: { not: null } }, orderBy: byElo ? { elo: 'desc' } : { bestTime: 'asc' }, take: 10, include: { user: { select: { displayName: true, username: true, avatarUrl: true } }, }, }); res.json(entries.map((e, i) => ({ rank: i + 1, displayName: e.user.displayName, username: e.user.username, avatarUrl: e.user.avatarUrl, bestTime: e.bestTime, elo: e.elo, }))); } catch { res.status(500).json({ error: 'Internal server error' }); } }); app.get('/me/stats', authMiddleware, async (req, res) => { try { const entries = await Promise.all( ['G3', 'G6', 'G9'].map(async grade => { const [eloResult, timeResult, totalPlayers, recentMatches] = await Promise.all([ getUserRank(req.user.userId, grade, 'elo'), getUserRank(req.user.userId, grade, 'bestTime'), db.leaderboardEntry.count({ where: { grade, gamesPlayed: { gt: 0 } } }), db.matchResult.findMany({ where: { userId: req.user.userId, grade }, orderBy: { playedAt: 'desc' }, take: 20, }), ]); const entry = eloResult?.entry ?? null; let winStreak = 0; for (const m of recentMatches) { if (m.won) winStreak++; else break; } return { grade, elo: entry?.elo ?? 800, bestTime: entry?.bestTime ?? null, gamesPlayed: entry?.gamesPlayed ?? 0, totalCorrect: entry?.totalCorrect ?? 0, eloRank: eloResult?.rank ?? null, timeRank: timeResult?.rank ?? null, totalPlayers, winStreak, }; }) ); res.json({ entries }); } catch { res.status(500).json({ error: 'Internal server error' }); } }); app.get('/me/recent-matches', authMiddleware, async (req, res) => { const { grade } = req.query; if (grade && !VALID_GRADES.has(grade)) { return res.status(400).json({ error: 'invalid grade' }); } try { const matches = await db.matchResult.findMany({ where: { userId: req.user.userId, ...(grade ? { grade } : {}) }, orderBy: { playedAt: 'desc' }, take: 5, }); res.json(matches); } catch { res.status(500).json({ error: 'Internal server error' }); } }); app.post('/game/complete', authMiddleware, async (req, res) => { const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body; if (!VALID_GRADES.has(grade)) { return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); } if (typeof correctAnswers !== 'number' || typeof totalQuestions !== 'number') { return res.status(400).json({ error: 'correctAnswers and totalQuestions required' }); } try { const userId = req.user.userId; const currentEntry = await db.leaderboardEntry.findUnique({ where: { userId_grade: { userId, grade } }, }); await upsertLeaderboardEntry(userId, grade, { bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined, correctAnswers, }); res.json({ elo: currentEntry?.elo ?? DEFAULT_ELO, eloChange: 0, bestTime: currentEntry?.bestTime ?? null, gamesPlayed: (currentEntry?.gamesPlayed ?? 0) + 1, totalCorrect: (currentEntry?.totalCorrect ?? 0) + correctAnswers, }); } catch (err) { console.error('[game/complete] error:', err); res.status(500).json({ error: 'Internal server error' }); } }); app.put('/me/username', authMiddleware, async (req, res) => { const { username } = req.body; if (!username || !USERNAME_RE.test(username)) { return res.status(400).json({ error: 'invalid' }); } const lower = username.toLowerCase(); try { const conflict = await db.user.findFirst({ where: { username: lower, id: { not: req.user.userId } }, }); if (conflict) return res.status(409).json({ error: 'taken' }); const updated = await db.user.update({ where: { id: req.user.userId }, data: { username: lower }, }); res.json({ username: updated.username }); } catch (err) { console.error('[me/username]', err); if (err.code === 'P2002') return res.status(409).json({ error: 'taken' }); res.status(500).json({ error: 'Internal server error' }); } }); const VALID_IMAGE_RE = /^data:image\/(png|jpeg|jpg|gif|webp);base64,/; app.post('/me/avatar', authMiddleware, async (req, res) => { const { image } = req.body; if (!image || typeof image !== 'string' || !VALID_IMAGE_RE.test(image)) { return res.status(400).json({ error: 'Invalid image data' }); } const base64Data = image.replace(/^data:image\/\w+;base64,/, ''); const buf = Buffer.from(base64Data, 'base64'); if (buf.length > 2 * 1024 * 1024) { return res.status(400).json({ error: 'Image too large (max 2MB)' }); } const mime = image.match(/^data:(image\/\w+);/)[1]; const ext = mime.split('/')[1].replace('jpeg', 'jpg'); const filename = `avatar_${req.user.userId}_${Date.now()}.${ext}`; const filepath = path.join(UPLOADS_DIR, filename); try { await fs.promises.writeFile(filepath, buf); const base = process.env.BACKEND_URL ?? `${req.protocol}://${req.get('host')}`; const avatarUrl = `${base}/uploads/${filename}`; await db.user.update({ where: { id: req.user.userId }, data: { avatarUrl } }); res.json({ avatarUrl }); } catch (err) { console.error('[me/avatar]', err); res.status(500).json({ error: 'Failed to save image' }); } }); app.get('/questions', authMiddleware, (req, res) => { const { grade } = req.query; if (!VALID_GRADES.has(grade)) { return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); } res.json(generateStage(grade, 1)); }); app.post('/session/start', authMiddleware, async (req, res) => { const { grade, subject } = req.body; const VALID_SUBJECTS = new Set(['MATH', 'READING', 'WRITING']); if (!VALID_GRADES.has(grade) || !VALID_SUBJECTS.has(subject)) { return res.status(400).json({ error: 'invalid grade or subject' }); } try { const session = await db.gameSession.create({ data: { userId: req.user.userId, grade, subject, totalQuestions: 22, status: 'IN_PROGRESS' }, }); res.json({ sessionId: session.id }); } catch (err) { console.error('[session/start]', err); res.status(500).json({ error: 'Internal server error' }); } }); app.post('/session/complete', authMiddleware, async (req, res) => { const { sessionId, correctAnswers, totalQuestions } = req.body; if (!sessionId) return res.status(400).json({ error: 'sessionId required' }); try { const session = await db.gameSession.findUnique({ where: { id: sessionId } }); if (!session || session.userId !== req.user.userId) { return res.status(404).json({ error: 'Session not found' }); } const total = totalQuestions ?? session.totalQuestions; await db.gameSession.update({ where: { id: sessionId }, data: { status: 'COMPLETED', correctAnswers: correctAnswers ?? 0, totalQuestions: total, score: Math.round(((correctAnswers ?? 0) / (total || 1)) * 100), completedAt: new Date(), }, }); res.json({ ok: true }); } catch (err) { console.error('[session/complete]', err); res.status(500).json({ error: 'Internal server error' }); } }); app.post('/me/best-time', authMiddleware, async (req, res) => { const { grade, timeMs } = req.body; if (!VALID_GRADES.has(grade) || typeof timeMs !== 'number' || timeMs <= 0) { return res.status(400).json({ error: 'Invalid grade or timeMs' }); } try { const entry = await db.leaderboardEntry.findUnique({ where: { userId_grade: { userId: req.user.userId, grade } }, }); const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime; console.log('[best-time] userId:', req.user.userId, 'grade:', grade, 'timeMs:', timeMs, 'isNewBest:', isNewBest); const updated = await db.leaderboardEntry.upsert({ where: { userId_grade: { userId: req.user.userId, grade } }, create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 }, update: { ...(isNewBest ? { bestTime: timeMs } : {}), gamesPlayed: { increment: 1 }, }, }); res.json({ updated: isNewBest, bestTime: updated.bestTime }); } catch { res.status(500).json({ error: 'Internal server error' }); } }); // ── Matchmaking ──────────────────────────────────────────────── app.post('/queue/join', authMiddleware, async (req, res) => { const { grade } = req.body; if (!VALID_GRADES.has(grade)) { return res.status(400).json({ error: 'grade must be one of G3, G6, G9' }); } try { if (playerGames[req.user.userId]) { return res.json({ status: 'matched', gameId: playerGames[req.user.userId] }); } if (queue[req.user.userId]) { queue[req.user.userId].grade = grade; queue[req.user.userId].lastSeen = Date.now(); return res.json({ status: 'waiting' }); } const user = await db.user.findUnique({ where: { id: req.user.userId } }); if (!user) return res.status(404).json({ error: 'User not found' }); console.log(`[queue/join] user=${req.user.userId} grade=${grade} queue_size=${Object.keys(queue).length} queue_keys=${JSON.stringify(Object.keys(queue))}`); const existing = Object.entries(queue).find(([id, q]) => id !== req.user.userId && q.grade === grade); if (existing) { const [opponentId, opponentQueue] = existing; delete queue[opponentId]; const gameGrade = opponentQueue.grade; console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`); const stage1 = generateStage(gameGrade, 1); const stage2 = generateStage(gameGrade, 2); const gameId = generateGameId(); const game = { id: gameId, grade: gameGrade, stage1Questions: stage1, stage2Questions: stage2, players: { [opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, [req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() }, }, status: 'active', winner: null, startedAt: Date.now(), completedAt: null, chatMessages: [], }; activeGames[gameId] = game; playerGames[opponentId] = gameId; playerGames[req.user.userId] = gameId; return res.json({ status: 'matched', gameId, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions } }); } queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, lastSeen: Date.now() }; res.json({ status: 'waiting' }); } catch (err) { console.error('[queue/join] error:', err); res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/queue/leave', authMiddleware, async (req, res) => { const userId = req.user.userId; delete queue[userId]; // If the player was matched into a game during the cancel window, forfeit it // immediately so the opponent isn't stuck waiting for a ghost. const gameId = playerGames[userId]; if (gameId) { const game = activeGames[gameId]; if (game && game.status === 'active') { const player = game.players[userId]; if (player && !player.finished) { const opponentId = Object.keys(game.players).find(id => id !== userId); // Leave timeSpentMs null so completeGame doesn't record a bestTime for // someone who cancelled before the game even started. player.correctAnswers = 0; player.finished = true; player.finishedAt = Date.now(); try { await completeGame(game, opponentId); } catch (err) { console.error('[queue/leave] completeGame error:', err); } } } } res.json({ success: true }); }); app.get('/queue/debug', authMiddleware, (_req, res) => { res.json({ queueSize: Object.keys(queue).length, queue: Object.entries(queue).map(([id, q]) => ({ id: id.slice(0,8), grade: q.grade, joinedAgo: Date.now() - q.joinedAt })), activeGames: Object.keys(activeGames).length, playerGames: Object.keys(playerGames).length, }); }); app.get('/queue/status', authMiddleware, (req, res) => { const gameId = playerGames[req.user.userId]; if (gameId) { const game = activeGames[gameId]; if (!game) { delete playerGames[req.user.userId]; return res.json({ status: 'not_in_queue' }); } game.players[req.user.userId].lastSeen = Date.now(); const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); const opponent = game.players[opponentId]; if (!opponent) { delete playerGames[req.user.userId]; delete activeGames[gameId]; return res.json({ status: 'not_in_queue' }); } return res.json({ status: 'matched', gameId, questions: { stage1: game.stage1Questions, stage2: game.stage2Questions }, opponent: { username: opponent.username ?? opponent.displayName, avatarUrl: opponent.avatarUrl ?? null, }, }); } if (queue[req.user.userId]) { queue[req.user.userId].lastSeen = Date.now(); return res.json({ status: 'waiting' }); } res.json({ status: 'not_in_queue' }); }); // ── Game management ──────────────────────────────────────────── app.post('/game/forfeit', authMiddleware, async (req, res) => { const { gameId } = req.body; if (!gameId) return res.status(400).json({ error: 'gameId required' }); const game = activeGames[gameId]; if (!game || game.status !== 'active') { return res.status(404).json({ error: 'Game not found or already completed' }); } const player = game.players[req.user.userId]; if (!player) { return res.status(403).json({ error: 'Not a participant in this game' }); } if (player.finished) return res.json({ success: true }); const elapsed = Date.now() - game.startedAt; player.timeSpentMs = Math.max(MIN_GAME_MS, elapsed + 5000); player.correctAnswers = 0; player.finished = true; player.finishedAt = Date.now(); game.abandonTimeout = null; const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); try { await completeGame(game, opponentId); } catch (err) { console.error('[game/forfeit] completeGame error:', err); } res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 }); }); app.post('/game/progress', authMiddleware, async (req, res) => { const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage } = req.body; const game = activeGames[gameId]; if (!game || game.status !== 'active') { return res.status(404).json({ error: 'Game not found or already completed' }); } const player = game.players[req.user.userId]; if (!player) { return res.status(403).json({ error: 'Not a participant in this game' }); } player.lastSeen = Date.now(); if (player.finished) return res.json({ success: true }); // idempotent if (currentQuestion !== undefined) { player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion)))); } if (correctAnswers !== undefined) { player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers)))); } if (stage !== undefined) player.currentStage = Number(stage) || 1; if (finished && stage === 1) { player.stage1Finished = true; player.stage1CorrectCount = player.correctAnswers ?? 0; } if (finished && stage === 2) { const elapsed = Date.now() - game.startedAt; const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed; // Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000)); player.stage2Finished = true; player.finished = true; player.finishedAt = Date.now(); player.completedQuiz = true; const winner = computeGameResult(game); if (winner) { try { await completeGame(game); } catch (err) { console.error('[game/progress] completeGame error:', err); } } else { // Opponent hasn't finished — award win to this player after 2 minutes if game still active const finishedUserId = req.user.userId; game.abandonTimeout = setTimeout(async () => { if (game.status === 'active') { try { await completeGame(game, finishedUserId); } catch (err) { console.error('[game/progress] abandonTimeout completeGame error:', err); } } }, 2 * 60 * 1000); } } res.json({ success: true }); }); app.post('/game/emote', authMiddleware, (req, res) => { const { gameId, emote } = req.body; if (!gameId) return res.status(400).json({ error: 'gameId required' }); if (!emote || typeof emote !== 'string' || !emote.trim() || emote.length > 20) { return res.status(400).json({ error: 'invalid emote' }); } const game = activeGames[gameId]; if (!game || game.status !== 'active') { return res.status(404).json({ error: 'Game not found or already completed' }); } const player = game.players[req.user.userId]; if (!player) return res.status(403).json({ error: 'Not a participant' }); player.lastEmote = { text: emote.trim(), sentAt: Date.now() }; res.json({ ok: true }); }); app.post('/game/chat', authMiddleware, (req, res) => { const { gameId, message } = req.body; if (!gameId) return res.status(400).json({ error: 'gameId required' }); if (!message || typeof message !== 'string' || !message.trim()) { return res.status(400).json({ error: 'invalid message' }); } const trimmed = message.trim().slice(0, 120); const game = activeGames[gameId]; if (!game || game.status !== 'active') { return res.status(404).json({ error: 'Game not found or already completed' }); } const player = game.players[req.user.userId]; if (!player) return res.status(403).json({ error: 'Not a participant' }); if (!player.chatMsgTimes) player.chatMsgTimes = []; const now = Date.now(); player.chatMsgTimes = player.chatMsgTimes.filter(t => now - t < 1000); if (player.chatMsgTimes.length >= 4) return res.status(429).json({ error: 'slow_down' }); player.chatMsgTimes.push(now); game.chatMessages.push({ userId: req.user.userId, username: player.username ?? player.displayName, text: trimmed, sentAt: now, elapsedMs: now - game.startedAt, }); if (game.chatMessages.length > 50) game.chatMessages.shift(); res.json({ ok: true }); }); app.get('/game/status/:gameId', authMiddleware, async (req, res) => { const game = activeGames[req.params.gameId]; if (!game) { return res.status(404).json({ error: 'Game not found' }); } const player = game.players[req.user.userId]; if (!player) { return res.status(403).json({ error: 'Not a participant in this game' }); } player.lastSeen = Date.now(); const opponentId = Object.keys(game.players).find(id => id !== req.user.userId); const opponent = game.players[opponentId]; // Detect opponent disconnect — award win after 20s of inactivity const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000; if (game.status === 'active' && !player.finished && opponentInactive) { game.disconnectedPlayer = opponentId; try { await completeGame(game, req.user.userId); } catch (err) { console.error('[game/status] completeGame error:', err); } } // Use cached name — avoids a DB query on every 1-second poll const opponentName = opponent.username ?? opponent.displayName; const opponentDced = game.disconnectedPlayer === opponentId; const youDced = game.disconnectedPlayer === req.user.userId; // Show DC badge early (8s inactivity) before the game officially ends at 20s const opponentConnected = !opponentDced && (!opponent.lastSeen || Date.now() - opponent.lastSeen < 8000); res.json({ status: game.status, youWon: game.winner !== null && game.winner === req.user.userId, opponentDced, youDced, opponentConnected, players: { you: { currentQuestion: player.currentQuestion, correctAnswers: player.correctAnswers, finished: player.finished, timeSpentMs: player.timeSpentMs, eloChange: player.eloChange, newElo: player.newElo, currentStage: player.currentStage ?? 1, stage1Finished: player.stage1Finished ?? false, stage: player.currentStage ?? 1, savedProgress: { currentQuestion: player.currentQuestion ?? 0, correctAnswers: player.correctAnswers ?? 0, stage: player.currentStage ?? 1, }, }, opponent: { username: opponentName, currentQuestion: opponent.currentQuestion, correctAnswers: opponent.correctAnswers, finished: opponent.finished, timeSpentMs: opponent.timeSpentMs, currentStage: opponent.currentStage ?? 1, stage1Finished: opponent.stage1Finished ?? false, lastEmote: (opponent.lastEmote && Date.now() - opponent.lastEmote.sentAt < 4000) ? opponent.lastEmote.text : null, }, }, chatMessages: game.chatMessages ?? [], }); }); app.get('/online-count', (_req, res) => { const now = Date.now(); const inQueue = Object.values(queue).filter(q => now - q.lastSeen <= 30000).length; const inGame = Object.entries(playerGames).filter(([uid, gameId]) => { const game = activeGames[gameId]; if (!game || game.status !== 'active') return false; const p = game.players[uid]; return p && p.lastSeen && now - p.lastSeen <= 30000; }).length; res.json({ online: inQueue + inGame }); }); app.get('/health', (_req, res) => res.json({ status: 'ok' })); const PORT = process.env.PORT || 3000; app.listen(PORT, () => console.log(`Server running on port ${PORT}`));