'use strict'; // ── Constants ───────────────────────────────────────────────────────────────── const RING_CIRCUMFERENCE = 219.9; // 2π × r(35) const ELO_RANKS = [ { label: 'PRIVATE', min: 0, icon: '⚡' }, { label: 'SERGEANT', min: 1100, icon: '★' }, { label: 'LIEUTENANT', min: 1300, icon: '☆' }, { label: 'CAPTAIN', min: 1500, icon: '⚔' }, { label: 'GENERAL', min: 1700, icon: '🏆' }, ]; function eloRankFor(elo) { for (let i = ELO_RANKS.length - 1; i >= 0; i--) { if (elo >= ELO_RANKS[i].min) return ELO_RANKS[i]; } return ELO_RANKS[0]; } // ── State ───────────────────────────────────────────────────────────────────── const state = { user: null, currentView: 'login', stratagems: [], settings: { timerDuration: 30, // 15 | 30 | 45 difficulty: 'normal', // 'easy' | 'normal' | 'hard' }, practice: { active: false, mode: 'timed', current: null, queue: [], // upcoming stratagems (for queue preview) progress: 0, timeLeft: 30, timerHandle: null, startTime: null, score: 0, streak: 0, selectedCats: new Set(), dailyTarget: null, // Endless mode lives: 3, // Drill mode drillPool: [], drillCompleted: 0, drillTotal: 0, // Speedrun mode speedrunStart: null, speedrunPool: [], speedrunElapsed: 0, // Session stats sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} }, }, lobby: { online: [], incoming: [], pendingChallenge: null, }, match: { roomId: null, opponent: null, matchScores: {}, current: null, myProgress: 0, oppProgress: 0, roundActive: false, roundHistory: [], }, leaderboard: { activeTab: 'practice' }, history: { page: 1, total: 0 }, ws: null, wsReconnectTimer: null, }; // ── Settings ────────────────────────────────────────────────────────────────── function loadSettings() { try { const raw = localStorage.getItem('hd2-settings'); if (raw) { const s = JSON.parse(raw); if ([15, 30, 45].includes(s.timerDuration)) state.settings.timerDuration = s.timerDuration; if (['easy', 'normal', 'hard'].includes(s.difficulty)) state.settings.difficulty = s.difficulty; } } catch { /* ignore */ } applySettingsToUI(); } function saveSettings() { localStorage.setItem('hd2-settings', JSON.stringify(state.settings)); } function applySettingsToUI() { document.querySelectorAll('[data-setting="timer"]').forEach(btn => { btn.classList.toggle('active', Number(btn.dataset.value) === state.settings.timerDuration); }); document.querySelectorAll('[data-setting="difficulty"]').forEach(btn => { btn.classList.toggle('active', btn.dataset.value === state.settings.difficulty); }); } // ── API ─────────────────────────────────────────────────────────────────────── async function api(method, endpoint, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch('/api' + endpoint, opts); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } // ── View system ─────────────────────────────────────────────────────────────── function showView(name) { document.querySelectorAll('.view').forEach(v => { v.classList.add('hidden'); v.classList.remove('view-fade-in'); }); const el = document.getElementById('view-' + name); if (el) { el.classList.remove('hidden'); requestAnimationFrame(() => el.classList.add('view-fade-in')); } state.currentView = name; document.querySelectorAll('.nav-btn').forEach(b => { b.classList.toggle('active', b.dataset.view === name); }); if (name !== 'practice') stopPracticeTimer(); if (name === 'dashboard') loadDashboard(); if (name === 'leaderboard') loadLeaderboard(); if (name === 'admin') loadAdmin(); if (name === 'practice') initPracticeView(); if (name === 'lobby') updateLobbyView(); if (name === 'history') loadHistory(); } // ── Auth ────────────────────────────────────────────────────────────────────── async function checkAuth() { try { const data = await api('GET', '/me'); if (data.user) { state.user = data; if (data.mustChange) showView('change-password'); else onLoggedIn(); } else { showView('login'); } } catch { showView('login'); } } async function onLoggedIn() { document.getElementById('main-nav').classList.remove('hidden'); document.getElementById('nav-username').textContent = state.user.user; document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin'); document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin'); state.stratagems = await api('GET', '/stratagems').catch(() => []); populateCategoryFilter(); loadSettings(); connectWS(); showView('dashboard'); } async function logout() { stopPracticeTimer(); if (state.ws) state.ws.close(); clearTimeout(state.wsReconnectTimer); await api('POST', '/logout').catch(() => {}); state.user = null; document.getElementById('main-nav').classList.add('hidden'); showView('login'); } function populateCategoryFilter() { const cats = [...new Set(state.stratagems.map(s => s.category))]; const sel = document.getElementById('history-filter-cat'); if (!sel) return; // Remove old options except the first while (sel.options.length > 1) sel.remove(1); cats.forEach(cat => { const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; sel.appendChild(opt); }); } document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const el = document.getElementById('login-error'); el.classList.add('hidden'); try { await api('POST', '/login', { username: document.getElementById('login-username').value.trim(), password: document.getElementById('login-password').value, }); await checkAuth(); } catch (err) { el.textContent = err.message; el.classList.remove('hidden'); } }); document.getElementById('change-password-form').addEventListener('submit', async (e) => { e.preventDefault(); const errEl = document.getElementById('cp-error'); const newPw = document.getElementById('cp-new').value; const confPw = document.getElementById('cp-confirm').value; errEl.classList.add('hidden'); if (newPw !== confPw) { errEl.textContent = 'Passwords do not match'; errEl.classList.remove('hidden'); return; } try { await api('POST', '/change-password', { oldPassword: document.getElementById('cp-old').value, newPassword: newPw, }); state.user.mustChange = false; onLoggedIn(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove('hidden'); } }); document.querySelectorAll('.nav-btn[data-view]').forEach(btn => { btn.addEventListener('click', () => showView(btn.dataset.view)); }); // ── Hamburger nav ───────────────────────────────────────────────────────────── function openDrawer() { document.getElementById('nav-drawer').classList.add('open'); document.getElementById('nav-overlay').classList.add('open'); document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'true'); } function closeDrawer() { document.getElementById('nav-drawer').classList.remove('open'); document.getElementById('nav-overlay').classList.remove('open'); document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'false'); } document.getElementById('nav-hamburger').addEventListener('click', openDrawer); document.getElementById('nav-overlay').addEventListener('click', closeDrawer); document.querySelectorAll('.drawer-btn[data-view]').forEach(btn => { btn.addEventListener('click', () => { showView(btn.dataset.view); closeDrawer(); }); }); document.getElementById('btn-logout-drawer')?.addEventListener('click', () => { closeDrawer(); logout(); }); // ── WebSocket ───────────────────────────────────────────────────────────────── function connectWS() { if (state.ws) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; state.ws = new WebSocket(proto + '//' + location.host); state.ws.onopen = () => clearTimeout(state.wsReconnectTimer); state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} }; state.ws.onerror = () => state.ws.close(); state.ws.onclose = () => { state.ws = null; if (state.user) state.wsReconnectTimer = setTimeout(connectWS, 3000); }; } function wsSend(type, payload) { if (state.ws?.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify({ type, payload: payload || {} })); } } function handleWSMessage({ type, payload }) { switch (type) { case 'lobby-update': state.lobby.online = payload.online || []; state.lobby.incoming = payload.incoming || []; if (state.currentView === 'lobby') updateLobbyView(); if (state.currentView === 'dashboard') updateDashboardOnline(payload.online); updateChallengeBadge(); break; case 'challenge-received': if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from); updateChallengeBadge(); openChallengeModal(payload.from, payload.elo ?? '?'); break; case 'challenge-declined': showToast(esc(payload.by) + ' declined your challenge.'); break; case 'room-joined': state.match.roomId = payload.roomId; state.match.opponent = payload.opponent; state.match.matchScores = payload.matchScores; state.match.myProgress = 0; state.match.oppProgress = 0; state.match.roundActive = false; state.match.roundHistory = []; closeChallengeModal(); showView('match'); renderMatchWaiting(); break; case 'round-start': state.match.current = payload.stratagem; state.match.myProgress = 0; state.match.oppProgress = 0; state.match.roundActive = true; renderMatchRound(); break; case 'input-result': if (payload.userId === state.user.user) { state.match.myProgress = payload.progress; updateMyArrows(payload.correct); } else { state.match.oppProgress = payload.progress; updateOppArrows(); } break; case 'round-complete': state.match.roundActive = false; state.match.matchScores = payload.matchScores; state.match.roundHistory.push({ stratagem: state.match.current?.name, winner: payload.winner }); renderRoundResult(payload.winner); break; case 'match-end': state.match.matchScores = payload.matchScores; openMatchResultModal({ winner: payload.winner, eloChanges: payload.eloChanges, roundHistory: payload.roundHistory || state.match.roundHistory, }); break; case 'opponent-left': showToast('Opponent left the match.'); setTimeout(() => showView('lobby'), 1800); break; } } // ── Dashboard ───────────────────────────────────────────────────────────────── async function loadDashboard() { setText('dash-hero-name', state.user?.user || '—'); try { const data = await api('GET', '/dashboard'); renderDashboard(data); } catch { /* ignore */ } } function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) { const myRank = eloRankFor(elo || 1000); 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); if (daily) { setText('dash-daily-name', daily.stratagem.name); setText('dash-daily-category', daily.stratagem.category); setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet'); state.practice.dailyTarget = daily.stratagem.name; renderDailySequencePreview(daily.stratagem.sequence); setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon); } const tbody = document.getElementById('dash-recent'); if (!recent?.length) { tbody.innerHTML = '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'); state.practice.active = false; } function getPool() { const cats = state.practice.selectedCats; if (cats.size === 0) return state.stratagems; return state.stratagems.filter(s => cats.has(s.category)); } function resetSessionStats() { state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} }; } function startPractice() { const pool = getPool(); if (!pool.length) { showToast('No stratagems match the selected filters'); return; } const mode = state.practice.mode; state.practice.active = true; state.practice.score = 0; state.practice.streak = 0; state.practice.lives = 3; state.practice.progress = 0; resetSessionStats(); document.getElementById('practice-idle').classList.add('hidden'); document.getElementById('practice-active').classList.remove('hidden'); if (mode === 'drill') { state.practice.drillPool = shuffleArray([...pool]); state.practice.drillCompleted = 0; state.practice.drillTotal = pool.length; document.getElementById('drill-progress-wrap').classList.remove('hidden'); document.getElementById('hud-timer-wrap').classList.remove('hidden'); document.getElementById('hud-lives-wrap').classList.add('hidden'); setText('hud-timer-label', 'TIME'); } else if (mode === 'endless') { document.getElementById('hud-lives-wrap').classList.remove('hidden'); document.getElementById('hud-timer-wrap').classList.add('hidden'); document.getElementById('drill-progress-wrap').classList.add('hidden'); updateLivesDisplay(); } else if (mode === 'speedrun') { state.practice.speedrunPool = shuffleArray([...state.stratagems]); state.practice.speedrunStart = Date.now(); state.practice.speedrunElapsed = 0; document.getElementById('hud-timer-wrap').classList.remove('hidden'); document.getElementById('hud-lives-wrap').classList.add('hidden'); document.getElementById('drill-progress-wrap').classList.add('hidden'); setText('hud-timer-label', 'ELAPSED'); } else { // Timed document.getElementById('hud-timer-wrap').classList.remove('hidden'); document.getElementById('hud-lives-wrap').classList.add('hidden'); document.getElementById('drill-progress-wrap').classList.add('hidden'); setText('hud-timer-label', 'TIME'); } nextStratagem(); } function shuffleArray(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function stopPracticeUI() { const p = state.practice; if (p.active && (p.sessionStats.completed > 0 || p.sessionStats.missed > 0)) { openSessionSummary(); } else { stopPracticeTimer(); showPracticeIdle(); } } function stopPracticeTimer() { clearInterval(state.practice.timerHandle); state.practice.timerHandle = null; state.practice.active = false; } function buildQueue() { const p = state.practice; const mode = p.mode; const pool = mode === 'speedrun' ? p.speedrunPool.slice(1, 4) : mode === 'drill' ? p.drillPool.slice(1, 4) : (() => { const fullPool = getPool().filter(s => s !== p.current); return shuffleArray([...fullPool]).slice(0, 3); })(); p.queue = pool; renderQueue(pool); } function renderQueue(queue) { const el = document.getElementById('practice-queue'); if (!el) return; if (!queue?.length) { el.innerHTML = ''; return; } el.innerHTML = queue.map(s => { const iconHtml = s.icon ? `${esc(s.name)}` : `
`; 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++; updateStreakDisplay(); shakeIcon(); setTimeout(nextStratagem, 500); } }, 1000); } function startSpeedrunTimer() { clearInterval(state.practice.timerHandle); state.practice.timerHandle = setInterval(() => { state.practice.speedrunElapsed = Date.now() - state.practice.speedrunStart; const secs = (state.practice.speedrunElapsed / 1000).toFixed(1); setText('practice-timer', secs + 's'); const ring = document.getElementById('timer-ring-fill'); if (ring) ring.style.strokeDashoffset = '0'; }, 100); } function renderPracticeStratagem() { const s = state.practice.current; const diff = state.settings.difficulty; setText('practice-category', diff === 'easy' ? s.category : ''); if (diff === 'hard') { setText('practice-name', '— ' + '● '.repeat(s.sequence.length).trim() + ' —'); } else { setText('practice-name', s.name); } renderArrows('practice-sequence', s.sequence, 0); updateTimerDisplay(state.settings.timerDuration); updateScoreDisplay(); updateStreakDisplay(); // Show icon const iconEl = document.getElementById('practice-icon'); const fallbackEl = document.getElementById('practice-icon-fallback'); if (s.icon) { setIcon(iconEl, s.icon); iconEl.classList.remove('icon-complete', 'icon-wrong'); if (fallbackEl) fallbackEl.style.display = 'none'; } else { if (iconEl) iconEl.style.display = 'none'; if (fallbackEl) fallbackEl.style.display = ''; } } function setIcon(imgEl, src) { if (!imgEl || !src) return; imgEl.src = src; imgEl.alt = ''; imgEl.style.display = ''; imgEl.onerror = () => { imgEl.style.display = 'none'; }; } function renderArrows(containerId, sequence, progress) { const ARROW = { up: '↑', down: '↓', left: '←', right: '→' }; const el = document.getElementById(containerId); if (!el) return; el.innerHTML = sequence.map((dir, i) => { let cls = 'arrow-key'; if (i < progress) cls += ' completed'; if (i === progress) cls += ' active'; return `
${ARROW[dir]}
`; }).join(''); } function updateTimerDisplay(total) { if (state.practice.mode === 'speedrun') return; const el = document.getElementById('practice-timer'); if (!el) return; const t = state.practice.timeLeft; const tot = total || state.settings.timerDuration; el.textContent = t; const isDanger = t <= 5 && t > 0; el.className = 'timer-ring-val' + (isDanger ? ' danger' : ''); const ring = document.getElementById('timer-ring-fill'); if (ring) { const fraction = Math.max(0, t / tot); ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - fraction)); ring.classList.toggle('danger', isDanger); } } function updateScoreDisplay() { setText('practice-score', state.practice.score); } function updateStreakDisplay() { setText('practice-streak', state.practice.streak); const streakItem = document.getElementById('hud-streak-item'); const comboBadge = document.getElementById('practice-combo'); const streak = state.practice.streak; if (streakItem) streakItem.classList.toggle('streak-fire', streak >= 5); if (comboBadge) { if (streak >= 2) { comboBadge.textContent = '×' + (1 + streak * 0.1).toFixed(1); comboBadge.classList.remove('hidden'); } else { comboBadge.classList.add('hidden'); } } } function updateLivesDisplay() { const el = document.getElementById('practice-lives'); if (!el) return; el.innerHTML = Array.from({ length: 3 }, (_, i) => `` ).join(''); } function updateDrillProgress() { const p = state.practice; setText('drill-progress-text', p.drillCompleted + ' / ' + p.drillTotal); const fill = document.getElementById('drill-progress-fill'); if (fill) fill.style.width = p.drillTotal > 0 ? Math.round((p.drillCompleted / p.drillTotal) * 100) + '%' : '0%'; } function shakeIcon() { const iconEl = document.getElementById('practice-icon'); if (!iconEl || iconEl.style.display === 'none') return; iconEl.classList.remove('icon-wrong'); requestAnimationFrame(() => iconEl.classList.add('icon-wrong')); setTimeout(() => iconEl.classList.remove('icon-wrong'), 400); } function handlePracticeInput(dir) { const p = state.practice; if (!p.active || !p.current) return; const mode = p.mode; const seq = p.current.sequence; const arrows = document.querySelectorAll('#practice-sequence .arrow-key'); const cur = arrows[p.progress]; if (dir === seq[p.progress]) { cur?.classList.add('flash-correct'); p.progress++; if (p.progress === seq.length) { clearInterval(p.timerHandle); document.getElementById('danger-vignette').classList.add('hidden'); const elapsed = Date.now() - p.startTime; const mult = 1 + p.streak * 0.1; let pts = 0; if (mode === 'timed' || mode === 'drill') { const secs = Math.min(state.settings.timerDuration, elapsed / 1000); pts = Math.round((100 + (state.settings.timerDuration - secs) * 3) * mult); } else if (mode === 'endless') { pts = Math.round(100 * mult); } else if (mode === 'speedrun') { pts = Math.round(50 * mult); } p.score += pts; p.streak++; p.sessionStats.completed++; if (elapsed < p.sessionStats.bestTime) p.sessionStats.bestTime = elapsed; // Track per-stratagem stats if (!p.sessionStats.stratagems[p.current.name]) { p.sessionStats.stratagems[p.current.name] = { count: 0, totalMs: 0 }; } p.sessionStats.stratagems[p.current.name].count++; p.sessionStats.stratagems[p.current.name].totalMs += elapsed; updateScoreDisplay(); updateStreakDisplay(); // Visual: all arrows complete + icon flash document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => { el.classList.remove('flash-correct'); el.classList.add('completed'); }); const iconEl = document.getElementById('practice-icon'); iconEl?.classList.add('icon-complete'); setTimeout(() => iconEl?.classList.remove('icon-complete'), 300); // Score popup showScorePopup('+' + pts); api('POST', '/scores/practice', { stratagem: p.current.name, category: p.current.category, time_ms: elapsed, score: pts, mode: mode, }).catch(() => {}); if (mode === 'drill') { p.drillPool.shift(); p.drillCompleted++; updateDrillProgress(); } else if (mode === 'speedrun') { p.speedrunPool.shift(); } setTimeout(nextStratagem, 550); } else { renderArrows('practice-sequence', seq, p.progress); } } else { // Wrong input cur?.classList.add('flash-wrong'); p.progress = 0; shakeIcon(); if (mode === 'endless') { p.lives--; p.streak = 0; updateLivesDisplay(); updateStreakDisplay(); p.sessionStats.missed++; if (p.lives <= 0) { setTimeout(() => openSessionSummary(), 600); return; } } else { p.streak = 0; updateStreakDisplay(); } setTimeout(() => renderArrows('practice-sequence', seq, 0), 350); } } // ── Settings modal ──────────────────────────────────────────────────────────── function openSettingsModal() { applySettingsToUI(); document.getElementById('modal-settings').classList.remove('hidden'); } function closeSettingsModal() { document.getElementById('modal-settings').classList.add('hidden'); } document.getElementById('btn-practice-settings')?.addEventListener('click', openSettingsModal); document.getElementById('btn-settings-close')?.addEventListener('click', closeSettingsModal); document.getElementById('modal-settings')?.addEventListener('click', (e) => { if (e.target === document.getElementById('modal-settings')) closeSettingsModal(); }); // Settings option click (delegation via main delegation handler) // handled in the data-action delegation below // ── Mode card selection ─────────────────────────────────────────────────────── document.getElementById('practice-mode-grid')?.addEventListener('click', (e) => { const card = e.target.closest('[data-mode]'); if (!card || state.practice.active) return; state.practice.mode = card.dataset.mode; document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active')); card.classList.add('active'); updateModeLabel(); }); function updateModeLabel() { const dur = state.settings.timerDuration; const labels = { timed: `Timed mode — ${dur}s per stratagem`, endless: 'Endless mode — 3 lives, no timer', drill: 'Category Drill — master your selection', speedrun: 'Speed Run — all stratagems, fastest time', }; setText('practice-mode-label', labels[state.practice.mode] || ''); } // ── Session summary modal ───────────────────────────────────────────────────── function openSessionSummary() { stopPracticeTimer(); document.getElementById('danger-vignette').classList.add('hidden'); const p = state.practice; const s = p.sessionStats; // Stats grid const accuracy = (s.completed + s.missed) > 0 ? Math.round(s.completed / (s.completed + s.missed) * 100) + '%' : '—'; const bestTimeStr = s.bestTime < Infinity ? (s.bestTime / 1000).toFixed(2) + 's' : '—'; const grid = document.getElementById('summary-grid'); if (grid) { grid.innerHTML = [ { label: 'Score', val: p.score }, { label: 'Completed', val: s.completed }, { label: 'Streak Max',val: p.streak }, { label: 'Accuracy', val: accuracy }, { label: 'Best Time', val: bestTimeStr }, { label: 'Mode', val: p.mode }, ].map(x => `
${esc(String(x.val))}
${esc(x.label)}
`).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'); // Hide match icon const matchIcon = document.getElementById('match-icon'); if (matchIcon) matchIcon.style.display = 'none'; } function renderMatchScores() { const m = state.match; setText('match-me-wins', m.matchScores[state.user.user] ?? 0); setText('match-opp-wins', m.matchScores[m.opponent] ?? 0); } function setReady() { wsSend('player-ready'); const btn = document.getElementById('match-ready-btn'); btn.textContent = 'Ready – waiting for opponent...'; btn.disabled = true; } function renderMatchRound() { const m = state.match; setText('match-status', m.current.name); setText('match-category', m.current.category); document.getElementById('match-round-area').classList.remove('hidden'); document.getElementById('match-ready-btn').classList.add('hidden'); renderArrows('match-me-sequence', m.current.sequence, 0); renderArrows('match-opp-sequence', m.current.sequence, 0); // Show stratagem icon in match const strat = state.stratagems.find(s => s.name === m.current.name); const matchIcon = document.getElementById('match-icon'); if (strat?.icon) setIcon(matchIcon, strat.icon); else if (matchIcon) matchIcon.style.display = 'none'; } function updateMyArrows(correct) { renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress); if (!correct) { const el = document.getElementById('match-me-sequence'); el?.classList.add('flash-wrong-seq'); setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350); } } function updateOppArrows() { renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress); } function handleMatchInput(dir) { if (!state.match.roundActive) return; wsSend('input-arrow', { direction: dir }); } function renderRoundResult(winner) { const won = winner === state.user.user; setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST'); renderMatchScores(); const matchIcon = document.getElementById('match-icon'); if (matchIcon) matchIcon.style.display = 'none'; setTimeout(() => { document.getElementById('match-round-area').classList.add('hidden'); const btn = document.getElementById('match-ready-btn'); btn.textContent = 'Ready for next round'; btn.disabled = false; btn.classList.remove('hidden'); setText('match-category', ''); }, 1600); } function openMatchResultModal({ winner, eloChanges, roundHistory }) { const isWinner = winner === state.user.user; const resultEl = document.getElementById('result-winner-text'); if (resultEl) { resultEl.textContent = isWinner ? '🏆 VICTORY' : '☠ DEFEAT'; resultEl.className = 'result-winner ' + (isWinner ? 'win' : 'loss'); } if (eloChanges && state.user?.user) { const myChange = eloChanges[state.user.user]; if (myChange) { setText('result-elo-old', myChange.old); setText('result-elo-new', myChange.new); const delta = myChange.delta; const deltaEl = document.getElementById('result-elo-delta'); if (deltaEl) { deltaEl.textContent = (delta >= 0 ? '+' : '') + delta; deltaEl.className = 'elo-delta-val ' + (delta >= 0 ? 'positive' : 'negative'); } } } const histEl = document.getElementById('result-round-history'); if (histEl && roundHistory?.length) { histEl.innerHTML = roundHistory.map((r, i) => { const iMine = r.winner === state.user.user; const stratName = r.stratagem || '—'; const strat = state.stratagems.find(s => s.name === stratName); const iconHtml = strat?.icon ? `` : ''; 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 = '#HelldiverRankTotal ScoreSessionsMatch 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 = '#HelldiverELORankMatches 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 = '#HelldiverTotal 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(); }); 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 = await api('GET', '/users'); renderAdminUsers(users); } catch { document.getElementById('admin-users').innerHTML = 'Error loading users'; } } function renderAdminUsers(users) { const el = document.getElementById('admin-users'); el.innerHTML = users.map(u => `
${esc(u.username)} ${u.role} ${u.mustChange ? 'temp pw' : ''} ${u.username !== state.user.user ? `` : ''}
` ).join(''); } async function createUser() { const username = document.getElementById('new-username').value.trim(); const role = document.getElementById('new-role').value; const errEl = document.getElementById('admin-error'); const pwEl = document.getElementById('new-pw-display'); errEl.classList.add('hidden'); pwEl.classList.add('hidden'); if (!username) return; try { const result = await api('POST', '/users', { username, role }); pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword); pwEl.classList.remove('hidden'); document.getElementById('new-username').value = ''; loadAdmin(); } catch (err) { errEl.textContent = err.message; errEl.classList.remove('hidden'); } } async function deleteUser(username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { await api('DELETE', '/users/' + encodeURIComponent(username)); loadAdmin(); } catch (err) { showToast('Error: ' + err.message); } } // ── Event delegation ────────────────────────────────────────────────────────── document.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const user = btn.dataset.user; const cat = btn.dataset.cat; if (action === 'challenge' && user) sendChallenge(user); if (action === 'accept' && user) acceptChallenge(user); if (action === 'decline' && user) declineChallenge(user); if (action === 'delete-user' && user) deleteUser(user); if (action === 'toggle-cat' && cat) toggleCategory(cat); // Settings options const settingBtn = e.target.closest('[data-setting]'); if (settingBtn) { const setting = settingBtn.dataset.setting; const value = settingBtn.dataset.value; if (setting === 'timer') state.settings.timerDuration = Number(value); else if (setting === 'difficulty') state.settings.difficulty = value; saveSettings(); applySettingsToUI(); updateModeLabel(); } }); // ── Keyboard ────────────────────────────────────────────────────────────────── document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeSettingsModal(); closeChallengeModal(); closeMatchResultModal(); if (document.getElementById('modal-session-summary')?.classList.contains('hidden') === false) { closeSessionSummary(); } if (state.currentView === 'practice' && state.practice.active) { stopPracticeUI(); } return; } if (e.key === 'Enter' && state.currentView === 'practice' && !state.practice.active) { startPractice(); return; } const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; const dir = MAP[e.key]; if (!dir) return; if (state.currentView === 'practice' || state.currentView === 'match') { e.preventDefault(); dpadInput(dir); } }); function dpadInput(dir) { if (state.currentView === 'practice') handlePracticeInput(dir); if (state.currentView === 'match') handleMatchInput(dir); } // ── Utils ───────────────────────────────────────────────────────────────────── function esc(str) { return String(str) .replace(/&/g, '&') .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);