'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);
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', checkAuth);