Files
helldivers/public/app.js
T
Jeremy Brandenburger 2d27d9fe4d feat: stratagem icons, session summary, queue preview, UX polish
- Download 65 SVG icons from community repo (scripts/download-icons.js)
- Gold CSS filter on all icons to match game theme
- Session summary modal with score/accuracy/top stratagems
- Queue preview strip (next 3 stratagems with icons)
- Score popup animation, icon shake on wrong input
- Icons in history, leaderboard, and best-per-stratagem tables
- server.js: icon fields on all stratagems, ELO in lobby-update WS events
2026-03-31 08:48:56 +02:00

1611 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ── Constants ─────────────────────────────────────────────────────────────────
const RING_CIRCUMFERENCE = 219.9; // 2π × r(35)
const ELO_RANKS = [
{ label: 'PRIVATE', min: 0, icon: '⚡' },
{ label: 'SERGEANT', min: 1100, icon: '★' },
{ label: 'LIEUTENANT', min: 1300, icon: '☆' },
{ label: 'CAPTAIN', min: 1500, icon: '⚔' },
{ label: 'GENERAL', min: 1700, icon: '🏆' },
];
function eloRankFor(elo) {
for (let i = ELO_RANKS.length - 1; i >= 0; i--) {
if (elo >= ELO_RANKS[i].min) return ELO_RANKS[i];
}
return ELO_RANKS[0];
}
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
user: null,
currentView: 'login',
stratagems: [],
settings: {
timerDuration: 30, // 15 | 30 | 45
difficulty: 'normal', // 'easy' | 'normal' | 'hard'
},
practice: {
active: false,
mode: 'timed',
current: null,
queue: [], // upcoming stratagems (for queue preview)
progress: 0,
timeLeft: 30,
timerHandle: null,
startTime: null,
score: 0,
streak: 0,
selectedCats: new Set(),
dailyTarget: null,
// Endless mode
lives: 3,
// Drill mode
drillPool: [],
drillCompleted: 0,
drillTotal: 0,
// Speedrun mode
speedrunStart: null,
speedrunPool: [],
speedrunElapsed: 0,
// Session stats
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} },
},
lobby: {
online: [],
incoming: [],
pendingChallenge: null,
},
match: {
roomId: null,
opponent: null,
matchScores: {},
current: null,
myProgress: 0,
oppProgress: 0,
roundActive: false,
roundHistory: [],
},
leaderboard: { activeTab: 'practice' },
history: { page: 1, total: 0 },
ws: null,
wsReconnectTimer: null,
};
// ── Settings ──────────────────────────────────────────────────────────────────
function loadSettings() {
try {
const raw = localStorage.getItem('hd2-settings');
if (raw) {
const s = JSON.parse(raw);
if ([15, 30, 45].includes(s.timerDuration)) state.settings.timerDuration = s.timerDuration;
if (['easy', 'normal', 'hard'].includes(s.difficulty)) state.settings.difficulty = s.difficulty;
}
} catch { /* ignore */ }
applySettingsToUI();
}
function saveSettings() {
localStorage.setItem('hd2-settings', JSON.stringify(state.settings));
}
function applySettingsToUI() {
document.querySelectorAll('[data-setting="timer"]').forEach(btn => {
btn.classList.toggle('active', Number(btn.dataset.value) === state.settings.timerDuration);
});
document.querySelectorAll('[data-setting="difficulty"]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.value === state.settings.difficulty);
});
}
// ── API ───────────────────────────────────────────────────────────────────────
async function api(method, endpoint, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + endpoint, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
// ── View system ───────────────────────────────────────────────────────────────
function showView(name) {
document.querySelectorAll('.view').forEach(v => {
v.classList.add('hidden');
v.classList.remove('view-fade-in');
});
const el = document.getElementById('view-' + name);
if (el) {
el.classList.remove('hidden');
requestAnimationFrame(() => el.classList.add('view-fade-in'));
}
state.currentView = name;
document.querySelectorAll('.nav-btn').forEach(b => {
b.classList.toggle('active', b.dataset.view === name);
});
if (name !== 'practice') stopPracticeTimer();
if (name === 'dashboard') loadDashboard();
if (name === 'leaderboard') loadLeaderboard();
if (name === 'admin') loadAdmin();
if (name === 'practice') initPracticeView();
if (name === 'lobby') updateLobbyView();
if (name === 'history') loadHistory();
}
// ── Auth ──────────────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const data = await api('GET', '/me');
if (data.user) {
state.user = data;
if (data.mustChange) showView('change-password');
else onLoggedIn();
} else {
showView('login');
}
} catch { showView('login'); }
}
async function onLoggedIn() {
document.getElementById('main-nav').classList.remove('hidden');
document.getElementById('nav-username').textContent = state.user.user;
document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin');
document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin');
state.stratagems = await api('GET', '/stratagems').catch(() => []);
populateCategoryFilter();
loadSettings();
connectWS();
showView('dashboard');
}
async function logout() {
stopPracticeTimer();
if (state.ws) state.ws.close();
clearTimeout(state.wsReconnectTimer);
await api('POST', '/logout').catch(() => {});
state.user = null;
document.getElementById('main-nav').classList.add('hidden');
showView('login');
}
function populateCategoryFilter() {
const cats = [...new Set(state.stratagems.map(s => s.category))];
const sel = document.getElementById('history-filter-cat');
if (!sel) return;
// Remove old options except the first
while (sel.options.length > 1) sel.remove(1);
cats.forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
sel.appendChild(opt);
});
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const el = document.getElementById('login-error');
el.classList.add('hidden');
try {
await api('POST', '/login', {
username: document.getElementById('login-username').value.trim(),
password: document.getElementById('login-password').value,
});
await checkAuth();
} catch (err) {
el.textContent = err.message;
el.classList.remove('hidden');
}
});
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('cp-error');
const newPw = document.getElementById('cp-new').value;
const confPw = document.getElementById('cp-confirm').value;
errEl.classList.add('hidden');
if (newPw !== confPw) {
errEl.textContent = 'Passwords do not match';
errEl.classList.remove('hidden');
return;
}
try {
await api('POST', '/change-password', {
oldPassword: document.getElementById('cp-old').value,
newPassword: newPw,
});
state.user.mustChange = false;
onLoggedIn();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.view));
});
// ── Hamburger nav ─────────────────────────────────────────────────────────────
function openDrawer() {
document.getElementById('nav-drawer').classList.add('open');
document.getElementById('nav-overlay').classList.add('open');
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'true');
}
function closeDrawer() {
document.getElementById('nav-drawer').classList.remove('open');
document.getElementById('nav-overlay').classList.remove('open');
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'false');
}
document.getElementById('nav-hamburger').addEventListener('click', openDrawer);
document.getElementById('nav-overlay').addEventListener('click', closeDrawer);
document.querySelectorAll('.drawer-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => { showView(btn.dataset.view); closeDrawer(); });
});
document.getElementById('btn-logout-drawer')?.addEventListener('click', () => { closeDrawer(); logout(); });
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWS() {
if (state.ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(proto + '//' + location.host);
state.ws.onopen = () => clearTimeout(state.wsReconnectTimer);
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
state.ws.onerror = () => state.ws.close();
state.ws.onclose = () => {
state.ws = null;
if (state.user) state.wsReconnectTimer = setTimeout(connectWS, 3000);
};
}
function wsSend(type, payload) {
if (state.ws?.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({ type, payload: payload || {} }));
}
}
function handleWSMessage({ type, payload }) {
switch (type) {
case 'lobby-update':
state.lobby.online = payload.online || [];
state.lobby.incoming = payload.incoming || [];
if (state.currentView === 'lobby') updateLobbyView();
if (state.currentView === 'dashboard') updateDashboardOnline(payload.online);
updateChallengeBadge();
break;
case 'challenge-received':
if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from);
updateChallengeBadge();
openChallengeModal(payload.from, payload.elo ?? '?');
break;
case 'challenge-declined':
showToast(esc(payload.by) + ' declined your challenge.');
break;
case 'room-joined':
state.match.roomId = payload.roomId;
state.match.opponent = payload.opponent;
state.match.matchScores = payload.matchScores;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = false;
state.match.roundHistory = [];
closeChallengeModal();
showView('match');
renderMatchWaiting();
break;
case 'round-start':
state.match.current = payload.stratagem;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = true;
renderMatchRound();
break;
case 'input-result':
if (payload.userId === state.user.user) {
state.match.myProgress = payload.progress;
updateMyArrows(payload.correct);
} else {
state.match.oppProgress = payload.progress;
updateOppArrows();
}
break;
case 'round-complete':
state.match.roundActive = false;
state.match.matchScores = payload.matchScores;
state.match.roundHistory.push({ stratagem: state.match.current?.name, winner: payload.winner });
renderRoundResult(payload.winner);
break;
case 'match-end':
state.match.matchScores = payload.matchScores;
openMatchResultModal({
winner: payload.winner,
eloChanges: payload.eloChanges,
roundHistory: payload.roundHistory || state.match.roundHistory,
});
break;
case 'opponent-left':
showToast('Opponent left the match.');
setTimeout(() => showView('lobby'), 1800);
break;
}
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
setText('dash-hero-name', state.user?.user || '—');
try {
const data = await api('GET', '/dashboard');
renderDashboard(data);
} catch { /* ignore */ }
}
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
const r = eloRankFor(elo || 1000);
setText('dash-hero-name', state.user.user);
setText('dash-rank-label', rankLabel || r.label);
setText('dash-elo', elo || 1000);
setText('dash-rank-icon', r.icon);
setText('dash-total-score', stats.totalScore || 0);
setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
setText('dash-sessions', stats.sessions || 0);
const wr = stats.matches > 0 ? Math.round(((stats.wins || 0) / stats.matches) * 100) + '%' : '—';
setText('dash-win-rate', wr);
if (daily) {
setText('dash-daily-name', daily.stratagem.name);
setText('dash-daily-category', daily.stratagem.category);
setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet');
state.practice.dailyTarget = daily.stratagem.name;
renderDailySequencePreview(daily.stratagem.sequence);
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
}
const tbody = document.getElementById('dash-recent');
if (!recent?.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>';
} else {
tbody.innerHTML = recent.map(r =>
`<tr>
<td><img class="stratagem-icon-sm" src="${esc(r.icon || '')}" alt="" ${r.icon ? '' : 'style="display:none"'}>${esc(r.stratagem)}</td>
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
<td>${r.score}</td>
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
</tr>`
).join('');
}
updateDashboardOnline(online);
}
function renderDailySequencePreview(sequence) {
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
const el = document.getElementById('dash-daily-sequence');
if (!el) return;
el.innerHTML = sequence.map(d => `<div class="daily-arrow">${ARROW[d]}</div>`).join('');
}
function updateDashboardOnline(online) {
const el = document.getElementById('dash-online');
if (!el) return;
const players = (online || []).filter(u => {
const name = typeof u === 'object' ? u.name : u;
return name !== state.user?.user;
});
if (!players.length) {
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
} else {
el.innerHTML = players.map(u => {
const name = typeof u === 'object' ? u.name : u;
const elo = typeof u === 'object' ? u.elo : '';
return `<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(name)}</span>
${elo ? `<span class="player-elo">${elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(name)}">⚔ Challenge</button>
</div>`;
}).join('');
}
}
function startDailyChallenge() {
if (!state.practice.dailyTarget) return;
state.practice.selectedCats.clear();
showView('practice');
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
if (strat) {
state.practice.selectedCats.add(strat.category);
startPractice();
}
}
// ── Practice ──────────────────────────────────────────────────────────────────
function initPracticeView() {
renderCategoryFilters();
if (!state.practice.active) showPracticeIdle();
updateModeLabel();
}
function renderCategoryFilters() {
const cats = [...new Set(state.stratagems.map(s => s.category))];
const el = document.getElementById('practice-categories');
if (!el) return;
el.innerHTML = cats.map(cat => {
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
return `<button class="cat-btn ${active ? 'active' : ''}" data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}</button>`;
}).join('');
}
function toggleCategory(cat) {
if (state.practice.selectedCats.has(cat)) state.practice.selectedCats.delete(cat);
else state.practice.selectedCats.add(cat);
renderCategoryFilters();
}
function showPracticeIdle() {
document.getElementById('practice-idle').classList.remove('hidden');
document.getElementById('practice-active').classList.add('hidden');
document.getElementById('drill-progress-wrap').classList.add('hidden');
document.getElementById('hud-lives-wrap').classList.add('hidden');
document.getElementById('hud-timer-wrap').classList.remove('hidden');
document.getElementById('danger-vignette').classList.add('hidden');
state.practice.active = false;
}
function getPool() {
const cats = state.practice.selectedCats;
if (cats.size === 0) return state.stratagems;
return state.stratagems.filter(s => cats.has(s.category));
}
function resetSessionStats() {
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} };
}
function startPractice() {
const pool = getPool();
if (!pool.length) { showToast('No stratagems match the selected filters'); return; }
const mode = state.practice.mode;
state.practice.active = true;
state.practice.score = 0;
state.practice.streak = 0;
state.practice.lives = 3;
state.practice.progress = 0;
resetSessionStats();
document.getElementById('practice-idle').classList.add('hidden');
document.getElementById('practice-active').classList.remove('hidden');
if (mode === 'drill') {
state.practice.drillPool = shuffleArray([...pool]);
state.practice.drillCompleted = 0;
state.practice.drillTotal = pool.length;
document.getElementById('drill-progress-wrap').classList.remove('hidden');
document.getElementById('hud-timer-wrap').classList.remove('hidden');
document.getElementById('hud-lives-wrap').classList.add('hidden');
setText('hud-timer-label', 'TIME');
} else if (mode === 'endless') {
document.getElementById('hud-lives-wrap').classList.remove('hidden');
document.getElementById('hud-timer-wrap').classList.add('hidden');
document.getElementById('drill-progress-wrap').classList.add('hidden');
updateLivesDisplay();
} else if (mode === 'speedrun') {
state.practice.speedrunPool = shuffleArray([...state.stratagems]);
state.practice.speedrunStart = Date.now();
state.practice.speedrunElapsed = 0;
document.getElementById('hud-timer-wrap').classList.remove('hidden');
document.getElementById('hud-lives-wrap').classList.add('hidden');
document.getElementById('drill-progress-wrap').classList.add('hidden');
setText('hud-timer-label', 'ELAPSED');
} else {
// Timed
document.getElementById('hud-timer-wrap').classList.remove('hidden');
document.getElementById('hud-lives-wrap').classList.add('hidden');
document.getElementById('drill-progress-wrap').classList.add('hidden');
setText('hud-timer-label', 'TIME');
}
nextStratagem();
}
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function stopPracticeUI() {
const p = state.practice;
if (p.active && (p.sessionStats.completed > 0 || p.sessionStats.missed > 0)) {
openSessionSummary();
} else {
stopPracticeTimer();
showPracticeIdle();
}
}
function stopPracticeTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = null;
state.practice.active = false;
}
function buildQueue() {
const p = state.practice;
const mode = p.mode;
const pool = mode === 'speedrun' ? p.speedrunPool.slice(1, 4)
: mode === 'drill' ? p.drillPool.slice(1, 4)
: (() => {
const fullPool = getPool().filter(s => s !== p.current);
return shuffleArray([...fullPool]).slice(0, 3);
})();
p.queue = pool;
renderQueue(pool);
}
function renderQueue(queue) {
const el = document.getElementById('practice-queue');
if (!el) return;
if (!queue?.length) { el.innerHTML = ''; return; }
el.innerHTML = queue.map(s => {
const iconHtml = s.icon
? `<img class="queue-icon" src="${esc(s.icon)}" alt="${esc(s.name)}">`
: `<div class="queue-icon-fallback">⚡</div>`;
return `<div class="queue-item">
${iconHtml}
<div class="queue-label">${esc(s.name)}</div>
</div>`;
}).join('');
}
function nextStratagem() {
const p = state.practice;
const mode = p.mode;
let strat;
if (mode === 'drill') {
if (!p.drillPool.length) {
clearInterval(p.timerHandle);
showToast('Drill complete! All stratagems mastered.');
openSessionSummary();
return;
}
strat = p.drillPool[0];
} else if (mode === 'speedrun') {
if (!p.speedrunPool.length) {
const totalMs = Date.now() - p.speedrunStart;
clearInterval(p.timerHandle);
api('POST', '/scores/practice', {
stratagem: '__speedrun__',
category: 'All',
time_ms: totalMs,
score: p.score,
mode: 'speedrun',
}).catch(() => {});
showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`);
openSessionSummary();
return;
}
strat = p.speedrunPool[0];
} else {
const pool = getPool();
if (!pool.length) { showPracticeIdle(); return; }
strat = pool[Math.floor(Math.random() * pool.length)];
}
p.current = strat;
p.progress = 0;
p.startTime = Date.now();
if (mode === 'timed') {
p.timeLeft = state.settings.timerDuration;
startPracticeTimer();
} else if (mode === 'drill') {
p.timeLeft = 60;
startPracticeTimer();
updateDrillProgress();
} else if (mode === 'speedrun') {
startSpeedrunTimer();
}
renderPracticeStratagem();
buildQueue();
}
function startPracticeTimer() {
clearInterval(state.practice.timerHandle);
const total = state.practice.mode === 'drill' ? 60 : state.settings.timerDuration;
state.practice.timerHandle = setInterval(() => {
state.practice.timeLeft--;
updateTimerDisplay(total);
const showVignette = state.practice.timeLeft <= 5 && state.practice.timeLeft > 0;
document.getElementById('danger-vignette').classList.toggle('hidden', !showVignette);
if (state.practice.timeLeft <= 0) {
clearInterval(state.practice.timerHandle);
document.getElementById('danger-vignette').classList.add('hidden');
state.practice.streak = 0;
state.practice.sessionStats.missed++;
updateStreakDisplay();
shakeIcon();
setTimeout(nextStratagem, 500);
}
}, 1000);
}
function startSpeedrunTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = setInterval(() => {
state.practice.speedrunElapsed = Date.now() - state.practice.speedrunStart;
const secs = (state.practice.speedrunElapsed / 1000).toFixed(1);
setText('practice-timer', secs + 's');
const ring = document.getElementById('timer-ring-fill');
if (ring) ring.style.strokeDashoffset = '0';
}, 100);
}
function renderPracticeStratagem() {
const s = state.practice.current;
const diff = state.settings.difficulty;
setText('practice-category', diff === 'easy' ? s.category : '');
if (diff === 'hard') {
setText('practice-name', '— ' + '● '.repeat(s.sequence.length).trim() + ' —');
} else {
setText('practice-name', s.name);
}
renderArrows('practice-sequence', s.sequence, 0);
updateTimerDisplay(state.settings.timerDuration);
updateScoreDisplay();
updateStreakDisplay();
// Show icon
const iconEl = document.getElementById('practice-icon');
const fallbackEl = document.getElementById('practice-icon-fallback');
if (s.icon) {
setIcon(iconEl, s.icon);
iconEl.classList.remove('icon-complete', 'icon-wrong');
if (fallbackEl) fallbackEl.style.display = 'none';
} else {
if (iconEl) iconEl.style.display = 'none';
if (fallbackEl) fallbackEl.style.display = '';
}
}
function setIcon(imgEl, src) {
if (!imgEl || !src) return;
imgEl.src = src;
imgEl.alt = '';
imgEl.style.display = '';
imgEl.onerror = () => { imgEl.style.display = 'none'; };
}
function renderArrows(containerId, sequence, progress) {
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = sequence.map((dir, i) => {
let cls = 'arrow-key';
if (i < progress) cls += ' completed';
if (i === progress) cls += ' active';
return `<div class="${cls}">${ARROW[dir]}</div>`;
}).join('');
}
function updateTimerDisplay(total) {
if (state.practice.mode === 'speedrun') return;
const el = document.getElementById('practice-timer');
if (!el) return;
const t = state.practice.timeLeft;
const tot = total || state.settings.timerDuration;
el.textContent = t;
const isDanger = t <= 5 && t > 0;
el.className = 'timer-ring-val' + (isDanger ? ' danger' : '');
const ring = document.getElementById('timer-ring-fill');
if (ring) {
const fraction = Math.max(0, t / tot);
ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - fraction));
ring.classList.toggle('danger', isDanger);
}
}
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
function updateStreakDisplay() {
setText('practice-streak', state.practice.streak);
const streakItem = document.getElementById('hud-streak-item');
const comboBadge = document.getElementById('practice-combo');
const streak = state.practice.streak;
if (streakItem) streakItem.classList.toggle('streak-fire', streak >= 5);
if (comboBadge) {
if (streak >= 2) {
comboBadge.textContent = '×' + (1 + streak * 0.1).toFixed(1);
comboBadge.classList.remove('hidden');
} else {
comboBadge.classList.add('hidden');
}
}
}
function updateLivesDisplay() {
const el = document.getElementById('practice-lives');
if (!el) return;
el.innerHTML = Array.from({ length: 3 }, (_, i) =>
`<span class="life-icon${i >= state.practice.lives ? ' lost' : ''}">❤</span>`
).join('');
}
function updateDrillProgress() {
const p = state.practice;
setText('drill-progress-text', p.drillCompleted + ' / ' + p.drillTotal);
const fill = document.getElementById('drill-progress-fill');
if (fill) fill.style.width = p.drillTotal > 0 ? Math.round((p.drillCompleted / p.drillTotal) * 100) + '%' : '0%';
}
function shakeIcon() {
const iconEl = document.getElementById('practice-icon');
if (!iconEl || iconEl.style.display === 'none') return;
iconEl.classList.remove('icon-wrong');
requestAnimationFrame(() => iconEl.classList.add('icon-wrong'));
setTimeout(() => iconEl.classList.remove('icon-wrong'), 400);
}
function handlePracticeInput(dir) {
const p = state.practice;
if (!p.active || !p.current) return;
const mode = p.mode;
const seq = p.current.sequence;
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
const cur = arrows[p.progress];
if (dir === seq[p.progress]) {
cur?.classList.add('flash-correct');
p.progress++;
if (p.progress === seq.length) {
clearInterval(p.timerHandle);
document.getElementById('danger-vignette').classList.add('hidden');
const elapsed = Date.now() - p.startTime;
const mult = 1 + p.streak * 0.1;
let pts = 0;
if (mode === 'timed' || mode === 'drill') {
const secs = Math.min(state.settings.timerDuration, elapsed / 1000);
pts = Math.round((100 + (state.settings.timerDuration - secs) * 3) * mult);
} else if (mode === 'endless') {
pts = Math.round(100 * mult);
} else if (mode === 'speedrun') {
pts = Math.round(50 * mult);
}
p.score += pts;
p.streak++;
p.sessionStats.completed++;
if (elapsed < p.sessionStats.bestTime) p.sessionStats.bestTime = elapsed;
// Track per-stratagem stats
if (!p.sessionStats.stratagems[p.current.name]) {
p.sessionStats.stratagems[p.current.name] = { count: 0, totalMs: 0 };
}
p.sessionStats.stratagems[p.current.name].count++;
p.sessionStats.stratagems[p.current.name].totalMs += elapsed;
updateScoreDisplay();
updateStreakDisplay();
// Visual: all arrows complete + icon flash
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
el.classList.remove('flash-correct');
el.classList.add('completed');
});
const iconEl = document.getElementById('practice-icon');
iconEl?.classList.add('icon-complete');
setTimeout(() => iconEl?.classList.remove('icon-complete'), 300);
// Score popup
showScorePopup('+' + pts);
if (mode !== 'speedrun') {
api('POST', '/scores/practice', {
stratagem: p.current.name,
category: p.current.category,
time_ms: elapsed,
score: pts,
mode: mode,
}).catch(() => {});
}
if (mode === 'drill') {
p.drillPool.shift();
p.drillCompleted++;
updateDrillProgress();
} else if (mode === 'speedrun') {
p.speedrunPool.shift();
}
setTimeout(nextStratagem, 550);
} else {
renderArrows('practice-sequence', seq, p.progress);
}
} else {
// Wrong input
cur?.classList.add('flash-wrong');
p.progress = 0;
shakeIcon();
if (mode === 'endless') {
p.lives--;
p.streak = 0;
updateLivesDisplay();
updateStreakDisplay();
p.sessionStats.missed++;
if (p.lives <= 0) {
setTimeout(() => openSessionSummary(), 600);
return;
}
} else {
p.streak = 0;
updateStreakDisplay();
}
setTimeout(() => renderArrows('practice-sequence', seq, 0), 350);
}
}
// ── Settings modal ────────────────────────────────────────────────────────────
function openSettingsModal() {
applySettingsToUI();
document.getElementById('modal-settings').classList.remove('hidden');
}
function closeSettingsModal() {
document.getElementById('modal-settings').classList.add('hidden');
}
document.getElementById('btn-practice-settings')?.addEventListener('click', openSettingsModal);
document.getElementById('btn-settings-close')?.addEventListener('click', closeSettingsModal);
document.getElementById('modal-settings')?.addEventListener('click', (e) => {
if (e.target === document.getElementById('modal-settings')) closeSettingsModal();
});
// Settings option click (delegation via main delegation handler)
// handled in the data-action delegation below
// ── Mode card selection ───────────────────────────────────────────────────────
document.getElementById('practice-mode-grid')?.addEventListener('click', (e) => {
const card = e.target.closest('[data-mode]');
if (!card || state.practice.active) return;
state.practice.mode = card.dataset.mode;
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
updateModeLabel();
});
function updateModeLabel() {
const dur = state.settings.timerDuration;
const labels = {
timed: `Timed mode — ${dur}s per stratagem`,
endless: 'Endless mode — 3 lives, no timer',
drill: 'Category Drill — master your selection',
speedrun: 'Speed Run — all stratagems, fastest time',
};
setText('practice-mode-label', labels[state.practice.mode] || '');
}
// ── Session summary modal ─────────────────────────────────────────────────────
function openSessionSummary() {
stopPracticeTimer();
document.getElementById('danger-vignette').classList.add('hidden');
const p = state.practice;
const s = p.sessionStats;
// Stats grid
const accuracy = (s.completed + s.missed) > 0
? Math.round(s.completed / (s.completed + s.missed) * 100) + '%'
: '—';
const bestTimeStr = s.bestTime < Infinity ? (s.bestTime / 1000).toFixed(2) + 's' : '—';
const grid = document.getElementById('summary-grid');
if (grid) {
grid.innerHTML = [
{ label: 'Score', val: p.score },
{ label: 'Completed', val: s.completed },
{ label: 'Streak Max',val: p.streak },
{ label: 'Accuracy', val: accuracy },
{ label: 'Best Time', val: bestTimeStr },
{ label: 'Mode', val: p.mode },
].map(x => `<div class="summary-stat">
<div class="summary-stat-val">${esc(String(x.val))}</div>
<div class="summary-stat-label">${esc(x.label)}</div>
</div>`).join('');
}
// Top stratagems by count
const topEl = document.getElementById('summary-top-stratagems');
if (topEl) {
const tops = Object.entries(s.stratagems)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 5);
if (tops.length) {
topEl.innerHTML = tops.map(([name, stat], i) => {
const strat = state.stratagems.find(x => x.name === name);
const avgMs = (stat.totalMs / stat.count).toFixed(0);
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
return `<div class="summary-top-item">
<span class="summary-top-rank">${i + 1}.</span>
${iconHtml}
<span class="summary-top-name">${esc(name)}</span>
<span class="summary-top-time">${(avgMs / 1000).toFixed(2)}s avg</span>
</div>`;
}).join('');
} else {
topEl.innerHTML = '<p class="muted" style="font-size:0.85rem">No data</p>';
}
}
document.getElementById('modal-session-summary').classList.remove('hidden');
}
function closeSessionSummary() {
document.getElementById('modal-session-summary').classList.add('hidden');
showPracticeIdle();
}
document.getElementById('btn-summary-dashboard')?.addEventListener('click', () => {
closeSessionSummary();
showView('dashboard');
});
document.getElementById('btn-summary-restart')?.addEventListener('click', () => {
document.getElementById('modal-session-summary').classList.add('hidden');
startPractice();
});
// ── Lobby ─────────────────────────────────────────────────────────────────────
function updateLobbyView() {
const others = state.lobby.online.filter(u => {
const name = typeof u === 'object' ? u.name : u;
return name !== state.user?.user;
});
const el = document.getElementById('lobby-players');
if (!el) return;
if (!others.length) {
el.innerHTML = `<div class="lobby-empty">
<div class="lobby-empty-icon">📡</div>
<p>No other Helldivers online.<br>Waiting for reinforcements...</p>
</div>`;
} else {
el.innerHTML = others.map(u => {
const name = typeof u === 'object' ? u.name : u;
const elo = typeof u === 'object' ? u.elo : '';
const rank = typeof u === 'object' ? u.rank : '';
return `<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(name)}</span>
${elo ? `<span class="player-elo">${esc(rank)} · ${elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(name)}">⚔ Challenge</button>
</div>`;
}).join('');
}
const challEl = document.getElementById('lobby-challenges');
if (!challEl) return;
const inc = state.lobby.incoming;
if (!inc.length) {
challEl.innerHTML = '<p class="muted">No incoming challenges</p>';
} else {
challEl.innerHTML = inc.map(from =>
`<div class="challenge-item">
<span style="flex:1"><strong>${esc(from)}</strong> challenges you!</span>
<button class="btn btn-sm btn-accent" data-action="accept" data-user="${esc(from)}">Accept</button>
<button class="btn btn-sm btn-muted" data-action="decline" data-user="${esc(from)}">Decline</button>
</div>`
).join('');
}
}
function sendChallenge(target) {
wsSend('challenge-user', { targetUser: target });
showToast('Challenge sent to ' + esc(target));
}
function acceptChallenge(from) {
wsSend('accept-challenge', { challengerId: from });
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
closeChallengeModal();
updateChallengeBadge();
}
function declineChallenge(from) {
wsSend('decline-challenge', { challengerId: from });
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
closeChallengeModal();
updateChallengeBadge();
if (state.currentView === 'lobby') updateLobbyView();
}
function updateChallengeBadge() {
const badge = document.getElementById('challenge-badge');
if (!badge) return;
const count = state.lobby.incoming.length;
if (count > 0) {
badge.textContent = count + ' Challenge' + (count > 1 ? 's' : '') + ' Go to 1v1';
badge.classList.remove('hidden');
badge.onclick = () => showView('lobby');
} else {
badge.classList.add('hidden');
}
}
function openChallengeModal(from, elo) {
state.lobby.pendingChallenge = { from, elo };
setText('modal-challenger-name', from);
setText('modal-challenger-elo', elo);
document.getElementById('modal-challenge').classList.remove('hidden');
}
function closeChallengeModal() {
document.getElementById('modal-challenge').classList.add('hidden');
state.lobby.pendingChallenge = null;
}
document.getElementById('btn-accept-challenge')?.addEventListener('click', () => {
if (state.lobby.pendingChallenge) acceptChallenge(state.lobby.pendingChallenge.from);
});
document.getElementById('btn-decline-challenge')?.addEventListener('click', () => {
if (state.lobby.pendingChallenge) declineChallenge(state.lobby.pendingChallenge.from);
});
document.getElementById('modal-challenge')?.addEventListener('click', (e) => {
if (e.target === document.getElementById('modal-challenge')) closeChallengeModal();
});
// ── Match ─────────────────────────────────────────────────────────────────────
function renderMatchWaiting() {
const m = state.match;
setText('match-me-name', state.user.user);
setText('match-opp-name', m.opponent);
setText('match-status', 'Waiting for both players...');
setText('match-category', '');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'READY';
btn.disabled = false;
btn.classList.remove('hidden');
// Hide match icon
const matchIcon = document.getElementById('match-icon');
if (matchIcon) matchIcon.style.display = 'none';
}
function renderMatchScores() {
const m = state.match;
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
}
function setReady() {
wsSend('player-ready');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready waiting for opponent...';
btn.disabled = true;
}
function renderMatchRound() {
const m = state.match;
setText('match-status', m.current.name);
setText('match-category', m.current.category);
document.getElementById('match-round-area').classList.remove('hidden');
document.getElementById('match-ready-btn').classList.add('hidden');
renderArrows('match-me-sequence', m.current.sequence, 0);
renderArrows('match-opp-sequence', m.current.sequence, 0);
// Show stratagem icon in match
const strat = state.stratagems.find(s => s.name === m.current.name);
const matchIcon = document.getElementById('match-icon');
if (strat?.icon) setIcon(matchIcon, strat.icon);
else if (matchIcon) matchIcon.style.display = 'none';
}
function updateMyArrows(correct) {
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
if (!correct) {
const el = document.getElementById('match-me-sequence');
el?.classList.add('flash-wrong-seq');
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
}
}
function updateOppArrows() {
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
}
function handleMatchInput(dir) {
if (!state.match.roundActive) return;
wsSend('input-arrow', { direction: dir });
}
function renderRoundResult(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
renderMatchScores();
const matchIcon = document.getElementById('match-icon');
if (matchIcon) matchIcon.style.display = 'none';
setTimeout(() => {
document.getElementById('match-round-area').classList.add('hidden');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready for next round';
btn.disabled = false;
btn.classList.remove('hidden');
setText('match-category', '');
}, 1600);
}
function openMatchResultModal({ winner, eloChanges, roundHistory }) {
const isWinner = winner === state.user.user;
const resultEl = document.getElementById('result-winner-text');
if (resultEl) {
resultEl.textContent = isWinner ? '🏆 VICTORY' : '☠ DEFEAT';
resultEl.className = 'result-winner ' + (isWinner ? 'win' : 'loss');
}
if (eloChanges && state.user?.user) {
const myChange = eloChanges[state.user.user];
if (myChange) {
setText('result-elo-old', myChange.old);
setText('result-elo-new', myChange.new);
const delta = myChange.delta;
const deltaEl = document.getElementById('result-elo-delta');
if (deltaEl) {
deltaEl.textContent = (delta >= 0 ? '+' : '') + delta;
deltaEl.className = 'elo-delta-val ' + (delta >= 0 ? 'positive' : 'negative');
}
}
}
const histEl = document.getElementById('result-round-history');
if (histEl && roundHistory?.length) {
histEl.innerHTML = roundHistory.map((r, i) => {
const iMine = r.winner === state.user.user;
const stratName = r.stratagem || '—';
const strat = state.stratagems.find(s => s.name === stratName);
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
return `<div class="round-row ${iMine ? 'won' : 'lost'}">
<span class="round-num">R${r.round || (i + 1)}</span>
${iconHtml}
<span class="round-strat">${esc(stratName)}</span>
<span class="round-result">${iMine ? '✓ Won' : '✗ Lost'}</span>
</div>`;
}).join('');
} else if (histEl) {
histEl.innerHTML = '';
}
document.getElementById('modal-match-result').classList.remove('hidden');
}
function closeMatchResultModal() {
document.getElementById('modal-match-result').classList.add('hidden');
}
function leaveMatch() {
wsSend('leave-room');
closeMatchResultModal();
showView('lobby');
}
document.getElementById('btn-result-lobby')?.addEventListener('click', () => {
closeMatchResultModal();
showView('lobby');
});
document.getElementById('btn-result-rematch')?.addEventListener('click', () => {
closeMatchResultModal();
const opp = state.match.opponent;
showView('lobby');
if (opp) sendChallenge(opp);
});
// ── Leaderboard ───────────────────────────────────────────────────────────────
async function loadLeaderboard() {
const tab = state.leaderboard.activeTab;
const tbody = document.getElementById('leaderboard-table-body');
const thead = document.getElementById('leaderboard-thead');
tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
try {
if (tab === 'practice') {
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Rank</th><th>Total Score</th><th>Sessions</th><th>Match W/Total</th></tr>';
const rows = await api('GET', '/scores/leaderboard');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No scores yet. Start practicing!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) => {
const rank = eloRankFor(r.elo || 1000);
return `<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td><span class="badge badge-rank">${rank.icon} ${rank.label}</span></td>
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
<td style="font-family:var(--font-mono)">${r.sessions}</td>
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
</tr>`;
}).join('');
}
} else if (tab === 'elo') {
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>ELO</th><th>Rank</th><th colspan="2">Matches W/Total</th></tr>';
const rows = await api('GET', '/scores/leaderboard/elo');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No ELO data yet. Play some 1v1 matches!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) => {
const rank = eloRankFor(r.elo);
return `<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td style="font-family:var(--font-mono);color:var(--accent)">${r.elo}</td>
<td><span class="badge badge-rank">${rank.icon} ${rank.label}</span></td>
<td colspan="2" style="font-family:var(--font-mono)">${r.wins || 0}/${r.matches || 0}</td>
</tr>`;
}).join('');
}
} else if (tab === 'speedrun') {
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Total Time</th><th colspan="3"></th></tr>';
const rows = await api('GET', '/scores/leaderboard/speedrun');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No speedrun data yet. Try Speed Run mode!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) =>
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td style="font-family:var(--font-mono);color:var(--accent)">${(r.totalTime / 1000).toFixed(2)}s</td>
<td colspan="3"></td>
</tr>`
).join('');
}
}
} catch {
tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading leaderboard</td></tr>';
}
}
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.leaderboard.activeTab = btn.dataset.tab;
loadLeaderboard();
});
});
// ── History ───────────────────────────────────────────────────────────────────
async function loadHistory() {
const h = state.history;
const tbody = document.getElementById('history-table-body');
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
const mode = document.getElementById('history-filter-mode')?.value || '';
const cat = document.getElementById('history-filter-cat')?.value || '';
const limit = 10;
try {
let url = `/history?page=${h.page}&limit=${limit}`;
if (mode) url += '&mode=' + encodeURIComponent(mode);
if (cat) url += '&cat=' + encodeURIComponent(cat);
const data = await api('GET', url);
h.total = data.total || 0;
if (!data.rows?.length) {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">No sessions yet</td></tr>';
} else {
if (tbody) {
tbody.innerHTML = data.rows.map(r => {
const strat = state.stratagems.find(s => s.name === r.stratagem);
const date = new Date(r.created_at || Date.now()).toLocaleDateString('de-DE');
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
return `<tr>
<td>${iconHtml}${esc(r.stratagem)}</td>
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(r.category || '—')}</td>
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
<td>${r.score}</td>
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
<td style="font-size:0.8rem;color:var(--text-muted)">${date}</td>
</tr>`;
}).join('');
}
renderHistoryChart(data.rows);
}
renderHistoryPagination(limit);
loadStratagemStats();
} catch {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading history</td></tr>';
}
}
function renderHistoryChart(sessions) {
const svg = document.getElementById('history-chart-svg');
if (!svg || sessions.length < 2) {
if (svg) svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="var(--text-muted)" font-size="13">Not enough data</text>';
return;
}
const W = 800, H = 160, PAD = 20;
const scores = sessions.map(s => s.score || 0);
const maxScore = Math.max(...scores, 1);
const minScore = Math.min(...scores);
const range = maxScore - minScore || 1;
const points = scores.map((s, i) => {
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.innerHTML = `<polyline fill="none" stroke="var(--accent)" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" points="${points}"/>
${scores.map((s, i) => {
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent)" opacity="0.7"/>`;
}).join('')}`;
}
function renderHistoryPagination(limit) {
const el = document.getElementById('history-pagination');
if (!el) return;
const total = state.history.total;
const page = state.history.page;
const pages = Math.ceil(total / limit);
if (pages <= 1) { el.innerHTML = ''; return; }
el.innerHTML = `
<button class="page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">← Prev</button>
<span class="page-info">Page ${page} / ${pages}</span>
<button class="page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">Next →</button>
`;
}
document.getElementById('history-pagination')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-page]');
if (!btn) return;
state.history.page = Number(btn.dataset.page);
loadHistory();
});
document.getElementById('history-filter-mode')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
document.getElementById('history-filter-cat')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
async function loadStratagemStats() {
try {
const rows = await api('GET', '/stats/stratagems');
const tbody = document.getElementById('best-per-stratagem-body');
if (!tbody) return;
if (!rows?.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No data yet</td></tr>';
} else {
tbody.innerHTML = rows.map(r => {
const strat = state.stratagems.find(s => s.name === r.stratagem);
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
return `<tr>
<td>${iconHtml}</td>
<td>${esc(r.stratagem)}</td>
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(strat?.category || '—')}</td>
<td style="color:var(--accent)">${r.best_time ? (r.best_time / 1000).toFixed(2) + 's' : '—'}</td>
<td>${r.attempts || 0}</td>
</tr>`;
}).join('');
}
} catch { /* ignore */ }
}
// ── Admin ─────────────────────────────────────────────────────────────────────
async function loadAdmin() {
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
try {
const users = await api('GET', '/users');
renderAdminUsers(users);
} catch {
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
}
}
function renderAdminUsers(users) {
const el = document.getElementById('admin-users');
el.innerHTML = users.map(u =>
`<div class="admin-user-row">
<span style="font-family:var(--font-mono);flex:1">${esc(u.username)}</span>
<span class="badge badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
: ''}
</div>`
).join('');
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const role = document.getElementById('new-role').value;
const errEl = document.getElementById('admin-error');
const pwEl = document.getElementById('new-pw-display');
errEl.classList.add('hidden');
pwEl.classList.add('hidden');
if (!username) return;
try {
const result = await api('POST', '/users', { username, role });
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
pwEl.classList.remove('hidden');
document.getElementById('new-username').value = '';
loadAdmin();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
async function deleteUser(username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
await api('DELETE', '/users/' + encodeURIComponent(username));
loadAdmin();
} catch (err) {
showToast('Error: ' + err.message);
}
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const user = btn.dataset.user;
const cat = btn.dataset.cat;
if (action === 'challenge' && user) sendChallenge(user);
if (action === 'accept' && user) acceptChallenge(user);
if (action === 'decline' && user) declineChallenge(user);
if (action === 'delete-user' && user) deleteUser(user);
if (action === 'toggle-cat' && cat) toggleCategory(cat);
// Settings options
const settingBtn = e.target.closest('[data-setting]');
if (settingBtn) {
const setting = settingBtn.dataset.setting;
const value = settingBtn.dataset.value;
if (setting === 'timer') state.settings.timerDuration = Number(value);
else if (setting === 'difficulty') state.settings.difficulty = value;
saveSettings();
applySettingsToUI();
updateModeLabel();
}
});
// ── Keyboard ──────────────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeSettingsModal();
closeChallengeModal();
closeMatchResultModal();
if (document.getElementById('modal-session-summary')?.classList.contains('hidden') === false) {
closeSessionSummary();
}
if (state.currentView === 'practice' && state.practice.active) {
stopPracticeUI();
}
return;
}
if (e.key === 'Enter' && state.currentView === 'practice' && !state.practice.active) {
startPractice();
return;
}
const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
const dir = MAP[e.key];
if (!dir) return;
if (state.currentView === 'practice' || state.currentView === 'match') {
e.preventDefault();
dpadInput(dir);
}
});
function dpadInput(dir) {
if (state.currentView === 'practice') handlePracticeInput(dir);
if (state.currentView === 'match') handleMatchInput(dir);
}
// ── Utils ─────────────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function showToast(msg) {
const container = document.getElementById('toast-container');
if (container.children.length >= 3) container.firstChild?.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
container.appendChild(toast);
requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show')));
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}, 3200);
}
function showScorePopup(text) {
const el = document.getElementById('score-popup');
if (!el) return;
el.textContent = text;
el.classList.remove('show', 'hidden');
requestAnimationFrame(() => {
el.classList.add('show');
el.addEventListener('animationend', () => {
el.classList.remove('show');
}, { once: true });
});
}
// ── Static button bindings ───────────────────────────────────────────────────
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);
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 ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', checkAuth);