leaderboard and ranked systems
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
const passport = require('passport');
|
||||
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
|
||||
async function generateUniqueUsername(base) {
|
||||
const candidate = base.toLowerCase();
|
||||
const taken = await db.user.findFirst({ where: { username: candidate } });
|
||||
if (!taken) return candidate;
|
||||
let attempts = 0;
|
||||
while (attempts < 20) {
|
||||
const suffix = String(Math.floor(1000 + Math.random() * 9000));
|
||||
const username = `${candidate}${suffix}`;
|
||||
const collision = await db.user.findFirst({ where: { username } });
|
||||
if (!collision) return username;
|
||||
attempts++;
|
||||
}
|
||||
return `${candidate}${Date.now()}`;
|
||||
}
|
||||
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||
},
|
||||
async (_accessToken, _refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = profile.emails?.[0]?.value ?? '';
|
||||
let user = await db.user.findUnique({ where: { googleId: profile.id } });
|
||||
|
||||
if (!user) {
|
||||
const username = await generateUniqueUsername(email.split('@')[0]);
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
googleId: profile.id,
|
||||
email,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.photos?.[0]?.value ?? null,
|
||||
username,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
user = await db.user.update({
|
||||
where: { googleId: profile.id },
|
||||
data: {
|
||||
email,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.photos?.[0]?.value ?? null,
|
||||
},
|
||||
});
|
||||
if (!user.username) {
|
||||
const username = await generateUniqueUsername((user.email ?? '').split('@')[0]);
|
||||
user = await db.user.update({ where: { id: user.id }, data: { username } });
|
||||
}
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
picture: user.avatarUrl,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ algorithm: 'HS256', expiresIn: '7d' }
|
||||
);
|
||||
|
||||
done(null, { token });
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
module.exports = passport;
|
||||
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
module.exports = db;
|
||||
+559
@@ -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}`));
|
||||
@@ -0,0 +1,19 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing authorization token' });
|
||||
}
|
||||
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
req.user = { userId: payload.userId, email: payload.email };
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = authMiddleware;
|
||||
@@ -0,0 +1,16 @@
|
||||
const DEFAULT_ELO = 800;
|
||||
const BASE_CHANGE = 10;
|
||||
const DIFF_STEP = 30;
|
||||
const MAX_CHANGE = 30;
|
||||
const MIN_CHANGE = 1;
|
||||
|
||||
// Custom ELO: base ±10, adjusted ±1 for every 30-point gap, clamped [1, 30].
|
||||
// Underdog who wins gains more; favourite who wins gains less.
|
||||
function calculateElo(yourElo, opponentElo, won) {
|
||||
const diff = opponentElo - yourElo;
|
||||
const magnitude = Math.max(MIN_CHANGE, Math.min(MAX_CHANGE, BASE_CHANGE + Math.floor(diff / DIFF_STEP)));
|
||||
const change = won ? magnitude : -magnitude;
|
||||
return { newElo: yourElo + change, change };
|
||||
}
|
||||
|
||||
module.exports = { calculateElo, DEFAULT_ELO };
|
||||
@@ -0,0 +1,73 @@
|
||||
const db = require('../db');
|
||||
|
||||
async function upsertLeaderboardEntry(userId, grade, { elo, bestTime, correctAnswers = 0 } = {}) {
|
||||
const existing = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId, grade } },
|
||||
});
|
||||
|
||||
return db.leaderboardEntry.upsert({
|
||||
where: { userId_grade: { userId, grade } },
|
||||
create: {
|
||||
userId,
|
||||
grade,
|
||||
elo: elo ?? 800,
|
||||
bestTime: bestTime ?? null,
|
||||
gamesPlayed: 1,
|
||||
totalCorrect: correctAnswers,
|
||||
},
|
||||
update: {
|
||||
...(elo !== undefined && { elo }),
|
||||
...(bestTime !== undefined && existing?.bestTime == null || bestTime < (existing?.bestTime ?? Infinity)
|
||||
? { bestTime }
|
||||
: {}),
|
||||
gamesPlayed: { increment: 1 },
|
||||
totalCorrect: { increment: correctAnswers },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getLeaderboard(grade, sortBy = 'elo', limit = 10) {
|
||||
const orderBy = sortBy === 'bestTime'
|
||||
? { bestTime: { sort: 'asc', nulls: 'last' } }
|
||||
: { elo: 'desc' };
|
||||
|
||||
const entries = await db.leaderboardEntry.findMany({
|
||||
where: { grade },
|
||||
orderBy,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { displayName: true, avatarUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return entries.map((entry, index) => ({ ...entry, rank: index + 1 }));
|
||||
}
|
||||
|
||||
async function getUserRank(userId, grade, sortBy = 'elo') {
|
||||
const entry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId, grade } },
|
||||
});
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
if (sortBy === 'bestTime') {
|
||||
if (entry.bestTime === null) return { rank: null, entry };
|
||||
const count = await db.leaderboardEntry.count({
|
||||
where: {
|
||||
grade,
|
||||
AND: [
|
||||
{ bestTime: { not: null } },
|
||||
{ bestTime: { lt: entry.bestTime } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return { rank: count + 1, entry };
|
||||
}
|
||||
|
||||
const count = await db.leaderboardEntry.count({
|
||||
where: { grade, elo: { gt: entry.elo } },
|
||||
});
|
||||
return { rank: count + 1, entry };
|
||||
}
|
||||
|
||||
module.exports = { upsertLeaderboardEntry, getLeaderboard, getUserRank };
|
||||
@@ -0,0 +1,110 @@
|
||||
const rand = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;
|
||||
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
const shuffle = arr => {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
function makeQ(question, correct, wrongs) {
|
||||
const ca = String(correct);
|
||||
const ws = [...new Set(wrongs.map(String))].filter(w => w !== ca);
|
||||
const choices = shuffle([ca, ...ws]);
|
||||
return { question, choices, correct: choices.indexOf(ca), layout: choices.length === 3 ? 'list' : 'grid' };
|
||||
}
|
||||
|
||||
const g3s1 = [
|
||||
() => { const p1 = rand(15,85), p2 = rand(10,75), t = p1+p2; const item1 = pick(['a pencil','an eraser','a ruler','a bookmark']), item2 = pick(['a sticker','a crayon','a marker','a folder']); return makeQ(`Sasha buys ${item1} for ${p1}¢ and ${item2} for ${p2}¢. How much does she pay altogether?`, `${t}¢`, [`${t+rand(5,15)}¢`,`${t-rand(3,10)}¢`,`${t+rand(20,35)}¢`]); },
|
||||
() => { const w = rand(2,6), t = w*7; return makeQ(`How many days are in ${w} weeks?`, t, [t+7, t-7, t+rand(1,3)]); },
|
||||
() => { const step = pick([2,5,10,3]), start = rand(1,8)*step, seq = [start,start+step,start+2*step,start+3*step], next = start+4*step; return makeQ(`What is the next number in this pattern?\n${seq.join(', ')}, ____`, next, [next+step, next-step, next+2*step]); },
|
||||
() => { const l = rand(3,12), w2 = rand(2,9), p = 2*(l+w2); return makeQ(`A rectangle is ${l} cm long and ${w2} cm wide. What is its perimeter?`, `${p} cm`, [`${p+4} cm`,`${p-4} cm`,`${l*w2} cm`]); },
|
||||
() => { const d = pick([2,3,4,6,8]), n = rand(1,d-1); return makeQ(`A pizza is cut into ${d} equal slices. ${n} slice${n>1?'s are':' is'} eaten. What fraction has been eaten?`, `${n}/${d}`, [`${n+1}/${d}`,`${n}/${d+1}`,`${d-n}/${d}`]); },
|
||||
() => { const g = rand(3,8), e = rand(2,9), t = g*e; const item = pick(['apples','books','stickers','marbles']); return makeQ(`There are ${g} bags with ${e} ${item} in each bag. How many ${item} are there in total?`, t, [t+e, t-e, g+e]); },
|
||||
() => { const [obj,unit,w1,w2,w3] = pick([['the length of a room','metres','centimetres','kilometres','millimetres'],['the weight of a watermelon','kilograms','grams','litres','metres'],['the length of an ant','millimetres','metres','kilometres','kilograms'],['the amount of water in a bathtub','litres','kilograms','metres','grams']]); return makeQ(`Which unit would you use to measure ${obj}?`, unit, [w1,w2,w3]); },
|
||||
() => { const sh = rand(8,11), dur = rand(1,4), eh = sh+dur; const item = pick(['movie','soccer game','music class','swimming lesson']); return makeQ(`A ${item} starts at ${sh}:00 and ends at ${eh}:00. How long does it last?`, `${dur} hour${dur>1?'s':''}`, [`${dur+1} hours`,`${dur>1?dur-1:dur+2} hours`,`${dur+2} hours`]); },
|
||||
() => { const total = rand(40,120), used = rand(10,total-10), left = total-used; const item = pick(['stickers','crayons','cards','beads']); return makeQ(`Jamal had ${total} ${item}. He gave ${used} to his friend. How many does he have now?`, left, [left+rand(5,10), left>5?left-rand(3,5):left+15, total+used]); },
|
||||
() => { const n = rand(10,99); const isEven = n%2===0; return makeQ(`Is the number ${n} even or odd?`, isEven?'Even':'Odd', isEven?['Odd','Neither','Both']:['Even','Neither','Both']); },
|
||||
() => { const animals=['dogs','cats','birds'], counts=[rand(3,9),rand(3,9),rand(3,9)], total=counts.reduce((a,b)=>a+b,0), table=animals.map((a,i)=>`${a}: ${counts[i]}`).join(' | '); return makeQ(`A survey counted pets:\n${table}\nHow many pets were counted in total?`, total, [total+rand(2,8), total-rand(2,5), counts[0]+counts[1]]); },
|
||||
];
|
||||
|
||||
const g3s2 = [
|
||||
() => { const l = rand(3,7), w2 = rand(2,6), area = l*w2; return makeQ(`A rectangle is ${l} units long and ${w2} units wide. What is its area?`, `${area} square units`, [`${area+l} square units`,`${2*(l+w2)} square units`,`${area-w2} square units`]); },
|
||||
() => { const start = rand(2,15), rule = rand(3,9), seq = [start,start+rule,start+2*rule,start+3*rule]; return makeQ(`This pattern follows a rule: ${seq.join(', ')} ...\nWhat is the rule?`, `Add ${rule}`, [`Add ${rule+1}`,`Add ${rule-1}`,`Multiply by 2`]); },
|
||||
() => { const b = pick([4,6,8]); const a = rand(1,b-1); const d = pick([4,6,8]); let c = rand(1,d-1); let tries=0; while(a*d===c*b && tries<20){c=rand(1,d-1);tries++;} const bigger = a/b>c/d?`${a}/${b}`:`${c}/${d}`, smaller = a/b>c/d?`${c}/${d}`:`${a}/${b}`; return makeQ(`Which fraction is larger: ${a}/${b} or ${c}/${d}?`, bigger, [smaller,'They are equal','Cannot tell']); },
|
||||
() => { const [shape,faces] = pick([['cube',6],['rectangular prism',6],['triangular prism',5],['square pyramid',5]]); return makeQ(`How many faces does a ${shape} have?`, faces, [faces+1, faces-1, faces+2]); },
|
||||
() => { const a = rand(3,9), b = rand(3,9), product = a*b; return makeQ(`What is ${a} × ${b}?`, product, [product+b, product-a, (a+1)*b]); },
|
||||
() => { const price = rand(25,95), paid = Math.ceil(price/25)*25+25, change = paid-price; return makeQ(`An item costs ${price}¢. You pay ${paid}¢. How much change do you get?`, `${change}¢`, [`${change+5}¢`,`${change-5}¢`,`${change+10}¢`]); },
|
||||
() => { const sh = rand(9,12), m = pick([0,30]), dur = rand(1,3), eh = sh+dur; const fmt = (h,mm) => `${h}:${mm===0?'00':mm}`; return makeQ(`A library visit starts at ${fmt(sh,m)} and lasts ${dur} hour${dur>1?'s':''}. What time does it end?`, fmt(eh,m), [fmt(eh+1,m),fmt(eh-1,m),fmt(eh,m===0?30:0)]); },
|
||||
() => { const rows = rand(3,6), cols = rand(3,6), extra = rand(2,8), total = rows*cols+extra; const item = pick(['chairs','books','tiles','eggs']); return makeQ(`There are ${rows} rows of ${cols} ${item} and ${extra} more on the side. How many ${item} altogether?`, total, [rows*cols, total+extra, total-rows]); },
|
||||
() => { const n = rand(12,97), rounded = Math.round(n/10)*10; return makeQ(`Round ${n} to the nearest 10.`, rounded, [rounded+10, rounded-10>=0?rounded-10:rounded+20, n]); },
|
||||
() => { const n = rand(2,10)*4, quarter = n/4; return makeQ(`What is one quarter of ${n}?`, quarter, [n/2, quarter*3, quarter+1]); },
|
||||
() => { const a = rand(100,499), b = rand(100,499), total = a+b; return makeQ(`What is ${a} + ${b}?`, total, [total+10, total-10, total+100]); },
|
||||
];
|
||||
|
||||
const g6s1 = [
|
||||
() => { const rate = rand(3,12), time = rand(4,10), total = rate*time; const item = pick(['pages','laps','problems','push-ups']); return makeQ(`Priya reads ${rate} ${item} per hour. How many will she read in ${time} hours?`, total, [total+rate, total-rate, rate+time]); },
|
||||
() => { const pct = pick([10,20,25,50]), whole = rand(2,20)*10, result = whole*pct/100; return makeQ(`What is ${pct}% of ${whole}?`, result, [result+5, result*2, whole-result]); },
|
||||
() => { const base = rand(4,14)*2, height = rand(3,10)*2, area = (base*height)/2; return makeQ(`A triangle has a base of ${base} cm and a height of ${height} cm. What is its area?`, `${area} cm²`, [`${base*height} cm²`,`${area+base} cm²`,`${area-height} cm²`]); },
|
||||
() => { const bp = rand(2,5), rp = rand(2,5), mult = rand(2,5), blue = bp*mult, red = rp*mult; return makeQ(`For every ${bp} blue marbles there are ${rp} red marbles. If there are ${blue} blue marbles, how many red marbles are there?`, red, [red+rp, red-rp, red+mult]); },
|
||||
() => { const a = rand(2,8), b = rand(2,6), c = rand(2,10), result = a+b*c; return makeQ(`Evaluate: ${a} + ${b} × ${c}`, result, [(a+b)*c, result+b, result-a]); },
|
||||
() => { const l = rand(2,8), w2 = rand(2,6), h = rand(2,5), vol = l*w2*h; return makeQ(`What is the volume of a rectangular prism with length ${l} cm, width ${w2} cm, and height ${h} cm?`, `${vol} cm³`, [`${vol+l*w2} cm³`,`${2*(l*w2+l*h+w2*h)} cm³`,`${l+w2+h} cm³`]); },
|
||||
() => { const d = pick([2,3,4,5]), whole = d*rand(3,8), result = whole/d; return makeQ(`What is 1/${d} of ${whole}?`, result, [result+d, result*d, result-1]); },
|
||||
() => { const a = rand(-8,-1), b = rand(1,10), sum = a+b; return makeQ(`What is ${a} + ${b}?`, sum, [sum+2, sum-2, Math.abs(a)+b]); },
|
||||
() => { const vals = [rand(4,12),rand(4,12),rand(4,12),rand(4,12)]; const s = vals.reduce((a,b)=>a+b,0); const adj = s%4; if(adj!==0) vals[3]+=4-adj; const s2 = vals.reduce((a,b)=>a+b,0), mean = s2/4; return makeQ(`Find the mean of: ${vals.join(', ')}`, mean, [mean+2, mean-1, s2]); },
|
||||
() => { const side = rand(3,15), perim = 4*side; return makeQ(`A square has a perimeter of ${perim} cm. What is the length of one side?`, `${side} cm`, [`${side+2} cm`,`${side-1} cm`,`${perim/2} cm`]); },
|
||||
() => { const a = rand(1,9)+rand(1,9)/10, b = rand(1,9)+rand(1,9)/10, total = Math.round((a+b)*10)/10; return makeQ(`What is ${a.toFixed(1)} + ${b.toFixed(1)}?`, total.toFixed(1), [(total+0.5).toFixed(1),(total-0.3).toFixed(1),(total+1).toFixed(1)]); },
|
||||
];
|
||||
|
||||
const g6s2 = [
|
||||
() => { const total = rand(4,20)*10, pct = pick([10,20,25,50]), part = total*pct/100; return makeQ(`In a class of ${total} students, ${pct}% walk to school. How many students walk?`, part, [part+5, part-5, total-part]); },
|
||||
() => { const km = rand(2,15), m = km*1000; return makeQ(`A hiking trail is ${km} km long. How many metres is this?`, `${m} m`, [`${m+100} m`,`${km*100} m`,`${m-500} m`]); },
|
||||
() => { const x = rand(3,15), mult = rand(2,6), product = mult*x; return makeQ(`Solve for n: ${mult}n = ${product}`, x, [x+1, x-1, mult+x]); },
|
||||
() => { const total = pick([4,5,6,8,10]), fav = rand(1,total-1); return makeQ(`A bag has ${total} marbles: ${fav} red and ${total-fav} blue. What is the probability of picking red?`, `${fav}/${total}`, [`${fav+1}/${total}`,`${total-fav}/${total}`,`${fav}/${total-1}`]); },
|
||||
() => { const deg = pick([30,45,60,90,120,150]); const type = deg<90?'acute':deg===90?'right':'obtuse'; return makeQ(`An angle measures ${deg}°. What type of angle is it?`, type, ['acute','right','obtuse','reflex'].filter(t=>t!==type).slice(0,3)); },
|
||||
() => { const boys = rand(3,8), girls = rand(3,8), total = boys+girls; return makeQ(`The ratio of boys to girls is ${boys}:${girls}. What fraction of the group are boys?`, `${boys}/${total}`, [`${boys}/${girls}`,`${girls}/${total}`,`${boys+1}/${total}`]); },
|
||||
() => { const base = pick([2,3,4,5]), exp = pick([2,3]), result = Math.pow(base,exp); return makeQ(`What is ${base}^${exp}?`, result, [base*exp, result+base, result-base]); },
|
||||
() => { const d = pick([4,6,8,12]); const n1 = rand(Math.floor(d/2)+1,d-1), n2 = rand(1,Math.floor(d/2)), diff = n1-n2; const gcd = (a,b) => b===0?a:gcd(b,a%b); const g = gcd(diff,d); const ans = `${diff/g}/${d/g}`; return makeQ(`What is ${n1}/${d} − ${n2}/${d}? (simplify if possible)`, ans, [`${diff+1}/${d}`,`${n1}/${d}`,`${diff}/${d+2}`]); },
|
||||
() => { const r = rand(3,10), circ = Math.round(2*3.14*r); return makeQ(`A circle has radius ${r} cm. What is its approximate circumference? (π ≈ 3.14)`, `${circ} cm`, [`${Math.round(3.14*r*r)} cm`,`${circ+6} cm`,`${Math.round(2*3.14*(r+1))} cm`]); },
|
||||
() => { const rise = rand(1,5), run = rand(1,5); return makeQ(`A line rises ${rise} units for every ${run} units to the right. What is the slope?`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`${rise}/${run+1}`]); },
|
||||
() => { const original = rand(4,20)*10, discount = pick([10,20,25]), saved = original*discount/100, final = original-saved; return makeQ(`A jacket costs $${original}. It is ${discount}% off. What is the sale price?`, `$${final}`, [`$${saved}`,`$${final+10}`,`$${final-5}`]); },
|
||||
];
|
||||
|
||||
const g9s1 = [
|
||||
() => { const x = rand(2,12), a = rand(2,7), b = rand(1,20), lhs = a*x+b; return makeQ(`Solve for x: ${a}x + ${b} = ${lhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${lhs}`]); },
|
||||
() => { const [a,b,c] = pick([[3,4,5],[5,12,13],[8,15,17],[6,8,10]]); return makeQ(`A right triangle has legs of ${a} and ${b}. What is the hypotenuse?`, c, [c+1, c-1, a+b]); },
|
||||
() => { const x1=rand(0,4),y1=rand(0,4),rise=rand(1,5),run=rand(1,5); return makeQ(`Find the slope of the line through (${x1}, ${y1}) and (${x1+run}, ${y1+rise}).`, `${rise}/${run}`, [`${run}/${rise}`,`${rise+1}/${run}`,`-${rise}/${run}`]); },
|
||||
() => { const a = rand(2,6), b = rand(1,8); return makeQ(`Expand: ${a}(x + ${b})`, `${a}x + ${a*b}`, [`${a}x + ${b}`,`${a+b}x`,`${a}x + ${a*b+1}`]); },
|
||||
() => { const original = rand(4,20)*10, pct = pick([10,15,20,25,30]), increase = original*pct/100, final = original+increase; return makeQ(`A price of $${original} increases by ${pct}%. What is the new price?`, `$${final}`, [`$${final+10}`,`$${original-increase}`,`$${final-5}`]); },
|
||||
() => { const a = rand(4,10), b = rand(6,14), h = rand(3,8), area = (a+b)*h/2; return makeQ(`A trapezoid has parallel sides of ${a} cm and ${b} cm, and height ${h} cm. What is its area?`, `${area} cm²`, [`${a*b*h/2} cm²`,`${area+h} cm²`,`${(a+b)*h} cm²`]); },
|
||||
() => { const base = pick([2,3,5]), e1 = rand(2,5), e2 = rand(2,4); return makeQ(`Simplify: ${base}^${e1} × ${base}^${e2}`, `${base}^${e1+e2}`, [`${base}^${e1*e2}`,`${base}^${e1+e2+1}`,`${base*2}^${e1+e2}`]); },
|
||||
() => { const m = rand(2,5), b = rand(1,8), x = rand(3,8), y = m*x+b; return makeQ(`If y = ${m}x + ${b}, what is y when x = ${x}?`, y, [y+m, y-m, m*x]); },
|
||||
() => { const x = rand(3,12), a = rand(2,5), b = rand(1,15), rhs = a*x-b; return makeQ(`Solve for x: ${a}x − ${b} = ${rhs}`, `x = ${x}`, [`x = ${x+1}`,`x = ${x-1}`,`x = ${rhs}`]); },
|
||||
() => { const s = rand(3,8), sa = 6*s*s; return makeQ(`A cube has side length ${s} cm. What is its surface area?`, `${sa} cm²`, [`${s*s*s} cm³`,`${sa+s*s} cm²`,`${4*s*s} cm²`]); },
|
||||
() => { const die = rand(1,5); const coin = pick(['heads','tails']); return makeQ(`A coin is flipped and a 6-sided die is rolled. What is the probability of getting ${coin} and rolling less than ${die+1}?`, `${die}/12`, [`${die}/6`,`1/${die+1}`,`${die+1}/12`]); },
|
||||
];
|
||||
|
||||
const g9s2 = [
|
||||
() => { const r = rand(2,6), s = rand(2,5), bc = r+s, cc = r*s; return makeQ(`Factor: x² + ${bc}x + ${cc}`, `(x + ${r})(x + ${s})`, [`(x + ${bc})(x + 1)`,`(x + ${r+1})(x + ${s-1})`,`(x − ${r})(x − ${s})`]); },
|
||||
() => { const p = rand(5,20)*100, rate = pick([5,10,20]), interest = p*rate/100, total = p+interest; return makeQ(`$${p} is invested at ${rate}% simple interest for 1 year. What is the total after 1 year?`, `$${total}`, [`$${interest}`,`$${total+50}`,`$${p*2}`]); },
|
||||
() => { const m = rand(1,4), b = rand(-5,8); const bs = b>=0?`+ ${b}`:`− ${Math.abs(b)}`; return makeQ(`What is the y-intercept of y = ${m}x ${bs}?`, `(0, ${b})`, [`(${b}, 0)`,`(0, ${b+1})`,`(${m}, 0)`]); },
|
||||
() => { const [a,bc,c] = pick([[3,4,5],[5,12,13],[8,15,17]]); return makeQ(`A right triangle has hypotenuse ${c} and one leg ${a}. What is the other leg?`, bc, [bc+1, bc-1, Math.round(Math.sqrt(c*c+a*a))]); },
|
||||
() => { const x = rand(3,10), a = rand(2,5), b = rand(1,10), lhs = a*x+b; return makeQ(`Which value of x satisfies ${a}x + ${b} > ${lhs-1}?`, `x = ${x}`, [`x = ${x-1}`,`x = ${x-2}`,`x = 0`]); },
|
||||
() => { const rate = rand(3,8), t1 = rand(2,5), t2 = t1+rand(1,3); return makeQ(`A cyclist travels ${rate*t1} km in ${t1} hours at constant speed. How far in ${t2} hours?`, `${rate*t2} km`, [`${rate*t2+rate} km`,`${rate*t1+t2} km`,`${rate*t2-rate} km`]); },
|
||||
() => { const angle = rand(30,80); return makeQ(`Two parallel lines are cut by a transversal. One angle is ${angle}°. What is its co-interior angle?`, `${180-angle}°`, [`${angle}°`,`${180+angle}°`,`${90-angle}°`]); },
|
||||
() => { const coeff = rand(1,9), exp2 = rand(3,5), value = coeff*Math.pow(10,exp2); return makeQ(`Which equals ${coeff} × 10^${exp2}?`, String(value), [String(value*10),String(value/10),String(coeff+exp2)]); },
|
||||
() => { const r = rand(3,7), h = rand(4,10), vol = Math.round(3.14*r*r*h); return makeQ(`A cylinder has radius ${r} cm and height ${h} cm. Approximate its volume (π ≈ 3.14).`, `${vol} cm³`, [`${Math.round(3.14*r*h)} cm³`,`${Math.round(3.14*2*r*h)} cm³`,`${Math.round(3.14*r*r)} cm³`]); },
|
||||
() => { const [trend,desc] = pick([['positive','as x increases, y increases'],['negative','as x increases, y decreases'],['no correlation','x and y show no pattern']]); return makeQ(`A scatter plot shows: ${desc}. What type of correlation is this?`, trend, ['positive','negative','no correlation'].filter(t=>t!==trend)); },
|
||||
() => { const a = rand(1,3), b = rand(1,5), x = rand(2,5), result = a*x*x+b*x; return makeQ(`Evaluate ${a}x² + ${b}x when x = ${x}.`, result, [result+b, result-a, a*x+b]); },
|
||||
];
|
||||
|
||||
const QUESTION_BANK = { G3: [...g3s1, ...g3s2], G6: [...g6s1, ...g6s2], G9: [...g9s1, ...g9s2] };
|
||||
const STAGE_SIZE = 11;
|
||||
|
||||
function generateStage(grade, stageNum) {
|
||||
return QUESTION_BANK[grade].slice((stageNum - 1) * STAGE_SIZE, stageNum * STAGE_SIZE).map(fn => fn());
|
||||
}
|
||||
|
||||
module.exports = { generateStage, STAGE_SIZE };
|
||||
Reference in New Issue
Block a user