leaderboard and ranked systems

This commit is contained in:
2026-07-11 11:13:28 -04:00
commit 02ac5f9593
44 changed files with 12318 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const cors = require('cors');
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 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 } }
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) {
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;
const players = Object.keys(game.players);
const winnerId = forcedWinnerId ?? computeGameResult(game);
game.winner = winnerId;
game.completedAt = Date.now();
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);
await upsertLeaderboardEntry(userId, game.grade, {
elo: result.newElo,
bestTime: p.timeSpentMs ?? undefined,
correctAnswers: p.correctAnswers,
});
p.eloChange = result.change;
p.newElo = result.newElo;
delete playerGames[userId];
}
// Set completed only after ELO is written so polls see final eloChange
game.status = 'completed';
setTimeout(() => {
delete activeGames[game.id];
}, 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
setInterval(() => {
const now = Date.now();
for (const [userId, q] of Object.entries(queue)) {
if (now - q.joinedAt > 300000) 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];
}
}
}, 30000);
const app = express();
app.use(express.json());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(passport.initialize());
app.get('/auth/google', passport.authenticate('google', {
scope: ['profile', 'email'],
session: false,
}));
app.get('/auth/google/callback', (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.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 } = req.query;
if (!VALID_GRADES.has(grade)) {
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
}
try {
const entries = await db.leaderboardEntry.findMany({
where: { grade, bestTime: { not: null } },
orderBy: { 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,
})));
} 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] = await Promise.all([
getUserRank(req.user.userId, grade, 'elo'),
getUserRank(req.user.userId, grade, 'bestTime'),
]);
const entry = eloResult?.entry ?? null;
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,
};
})
);
res.json({ entries });
} 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' ? Math.round(timeSpentMs / 1000) : 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, NOT: { id: 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 {
res.status(500).json({ error: 'Internal server error' });
}
});
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;
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]) {
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 questions = generateStage(gameGrade, 1);
const gameId = generateGameId();
const game = {
id: gameId,
grade: gameGrade,
questions,
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,
};
activeGames[gameId] = game;
playerGames[opponentId] = gameId;
playerGames[req.user.userId] = gameId;
return res.json({ status: 'matched', gameId });
}
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl };
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, (req, res) => {
delete queue[req.user.userId];
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, async (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' });
}
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' });
}
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,
opponent: {
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
},
});
}
if (queue[req.user.userId]) {
return res.json({ status: 'waiting' });
}
res.json({ status: 'not_in_queue' });
});
// ── Game management ────────────────────────────────────────────
app.post('/game/progress', authMiddleware, (req, res) => {
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = 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' });
}
if (player.finished) return res.json({ success: true }); // idempotent
player.lastSeen = Date.now();
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 (finished) {
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.finished = true;
player.finishedAt = Date.now();
const winner = computeGameResult(game);
if (winner) completeGame(game);
}
res.json({ success: 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];
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
res.json({
status: game.status,
youWon: game.winner !== null && game.winner === req.user.userId,
players: {
you: {
currentQuestion: player.currentQuestion,
correctAnswers: player.correctAnswers,
finished: player.finished,
timeSpentMs: player.timeSpentMs,
eloChange: player.eloChange,
newElo: player.newElo,
},
opponent: {
username: opponentName,
currentQuestion: opponent.currentQuestion,
correctAnswers: opponent.correctAnswers,
finished: opponent.finished,
timeSpentMs: opponent.timeSpentMs,
},
},
});
});
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}`));