finished many ui changes + polishing dopamine hits
This commit is contained in:
+290
-65
@@ -5,6 +5,9 @@ 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');
|
||||
@@ -13,7 +16,51 @@ 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
|
||||
|
||||
@@ -32,6 +79,7 @@ function computeGameResult(game) {
|
||||
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];
|
||||
@@ -53,51 +101,65 @@ async function completeGame(game, forcedWinnerId = null) {
|
||||
game.winner = winnerId;
|
||||
game.completedAt = Date.now();
|
||||
|
||||
// Snapshot both ELOs before any writes
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
const loserId = players.find(id => id !== winnerId);
|
||||
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
|
||||
// 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;
|
||||
}
|
||||
|
||||
for (const userId of players) {
|
||||
const p = game.players[userId];
|
||||
const isWinner = userId === winnerId;
|
||||
const change = isWinner ? winnerChange : loserChange;
|
||||
const newElo = eloSnapshot[userId] + change;
|
||||
const loserId = players.find(id => id !== winnerId);
|
||||
const { winnerChange, loserChange } = calculateElo(eloSnapshot[winnerId], eloSnapshot[loserId]);
|
||||
|
||||
await upsertLeaderboardEntry(userId, game.grade, {
|
||||
elo: newElo,
|
||||
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
|
||||
correctAnswers: p.correctAnswers,
|
||||
});
|
||||
for (const userId of players) {
|
||||
const p = game.players[userId];
|
||||
const isWinner = userId === winnerId;
|
||||
const change = isWinner ? winnerChange : loserChange;
|
||||
const newElo = eloSnapshot[userId] + change;
|
||||
|
||||
p.eloChange = change;
|
||||
p.newElo = newElo;
|
||||
await upsertLeaderboardEntry(userId, game.grade, {
|
||||
elo: newElo,
|
||||
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
|
||||
correctAnswers: p.correctAnswers,
|
||||
});
|
||||
|
||||
delete playerGames[userId];
|
||||
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];
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Set completed only after ELO is written so polls see final eloChange
|
||||
game.status = 'completed';
|
||||
|
||||
@@ -106,7 +168,7 @@ async function completeGame(game, forcedWinnerId = null) {
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s
|
||||
// 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)) {
|
||||
@@ -115,6 +177,18 @@ setInterval(() => {
|
||||
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);
|
||||
@@ -129,12 +203,12 @@ 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', passport.authenticate('google', {
|
||||
app.get('/auth/google', authLimiter, passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
session: false,
|
||||
}));
|
||||
|
||||
app.get('/auth/google/callback', (req, res, next) => {
|
||||
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);
|
||||
@@ -144,6 +218,51 @@ app.get('/auth/google/callback', (req, res, next) => {
|
||||
})(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 } });
|
||||
@@ -261,7 +380,7 @@ app.post('/game/complete', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
await upsertLeaderboardEntry(userId, grade, {
|
||||
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined,
|
||||
bestTime: typeof timeSpentMs === 'number' ? timeSpentMs : undefined,
|
||||
correctAnswers,
|
||||
});
|
||||
|
||||
@@ -319,7 +438,8 @@ app.post('/me/avatar', authMiddleware, async (req, res) => {
|
||||
const filepath = path.join(UPLOADS_DIR, filename);
|
||||
try {
|
||||
await fs.promises.writeFile(filepath, buf);
|
||||
const avatarUrl = `${req.protocol}://${req.get('host')}/uploads/${filename}`;
|
||||
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) {
|
||||
@@ -389,6 +509,7 @@ app.post('/me/best-time', authMiddleware, async (req, res) => {
|
||||
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 },
|
||||
@@ -417,6 +538,8 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
}
|
||||
|
||||
if (queue[req.user.userId]) {
|
||||
queue[req.user.userId].grade = grade;
|
||||
queue[req.user.userId].lastSeen = Date.now();
|
||||
return res.json({ status: 'waiting' });
|
||||
}
|
||||
|
||||
@@ -432,12 +555,14 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
const gameGrade = opponentQueue.grade;
|
||||
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
|
||||
|
||||
const questions = generateStage(gameGrade, 1);
|
||||
const stage1 = generateStage(gameGrade, 1);
|
||||
const stage2 = generateStage(gameGrade, 2);
|
||||
const gameId = generateGameId();
|
||||
const game = {
|
||||
id: gameId,
|
||||
grade: gameGrade,
|
||||
questions,
|
||||
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() },
|
||||
@@ -446,12 +571,13 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
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 });
|
||||
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() };
|
||||
@@ -480,7 +606,11 @@ app.delete('/queue/leave', authMiddleware, async (req, res) => {
|
||||
player.correctAnswers = 0;
|
||||
player.finished = true;
|
||||
player.finishedAt = Date.now();
|
||||
await completeGame(game, opponentId);
|
||||
try {
|
||||
await completeGame(game, opponentId);
|
||||
} catch (err) {
|
||||
console.error('[queue/leave] completeGame error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,7 +627,7 @@ app.get('/queue/debug', authMiddleware, (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
app.get('/queue/status', authMiddleware, (req, res) => {
|
||||
const gameId = playerGames[req.user.userId];
|
||||
|
||||
if (gameId) {
|
||||
@@ -518,15 +648,13 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
return res.json({ status: 'not_in_queue' });
|
||||
}
|
||||
|
||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } });
|
||||
|
||||
return res.json({
|
||||
status: 'matched',
|
||||
gameId,
|
||||
questions: game.questions,
|
||||
questions: { stage1: game.stage1Questions, stage2: game.stage2Questions },
|
||||
opponent: {
|
||||
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
|
||||
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
|
||||
username: opponent.username ?? opponent.displayName,
|
||||
avatarUrl: opponent.avatarUrl ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -566,13 +694,17 @@ app.post('/game/forfeit', authMiddleware, async (req, res) => {
|
||||
game.abandonTimeout = null;
|
||||
|
||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||
completeGame(game, opponentId);
|
||||
try {
|
||||
await completeGame(game, opponentId);
|
||||
} catch (err) {
|
||||
console.error('[game/forfeit] completeGame error:', err);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, eloChange: player.eloChange ?? 0, newElo: player.newElo ?? 800 });
|
||||
});
|
||||
|
||||
app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
||||
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') {
|
||||
@@ -594,31 +726,96 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
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) {
|
||||
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) {
|
||||
completeGame(game);
|
||||
try {
|
||||
await completeGame(game);
|
||||
} catch (err) {
|
||||
console.error('[game/progress] completeGame error:', err);
|
||||
}
|
||||
} else {
|
||||
// Opponent hasn't finished — award win to this player after 5 minutes if game still active
|
||||
// Opponent hasn't finished — award win to this player after 2 minutes if game still active
|
||||
const finishedUserId = req.user.userId;
|
||||
game.abandonTimeout = setTimeout(() => {
|
||||
if (game.status === 'active') completeGame(game, finishedUserId);
|
||||
}, 5 * 60 * 1000);
|
||||
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) {
|
||||
@@ -639,11 +836,15 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||
const opponentInactive = opponent.lastSeen && Date.now() - opponent.lastSeen > 20000;
|
||||
if (game.status === 'active' && !player.finished && opponentInactive) {
|
||||
game.disconnectedPlayer = opponentId;
|
||||
await completeGame(game, req.user.userId);
|
||||
try {
|
||||
await completeGame(game, req.user.userId);
|
||||
} catch (err) {
|
||||
console.error('[game/status] completeGame error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
||||
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
|
||||
// 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;
|
||||
@@ -664,6 +865,14 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||
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,
|
||||
@@ -671,11 +880,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user