'use strict'; // ── State ───────────────────────────────────────────────────────────────────── const state = { user: null, // { user, role, mustChange } currentView: 'login', stratagems: [], practice: { active: false, current: null, progress: 0, timeLeft: 30, timerHandle: null, startTime: null, score: 0, streak: 0, selectedCats: new Set(), // empty = all categories dailyTarget: null, // set when using daily challenge shortcut }, lobby: { online: [], incoming: [], // usernames who challenged me }, match: { roomId: null, opponent: null, matchScores: {}, current: null, myProgress: 0, oppProgress: 0, roundActive: false, }, ws: null, wsReconnectTimer: null, }; // ── API helpers ─────────────────────────────────────────────────────────────── 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')); const el = document.getElementById('view-' + name); if (el) el.classList.remove('hidden'); state.currentView = name; // Highlight active nav button document.querySelectorAll('.nav-btn').forEach(b => { b.classList.toggle('active', b.dataset.view === name); }); // Stop practice timer when navigating away if (name !== 'practice') stopPracticeTimer(); // View-specific init if (name === 'dashboard') loadDashboard(); if (name === 'leaderboard') loadLeaderboard(); if (name === 'admin') loadAdmin(); if (name === 'practice') initPracticeView(); if (name === 'lobby') updateLobbyView(); } // ── Authentication ──────────────────────────────────────────────────────────── 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'); // Stratagems are served via authenticated API – not as a public static file state.stratagems = await api('GET', '/stratagems').catch(() => []); 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'); } // Login form 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'); } }); // Change password form 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'); } }); // Nav buttons document.querySelectorAll('.nav-btn[data-view]').forEach(btn => { btn.addEventListener('click', () => showView(btn.dataset.view)); }); // ── 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(); if (state.currentView === 'lobby') updateLobbyView(); showToast(esc(payload.from) + ' challenges you! Go to 1v1 to respond.'); 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; 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(payload.correct); } break; case 'round-complete': state.match.roundActive = false; state.match.matchScores = payload.matchScores; renderRoundResult(payload.winner); break; case 'match-end': state.match.matchScores = payload.matchScores; renderMatchEnd(payload.winner); break; case 'opponent-left': showToast('Opponent left the match.'); setTimeout(() => showView('lobby'), 1800); break; } } // ── Dashboard ───────────────────────────────────────────────────────────────── async function loadDashboard() { try { const data = await api('GET', '/dashboard'); renderDashboard(data); } catch { /* silently ignore – dashboard is cosmetic */ } } function renderDashboard({ stats, rank, online, recent, daily }) { 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 / 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'); // Store for the "Practice this" shortcut state.practice.dailyTarget = daily.stratagem.name; } const tbody = document.getElementById('dash-recent'); if (recent.length === 0) { tbody.innerHTML = 'No sessions yet'; } else { tbody.innerHTML = recent.map(r => `${esc(r.stratagem)}${r.score}${(r.time_ms / 1000).toFixed(2)}s` ).join(''); } updateDashboardOnline(online); } function updateDashboardOnline(online) { const el = document.getElementById('dash-online'); if (!el) return; const others = (online || []).filter(u => u !== state.user?.user); if (others.length === 0) { el.innerHTML = 'No other Helldivers online'; } else { el.innerHTML = others.map(u => `
${esc(u)}
` ).join(''); } } function startDailyChallenge() { if (!state.practice.dailyTarget) return; state.practice.selectedCats.clear(); showView('practice'); // start a practice session focused on the daily stratagem const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget); if (strat) { state.practice.selectedCats.add(strat.category); startPractice(); } } // ── Practice mode ───────────────────────────────────────────────────────────── function initPracticeView() { renderCategoryFilters(); if (!state.practice.active) showPracticeIdle(); } function renderCategoryFilters() { const cats = [...new Set(state.stratagems.map(s => s.category))]; const el = document.getElementById('practice-categories'); 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'); state.practice.active = false; } function startPractice() { if (getPool().length === 0) { showToast('No stratagems match the selected filters'); return; } state.practice.active = true; state.practice.score = 0; state.practice.streak = 0; document.getElementById('practice-idle').classList.add('hidden'); document.getElementById('practice-active').classList.remove('hidden'); nextStratagem(); } function stopPracticeUI() { stopPracticeTimer(); showPracticeIdle(); } function stopPracticeTimer() { clearInterval(state.practice.timerHandle); state.practice.timerHandle = null; 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 nextStratagem() { const pool = getPool(); if (pool.length === 0) { showPracticeIdle(); return; } state.practice.current = pool[Math.floor(Math.random() * pool.length)]; state.practice.progress = 0; state.practice.timeLeft = 30; state.practice.startTime = Date.now(); renderPracticeStratagem(); startPracticeTimer(); } function startPracticeTimer() { clearInterval(state.practice.timerHandle); state.practice.timerHandle = setInterval(() => { state.practice.timeLeft--; updateTimerDisplay(); if (state.practice.timeLeft <= 0) { clearInterval(state.practice.timerHandle); state.practice.streak = 0; updateStreakDisplay(); // Flash timer to signal timeout const timerEl = document.getElementById('practice-timer'); timerEl.classList.add('flash-wrong'); setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700); } }, 1000); } function renderPracticeStratagem() { const s = state.practice.current; setText('practice-category', s.category); setText('practice-name', s.name); renderArrows('practice-sequence', s.sequence, state.practice.progress); updateTimerDisplay(); updateScoreDisplay(); updateStreakDisplay(); } 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() { const el = document.getElementById('practice-timer'); if (!el) return; el.textContent = state.practice.timeLeft; el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : ''); } function updateScoreDisplay() { setText('practice-score', state.practice.score); } function updateStreakDisplay() { setText('practice-streak', state.practice.streak); } function handlePracticeInput(dir) { const p = state.practice; if (!p.active || !p.current) return; const seq = p.current.sequence; const arrows = document.querySelectorAll('#practice-sequence .arrow-key'); const curArrow = arrows[p.progress]; if (dir === seq[p.progress]) { // Correct input curArrow?.classList.add('flash-correct'); p.progress++; if (p.progress === seq.length) { // Stratagem completed! clearInterval(p.timerHandle); const elapsed = Date.now() - p.startTime; const secs = Math.min(30, elapsed / 1000); const pts = Math.round((100 + (30 - secs) * 3) * (1 + p.streak * 0.1)); p.score += pts; p.streak++; updateScoreDisplay(); updateStreakDisplay(); // Flash all arrows green document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => { el.classList.remove('flash-correct'); el.classList.add('completed'); }); // Save result (fire-and-forget) api('POST', '/scores/practice', { stratagem: p.current.name, category: p.current.category, time_ms: elapsed, score: pts, }).catch(() => {}); setTimeout(nextStratagem, 600); } else { // Re-render with updated progress (highlights next arrow) renderArrows('practice-sequence', seq, p.progress); } } else { // Wrong input – reset progress curArrow?.classList.add('flash-wrong'); p.progress = 0; p.streak = 0; updateStreakDisplay(); setTimeout(() => { renderArrows('practice-sequence', seq, 0); }, 350); } } // ── Lobby ───────────────────────────────────────────────────────────────────── function updateLobbyView() { const others = state.lobby.online.filter(u => u !== state.user?.user); const el = document.getElementById('lobby-players'); if (!el) return; if (others.length === 0) { el.innerHTML = '

No other Helldivers online. Waiting for reinforcements...

'; } else { el.innerHTML = others.map(u => `
${esc(u)}
` ).join(''); } // Incoming challenges const challEl = document.getElementById('lobby-challenges'); if (!challEl) return; const inc = state.lobby.incoming; if (inc.length === 0) { challEl.innerHTML = ''; } else { challEl.innerHTML = inc.map(from => `
${esc(from)} challenges you to a duel!
` ).join(''); } } function sendChallenge(target) { wsSend('challenge-user', { targetUser: target }); showToast('Challenge sent to ' + esc(target)); } function acceptChallenge(from) { wsSend('accept-challenge', { challengerId: from }); // Remove from incoming list state.lobby.incoming = state.lobby.incoming.filter(u => u !== from); updateChallengeBadge(); } function declineChallenge(from) { wsSend('decline-challenge', { challengerId: from }); state.lobby.incoming = state.lobby.incoming.filter(u => u !== from); 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'); } } // ── 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 readyBtn = document.getElementById('match-ready-btn'); readyBtn.textContent = 'READY'; readyBtn.disabled = false; readyBtn.classList.remove('hidden'); } 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); } 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(correct) { 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(); // Short pause then show ready button for next round 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 renderMatchEnd(winner) { const won = winner === state.user.user; setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST'); renderMatchScores(); document.getElementById('match-round-area').classList.add('hidden'); document.getElementById('match-ready-btn').classList.add('hidden'); setTimeout(() => { if (state.currentView === 'match') showView('lobby'); }, 3000); } function leaveMatch() { wsSend('leave-room'); showView('lobby'); } // ── Leaderboard ─────────────────────────────────────────────────────────────── async function loadLeaderboard() { const tbody = document.getElementById('leaderboard-table-body'); try { const rows = await api('GET', '/scores/leaderboard'); if (rows.length === 0) { tbody.innerHTML = 'No scores yet. Start practicing!'; } else { tbody.innerHTML = rows.map((r, i) => ` ${i + 1} ${esc(r.username)} ${r.totalScore} ${r.sessions} ${r.wins}/${r.matches} ` ).join(''); } } catch { tbody.innerHTML = 'Error loading leaderboard'; } } // ── Admin panel ─────────────────────────────────────────────────────────────── 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 (replaces inline onclick for user-data actions) ────────── 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); }); // ── Keyboard input ──────────────────────────────────────────────────────────── document.addEventListener('keydown', (e) => { 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(); // prevent page scroll dpadInput(dir); } }); // Called by on-screen D-pad and keyboard handler 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'); // Limit simultaneous toasts to avoid stacking 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); } // ── Static button bindings (replaces inline onclick – blocked by CSP script-src-attr) ── document.getElementById('btn-logout') ?.addEventListener('click', logout); document.getElementById('btn-daily-challenge') ?.addEventListener('click', startDailyChallenge); document.getElementById('btn-start-practice') ?.addEventListener('click', startPractice); document.getElementById('btn-stop-practice') ?.addEventListener('click', stopPracticeUI); document.getElementById('match-ready-btn') ?.addEventListener('click', setReady); document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch); document.getElementById('btn-create-user') ?.addEventListener('click', createUser); // D-pad: practice and match both use data-dir buttons document.getElementById('practice-dpad')?.addEventListener('click', (e) => { const dir = e.target.closest('[data-dir]')?.dataset.dir; if (dir) dpadInput(dir); }); document.getElementById('match-dpad')?.addEventListener('click', (e) => { const dir = e.target.closest('[data-dir]')?.dataset.dir; if (dir) dpadInput(dir); }); // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', checkAuth);