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:
Jeremy Brandenburger
2026-03-30 18:31:46 +02:00
parent 7de283a8e1
commit 0d971745a6
5 changed files with 1226 additions and 212 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog helldivers-trainer # Changelog helldivers-trainer
## [2.0.0] 2026-03-30
### Added
- **4 Practice modes**: Timed (configurable 15/30/45s, SVG ring timer), Endless (3 lives), Category Drill (progress bar), Speed Run (all stratagems, total time)
- **Settings modal**: timer duration and difficulty (Easy/Normal/Hard) persisted in localStorage
- **History view**: paginated session table with mode/category filters, SVG polyline score chart, best-time-per-stratagem table
- **ELO rating system**: server-side ELO (K=32) calculated after every match; rank tiers PRIVATE → GENERAL
- **Post-match result modal**: ELO delta display (`before → after +N`), round-by-round history
- **Challenge modal**: incoming challenge shows challenger name and ELO; replaces toast notification
- **Dashboard hero card**: ELO rank badge, rank icon, daily sequence preview arrows, mode column in recent sessions
- **Leaderboard tabs**: Practice Score / ELO Rating / Speed Run with correct column headers per tab
- **Mobile hamburger nav**: slide-in drawer with overlay backdrop, Esc key closes modals
- **DB migration**: `elo` column on users, `mode` column on practice_sessions, `stratagem_stats` table
- **Lobby redesign**: ELO + rank shown per player, 2-column layout (Online + Incoming Challenges)
- View fade transitions on every view switch
- Danger vignette flash at ≤5s timer remaining
- Streak fire glow effect (streak ≥ 5), combo multiplier badge (×N, streak ≥ 2)
- Keyboard shortcuts: `Esc` stops practice / closes modals, `Enter` starts practice
- `aria-label` on all D-pad buttons, modals with `role="dialog"` and `aria-modal="true"`
### Changed
- Full CSS redesign (~700 → ~1200 lines): glassmorphism cards, new design tokens, SVG timer ring, skeleton shimmer, animated modals
- Dashboard online list now shows ELO; lobby shows ELO + rank per player
- WS `lobby-update` now sends `[{name, elo, rank}]` objects instead of plain usernames
- WS `challenge-received` now includes challenger ELO
- Practice leaderboard query joined with `users.elo` for rank badge display
---
## [1.0.0] 2026-03-30 ## [1.0.0] 2026-03-30
### Added ### Added
+19
View File
@@ -795,5 +795,24 @@ function showToast(msg) {
}, 3200); }, 3200);
} }
// ── Static button bindings (replaces inline onclick blocked by CSP script-src-attr) ──
document.getElementById('btn-logout') ?.addEventListener('click', logout);
document.getElementById('btn-daily-challenge') ?.addEventListener('click', startDailyChallenge);
document.getElementById('btn-start-practice') ?.addEventListener('click', startPractice);
document.getElementById('btn-stop-practice') ?.addEventListener('click', stopPracticeUI);
document.getElementById('match-ready-btn') ?.addEventListener('click', setReady);
document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch);
document.getElementById('btn-create-user') ?.addEventListener('click', createUser);
// D-pad: practice and match both use data-dir buttons
document.getElementById('practice-dpad')?.addEventListener('click', (e) => {
const dir = e.target.closest('[data-dir]')?.dataset.dir;
if (dir) dpadInput(dir);
});
document.getElementById('match-dpad')?.addEventListener('click', (e) => {
const dir = e.target.closest('[data-dir]')?.dataset.dir;
if (dir) dpadInput(dir);
});
// ── Init ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', checkAuth); document.addEventListener('DOMContentLoaded', checkAuth);
+17 -17
View File
@@ -26,7 +26,7 @@
</div> </div>
<div class="nav-user"> <div class="nav-user">
<span class="nav-username" id="nav-username"></span> <span class="nav-username" id="nav-username"></span>
<button class="btn btn-muted btn-sm" onclick="logout()">Logout</button> <button class="btn btn-muted btn-sm" id="btn-logout">Logout</button>
</div> </div>
</nav> </nav>
@@ -122,7 +122,7 @@
<div class="daily-best"> <div class="daily-best">
Best time: <span id="dash-daily-best"></span> Best time: <span id="dash-daily-best"></span>
</div> </div>
<button class="btn btn-accent" onclick="startDailyChallenge()">Practice this stratagem</button> <button class="btn btn-accent" id="btn-daily-challenge">Practice this stratagem</button>
</div> </div>
</div> </div>
@@ -161,7 +161,7 @@
<!-- Idle (start screen) --> <!-- Idle (start screen) -->
<div id="practice-idle" class="practice-idle"> <div id="practice-idle" class="practice-idle">
<div class="idle-hint">Select categories above, then start training</div> <div class="idle-hint">Select categories above, then start training</div>
<button class="btn btn-accent btn-lg" onclick="startPractice()">⚡ START TRAINING</button> <button class="btn btn-accent btn-lg" id="btn-start-practice">⚡ START TRAINING</button>
</div> </div>
<!-- Active training --> <!-- Active training -->
@@ -189,21 +189,21 @@
</div> </div>
<!-- D-Pad (mobile) --> <!-- D-Pad (mobile) -->
<div class="dpad"> <div class="dpad" id="practice-dpad">
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button> <button class="dpad-btn dpad-up" data-dir="up"></button>
</div> </div>
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button> <button class="dpad-btn dpad-left" data-dir="left"></button>
<div class="dpad-center"></div> <div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button> <button class="dpad-btn dpad-right" data-dir="right"></button>
</div> </div>
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button> <button class="dpad-btn dpad-down" data-dir="down"></button>
</div> </div>
</div> </div>
<button class="btn btn-muted" onclick="stopPracticeUI()">Stop Training</button> <button class="btn btn-muted" id="btn-stop-practice">Stop Training</button>
</div> </div>
</div> </div>
@@ -257,24 +257,24 @@
</div> </div>
</div> </div>
<!-- D-Pad (mobile) --> <!-- D-Pad (mobile) -->
<div class="dpad"> <div class="dpad" id="match-dpad">
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button> <button class="dpad-btn dpad-up" data-dir="up"></button>
</div> </div>
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button> <button class="dpad-btn dpad-left" data-dir="left"></button>
<div class="dpad-center"></div> <div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button> <button class="dpad-btn dpad-right" data-dir="right"></button>
</div> </div>
<div class="dpad-row"> <div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button> <button class="dpad-btn dpad-down" data-dir="down"></button>
</div> </div>
</div> </div>
</div> </div>
<div class="match-actions"> <div class="match-actions">
<button class="btn btn-accent hidden" id="match-ready-btn" onclick="setReady()">READY</button> <button class="btn btn-accent hidden" id="match-ready-btn">READY</button>
<button class="btn btn-muted btn-sm" onclick="leaveMatch()">Leave Match</button> <button class="btn btn-muted btn-sm" id="btn-leave-match">Leave Match</button>
</div> </div>
</div> </div>
@@ -325,7 +325,7 @@
</select> </select>
</div> </div>
<p id="admin-error" class="error hidden"></p> <p id="admin-error" class="error hidden"></p>
<button class="btn btn-accent" onclick="createUser()">Create User</button> <button class="btn btn-accent" id="btn-create-user">Create User</button>
<div id="new-pw-display" class="pw-display hidden"></div> <div id="new-pw-display" class="pw-display hidden"></div>
</div> </div>
+992 -172
View File
File diff suppressed because it is too large Load Diff
+159 -13
View File
@@ -127,10 +127,35 @@ function initDB() {
loser_rounds INTEGER NOT NULL, loser_rounds INTEGER NOT NULL,
created_at TEXT 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_ps_user ON practice_sessions(username);
CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner); CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner);
CREATE INDEX IF NOT EXISTS idx_m_loser ON matches(loser); 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() { async function initUsers() {
@@ -143,13 +168,33 @@ async function initUsers() {
if (!exists) { if (!exists) {
const tempPw = crypto.randomBytes(6).toString('hex'); const tempPw = crypto.randomBytes(6).toString('hex');
const hash = await bcrypt.hash(tempPw, 12); 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); .run(username, hash, role);
console.log(`[INIT] Created user '${username}' temp password: ${tempPw}`); 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 ──────────────────────────────────────────────────────────── // ── Session secret ────────────────────────────────────────────────────────────
function getSessionSecret() { function getSessionSecret() {
const file = path.join(DATA_DIR, '.session-secret'); const file = path.join(DATA_DIR, '.session-secret');
@@ -313,6 +358,7 @@ function getLeaderboard() {
lbCache = db.prepare(` lbCache = db.prepare(`
SELECT SELECT
u.username, u.username,
COALESCE(u.elo, 1000) AS elo,
COALESCE(ps.sessions, 0) AS sessions, COALESCE(ps.sessions, 0) AS sessions,
COALESCE(ps.totalScore, 0) AS totalScore, COALESCE(ps.totalScore, 0) AS totalScore,
COALESCE(ps.fastestTime, 0) AS fastestTime, COALESCE(ps.fastestTime, 0) AS fastestTime,
@@ -356,7 +402,7 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null; const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null;
const recent = db.prepare(` 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 = ? FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 5 ORDER BY created_at DESC LIMIT 5
`).all(u); `).all(u);
@@ -368,10 +414,20 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
WHERE stratagem = ? AND username = ? WHERE stratagem = ? AND username = ?
`).get(dailyStrat.name, u); `).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({ res.json({
stats: { ...stats, ...matchStats }, stats: { ...stats, ...matchStats },
rank, rank,
online: [...userSockets.keys()], elo,
eloRank: eloRank(elo),
online: onlineWithElo,
recent, recent,
daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null }, daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null },
}); });
@@ -379,15 +435,27 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
// ── Scores ──────────────────────────────────────────────────────────────────── // ── Scores ────────────────────────────────────────────────────────────────────
app.post('/api/scores/practice', requireAuth, (req, res) => { app.post('/api/scores/practice', requireAuth, (req, res) => {
const { stratagem, category, time_ms, score } = req.body || {}; const { stratagem, category, time_ms, score, mode } = req.body || {};
if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' }); 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 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 > 15_000) return res.status(400).json({ error: 'Invalid score' }); 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(` db.prepare(`
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, created_at) INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, mode, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(req.session.user, stratagem, category || '', time_ms, score, new Date().toISOString()); `).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(); invalidateLB();
res.json({ ok: true }); res.json({ ok: true });
@@ -397,10 +465,27 @@ app.get('/api/scores/leaderboard', requireAuth, (req, res) => {
res.json(getLeaderboard()); 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) => { app.get('/api/scores/me', requireAuth, (req, res) => {
const u = req.session.user; const u = req.session.user;
const practice = db.prepare(` 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 = ? FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 50 ORDER BY created_at DESC LIMIT 50
`).all(u); `).all(u);
@@ -411,6 +496,41 @@ app.get('/api/scores/me', requireAuth, (req, res) => {
res.json({ practice, matches }); 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) ──────────────────────────────────────────── // ── Stratagems API (authenticated) ────────────────────────────────────────────
// Stratagem sequences are served via API not as a public static file. // Stratagem sequences are served via API not as a public static file.
app.get('/api/stratagems', requireAuth, (req, res) => { app.get('/api/stratagems', requireAuth, (req, res) => {
@@ -441,7 +561,10 @@ async function main() {
} }
function broadcastLobbyUpdate() { 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 => { wss.clients.forEach(ws => {
if (ws.readyState !== WebSocket.OPEN || !ws.userId) return; if (ws.readyState !== WebSocket.OPEN || !ws.userId) return;
const incoming = [...pendingChallenges.entries()] const incoming = [...pendingChallenges.entries()]
@@ -470,15 +593,37 @@ async function main() {
room.matchScores[winnerId]++; room.matchScores[winnerId]++;
const matchScores = { ...room.matchScores }; 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 }); broadcastToRoom(room, 'round-complete', { winner: winnerId, matchScores });
if (room.matchScores[winnerId] >= 5) { 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(` db.prepare(`
INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at) INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(winnerId, loser.userId, room.matchScores[winnerId], room.matchScores[loser.userId], new Date().toISOString()); `).run(winnerId, loser.userId, room.matchScores[winnerId], room.matchScores[loser.userId], new Date().toISOString());
invalidateLB(); 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); rooms.delete(room.roomId);
} else { } else {
room.state = 'waiting'; room.state = 'waiting';
@@ -501,7 +646,8 @@ async function main() {
const { targetUser } = payload; const { targetUser } = payload;
if (!userSockets.has(targetUser) || targetUser === userId) return; if (!userSockets.has(targetUser) || targetUser === userId) return;
pendingChallenges.set(userId, targetUser); 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(); broadcastLobbyUpdate();
break; break;
} }