Files
helldivers/public/app.js
T
2026-04-03 11:59:24 +02:00

1843 lines
70 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: {}, mistakes: {}, maxStreak: 0 },
},
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();
if (name !== 'practice') document.body.classList.remove('in-practice-session');
if (name !== 'match') document.body.classList.remove('in-match-round');
}
function focusGameplayArea(id) {
const el = document.getElementById(id);
if (!el) return;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runGameplayCountdown(label = 'Deploying', steps = ['3', '2', '1', 'GO']) {
const el = document.getElementById('gameplay-countdown');
if (!el) return;
el.classList.remove('hidden');
for (const step of steps) {
el.innerHTML = `<span class="countdown-label">${esc(label)}</span><strong>${esc(step)}</strong>`;
el.classList.remove('countdown-pop');
void el.offsetWidth;
el.classList.add('countdown-pop');
await sleep(step === 'GO' ? 380 : 520);
}
el.classList.add('hidden');
}
function showGameplayFeedback(id, text, tone = 'info', duration = 900) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.className = `gameplay-feedback gameplay-feedback-${tone}`;
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(() => {
el.className = 'gameplay-feedback hidden';
}, duration);
}
// ── 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));
});
document.getElementById('btn-briefing-practice')?.addEventListener('click', () => showView('practice'));
document.getElementById('btn-briefing-lobby')?.addEventListener('click', () => showView('lobby'));
document.getElementById('btn-briefing-leaderboard')?.addEventListener('click', () => showView('leaderboard'));
// ── 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 = false;
renderMatchRound();
beginMatchRound();
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 myRank = eloRankFor(elo || 1000);
const onlineOthers = (online || []).filter(u => u.name !== state.user?.user);
setText('dash-hero-name', state.user.user);
setText('dash-rank-label', rankLabel || myRank.label);
setText('dash-elo', elo || 1000);
setText('dash-rank-icon', myRank.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);
setText('dash-online-count', String(onlineOthers.length));
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');
setText('dash-daily-focus', daily.stratagem.category);
setText('dash-status-line', daily.bestTime
? `Daily focus is ${daily.stratagem.name}. Your best run is ${(daily.bestTime / 1000).toFixed(2)}s, so a cleaner sequence could move you up fast.`
: `Daily focus is ${daily.stratagem.name}. No record logged yet, so this is a clean chance to set the pace for today.`);
state.practice.dailyTarget = daily.stratagem.name;
renderDailySequencePreview(daily.stratagem.sequence);
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
} else {
setText('dash-daily-focus', 'Stand By');
setText('dash-status-line', 'Systems are online. Review recent runs, sharpen your execution, and push your rank before heading into the arena.');
}
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 => {
const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
return `<tr>
<td><img class="stratagem-icon-sm" src="${esc(icon)}" alt="" ${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 => u.name !== state.user?.user);
if (!players.length) {
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
} else {
el.innerHTML = players.map(u =>
`<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(u.name)}</span>
${u.elo ? `<span class="player-elo">${u.elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.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');
document.body.classList.remove('in-practice-session');
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: {}, mistakes: {}, maxStreak: 0 };
}
async 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');
document.body.classList.add('in-practice-session');
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');
}
focusGameplayArea('practice-active');
showGameplayFeedback('practice-feedback', 'Stand by. Deployment starting.', 'info', 1500);
await runGameplayCountdown('Deployment');
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);
showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`);
openSessionSummary();
return;
}
strat = p.speedrunPool[0];
} else {
// timed & endless: pick random from pool
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++;
trackPracticeMistake();
updateStreakDisplay();
shakeIcon();
showGameplayFeedback('practice-feedback', 'Timer expired. Next stratagem.', 'danger', 1000);
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 = '';
}
showGameplayFeedback('practice-feedback', `${s.name} ready. Execute the sequence.`, 'info', 900);
}
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 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}">${renderDirGlyph(dir)}</div>`;
}).join('');
}
function renderDirGlyph(dir) {
return `<span class="dir-glyph dir-${esc(dir)}" aria-hidden="true">
<svg viewBox="0 0 64 64" focusable="false">
<path class="dir-line" d="M32 50 L32 18" />
<path class="dir-head" d="M32 12 L20 24 H28 V50 H36 V24 H44 Z" />
</svg>
</span>`;
}
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 trackPracticeMistake() {
const current = state.practice.current;
if (!current) return;
const mistakes = state.practice.sessionStats.mistakes;
mistakes[current.name] = (mistakes[current.name] || 0) + 1;
}
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.maxStreak = Math.max(p.sessionStats.maxStreak, 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);
showGameplayFeedback('practice-feedback', `${p.streak >= 5 ? 'Perfect chain' : 'Confirmed'} +${pts}`, 'success', 1100);
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();
trackPracticeMistake();
showGameplayFeedback('practice-feedback', 'Wrong input. Sequence reset.', 'danger', 1000);
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: s.maxStreak },
{ 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('');
}
const insightsEl = document.getElementById('summary-insights');
if (insightsEl) {
const mistakeEntries = Object.entries(s.mistakes).sort((a, b) => b[1] - a[1]);
const stratEntries = Object.entries(s.stratagems)
.map(([name, stat]) => ({ name, avg: stat.totalMs / stat.count, count: stat.count }))
.sort((a, b) => b.avg - a.avg);
const insights = [
`Accuracy landed at ${accuracy}${s.maxStreak >= 5 ? ` with a peak streak of ${s.maxStreak}.` : '.'}`,
stratEntries[0] ? `Slowest repeated stratagem was ${stratEntries[0].name} at ${(stratEntries[0].avg / 1000).toFixed(2)}s average.` : 'Run more sessions to identify your slowest stratagems.',
mistakeEntries[0] ? `Most common reset came from ${mistakeEntries[0][0]} with ${mistakeEntries[0][1]} mistake${mistakeEntries[0][1] > 1 ? 's' : ''}.` : 'No input resets recorded in this session.',
];
insightsEl.innerHTML = insights.map(line => `<div class="summary-insight">${esc(line)}</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 => u.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 =>
`<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(u.name)}</span>
${u.elo ? `<span class="player-elo">${esc(u.rank)} · ${u.elo}</span>` : ''}
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.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');
document.body.classList.remove('in-match-round');
showGameplayFeedback('match-feedback', 'Waiting for both divers to ready up.', 'info', 1200);
updateMatchProgressUI();
// 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');
document.body.classList.add('in-match-round');
renderArrows('match-me-sequence', m.current.sequence, 0);
renderArrows('match-opp-sequence', m.current.sequence, 0);
updateMatchProgressUI();
// 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';
focusGameplayArea('match-round-area');
}
function updateMyArrows(correct) {
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
updateMatchProgressUI();
if (!correct) {
const el = document.getElementById('match-me-sequence');
el?.classList.add('flash-wrong-seq');
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
showGameplayFeedback('match-feedback', 'Input rejected. Recover now.', 'danger', 900);
} else {
showGameplayFeedback('match-feedback', 'Confirmed. Keep pushing.', 'success', 550);
}
}
function updateOppArrows() {
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
updateMatchProgressUI();
}
function handleMatchInput(dir) {
if (!state.match.roundActive) return;
wsSend('input-arrow', { direction: dir });
}
function updateMatchProgressUI() {
const total = state.match.current?.sequence?.length || 0;
const myFill = document.getElementById('match-me-progress-fill');
const oppFill = document.getElementById('match-opp-progress-fill');
const myText = document.getElementById('match-me-progress-text');
const oppText = document.getElementById('match-opp-progress-text');
if (myFill) myFill.style.width = total ? `${(state.match.myProgress / total) * 100}%` : '0%';
if (oppFill) oppFill.style.width = total ? `${(state.match.oppProgress / total) * 100}%` : '0%';
if (myText) myText.textContent = `${state.match.myProgress} / ${total}`;
if (oppText) oppText.textContent = `${state.match.oppProgress} / ${total}`;
}
async function beginMatchRound() {
focusGameplayArea('match-round-area');
showGameplayFeedback('match-feedback', 'Round locked. Prepare to input.', 'info', 1200);
await runGameplayCountdown('Round Start');
state.match.roundActive = true;
showGameplayFeedback('match-feedback', 'Go go go.', 'success', 700);
}
function renderRoundResult(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
renderMatchScores();
showGameplayFeedback('match-feedback', won ? 'Round secured.' : 'Opponent took the round.', won ? 'success' : 'danger', 1200);
const matchIcon = document.getElementById('match-icon');
if (matchIcon) matchIcon.style.display = 'none';
setTimeout(() => {
document.getElementById('match-round-area').classList.add('hidden');
document.body.classList.remove('in-match-round');
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(); });
document.getElementById('admin-user-search')?.addEventListener('input', () => {
renderAdminUsers(state.adminUsers || []);
});
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, overview, activity] = await Promise.all([
api('GET', '/users'),
api('GET', '/admin/overview'),
api('GET', '/admin/activity'),
]);
state.adminUsers = users;
renderAdminOverview(overview);
renderAdminUsers(users);
renderAdminActivity(activity);
} catch {
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
}
}
function renderAdminOverview(data = {}) {
setText('admin-total-users', String(data.totals?.users ?? 0));
setText('admin-total-admins', String(data.totals?.admins ?? 0));
setText('admin-temp-passwords', String(data.totals?.tempPasswords ?? 0));
setText('admin-practice-sessions', String(data.activity?.practiceSessions ?? 0));
const topUser = data.topUser?.username || 'No data yet';
const topMeta = data.topUser
? `Score ${Number(data.topUser.totalScore || 0).toLocaleString()} across ${data.topUser.sessions || 0} sessions`
: 'Waiting for enough runs to identify a standout Helldiver.';
setText('admin-top-user', topUser);
setText('admin-top-user-meta', topMeta);
}
function renderAdminUsers(users) {
const el = document.getElementById('admin-users');
const search = document.getElementById('admin-user-search')?.value.trim().toLowerCase() || '';
const filtered = users.filter((u) => {
if (!search) return true;
return [
u.username,
u.role,
String(u.elo ?? ''),
String(u.sessions ?? ''),
].join(' ').toLowerCase().includes(search);
});
if (!filtered.length) {
el.innerHTML = '<div class="admin-empty">No matching users found.</div>';
return;
}
el.innerHTML = filtered.map((u) => {
const isSelf = u.username === state.user.user;
const nextRole = u.role === 'admin' ? 'user' : 'admin';
const lastPlayed = u.lastPlayed ? new Date(u.lastPlayed).toLocaleString() : 'No activity yet';
return `<div class="admin-user-row">
<div class="admin-user-main">
<div class="admin-user-name-row">
<span class="user-name">${esc(u.username)}</span>
<span class="badge badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
${isSelf ? '<span class="badge">you</span>' : ''}
</div>
<div class="admin-user-meta">
<span>ELO ${Number(u.elo ?? 1000)}</span>
<span>${Number(u.sessions ?? 0)} sessions</span>
<span>${esc(lastPlayed)}</span>
</div>
</div>
<div class="admin-user-actions">
<button class="btn btn-muted btn-sm" data-action="reset-password" data-user="${esc(u.username)}">Reset password</button>
${isSelf ? '' : `<button class="btn btn-sm" data-action="toggle-role" data-user="${esc(u.username)}" data-role="${nextRole}">${u.role === 'admin' ? 'Make user' : 'Make admin'}</button>`}
${isSelf ? '' : `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`}
</div>
</div>`;
}).join('');
}
function renderAdminActivity(data = {}) {
const practiceEl = document.getElementById('admin-recent-practice');
const matchesEl = document.getElementById('admin-recent-matches');
const practiceRows = data.practice || [];
const matchRows = data.matches || [];
practiceEl.innerHTML = practiceRows.length
? practiceRows.map((row) => `
<div class="admin-activity-item">
<div class="admin-activity-head">
<strong>${esc(row.username)}</strong>
<span>${esc(row.mode || 'practice')}</span>
</div>
<div class="admin-activity-body">${esc(row.stratagem || 'Unknown stratagem')}</div>
<div class="admin-activity-meta">
<span>${Number(row.score || 0)} pts</span>
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
</div>
</div>
`).join('')
: '<div class="admin-empty">No recent practice activity.</div>';
matchesEl.innerHTML = matchRows.length
? matchRows.map((row) => `
<div class="admin-activity-item">
<div class="admin-activity-head">
<strong>${esc(row.winner || 'Pending')}</strong>
<span>${esc(row.winner || 'Pending')} vs ${esc(row.loser || 'Unknown')}</span>
</div>
<div class="admin-activity-body">Scoreline: ${row.winner_rounds ?? 0} : ${row.loser_rounds ?? 0}</div>
<div class="admin-activity-meta">
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
</div>
</div>
`).join('')
: '<div class="admin-empty">No recent match activity.</div>';
}
async function resetUserPassword(username) {
if (!confirm(`Reset password for "${username}" and require a password change on next login?`)) return;
try {
const result = await api('POST', `/users/${encodeURIComponent(username)}/reset-password`);
const pwEl = document.getElementById('new-pw-display');
pwEl.textContent = `Temp password for ${username}: ${result.tempPassword}`;
pwEl.classList.remove('hidden');
showToast(`Password reset for ${username}`);
loadAdmin();
} catch (err) {
showToast('Error: ' + err.message);
}
}
async function updateUserRole(username, role) {
const label = role === 'admin' ? 'promote' : 'demote';
if (!confirm(`Really ${label} "${username}"?`)) return;
try {
await api('PATCH', `/users/${encodeURIComponent(username)}`, { role });
showToast(`Role updated for ${username}`);
loadAdmin();
} catch (err) {
showToast('Error: ' + err.message);
}
}
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 === 'reset-password' && user) resetUserPassword(user);
if (action === 'toggle-role' && user) updateUserRole(user, btn.dataset.role);
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);