feat: major redesign — ELO system, 4 practice modes, history view, mobile nav
- 4 practice modes: Timed (SVG ring), Endless (3 lives), Category Drill, Speed Run
- Practice settings modal (timer duration, difficulty) with localStorage persistence
- History view: paginated table, SVG score chart, best-times-per-stratagem
- ELO rating system with server-side K=32 calculation and rank tiers PRIVATE–GENERAL
- Post-match result modal with ELO delta and round history
- Challenge modal showing challenger name + ELO (replaces toast)
- Dashboard hero card with ELO rank icon and daily sequence preview
- Leaderboard tabs: Practice Score / ELO / Speed Run
- Mobile hamburger nav drawer with slide-in animation
- DB migration: elo column, mode column, stratagem_stats table
- WS lobby-update now sends {name, elo, rank} objects
- View fade transitions, danger vignette at ≤5s, streak fire glow, combo badge
- Esc/Enter keyboard shortcuts for modals and practice
This commit is contained in:
@@ -127,10 +127,35 @@ function initDB() {
|
||||
loser_rounds INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS stratagem_stats (
|
||||
username TEXT NOT NULL,
|
||||
stratagem TEXT NOT NULL,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
completions INTEGER DEFAULT 0,
|
||||
best_time INTEGER DEFAULT NULL,
|
||||
PRIMARY KEY (username, stratagem)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ps_user ON practice_sessions(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner);
|
||||
CREATE INDEX IF NOT EXISTS idx_m_loser ON matches(loser);
|
||||
`);
|
||||
|
||||
// Schema migrations (user_version tracks applied version)
|
||||
const version = db.pragma('user_version', { simple: true });
|
||||
|
||||
if (version < 1) {
|
||||
// Add elo column to users if missing
|
||||
const cols = db.pragma('table_info(users)').map(c => c.name);
|
||||
if (!cols.includes('elo')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN elo INTEGER DEFAULT 1000');
|
||||
}
|
||||
// Add mode column to practice_sessions if missing
|
||||
const psCols = db.pragma('table_info(practice_sessions)').map(c => c.name);
|
||||
if (!psCols.includes('mode')) {
|
||||
db.exec("ALTER TABLE practice_sessions ADD COLUMN mode TEXT DEFAULT 'timed'");
|
||||
}
|
||||
db.pragma('user_version = 1');
|
||||
}
|
||||
}
|
||||
|
||||
async function initUsers() {
|
||||
@@ -143,13 +168,33 @@ async function initUsers() {
|
||||
if (!exists) {
|
||||
const tempPw = crypto.randomBytes(6).toString('hex');
|
||||
const hash = await bcrypt.hash(tempPw, 12);
|
||||
db.prepare('INSERT INTO users (username, hash, role, mustChange) VALUES (?, ?, ?, 1)')
|
||||
db.prepare('INSERT INTO users (username, hash, role, mustChange, elo) VALUES (?, ?, ?, 1, 1000)')
|
||||
.run(username, hash, role);
|
||||
console.log(`[INIT] Created user '${username}' – temp password: ${tempPw}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ELO calculation ───────────────────────────────────────────────────────────
|
||||
function calcElo(winnerElo, loserElo, k = 32) {
|
||||
const expected = 1 / (1 + 10 ** ((loserElo - winnerElo) / 400));
|
||||
const delta = Math.round(k * (1 - expected));
|
||||
return {
|
||||
winner: Math.max(0, winnerElo + delta),
|
||||
loser: Math.max(0, loserElo - delta),
|
||||
delta,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ELO rank label ─────────────────────────────────────────────────────────────
|
||||
function eloRank(elo) {
|
||||
if (elo >= 1700) return 'GENERAL';
|
||||
if (elo >= 1500) return 'CAPTAIN';
|
||||
if (elo >= 1300) return 'LIEUTENANT';
|
||||
if (elo >= 1100) return 'SERGEANT';
|
||||
return 'PRIVATE';
|
||||
}
|
||||
|
||||
// ── Session secret ────────────────────────────────────────────────────────────
|
||||
function getSessionSecret() {
|
||||
const file = path.join(DATA_DIR, '.session-secret');
|
||||
@@ -313,6 +358,7 @@ function getLeaderboard() {
|
||||
lbCache = db.prepare(`
|
||||
SELECT
|
||||
u.username,
|
||||
COALESCE(u.elo, 1000) AS elo,
|
||||
COALESCE(ps.sessions, 0) AS sessions,
|
||||
COALESCE(ps.totalScore, 0) AS totalScore,
|
||||
COALESCE(ps.fastestTime, 0) AS fastestTime,
|
||||
@@ -351,27 +397,37 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||
FROM matches WHERE winner = ? OR loser = ?
|
||||
`).get(u, u, u);
|
||||
|
||||
const lb = getLeaderboard();
|
||||
const lb = getLeaderboard();
|
||||
const rankIdx = lb.findIndex(r => r.username === u);
|
||||
const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null;
|
||||
const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null;
|
||||
|
||||
const recent = db.prepare(`
|
||||
SELECT stratagem, category, score, time_ms, created_at
|
||||
SELECT stratagem, category, score, time_ms, mode, created_at
|
||||
FROM practice_sessions WHERE username = ?
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
`).all(u);
|
||||
|
||||
const dayOfYear = Math.floor((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86_400_000);
|
||||
const dailyStrat = STRATAGEMS[dayOfYear % STRATAGEMS.length];
|
||||
const dailyBest = db.prepare(`
|
||||
const dayOfYear = Math.floor((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86_400_000);
|
||||
const dailyStrat = STRATAGEMS[dayOfYear % STRATAGEMS.length];
|
||||
const dailyBest = db.prepare(`
|
||||
SELECT MIN(time_ms) AS bestTime FROM practice_sessions
|
||||
WHERE stratagem = ? AND username = ?
|
||||
`).get(dailyStrat.name, u);
|
||||
|
||||
const userRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(u);
|
||||
const elo = userRow?.elo ?? 1000;
|
||||
|
||||
const onlineWithElo = [...userSockets.keys()].map(name => {
|
||||
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
|
||||
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
|
||||
});
|
||||
|
||||
res.json({
|
||||
stats: { ...stats, ...matchStats },
|
||||
rank,
|
||||
online: [...userSockets.keys()],
|
||||
elo,
|
||||
eloRank: eloRank(elo),
|
||||
online: onlineWithElo,
|
||||
recent,
|
||||
daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null },
|
||||
});
|
||||
@@ -379,15 +435,27 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||
|
||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||
app.post('/api/scores/practice', requireAuth, (req, res) => {
|
||||
const { stratagem, category, time_ms, score } = req.body || {};
|
||||
if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' });
|
||||
if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 31_000) return res.status(400).json({ error: 'Invalid time' });
|
||||
if (typeof score !== 'number' || score < 0 || score > 15_000) return res.status(400).json({ error: 'Invalid score' });
|
||||
const { stratagem, category, time_ms, score, mode } = req.body || {};
|
||||
if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' });
|
||||
if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 600_000) return res.status(400).json({ error: 'Invalid time' });
|
||||
if (typeof score !== 'number' || score < 0 || score > 50_000) return res.status(400).json({ error: 'Invalid score' });
|
||||
|
||||
const safeMode = ['timed','endless','drill','speedrun'].includes(mode) ? mode : 'timed';
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(req.session.user, stratagem, category || '', time_ms, score, new Date().toISOString());
|
||||
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, mode, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(req.session.user, stratagem, category || '', time_ms, score, safeMode, new Date().toISOString());
|
||||
|
||||
// Update stratagem_stats (upsert)
|
||||
db.prepare(`
|
||||
INSERT INTO stratagem_stats (username, stratagem, attempts, completions, best_time)
|
||||
VALUES (?, ?, 1, 1, ?)
|
||||
ON CONFLICT(username, stratagem) DO UPDATE SET
|
||||
attempts = attempts + 1,
|
||||
completions = completions + 1,
|
||||
best_time = CASE WHEN best_time IS NULL OR ? < best_time THEN ? ELSE best_time END
|
||||
`).run(req.session.user, stratagem, time_ms, time_ms, time_ms);
|
||||
|
||||
invalidateLB();
|
||||
res.json({ ok: true });
|
||||
@@ -397,10 +465,27 @@ app.get('/api/scores/leaderboard', requireAuth, (req, res) => {
|
||||
res.json(getLeaderboard());
|
||||
});
|
||||
|
||||
app.get('/api/scores/leaderboard/elo', requireAuth, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT username, elo, role FROM users ORDER BY elo DESC LIMIT 20
|
||||
`).all();
|
||||
res.json(rows.map(r => ({ ...r, rank: eloRank(r.elo) })));
|
||||
});
|
||||
|
||||
app.get('/api/scores/leaderboard/speedrun', requireAuth, (req, res) => {
|
||||
// Speedrun: sum of all time_ms per user where mode = 'speedrun', order by total time ASC
|
||||
const rows = db.prepare(`
|
||||
SELECT username, SUM(time_ms) AS totalTime, COUNT(*) AS stratagems
|
||||
FROM practice_sessions WHERE mode = 'speedrun'
|
||||
GROUP BY username ORDER BY totalTime ASC LIMIT 20
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/scores/me', requireAuth, (req, res) => {
|
||||
const u = req.session.user;
|
||||
const practice = db.prepare(`
|
||||
SELECT stratagem, category, score, time_ms, created_at
|
||||
SELECT stratagem, category, score, time_ms, mode, created_at
|
||||
FROM practice_sessions WHERE username = ?
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
`).all(u);
|
||||
@@ -411,6 +496,41 @@ app.get('/api/scores/me', requireAuth, (req, res) => {
|
||||
res.json({ practice, matches });
|
||||
});
|
||||
|
||||
// ── History (paginated) ────────────────────────────────────────────────────────
|
||||
app.get('/api/history', requireAuth, (req, res) => {
|
||||
const u = req.session.user;
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit) || 10));
|
||||
const mode = req.query.mode || '';
|
||||
const cat = req.query.cat || '';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let where = 'WHERE username = ?';
|
||||
const params = [u];
|
||||
if (mode) { where += ' AND mode = ?'; params.push(mode); }
|
||||
if (cat) { where += ' AND category = ?'; params.push(cat); }
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS n FROM practice_sessions ${where}`).get(...params).n;
|
||||
const rows = db.prepare(`
|
||||
SELECT stratagem, category, score, time_ms, mode, created_at
|
||||
FROM practice_sessions ${where}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({ rows, total, page, pages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
// ── Stratagem stats ────────────────────────────────────────────────────────────
|
||||
app.get('/api/stats/stratagems', requireAuth, (req, res) => {
|
||||
const u = req.session.user;
|
||||
const rows = db.prepare(`
|
||||
SELECT stratagem, attempts, completions, best_time
|
||||
FROM stratagem_stats WHERE username = ?
|
||||
ORDER BY best_time ASC NULLS LAST
|
||||
`).all(u);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── Stratagems API (authenticated) ────────────────────────────────────────────
|
||||
// Stratagem sequences are served via API – not as a public static file.
|
||||
app.get('/api/stratagems', requireAuth, (req, res) => {
|
||||
@@ -441,7 +561,10 @@ async function main() {
|
||||
}
|
||||
|
||||
function broadcastLobbyUpdate() {
|
||||
const online = [...userSockets.keys()];
|
||||
const online = [...userSockets.keys()].map(name => {
|
||||
const row = db.prepare('SELECT elo FROM users WHERE username = ?').get(name);
|
||||
return { name, elo: row?.elo ?? 1000, rank: eloRank(row?.elo ?? 1000) };
|
||||
});
|
||||
wss.clients.forEach(ws => {
|
||||
if (ws.readyState !== WebSocket.OPEN || !ws.userId) return;
|
||||
const incoming = [...pendingChallenges.entries()]
|
||||
@@ -470,15 +593,37 @@ async function main() {
|
||||
room.matchScores[winnerId]++;
|
||||
const matchScores = { ...room.matchScores };
|
||||
|
||||
// Track round history for post-match screen
|
||||
if (!room.roundHistory) room.roundHistory = [];
|
||||
room.roundHistory.push({ round: room.roundHistory.length + 1, winner: winnerId });
|
||||
|
||||
broadcastToRoom(room, 'round-complete', { winner: winnerId, matchScores });
|
||||
|
||||
if (room.matchScores[winnerId] >= 5) {
|
||||
broadcastToRoom(room, 'match-end', { winner: winnerId, matchScores });
|
||||
// Calculate ELO delta
|
||||
const wRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(winnerId);
|
||||
const lRow = db.prepare('SELECT elo FROM users WHERE username = ?').get(loser.userId);
|
||||
const eloResult = calcElo(wRow?.elo ?? 1000, lRow?.elo ?? 1000);
|
||||
|
||||
db.prepare('UPDATE users SET elo = ? WHERE username = ?').run(eloResult.winner, winnerId);
|
||||
db.prepare('UPDATE users SET elo = ? WHERE username = ?').run(eloResult.loser, loser.userId);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(winnerId, loser.userId, room.matchScores[winnerId], room.matchScores[loser.userId], new Date().toISOString());
|
||||
|
||||
invalidateLB();
|
||||
|
||||
broadcastToRoom(room, 'match-end', {
|
||||
winner: winnerId,
|
||||
matchScores,
|
||||
roundHistory: room.roundHistory,
|
||||
eloChanges: {
|
||||
[winnerId]: { old: wRow?.elo ?? 1000, new: eloResult.winner, delta: +eloResult.delta },
|
||||
[loser.userId]: { old: lRow?.elo ?? 1000, new: eloResult.loser, delta: -eloResult.delta },
|
||||
},
|
||||
});
|
||||
rooms.delete(room.roomId);
|
||||
} else {
|
||||
room.state = 'waiting';
|
||||
@@ -501,7 +646,8 @@ async function main() {
|
||||
const { targetUser } = payload;
|
||||
if (!userSockets.has(targetUser) || targetUser === userId) return;
|
||||
pendingChallenges.set(userId, targetUser);
|
||||
send(userSockets.get(targetUser), 'challenge-received', { from: userId });
|
||||
const challengerElo = db.prepare('SELECT elo FROM users WHERE username = ?').get(userId)?.elo ?? 1000;
|
||||
send(userSockets.get(targetUser), 'challenge-received', { from: userId, elo: challengerElo });
|
||||
broadcastLobbyUpdate();
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user