'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 = `${esc(label)}${esc(step)}`;
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 = '
| No sessions yet |
';
} else {
tbody.innerHTML = recent.map(r => {
const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
return `
${esc(r.stratagem)} |
${esc(r.mode || 'timed')} |
${r.score} |
${(r.time_ms / 1000).toFixed(2)}s |
`;
}).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 => `${ARROW[d]}
`).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 = 'No other Helldivers online';
} else {
el.innerHTML = players.map(u =>
`
${esc(u.name)}
${u.elo ? `${u.elo}` : ''}
`
).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 ``;
}).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
? `
`
: `⚡
`;
return `
${iconHtml}
${esc(s.name)}
`;
}).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 `${renderDirGlyph(dir)}
`;
}).join('');
}
function renderDirGlyph(dir) {
return `
`;
}
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) =>
`❤`
).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 => `
${esc(String(x.val))}
${esc(x.label)}
`).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 => `${esc(line)}
`).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 ? `
` : '';
return `
${i + 1}.
${iconHtml}
${esc(name)}
${(avgMs / 1000).toFixed(2)}s avg
`;
}).join('');
} else {
topEl.innerHTML = 'No data
';
}
}
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 = `
📡
No other Helldivers online.
Waiting for reinforcements...
`;
} else {
el.innerHTML = others.map(u =>
`
${esc(u.name)}
${u.elo ? `${esc(u.rank)} · ${u.elo}` : ''}
`
).join('');
}
const challEl = document.getElementById('lobby-challenges');
if (!challEl) return;
const inc = state.lobby.incoming;
if (!inc.length) {
challEl.innerHTML = 'No incoming challenges
';
} else {
challEl.innerHTML = inc.map(from =>
`
${esc(from)} challenges you!
`
).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 ? `
` : '';
return `
R${r.round || (i + 1)}
${iconHtml}
${esc(stratName)}
${iMine ? '✓ Won' : '✗ Lost'}
`;
}).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 = '| Loading... |
';
try {
if (tab === 'practice') {
if (thead) thead.innerHTML = '| # | Helldiver | Rank | Total Score | Sessions | Match W/Total |
';
const rows = await api('GET', '/scores/leaderboard');
if (!rows.length) {
tbody.innerHTML = '| No scores yet. Start practicing! |
';
} else {
tbody.innerHTML = rows.map((r, i) => {
const rank = eloRankFor(r.elo || 1000);
return `
| ${i + 1} |
${esc(r.username)} |
${rank.icon} ${rank.label} |
${r.totalScore} |
${r.sessions} |
${r.wins}/${r.matches} |
`;
}).join('');
}
} else if (tab === 'elo') {
if (thead) thead.innerHTML = '| # | Helldiver | ELO | Rank | Matches W/Total |
';
const rows = await api('GET', '/scores/leaderboard/elo');
if (!rows.length) {
tbody.innerHTML = '| No ELO data yet. Play some 1v1 matches! |
';
} else {
tbody.innerHTML = rows.map((r, i) => {
const rank = eloRankFor(r.elo);
return `
| ${i + 1} |
${esc(r.username)} |
${r.elo} |
${rank.icon} ${rank.label} |
${r.wins || 0}/${r.matches || 0} |
`;
}).join('');
}
} else if (tab === 'speedrun') {
if (thead) thead.innerHTML = '| # | Helldiver | Total Time | |
';
const rows = await api('GET', '/scores/leaderboard/speedrun');
if (!rows.length) {
tbody.innerHTML = '| No speedrun data yet. Try Speed Run mode! |
';
} else {
tbody.innerHTML = rows.map((r, i) =>
`
| ${i + 1} |
${esc(r.username)} |
${(r.totalTime / 1000).toFixed(2)}s |
|
`
).join('');
}
}
} catch {
tbody.innerHTML = '| Error loading leaderboard |
';
}
}
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 = '| Loading... |
';
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 = '| No sessions yet |
';
} 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 ? `
` : '';
return `
| ${iconHtml}${esc(r.stratagem)} |
${esc(r.category || '—')} |
${esc(r.mode || 'timed')} |
${r.score} |
${(r.time_ms / 1000).toFixed(2)}s |
${date} |
`;
}).join('');
}
renderHistoryChart(data.rows);
}
renderHistoryPagination(limit);
loadStratagemStats();
} catch {
if (tbody) tbody.innerHTML = '| Error loading history |
';
}
}
function renderHistoryChart(sessions) {
const svg = document.getElementById('history-chart-svg');
if (!svg || sessions.length < 2) {
if (svg) svg.innerHTML = 'Not enough data';
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 = `
${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 ``;
}).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 = `
Page ${page} / ${pages}
`;
}
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 = '| No data yet |
';
} else {
tbody.innerHTML = rows.map(r => {
const strat = state.stratagems.find(s => s.name === r.stratagem);
const iconHtml = strat?.icon ? `
` : '';
return `
| ${iconHtml} |
${esc(r.stratagem)} |
${esc(strat?.category || '—')} |
${r.best_time ? (r.best_time / 1000).toFixed(2) + 's' : '—'} |
${r.attempts || 0} |
`;
}).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 = 'Error loading users';
}
}
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 = 'No matching users found.
';
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 `
${esc(u.username)}
${u.role}
${u.mustChange ? 'temp pw' : ''}
${isSelf ? 'you' : ''}
ELO ${Number(u.elo ?? 1000)}
${Number(u.sessions ?? 0)} sessions
${esc(lastPlayed)}
${isSelf ? '' : ``}
${isSelf ? '' : ``}
`;
}).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) => `
${esc(row.username)}
${esc(row.mode || 'practice')}
${esc(row.stratagem || 'Unknown stratagem')}
${Number(row.score || 0)} pts
${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}
`).join('')
: 'No recent practice activity.
';
matchesEl.innerHTML = matchRows.length
? matchRows.map((row) => `
${esc(row.winner || 'Pending')}
${esc(row.winner || 'Pending')} vs ${esc(row.loser || 'Unknown')}
Scoreline: ${row.winner_rounds ?? 0} : ${row.loser_rounds ?? 0}
${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}
`).join('')
: 'No recent match activity.
';
}
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, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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);