fixed so the code compiles
This commit is contained in:
+199
-76
@@ -2,6 +2,8 @@ require('dotenv').config();
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const cors = require('cors');
|
||||
const passport = require('./auth/google');
|
||||
const authMiddleware = require('./middleware/auth');
|
||||
@@ -14,8 +16,6 @@ const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
const STAGE_SIZE = 11;
|
||||
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
|
||||
const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected
|
||||
const DC_GRACE_MS = 15000; // don't check until this long after game start
|
||||
|
||||
// ── In-memory matchmaking ──────────────────────────────────────
|
||||
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
|
||||
@@ -43,38 +43,61 @@ 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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 won = userId === winnerId;
|
||||
|
||||
const currentEntry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId, grade: game.grade } },
|
||||
});
|
||||
const currentElo = currentEntry?.elo ?? DEFAULT_ELO;
|
||||
const otherId = players.find(id => id !== userId);
|
||||
const otherEntry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId: otherId, grade: game.grade } },
|
||||
});
|
||||
const opponentElo = otherEntry?.elo ?? DEFAULT_ELO;
|
||||
const result = calculateElo(currentElo, opponentElo, won);
|
||||
const isWinner = userId === winnerId;
|
||||
const change = isWinner ? winnerChange : loserChange;
|
||||
const newElo = eloSnapshot[userId] + change;
|
||||
|
||||
await upsertLeaderboardEntry(userId, game.grade, {
|
||||
elo: result.newElo,
|
||||
bestTime: p.timeSpentMs ?? undefined,
|
||||
elo: newElo,
|
||||
bestTime: p.completedQuiz ? (p.timeSpentMs ?? undefined) : undefined,
|
||||
correctAnswers: p.correctAnswers,
|
||||
});
|
||||
|
||||
p.eloChange = result.change;
|
||||
p.newElo = result.newElo;
|
||||
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,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
// Set completed only after ELO is written so polls see final eloChange
|
||||
game.status = 'completed';
|
||||
|
||||
@@ -83,56 +106,13 @@ async function completeGame(game, forcedWinnerId = null) {
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Disconnect detection — runs every 3s
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const game of Object.values(activeGames)) {
|
||||
if (game.status !== 'active' || game.completing) continue;
|
||||
if (now - game.startedAt < DC_GRACE_MS) continue;
|
||||
|
||||
const players = Object.keys(game.players);
|
||||
const gone = players.filter(id => {
|
||||
const p = game.players[id];
|
||||
return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
if (gone.length === 2) {
|
||||
// Both gone — cancel, no ELO change
|
||||
game.completing = true;
|
||||
game.status = 'completed';
|
||||
game.winner = null;
|
||||
game.completedAt = now;
|
||||
for (const id of players) delete playerGames[id];
|
||||
setTimeout(() => { delete activeGames[game.id]; }, 30000);
|
||||
} else if (gone.length === 1) {
|
||||
const winnerId = players.find(id => id !== gone[0]);
|
||||
completeGame(game, winnerId);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s
|
||||
// Cleanup stale queue entries (30s no heartbeat) and old completed games every 30s
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [userId, q] of Object.entries(queue)) {
|
||||
if (now - q.joinedAt > 300000) delete queue[userId];
|
||||
if (now - q.lastSeen > 30000) delete queue[userId];
|
||||
}
|
||||
for (const [id, game] of Object.entries(activeGames)) {
|
||||
if (game.status === 'active' && now - game.startedAt > 300000) {
|
||||
const players = Object.keys(game.players);
|
||||
const p0 = game.players[players[0]];
|
||||
const p1 = game.players[players[1]];
|
||||
if (p0.finished || p1.finished) {
|
||||
completeGame(game);
|
||||
} else {
|
||||
game.completing = true;
|
||||
game.status = 'completed';
|
||||
game.winner = null;
|
||||
game.completedAt = now;
|
||||
for (const userId of players) delete playerGames[userId];
|
||||
delete activeGames[id];
|
||||
}
|
||||
}
|
||||
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
|
||||
delete activeGames[id];
|
||||
}
|
||||
@@ -141,10 +121,14 @@ setInterval(() => {
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
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', passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
session: false,
|
||||
@@ -177,14 +161,15 @@ app.get('/auth/me', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/leaderboard', async (req, res) => {
|
||||
const { grade } = req.query;
|
||||
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: { grade, bestTime: { not: null } },
|
||||
orderBy: { bestTime: 'asc' },
|
||||
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 } },
|
||||
@@ -196,6 +181,7 @@ app.get('/leaderboard', async (req, res) => {
|
||||
username: e.user.username,
|
||||
avatarUrl: e.user.avatarUrl,
|
||||
bestTime: e.bestTime,
|
||||
elo: e.elo,
|
||||
})));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -206,11 +192,22 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
['G3', 'G6', 'G9'].map(async grade => {
|
||||
const [eloResult, timeResult] = await Promise.all([
|
||||
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,
|
||||
@@ -219,6 +216,8 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
||||
totalCorrect: entry?.totalCorrect ?? 0,
|
||||
eloRank: eloResult?.rank ?? null,
|
||||
timeRank: timeResult?.rank ?? null,
|
||||
totalPlayers,
|
||||
winStreak,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -228,6 +227,23 @@ app.get('/me/stats', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -270,7 +286,7 @@ app.put('/me/username', authMiddleware, async (req, res) => {
|
||||
const lower = username.toLowerCase();
|
||||
try {
|
||||
const conflict = await db.user.findFirst({
|
||||
where: { username: lower, NOT: { id: req.user.userId } },
|
||||
where: { username: lower, id: { not: req.user.userId } },
|
||||
});
|
||||
if (conflict) return res.status(409).json({ error: 'taken' });
|
||||
const updated = await db.user.update({
|
||||
@@ -278,11 +294,40 @@ app.put('/me/username', authMiddleware, async (req, res) => {
|
||||
data: { username: lower },
|
||||
});
|
||||
res.json({ username: updated.username });
|
||||
} catch {
|
||||
} 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 avatarUrl = `${req.protocol}://${req.get('host')}/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)) {
|
||||
@@ -409,7 +454,7 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
return res.json({ status: 'matched', gameId });
|
||||
}
|
||||
|
||||
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl };
|
||||
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);
|
||||
@@ -417,8 +462,29 @@ app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/queue/leave', authMiddleware, (req, res) => {
|
||||
delete queue[req.user.userId];
|
||||
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();
|
||||
await completeGame(game, opponentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -441,6 +507,8 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
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];
|
||||
|
||||
@@ -464,6 +532,7 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
}
|
||||
|
||||
if (queue[req.user.userId]) {
|
||||
queue[req.user.userId].lastSeen = Date.now();
|
||||
return res.json({ status: 'waiting' });
|
||||
}
|
||||
|
||||
@@ -472,6 +541,36 @@ app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
|
||||
// ── 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);
|
||||
completeGame(game, opponentId);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
||||
const game = activeGames[gameId];
|
||||
@@ -485,10 +584,10 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
return res.status(403).json({ error: 'Not a participant in this game' });
|
||||
}
|
||||
|
||||
if (player.finished) return res.json({ success: true }); // idempotent
|
||||
|
||||
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))));
|
||||
}
|
||||
@@ -503,9 +602,18 @@ app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
|
||||
player.finished = true;
|
||||
player.finishedAt = Date.now();
|
||||
player.completedQuiz = true;
|
||||
|
||||
const winner = computeGameResult(game);
|
||||
if (winner) completeGame(game);
|
||||
if (winner) {
|
||||
completeGame(game);
|
||||
} else {
|
||||
// Opponent hasn't finished — award win to this player after 5 minutes if game still active
|
||||
const finishedUserId = req.user.userId;
|
||||
game.abandonTimeout = setTimeout(() => {
|
||||
if (game.status === 'active') completeGame(game, finishedUserId);
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -527,12 +635,27 @@ app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||
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;
|
||||
await completeGame(game, req.user.userId);
|
||||
}
|
||||
|
||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
||||
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? 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,
|
||||
|
||||
Reference in New Issue
Block a user