From 3c22196f8172cceef199cdb95bb6eef8ad9539d5 Mon Sep 17 00:00:00 2001 From: Jeremy Brandenburger Date: Mon, 30 Mar 2026 13:32:55 +0200 Subject: [PATCH] feat: initial Helldivers 2 Stratagem Trainer (practice, 1v1, leaderboard, dashboard) --- CHANGELOG.md | 17 + ecosystem.config.js | 10 + package.json | 22 ++ public/app.js | 778 ++++++++++++++++++++++++++++++++++++++++ public/index.html | 346 ++++++++++++++++++ public/stratagems.js | 87 +++++ public/styles.css | 833 +++++++++++++++++++++++++++++++++++++++++++ server.js | 639 +++++++++++++++++++++++++++++++++ 8 files changed, 2732 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 ecosystem.config.js create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/stratagems.js create mode 100644 public/styles.css create mode 100644 server.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b2c458 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog – helldivers-trainer + +## [1.0.0] – 2026-03-30 + +### Added +- Initial release +- Session-based auth with admin and user roles (mustChange password flow) +- Admin panel: create/delete users, view temp passwords +- Practice mode: randomized stratagem training with 30s timer, streak scoring +- Category filters for practice mode +- 1v1 mode via WebSocket: challenge system, lobby, real-time match (first to 5 rounds) +- Dashboard: personal stats, daily challenge, online users, recent sessions +- Leaderboard: top-20 by total score, sessions, match win rate +- SQLite database (WAL mode) for users, practice sessions, matches +- Helldivers 2 military UI theme (dark, yellow accents, grid overlay, scanlines) +- Mobile D-pad support +- Arrow key input with correct/wrong animations diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..2ff1fa7 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,10 @@ +module.exports = { + apps: [{ + name: 'helldivers', + script: 'server.js', + env_production: { + NODE_ENV: 'production', + PORT: 3012, + }, + }], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b356c6e --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "helldivers-trainer", + "version": "1.0.0", + "description": "Helldivers 2 Stratagem Trainer – Practice, 1v1, Leaderboard", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "bcryptjs": "^3.0.3", + "better-sqlite3": "^11.10.0", + "express": "^5.1.0", + "express-rate-limit": "^8.3.1", + "express-session": "^1.18.1", + "helmet": "^8.0.0", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..9fb340f --- /dev/null +++ b/public/app.js @@ -0,0 +1,778 @@ +'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'); + } +} + +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'); + state.stratagems = window.STRATAGEMS || []; + 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(); + 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.style.display = 'inline-flex'; +} + +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').style.display = 'none'; + + 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.style.display = 'inline-flex'; + 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').style.display = 'none'; + setTimeout(() => 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 "' + username + '"? This cannot be undone.')) return; + try { + await api('DELETE', '/users/' + encodeURIComponent(username)); + loadAdmin(); + } catch (err) { + showToast('Error: ' + err.message); + } +} + +// ── 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'); + 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'); + setTimeout(() => toast.remove(), 300); + }, 3200); +} + +// ── Init ────────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', checkAuth); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f62a8b1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,346 @@ + + + + + + HELLDIVERS 2 – Stratagem Trainer + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/public/stratagems.js b/public/stratagems.js new file mode 100644 index 0000000..76d820e --- /dev/null +++ b/public/stratagems.js @@ -0,0 +1,87 @@ +// Helldivers 2 – complete stratagem list +// Sequences use: 'up' | 'down' | 'left' | 'right' +// Source: Helldivers 2 community wiki (helldivers.wiki.gg) +const STRATAGEMS = [ + // ── Patriotic Administration Center ────────────────────────────────────── + { name: 'Reinforce', category: 'Patriotic Administration Center', sequence: ['up','down','right','left','up'] }, + { name: 'Resupply', category: 'Patriotic Administration Center', sequence: ['down','down','up','right'] }, + { name: 'SOS Beacon', category: 'Patriotic Administration Center', sequence: ['up','down','right','up'] }, + { name: 'Hellbomb', category: 'Patriotic Administration Center', sequence: ['down','up','left','down','up','right','down','up'] }, + { name: 'SEAF Artillery', category: 'Patriotic Administration Center', sequence: ['right','up','up','down'] }, + { name: 'Upload Data', category: 'Patriotic Administration Center', sequence: ['right','right','left','up','up'] }, + { name: 'Eagle Rearm', category: 'Patriotic Administration Center', sequence: ['up','up','left','up','right'] }, + { name: 'Prospecting Drill', category: 'Patriotic Administration Center', sequence: ['down','down','left','right','down'] }, + + // ── Orbital Cannons ─────────────────────────────────────────────────────── + { name: 'Orbital Gatling Barrage', category: 'Orbital Cannons', sequence: ['right','down','left','up','up'] }, + { name: 'Orbital Airburst Strike', category: 'Orbital Cannons', sequence: ['right','right','right'] }, + { name: 'Orbital 120MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','right','down','left','right','down'] }, + { name: 'Orbital 380MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','down','up','up','left','down','down'] }, + { name: 'Orbital Walking Barrage', category: 'Orbital Cannons', sequence: ['right','down','right','down','right','down'] }, + { name: 'Orbital Laser', category: 'Orbital Cannons', sequence: ['right','down','up','right','down'] }, + { name: 'Orbital Railcannon Strike', category: 'Orbital Cannons', sequence: ['right','up','down','down','right'] }, + { name: 'Orbital Precision Strike', category: 'Orbital Cannons', sequence: ['right','right','up'] }, + { name: 'Orbital Gas Strike', category: 'Orbital Cannons', sequence: ['right','right','down','right'] }, + { name: 'Orbital EMS Strike', category: 'Orbital Cannons', sequence: ['right','right','left','down'] }, + { name: 'Orbital Smoke Strike', category: 'Orbital Cannons', sequence: ['right','right','down','up'] }, + { name: 'Orbital Illumination Flare',category: 'Orbital Cannons', sequence: ['right','right','left','left'] }, + + // ── Hangar ──────────────────────────────────────────────────────────────── + { name: 'Eagle Strafing Run', category: 'Hangar', sequence: ['up','right','right'] }, + { name: 'Eagle Airstrike', category: 'Hangar', sequence: ['up','right','down','right'] }, + { name: 'Eagle Cluster Bomb', category: 'Hangar', sequence: ['up','right','down','down','right'] }, + { name: 'Eagle Napalm Airstrike', category: 'Hangar', sequence: ['up','right','down','up'] }, + { name: 'LIFT-850 Jump Pack', category: 'Hangar', sequence: ['down','up','up','down','up'] }, + { name: 'Eagle Smoke Strike', category: 'Hangar', sequence: ['up','right','up','down'] }, + { name: 'Eagle 110MM Rocket Pods', category: 'Hangar', sequence: ['up','right','up','left'] }, + { name: 'Eagle 500KG Bomb', category: 'Hangar', sequence: ['up','right','down','down','down'] }, + + // ── Bridge ──────────────────────────────────────────────────────────────── + { name: 'Patriot Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','right'] }, + { name: 'Emancipator Exosuit',category: 'Bridge', sequence: ['left','down','right','up','left','down','down'] }, + + // ── Engineering Bay – Support Weapons ──────────────────────────────────── + { name: 'Machine Gun', category: 'Engineering Bay', sequence: ['down','left','down','up','right'] }, + { name: 'Anti-Materiel Rifle', category: 'Engineering Bay', sequence: ['down','left','right','up','down'] }, + { name: 'Stalwart', category: 'Engineering Bay', sequence: ['down','left','down','up','up','left'] }, + { name: 'Expendable Anti-Tank', category: 'Engineering Bay', sequence: ['down','down','left','up'] }, + { name: 'Recoilless Rifle', category: 'Engineering Bay', sequence: ['down','left','right','right','left'] }, + { name: 'Flamethrower', category: 'Engineering Bay', sequence: ['down','left','up','down','up'] }, + { name: 'Autocannon', category: 'Engineering Bay', sequence: ['down','left','down','up','up','right'] }, + { name: 'Heavy Machine Gun', category: 'Engineering Bay', sequence: ['down','left','up','down','down'] }, + { name: 'Airburst Rocket Launcher', category: 'Engineering Bay', sequence: ['down','up','up','left','right'] }, + { name: 'Commando', category: 'Engineering Bay', sequence: ['down','left','up','down','right'] }, + { name: 'Railgun', category: 'Engineering Bay', sequence: ['down','right','down','up','left','right'] }, + { name: 'Spear', category: 'Engineering Bay', sequence: ['down','down','up','down','down'] }, + { name: 'Quasar Cannon', category: 'Engineering Bay', sequence: ['down','down','up','left','right'] }, + { name: 'Arc Thrower', category: 'Engineering Bay', sequence: ['down','right','down','up','left','left'] }, + { name: 'Laser Cannon', category: 'Engineering Bay', sequence: ['down','left','down','up','left'] }, + { name: 'Grenade Launcher', category: 'Engineering Bay', sequence: ['down','left','up','left','down'] }, + + // ── Engineering Bay – Equipment / Backpacks ─────────────────────────────── + { name: 'Supply Pack', category: 'Engineering Bay', sequence: ['down','left','down','up','up','down'] }, + { name: 'Guard Dog Rover', category: 'Engineering Bay', sequence: ['down','up','left','up','right','right'] }, + { name: 'Guard Dog', category: 'Engineering Bay', sequence: ['down','up','left','up','right','down'] }, + { name: 'Ballistic Shield Backpack', category: 'Engineering Bay', sequence: ['down','left','down','down','up','left'] }, + { name: 'Shield Generator Pack', category: 'Engineering Bay', sequence: ['down','up','left','right','left','right'] }, + { name: 'Directional Shield', category: 'Engineering Bay', sequence: ['down','left','up','up','right'] }, + + // ── Engineering Bay – Mines ─────────────────────────────────────────────── + { name: 'Anti-Personnel Minefield', category: 'Engineering Bay', sequence: ['down','left','up','right'] }, + { name: 'Incendiary Mines', category: 'Engineering Bay', sequence: ['down','left','left','down'] }, + { name: 'Anti-Tank Mines', category: 'Engineering Bay', sequence: ['down','down','left','left'] }, + + // ── Robotics Workshop – Sentries ────────────────────────────────────────── + { name: 'Machine Gun Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','up'] }, + { name: 'Gatling Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','left'] }, + { name: 'Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','down'] }, + { name: 'Autocannon Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up'] }, + { name: 'Rocket Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','left'] }, + { name: 'EMS Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','down','right'] }, + { name: 'Tesla Tower', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up','up'] }, + + // ── Defensive / Other ───────────────────────────────────────────────────── + { name: 'Shield Generator Relay', category: 'Defensive', sequence: ['down','up','left','right','left','down'] }, + { name: 'Anti-Tank Emplacement', category: 'Defensive', sequence: ['down','right','right','up','left'] }, + { name: 'Orbital Shield Generator', category: 'Defensive', sequence: ['right','right','left','down','left','down'] }, +]; diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..bf418b6 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,833 @@ +/* ── Custom properties ─────────────────────────────────────────────────────── */ +:root { + --bg: #0d0d14; + --bg-surface: #131325; + --bg-surface2: #1a1a2e; + --accent: #ffe710; + --accent-dim: rgba(255, 231, 16, 0.15); + --brand: #41639c; + --brand-dim: rgba(65, 99, 156, 0.12); + --danger: #ff525d; + --danger-dim: rgba(255, 82, 93, 0.15); + --success: #4dff91; + --success-dim: rgba(77, 255, 145, 0.15); + --text: #e0e0e0; + --text-muted: #556; + --border: rgba(65, 99, 156, 0.3); + + --font-heading: 'Rajdhani', 'Exo 2', sans-serif; + --font-mono: 'Share Tech Mono', 'Courier New', monospace; + --font-body: 'Exo 2', system-ui, sans-serif; + + --radius: 4px; + --transition: 0.15s ease; +} + +/* ── Reset & base ──────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.5; + overflow-x: hidden; +} + +/* ── Military grid overlay ─────────────────────────────────────────────────── */ +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(65, 99, 156, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(65, 99, 156, 0.06) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +/* Scanlines */ +body::after { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 3px, + rgba(0, 0, 0, 0.06) 3px, + rgba(0, 0, 0, 0.06) 4px + ); + pointer-events: none; + z-index: 0; +} + +/* ── Layout helpers ────────────────────────────────────────────────────────── */ +.hidden { display: none !important; } + +.view { + position: relative; + z-index: 1; + min-height: calc(100vh - 64px); + padding: 24px 20px 48px; + max-width: 1100px; + margin: 0 auto; +} + +.view-centered { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 20px; +} + +/* ── Navigation ────────────────────────────────────────────────────────────── */ +#main-nav { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 16px; + padding: 0 20px; + height: 56px; + background: rgba(13, 13, 20, 0.92); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(8px); +} + +.nav-brand { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-heading); + font-size: 1.1rem; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.05em; + white-space: nowrap; +} + +.nav-logo { font-size: 1.4rem; } + +.nav-links { + display: flex; + gap: 4px; + flex: 1; + justify-content: center; +} + +.nav-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-family: var(--font-heading); + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.05em; + padding: 6px 14px; + border-radius: var(--radius); + cursor: pointer; + transition: color var(--transition), background var(--transition); +} + +.nav-btn:hover { color: var(--text); background: var(--brand-dim); } +.nav-btn.active { color: var(--accent); background: var(--accent-dim); } + +.nav-user { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + white-space: nowrap; +} + +.nav-username { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--brand); +} + +/* ── Cards ─────────────────────────────────────────────────────────────────── */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.card-accent { border-color: rgba(255, 231, 16, 0.3); } + +.card-title { + font-family: var(--font-heading); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--brand); + margin-bottom: 16px; + text-transform: uppercase; +} + +/* ── Buttons ───────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border: 1px solid transparent; + border-radius: var(--radius); + font-family: var(--font-heading); + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.08em; + cursor: pointer; + transition: all var(--transition); + text-transform: uppercase; + white-space: nowrap; +} + +.btn-accent { + background: var(--accent); + color: #000; + border-color: var(--accent); +} +.btn-accent:hover { background: #fff066; box-shadow: 0 0 16px rgba(255,231,16,0.4); } + +.btn-muted { + background: transparent; + color: var(--text-muted); + border-color: var(--border); +} +.btn-muted:hover { color: var(--text); border-color: var(--text-muted); } + +.btn-danger { + background: var(--danger-dim); + color: var(--danger); + border-color: rgba(255,82,93,0.4); +} +.btn-danger:hover { background: rgba(255,82,93,0.25); } + +.btn-sm { padding: 5px 12px; font-size: 0.8rem; } +.btn-lg { padding: 14px 32px; font-size: 1.1rem; } +.btn-full { width: 100%; } + +/* ── Form elements ─────────────────────────────────────────────────────────── */ +.field { margin-bottom: 14px; } + +label { + display: block; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; +} + +input[type="text"], +input[type="password"], +select { + width: 100%; + background: var(--bg-surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font-body); + font-size: 1rem; + padding: 10px 14px; + outline: none; + transition: border-color var(--transition); +} + +input:focus, select:focus { border-color: var(--accent); } +select option { background: var(--bg-surface2); } + +.error { + color: var(--danger); + font-size: 0.85rem; + margin-bottom: 10px; +} + +/* ── Login ─────────────────────────────────────────────────────────────────── */ +.login-box { + width: 100%; + max-width: 400px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 36px 32px; +} + +.login-header { text-align: center; margin-bottom: 28px; } + +.login-logo { + font-size: 3rem; + line-height: 1; + display: block; + margin-bottom: 8px; + filter: drop-shadow(0 0 12px rgba(255,231,16,0.6)); +} + +.login-header h1 { + font-family: var(--font-heading); + font-size: 1.8rem; + font-weight: 700; + letter-spacing: 0.15em; + color: var(--accent); + text-shadow: 0 0 20px rgba(255,231,16,0.3); +} + +.login-sub { + font-size: 0.7rem; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-top: 4px; +} + +/* ── Page headers ──────────────────────────────────────────────────────────── */ +.page-header { margin-bottom: 24px; } + +.page-title { + font-family: var(--font-heading); + font-size: 1.6rem; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--accent); + text-transform: uppercase; +} + +.page-sub { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 4px; +} + +/* ── Dashboard grid ────────────────────────────────────────────────────────── */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.stat-item { + text-align: center; + padding: 12px 8px; + background: var(--bg-surface2); + border-radius: var(--radius); +} + +.stat-value { + font-family: var(--font-mono); + font-size: 1.6rem; + font-weight: 700; + color: var(--text); +} + +.stat-value.accent { color: var(--accent); } +.stat-label { font-size: 0.7rem; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; margin-top: 4px; } + +/* Daily challenge */ +.daily-name { + font-family: var(--font-heading); + font-size: 1.3rem; + font-weight: 700; + color: var(--accent); + margin-bottom: 4px; +} +.daily-category { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; } +.daily-best { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 16px; } + +/* Online list */ +.online-list { display: flex; flex-direction: column; gap: 8px; } + +.online-user { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-surface2); + border-radius: var(--radius); +} + +.online-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 6px var(--success); + flex-shrink: 0; +} + +/* Data table */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +.data-table th { + text-align: left; + font-size: 0.7rem; + letter-spacing: 0.1em; + color: var(--text-muted); + padding: 6px 8px; + border-bottom: 1px solid var(--border); + text-transform: uppercase; +} +.data-table td { + padding: 8px 8px; + border-bottom: 1px solid rgba(65, 99, 156, 0.1); + font-family: var(--font-mono); + font-size: 0.85rem; +} +.data-table tr.row-me td { color: var(--accent); } +.data-table .rank { + font-size: 1rem; + color: var(--accent); + font-weight: 700; + text-align: center; + width: 40px; +} +.muted { color: var(--text-muted); font-size: 0.85rem; } + +/* ── Practice mode ─────────────────────────────────────────────────────────── */ +.category-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; +} + +.cat-btn { + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-muted); + border-radius: 20px; + padding: 5px 14px; + font-family: var(--font-body); + font-size: 0.8rem; + cursor: pointer; + transition: all var(--transition); +} +.cat-btn.active { + background: var(--brand-dim); + border-color: var(--brand); + color: var(--text); +} +.cat-btn:hover { border-color: var(--text-muted); color: var(--text); } + +.practice-idle { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 40px 20px; +} +.idle-hint { color: var(--text-muted); font-size: 0.9rem; } + +.practice-active { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.stratagem-display { + width: 100%; + max-width: 600px; + text-align: center; +} + +.stratagem-category { + font-size: 0.75rem; + letter-spacing: 0.12em; + color: var(--brand); + text-transform: uppercase; + margin-bottom: 6px; +} + +.stratagem-name { + font-family: var(--font-heading); + font-size: 2rem; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.05em; + margin-bottom: 20px; + text-shadow: 0 0 20px rgba(255,231,16,0.2); +} + +.practice-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 16px; + letter-spacing: 0.08em; +} + +/* ── Arrow key indicators ──────────────────────────────────────────────────── */ +.arrow-sequence { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + align-items: center; + min-height: 56px; +} + +.arrow-key { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 2px solid var(--border); + border-radius: 6px; + font-family: var(--font-mono); + font-size: 1.3rem; + color: var(--text-muted); + background: var(--bg-surface2); + transition: all 0.12s ease; + user-select: none; +} + +.arrow-key.active { + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 0 12px rgba(255,231,16,0.35); +} + +.arrow-key.completed { + border-color: var(--success); + color: var(--success); + background: var(--success-dim); +} + +.arrow-key.flash-correct { + border-color: var(--success); + background: var(--success-dim); + color: var(--success); + animation: pop 0.25s ease; +} + +.arrow-key.flash-wrong { + border-color: var(--danger); + background: var(--danger-dim); + color: var(--danger); + animation: shake 0.35s ease; +} + +@keyframes pop { + 0% { transform: scale(1.25); } + 100% { transform: scale(1); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-5px); } + 40% { transform: translateX(5px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +/* ── Practice HUD ──────────────────────────────────────────────────────────── */ +.practice-hud { + display: flex; + gap: 24px; + align-items: center; + justify-content: center; +} + +.hud-item { text-align: center; } +.hud-label { font-size: 0.65rem; letter-spacing: 0.15em; color: var(--text-muted); text-transform: uppercase; } +.hud-value { font-family: var(--font-mono); font-size: 2rem; font-weight: 700; } +.hud-value.accent { color: var(--accent); } + +.timer { + font-family: var(--font-mono); + font-size: 2.5rem; + font-weight: 700; + color: var(--text); + transition: color var(--transition); + min-width: 60px; + text-align: center; +} + +.timer.timer-danger { color: var(--danger); animation: pulse 0.8s infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.timer.flash-wrong { animation: shake 0.4s ease; } + +/* ── D-Pad ─────────────────────────────────────────────────────────────────── */ +.dpad { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.dpad-row { + display: flex; + gap: 4px; + align-items: center; +} + +.dpad-btn { + width: 56px; + height: 56px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 1.4rem; + cursor: pointer; + transition: all var(--transition); + font-family: var(--font-mono); + display: flex; + align-items: center; + justify-content: center; +} + +.dpad-btn:hover { background: var(--brand-dim); border-color: var(--brand); } +.dpad-btn:active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); transform: scale(0.92); } + +.dpad-center { + width: 56px; + height: 56px; + background: var(--bg-surface2); + border-radius: var(--radius); +} + +/* ── Lobby ─────────────────────────────────────────────────────────────────── */ +.lobby-layout { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 600px; +} + +.player-list { display: flex; flex-direction: column; gap: 10px; } + +.lobby-player { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--bg-surface2); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.player-name { flex: 1; font-family: var(--font-mono); } + +.challenge-list { display: flex; flex-direction: column; gap: 10px; } + +.challenge-item { + display: flex; + align-items: center; + gap: 10px; + padding: 14px; + background: var(--accent-dim); + border: 1px solid rgba(255,231,16,0.3); + border-radius: var(--radius); + flex-wrap: wrap; +} + +.challenge-badge { + position: fixed; + top: 64px; + right: 16px; + background: var(--accent); + color: #000; + font-family: var(--font-heading); + font-size: 0.8rem; + font-weight: 700; + padding: 6px 14px; + border-radius: 20px; + cursor: pointer; + z-index: 200; + box-shadow: 0 0 12px rgba(255,231,16,0.5); + animation: pulse 1.5s infinite; +} + +/* ── Match ─────────────────────────────────────────────────────────────────── */ +.match-header { + position: relative; + z-index: 1; + text-align: center; + padding: 24px 20px 16px; +} + +.match-status-text { + font-family: var(--font-heading); + font-size: 1.8rem; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.match-category { + font-size: 0.8rem; + color: var(--text-muted); + letter-spacing: 0.1em; + margin-top: 4px; +} + +.match-scoreboard { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + padding: 16px; + max-width: 600px; + margin: 0 auto 24px; +} + +.match-player { text-align: center; } +.match-player-name { font-family: var(--font-mono); font-size: 0.9rem; color: var(--text-muted); } +.match-wins { + font-family: var(--font-mono); + font-size: 3rem; + font-weight: 700; + color: var(--text); +} +.match-player.me .match-wins { color: var(--accent); } +.match-vs { font-family: var(--font-heading); font-size: 1rem; color: var(--text-muted); } + +.match-round-area { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + max-width: 700px; + margin: 0 auto; + padding: 0 20px; +} + +.match-sequences { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + width: 100%; +} + +.match-seq-col { display: flex; flex-direction: column; align-items: center; gap: 10px; } +.match-seq-label { + font-size: 0.7rem; + letter-spacing: 0.15em; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; +} + +.match-actions { + position: relative; + z-index: 1; + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + padding: 16px; +} + +/* ── Leaderboard ───────────────────────────────────────────────────────────── */ +.leaderboard-table { max-width: 700px; } + +/* ── Admin ─────────────────────────────────────────────────────────────────── */ +.admin-layout { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 16px; + max-width: 800px; +} + +.admin-user-list { display: flex; flex-direction: column; gap: 8px; } + +.admin-user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-surface2); + border-radius: var(--radius); + flex-wrap: wrap; +} + +.user-name { flex: 1; font-family: var(--font-mono); } + +.user-role { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 20px; + font-weight: 700; + letter-spacing: 0.08em; +} +.badge-admin { background: var(--accent-dim); color: var(--accent); } +.badge-user { background: var(--brand-dim); color: var(--brand); } +.badge-warning { background: var(--danger-dim); color: var(--danger); font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; } + +.pw-display { + margin-top: 12px; + padding: 10px 14px; + background: var(--bg-surface2); + border: 1px solid var(--accent); + border-radius: var(--radius); + font-family: var(--font-mono); + color: var(--accent); + font-size: 0.9rem; + word-break: break-all; +} + +/* ── Toast notifications ───────────────────────────────────────────────────── */ +#toast-container { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 500; + pointer-events: none; +} + +.toast { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 16px; + font-size: 0.9rem; + opacity: 0; + transform: translateX(20px); + transition: all 0.25s ease; + max-width: 320px; + pointer-events: auto; +} + +.toast.show { opacity: 1; transform: translateX(0); } + +/* ── Responsive ────────────────────────────────────────────────────────────── */ +@media (max-width: 600px) { + .nav-links { display: none; } + .dashboard-grid { grid-template-columns: 1fr; } + .admin-layout { grid-template-columns: 1fr; } + .match-sequences { grid-template-columns: 1fr; } + .stratagem-name { font-size: 1.4rem; } + .arrow-key { width: 40px; height: 40px; font-size: 1.1rem; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..46737fb --- /dev/null +++ b/server.js @@ -0,0 +1,639 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const crypto = require('crypto'); +const express = require('express'); +const session = require('express-session'); +const bcrypt = require('bcryptjs'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const WebSocket = require('ws'); +const Database = require('better-sqlite3'); + +const PORT = process.env.PORT || 3012; +const DATA_DIR = path.join(__dirname, 'data'); + +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + +// ── Stratagems (mirrored in public/stratagems.js) ───────────────────────────── +const STRATAGEMS = [ + // Patriotic Administration Center + { name: 'Reinforce', category: 'Patriotic Administration Center', sequence: ['up','down','right','left','up'] }, + { name: 'Resupply', category: 'Patriotic Administration Center', sequence: ['down','down','up','right'] }, + { name: 'SOS Beacon', category: 'Patriotic Administration Center', sequence: ['up','down','right','up'] }, + { name: 'Hellbomb', category: 'Patriotic Administration Center', sequence: ['down','up','left','down','up','right','down','up'] }, + { name: 'SEAF Artillery', category: 'Patriotic Administration Center', sequence: ['right','up','up','down'] }, + { name: 'Upload Data', category: 'Patriotic Administration Center', sequence: ['right','right','left','up','up'] }, + { name: 'Eagle Rearm', category: 'Patriotic Administration Center', sequence: ['up','up','left','up','right'] }, + { name: 'Prospecting Drill', category: 'Patriotic Administration Center', sequence: ['down','down','left','right','down'] }, + // Orbital Cannons + { name: 'Orbital Gatling Barrage', category: 'Orbital Cannons', sequence: ['right','down','left','up','up'] }, + { name: 'Orbital Airburst Strike', category: 'Orbital Cannons', sequence: ['right','right','right'] }, + { name: 'Orbital 120MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','right','down','left','right','down'] }, + { name: 'Orbital 380MM HE Barrage', category: 'Orbital Cannons', sequence: ['right','down','up','up','left','down','down'] }, + { name: 'Orbital Walking Barrage', category: 'Orbital Cannons', sequence: ['right','down','right','down','right','down'] }, + { name: 'Orbital Laser', category: 'Orbital Cannons', sequence: ['right','down','up','right','down'] }, + { name: 'Orbital Railcannon Strike', category: 'Orbital Cannons', sequence: ['right','up','down','down','right'] }, + { name: 'Orbital Precision Strike', category: 'Orbital Cannons', sequence: ['right','right','up'] }, + { name: 'Orbital Gas Strike', category: 'Orbital Cannons', sequence: ['right','right','down','right'] }, + { name: 'Orbital EMS Strike', category: 'Orbital Cannons', sequence: ['right','right','left','down'] }, + { name: 'Orbital Smoke Strike', category: 'Orbital Cannons', sequence: ['right','right','down','up'] }, + { name: 'Orbital Illumination Flare', category: 'Orbital Cannons', sequence: ['right','right','left','left'] }, + // Hangar + { name: 'Eagle Strafing Run', category: 'Hangar', sequence: ['up','right','right'] }, + { name: 'Eagle Airstrike', category: 'Hangar', sequence: ['up','right','down','right'] }, + { name: 'Eagle Cluster Bomb', category: 'Hangar', sequence: ['up','right','down','down','right'] }, + { name: 'Eagle Napalm Airstrike', category: 'Hangar', sequence: ['up','right','down','up'] }, + { name: 'LIFT-850 Jump Pack', category: 'Hangar', sequence: ['down','up','up','down','up'] }, + { name: 'Eagle Smoke Strike', category: 'Hangar', sequence: ['up','right','up','down'] }, + { name: 'Eagle 110MM Rocket Pods', category: 'Hangar', sequence: ['up','right','up','left'] }, + { name: 'Eagle 500KG Bomb', category: 'Hangar', sequence: ['up','right','down','down','down'] }, + // Bridge + { name: 'Patriot Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','right'] }, + { name: 'Emancipator Exosuit', category: 'Bridge', sequence: ['left','down','right','up','left','down','down'] }, + // Engineering Bay – Support Weapons + { name: 'Machine Gun', category: 'Engineering Bay', sequence: ['down','left','down','up','right'] }, + { name: 'Anti-Materiel Rifle', category: 'Engineering Bay', sequence: ['down','left','right','up','down'] }, + { name: 'Stalwart', category: 'Engineering Bay', sequence: ['down','left','down','up','up','left'] }, + { name: 'Expendable Anti-Tank', category: 'Engineering Bay', sequence: ['down','down','left','up'] }, + { name: 'Recoilless Rifle', category: 'Engineering Bay', sequence: ['down','left','right','right','left'] }, + { name: 'Flamethrower', category: 'Engineering Bay', sequence: ['down','left','up','down','up'] }, + { name: 'Autocannon', category: 'Engineering Bay', sequence: ['down','left','down','up','up','right'] }, + { name: 'Heavy Machine Gun', category: 'Engineering Bay', sequence: ['down','left','up','down','down'] }, + { name: 'Airburst Rocket Launcher', category: 'Engineering Bay', sequence: ['down','up','up','left','right'] }, + { name: 'Commando', category: 'Engineering Bay', sequence: ['down','left','up','down','right'] }, + { name: 'Railgun', category: 'Engineering Bay', sequence: ['down','right','down','up','left','right'] }, + { name: 'Spear', category: 'Engineering Bay', sequence: ['down','down','up','down','down'] }, + { name: 'Quasar Cannon', category: 'Engineering Bay', sequence: ['down','down','up','left','right'] }, + { name: 'Arc Thrower', category: 'Engineering Bay', sequence: ['down','right','down','up','left','left'] }, + { name: 'Laser Cannon', category: 'Engineering Bay', sequence: ['down','left','down','up','left'] }, + { name: 'Grenade Launcher', category: 'Engineering Bay', sequence: ['down','left','up','left','down'] }, + // Engineering Bay – Equipment + { name: 'Supply Pack', category: 'Engineering Bay', sequence: ['down','left','down','up','up','down'] }, + { name: 'Guard Dog Rover', category: 'Engineering Bay', sequence: ['down','up','left','up','right','right'] }, + { name: 'Guard Dog', category: 'Engineering Bay', sequence: ['down','up','left','up','right','down'] }, + { name: 'Ballistic Shield Backpack', category: 'Engineering Bay', sequence: ['down','left','down','down','up','left'] }, + { name: 'Shield Generator Pack', category: 'Engineering Bay', sequence: ['down','up','left','right','left','right'] }, + { name: 'Directional Shield', category: 'Engineering Bay', sequence: ['down','left','up','up','right'] }, + // Engineering Bay – Mines + { name: 'Anti-Personnel Minefield', category: 'Engineering Bay', sequence: ['down','left','up','right'] }, + { name: 'Incendiary Mines', category: 'Engineering Bay', sequence: ['down','left','left','down'] }, + { name: 'Anti-Tank Mines', category: 'Engineering Bay', sequence: ['down','down','left','left'] }, + // Robotics Workshop + { name: 'Machine Gun Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','up'] }, + { name: 'Gatling Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','left'] }, + { name: 'Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','down'] }, + { name: 'Autocannon Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up'] }, + { name: 'Rocket Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','right','left'] }, + { name: 'EMS Mortar Sentry', category: 'Robotics Workshop', sequence: ['down','up','right','down','right'] }, + { name: 'Tesla Tower', category: 'Robotics Workshop', sequence: ['down','up','right','up','left','up','up'] }, + // Defensive + { name: 'Shield Generator Relay', category: 'Defensive', sequence: ['down','up','left','right','left','down'] }, + { name: 'Anti-Tank Emplacement', category: 'Defensive', sequence: ['down','right','right','up','left'] }, + { name: 'Orbital Shield Generator', category: 'Defensive', sequence: ['right','right','left','down','left','down'] }, +]; + +const VALID_NAMES = new Set(STRATAGEMS.map(s => s.name)); + +// ── SQLite ──────────────────────────────────────────────────────────────────── +const db = new Database(path.join(DATA_DIR, 'helldivers.db')); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +function initDB() { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + mustChange INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS practice_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + stratagem TEXT NOT NULL, + category TEXT NOT NULL, + time_ms INTEGER NOT NULL, + score INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + winner TEXT NOT NULL, + loser TEXT NOT NULL, + winner_rounds INTEGER NOT NULL, + loser_rounds INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ps_user ON practice_sessions(username); + CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner); + CREATE INDEX IF NOT EXISTS idx_m_loser ON matches(loser); + `); +} + +async function initUsers() { + const defaults = [ + { username: 'admin', role: 'admin' }, + { username: 'jeremy', role: 'user' }, + ]; + for (const { username, role } of defaults) { + const exists = db.prepare('SELECT username FROM users WHERE username = ?').get(username); + if (!exists) { + const tempPw = crypto.randomBytes(6).toString('hex'); + const hash = await bcrypt.hash(tempPw, 12); + db.prepare('INSERT INTO users (username, hash, role, mustChange) VALUES (?, ?, ?, 1)') + .run(username, hash, role); + console.log(`[INIT] Created user '${username}' – temp password: ${tempPw}`); + } + } +} + +// ── Session secret ──────────────────────────────────────────────────────────── +function getSessionSecret() { + const file = path.join(DATA_DIR, '.session-secret'); + if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').trim(); + const secret = crypto.randomBytes(32).toString('hex'); + fs.writeFileSync(file, secret, { mode: 0o600 }); + return secret; +} + +// ── WebSocket shared state (module-level so routes can read userSockets) ───── +const userSockets = new Map(); // userId → ws +const pendingChallenges = new Map(); // challengerId → targetId +const rooms = new Map(); // roomId → roomState + +// ── Express ─────────────────────────────────────────────────────────────────── +const app = express(); +app.set('trust proxy', 1); + +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], + imgSrc: ["'self'", 'data:'], + connectSrc: ["'self'", 'ws:', 'wss:'], + }, + }, +})); + +const generalLimiter = rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }); +const loginLimiter = rateLimit({ windowMs: 15 * 60_000, max: 10, message: { error: 'Too many login attempts, try again later' } }); + +app.use(generalLimiter); +app.use(express.json({ limit: '10kb' })); + +const sessionMiddleware = session({ + secret: getSessionSecret(), + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, + }, +}); +app.use(sessionMiddleware); + +// ── Middleware ──────────────────────────────────────────────────────────────── +function requireAuth(req, res, next) { + if (!req.session.user) return res.status(401).json({ error: 'Not logged in' }); + if (req.session.mustChange) { + const allowed = ['/api/change-password', '/api/logout', '/api/me']; + if (!allowed.includes(req.path)) { + return res.status(403).json({ error: 'Password change required', mustChange: true }); + } + } + next(); +} + +function requireAdmin(req, res, next) { + if (req.session.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); + next(); +} + +// ── Auth ────────────────────────────────────────────────────────────────────── +app.post('/api/login', loginLimiter, async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + + const valid = await bcrypt.compare(password, user.hash); + if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); + + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Session error' }); + req.session.user = user.username; + req.session.role = user.role; + req.session.mustChange = user.mustChange === 1; + res.json({ ok: true, user: user.username, role: user.role, mustChange: user.mustChange === 1 }); + }); +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy(() => res.json({ ok: true })); +}); + +app.get('/api/me', (req, res) => { + if (!req.session.user) return res.json({ user: null }); + res.json({ user: req.session.user, role: req.session.role, mustChange: !!req.session.mustChange }); +}); + +app.post('/api/change-password', requireAuth, async (req, res) => { + const { oldPassword, newPassword } = req.body || {}; + if (!oldPassword || !newPassword) return res.status(400).json({ error: 'Both passwords required' }); + if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(req.session.user); + const valid = await bcrypt.compare(oldPassword, user.hash); + if (!valid) return res.status(401).json({ error: 'Current password incorrect' }); + + const hash = await bcrypt.hash(newPassword, 12); + db.prepare('UPDATE users SET hash = ?, mustChange = 0 WHERE username = ?').run(hash, req.session.user); + req.session.mustChange = false; + res.json({ ok: true }); +}); + +// ── User management (admin) ─────────────────────────────────────────────────── +app.get('/api/users', requireAuth, requireAdmin, (req, res) => { + const users = db.prepare('SELECT username, role, mustChange FROM users ORDER BY username').all(); + res.json(users.map(u => ({ ...u, mustChange: u.mustChange === 1 }))); +}); + +app.post('/api/users', requireAuth, requireAdmin, async (req, res) => { + const { username, role } = req.body || {}; + if (!username || !['admin', 'user'].includes(role)) + return res.status(400).json({ error: 'Valid username and role required' }); + if (!/^[a-zA-Z0-9_-]{2,32}$/.test(username)) + return res.status(400).json({ error: 'Username: 2-32 alphanumeric chars, _ or -' }); + + const exists = db.prepare('SELECT username FROM users WHERE username = ?').get(username); + if (exists) return res.status(409).json({ error: 'User already exists' }); + + const tempPw = crypto.randomBytes(6).toString('hex'); + const hash = await bcrypt.hash(tempPw, 12); + db.prepare('INSERT INTO users (username, hash, role, mustChange) VALUES (?, ?, ?, 1)').run(username, hash, role); + res.json({ ok: true, tempPassword: tempPw }); +}); + +app.delete('/api/users/:username', requireAuth, requireAdmin, (req, res) => { + const { username } = req.params; + if (username === req.session.user) return res.status(400).json({ error: 'Cannot delete yourself' }); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user) return res.status(404).json({ error: 'User not found' }); + + if (user.role === 'admin') { + const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'").get().c; + if (adminCount <= 1) return res.status(400).json({ error: 'Cannot delete the last admin' }); + } + + db.prepare('DELETE FROM users WHERE username = ?').run(username); + res.json({ ok: true }); +}); + +// ── Leaderboard cache ───────────────────────────────────────────────────────── +let lbCache = null; +let lbCacheTime = 0; +const LB_TTL = 30_000; + +function invalidateLB() { lbCache = null; } + +function getLeaderboard() { + if (lbCache && Date.now() - lbCacheTime < LB_TTL) return lbCache; + lbCache = db.prepare(` + SELECT + u.username, + COALESCE(ps.sessions, 0) AS sessions, + COALESCE(ps.totalScore, 0) AS totalScore, + COALESCE(ps.fastestTime, 0) AS fastestTime, + COALESCE(mw.wins, 0) AS wins, + COALESCE(mw.wins, 0) + COALESCE(ml.losses, 0) AS matches + FROM users u + LEFT JOIN ( + SELECT username, COUNT(*) AS sessions, SUM(score) AS totalScore, MIN(time_ms) AS fastestTime + FROM practice_sessions GROUP BY username + ) ps ON ps.username = u.username + LEFT JOIN (SELECT winner AS username, COUNT(*) AS wins FROM matches GROUP BY winner) mw ON mw.username = u.username + LEFT JOIN (SELECT loser AS username, COUNT(*) AS losses FROM matches GROUP BY loser) ml ON ml.username = u.username + WHERE COALESCE(ps.sessions,0) > 0 OR COALESCE(mw.wins,0) > 0 + ORDER BY totalScore DESC, fastestTime ASC + LIMIT 20 + `).all(); + lbCacheTime = Date.now(); + return lbCache; +} + +// ── Dashboard ───────────────────────────────────────────────────────────────── +app.get('/api/dashboard', requireAuth, (req, res) => { + const u = req.session.user; + + const stats = db.prepare(` + SELECT COUNT(*) AS sessions, + COALESCE(SUM(score),0) AS totalScore, + COALESCE(MAX(score),0) AS bestScore, + COALESCE(MIN(time_ms),0) AS fastestTime + FROM practice_sessions WHERE username = ? + `).get(u); + + const matchStats = db.prepare(` + SELECT COUNT(*) AS matches, + SUM(CASE WHEN winner = ? THEN 1 ELSE 0 END) AS wins + FROM matches WHERE winner = ? OR loser = ? + `).get(u, u, u); + + const lb = getLeaderboard(); + const rankIdx = lb.findIndex(r => r.username === u); + const rank = rankIdx >= 0 ? { position: rankIdx + 1 } : null; + + const recent = db.prepare(` + SELECT stratagem, category, score, time_ms, created_at + FROM practice_sessions WHERE username = ? + ORDER BY created_at DESC LIMIT 5 + `).all(u); + + const dayOfYear = Math.floor((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86_400_000); + const dailyStrat = STRATAGEMS[dayOfYear % STRATAGEMS.length]; + const dailyBest = db.prepare(` + SELECT MIN(time_ms) AS bestTime FROM practice_sessions + WHERE stratagem = ? AND username = ? + `).get(dailyStrat.name, u); + + res.json({ + stats: { ...stats, ...matchStats }, + rank, + online: [...userSockets.keys()], + recent, + daily: { stratagem: dailyStrat, bestTime: dailyBest?.bestTime ?? null }, + }); +}); + +// ── Scores ──────────────────────────────────────────────────────────────────── +app.post('/api/scores/practice', requireAuth, (req, res) => { + const { stratagem, category, time_ms, score } = req.body || {}; + if (!VALID_NAMES.has(stratagem)) return res.status(400).json({ error: 'Invalid stratagem' }); + if (typeof time_ms !== 'number' || time_ms <= 0 || time_ms > 35_000) return res.status(400).json({ error: 'Invalid time' }); + if (typeof score !== 'number' || score < 0 || score > 15_000) return res.status(400).json({ error: 'Invalid score' }); + + db.prepare(` + INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(req.session.user, stratagem, category || '', time_ms, score, new Date().toISOString()); + + invalidateLB(); + res.json({ ok: true }); +}); + +app.get('/api/scores/leaderboard', requireAuth, (req, res) => { + res.json(getLeaderboard()); +}); + +app.get('/api/scores/me', requireAuth, (req, res) => { + const u = req.session.user; + const practice = db.prepare(` + SELECT stratagem, category, score, time_ms, created_at + FROM practice_sessions WHERE username = ? + ORDER BY created_at DESC LIMIT 50 + `).all(u); + const matches = db.prepare(` + SELECT * FROM matches WHERE winner = ? OR loser = ? + ORDER BY created_at DESC LIMIT 20 + `).all(u, u); + res.json({ practice, matches }); +}); + +// ── Static files ────────────────────────────────────────────────────────────── +app.use(express.static(path.join(__dirname, 'public'), { + etag: false, + setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'), +})); +app.use((req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html'))); + +// ── Boot ────────────────────────────────────────────────────────────────────── +async function main() { + initDB(); + await initUsers(); + + const server = app.listen(PORT, () => { + console.log(`[helldivers] listening on http://localhost:${PORT}`); + }); + + // ── WebSocket server ──────────────────────────────────────────────────────── + const wss = new WebSocket.Server({ server }); + + function send(ws, type, payload) { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type, payload })); + } + + function broadcastLobbyUpdate() { + const online = [...userSockets.keys()]; + wss.clients.forEach(ws => { + if (ws.readyState !== WebSocket.OPEN || !ws.userId) return; + const incoming = [...pendingChallenges.entries()] + .filter(([, target]) => target === ws.userId) + .map(([from]) => from); + send(ws, 'lobby-update', { online, incoming }); + }); + } + + function getRoomForUser(userId) { + for (const room of rooms.values()) { + if (room.players.some(p => p.userId === userId)) return room; + } + return null; + } + + function startRound(room) { + room.state = 'active'; + room.current = STRATAGEMS[Math.floor(Math.random() * STRATAGEMS.length)]; + room.players.forEach(p => { p.progress = 0; }); + broadcastToRoom(room, 'round-start', { stratagem: room.current }); + } + + function resolveRound(room, winnerId) { + const loser = room.players.find(p => p.userId !== winnerId); + room.matchScores[winnerId]++; + const matchScores = { ...room.matchScores }; + + broadcastToRoom(room, 'round-complete', { winner: winnerId, matchScores }); + + if (room.matchScores[winnerId] >= 5) { + broadcastToRoom(room, 'match-end', { winner: winnerId, matchScores }); + db.prepare(` + INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(winnerId, loser.userId, room.matchScores[winnerId], room.matchScores[loser.userId], new Date().toISOString()); + invalidateLB(); + rooms.delete(room.roomId); + } else { + room.state = 'waiting'; + room.readyPlayers.clear(); + } + } + + function broadcastToRoom(room, type, payload) { + room.players.forEach(({ userId }) => { + const ws = userSockets.get(userId); + if (ws) send(ws, type, payload); + }); + } + + function handleMessage(ws, type, payload) { + const userId = ws.userId; + + switch (type) { + case 'challenge-user': { + const { targetUser } = payload; + if (!userSockets.has(targetUser) || targetUser === userId) return; + pendingChallenges.set(userId, targetUser); + send(userSockets.get(targetUser), 'challenge-received', { from: userId }); + broadcastLobbyUpdate(); + break; + } + + case 'accept-challenge': { + const { challengerId } = payload; + if (!userSockets.has(challengerId) || pendingChallenges.get(challengerId) !== userId) return; + pendingChallenges.delete(challengerId); + + const roomId = crypto.randomUUID(); + const room = { + roomId, + players: [{ userId: challengerId, progress: 0 }, { userId, progress: 0 }], + state: 'waiting', + current: null, + matchScores: { [challengerId]: 0, [userId]: 0 }, + readyPlayers: new Set(), + }; + rooms.set(roomId, room); + + send(userSockets.get(challengerId), 'room-joined', { roomId, opponent: userId, matchScores: room.matchScores }); + send(ws, 'room-joined', { roomId, opponent: challengerId, matchScores: room.matchScores }); + broadcastLobbyUpdate(); + break; + } + + case 'decline-challenge': { + const { challengerId } = payload; + pendingChallenges.delete(challengerId); + const cWs = userSockets.get(challengerId); + if (cWs) send(cWs, 'challenge-declined', { by: userId }); + broadcastLobbyUpdate(); + break; + } + + case 'player-ready': { + const room = getRoomForUser(userId); + if (!room || room.state !== 'waiting') return; + room.readyPlayers.add(userId); + if (room.readyPlayers.size >= 2) startRound(room); + break; + } + + case 'input-arrow': { + const room = getRoomForUser(userId); + if (!room || room.state !== 'active') return; + const { direction } = payload; + if (!['up','down','left','right'].includes(direction)) return; + + const player = room.players.find(p => p.userId === userId); + const expected = room.current.sequence[player.progress]; + + if (direction === expected) { + player.progress++; + broadcastToRoom(room, 'input-result', { userId, correct: true, progress: player.progress }); + + if (player.progress === room.current.sequence.length) { + room.state = 'round-resolving'; // lock before any async work + resolveRound(room, userId); + } + } else { + player.progress = 0; + broadcastToRoom(room, 'input-result', { userId, correct: false, progress: 0 }); + } + break; + } + + case 'leave-room': { + const room = getRoomForUser(userId); + if (!room) return; + const opponent = room.players.find(p => p.userId !== userId); + if (opponent) { + const oWs = userSockets.get(opponent.userId); + if (oWs) send(oWs, 'opponent-left', {}); + } + rooms.delete(room.roomId); + break; + } + } + } + + wss.on('connection', (ws, req) => { + // Re-use session middleware to authenticate the WS upgrade request + sessionMiddleware(req, {}, () => { + if (!req.session?.user) { ws.close(1008, 'Unauthorized'); return; } + + const userId = req.session.user; + + // Close any stale socket for this user + const stale = userSockets.get(userId); + if (stale && stale !== ws) stale.terminate(); + + userSockets.set(userId, ws); + ws.userId = userId; + ws.isAlive = true; + + broadcastLobbyUpdate(); + + ws.on('message', (raw) => { + try { + const { type, payload } = JSON.parse(raw.toString()); + handleMessage(ws, type, payload || {}); + } catch { /* ignore malformed */ } + }); + + ws.on('pong', () => { ws.isAlive = true; }); + + ws.on('close', () => { + userSockets.delete(userId); + pendingChallenges.delete(userId); + + // Notify opponent if in a room + const room = getRoomForUser(userId); + if (room) { + const opponent = room.players.find(p => p.userId !== userId); + if (opponent) { + const oWs = userSockets.get(opponent.userId); + if (oWs) send(oWs, 'opponent-left', {}); + } + rooms.delete(room.roomId); + } + + broadcastLobbyUpdate(); + }); + }); + }); + + // Heartbeat – terminates stale connections every 30s + const heartbeat = setInterval(() => { + wss.clients.forEach(ws => { + if (!ws.isAlive) { ws.terminate(); return; } + ws.isAlive = false; + ws.ping(); + }); + }, 30_000); + + wss.on('close', () => clearInterval(heartbeat)); +} + +main().catch(err => { console.error(err); process.exit(1); }); + +process.on('SIGTERM', () => { db.close(); process.exit(0); }); +process.on('SIGINT', () => { db.close(); process.exit(0); });