feat: initial Helldivers 2 Stratagem Trainer (practice, 1v1, leaderboard, dashboard)

This commit is contained in:
Jeremy Brandenburger
2026-03-30 13:32:55 +02:00
commit 3c22196f81
8 changed files with 2732 additions and 0 deletions
+17
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
apps: [{
name: 'helldivers',
script: 'server.js',
env_production: {
NODE_ENV: 'production',
PORT: 3012,
},
}],
};
+22
View File
@@ -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"
}
}
+778
View File
@@ -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 = '<tr><td colspan="3" class="muted">No sessions yet</td></tr>';
} else {
tbody.innerHTML = recent.map(r =>
`<tr><td>${esc(r.stratagem)}</td><td>${r.score}</td><td>${(r.time_ms / 1000).toFixed(2)}s</td></tr>`
).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 = '<span class="muted">No other Helldivers online</span>';
} else {
el.innerHTML = others.map(u =>
`<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).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 `<button class="cat-btn ${active ? 'active' : ''}"
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
}).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 `<div class="${cls}">${ARROW[dir]}</div>`;
}).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 = '<p class="muted">No other Helldivers online. Waiting for reinforcements...</p>';
} else {
el.innerHTML = others.map(u =>
`<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).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 =>
`<div class="challenge-item">
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
<button class="btn btn-sm btn-accent" onclick="acceptChallenge('${esc(from)}')">Accept</button>
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
</div>`
).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 = '<tr><td colspan="5" class="muted">No scores yet. Start practicing!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) =>
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
<td style="font-family:var(--font-mono)">${r.sessions}</td>
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
</tr>`
).join('');
}
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Error loading leaderboard</td></tr>';
}
}
// ── 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 = '<span class="muted">Error loading users</span>';
}
}
function renderAdminUsers(users) {
const el = document.getElementById('admin-users');
el.innerHTML = users.map(u =>
`<div class="admin-user-row">
<span class="user-name">${esc(u.username)}</span>
<span class="user-role badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
: ''}
</div>`
).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
+346
View File
@@ -0,0 +1,346 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HELLDIVERS 2 Stratagem Trainer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700&family=Rajdhani:wght@600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- ── Navigation ─────────────────────────────────────────────── -->
<nav id="main-nav" class="hidden">
<div class="nav-brand">
<span class="nav-logo"></span>
<span>HELLDIVERS 2</span>
</div>
<div class="nav-links">
<button class="nav-btn" data-view="dashboard">Dashboard</button>
<button class="nav-btn" data-view="practice">Training</button>
<button class="nav-btn" data-view="lobby">1v1</button>
<button class="nav-btn" data-view="leaderboard">Highscores</button>
<button class="nav-btn nav-btn-admin hidden" id="nav-admin" data-view="admin">Admin</button>
</div>
<div class="nav-user">
<span class="nav-username" id="nav-username"></span>
<button class="btn btn-muted btn-sm" onclick="logout()">Logout</button>
</div>
</nav>
<!-- Incoming challenge badge (shown anywhere) -->
<div id="challenge-badge" class="challenge-badge hidden"></div>
<!-- ── LOGIN ─────────────────────────────────────────────────── -->
<div id="view-login" class="view view-centered">
<div class="login-box">
<div class="login-header">
<div class="login-logo"></div>
<h1>HELLDIVERS 2</h1>
<p class="login-sub">STRATAGEM TRAINER — SUPER EARTH AUTHORIZED</p>
</div>
<form id="login-form" class="login-form" autocomplete="off">
<div class="field">
<label for="login-username">Helldiver ID</label>
<input id="login-username" type="text" placeholder="Username" autocomplete="username" required>
</div>
<div class="field">
<label for="login-password">Access Code</label>
<input id="login-password" type="password" placeholder="Password" autocomplete="current-password" required>
</div>
<p id="login-error" class="error hidden"></p>
<button type="submit" class="btn btn-accent btn-full">AUTHENTICATE</button>
</form>
</div>
</div>
<!-- ── CHANGE PASSWORD ───────────────────────────────────────── -->
<div id="view-change-password" class="view view-centered hidden">
<div class="login-box">
<div class="login-header">
<h2>CHANGE ACCESS CODE</h2>
<p class="login-sub">Temporary password must be changed before proceeding</p>
</div>
<form id="change-password-form" class="login-form">
<div class="field">
<label for="cp-old">Current Password</label>
<input id="cp-old" type="password" required autocomplete="current-password">
</div>
<div class="field">
<label for="cp-new">New Password (min 8 chars)</label>
<input id="cp-new" type="password" required minlength="8" autocomplete="new-password">
</div>
<div class="field">
<label for="cp-confirm">Confirm New Password</label>
<input id="cp-confirm" type="password" required autocomplete="new-password">
</div>
<p id="cp-error" class="error hidden"></p>
<button type="submit" class="btn btn-accent btn-full">SET NEW PASSWORD</button>
</form>
</div>
</div>
<!-- ── DASHBOARD ─────────────────────────────────────────────── -->
<div id="view-dashboard" class="view hidden">
<div class="page-header">
<h2 class="page-title">COMMAND CENTER</h2>
<p class="page-sub">Welcome back, Helldiver. For Super Earth.</p>
</div>
<div class="dashboard-grid">
<!-- Stats card -->
<div class="card">
<h3 class="card-title">YOUR STATS</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value" id="dash-total-score"></div>
<div class="stat-label">Total Score</div>
</div>
<div class="stat-item">
<div class="stat-value accent" id="dash-rank"></div>
<div class="stat-label">Global Rank</div>
</div>
<div class="stat-item">
<div class="stat-value" id="dash-sessions"></div>
<div class="stat-label">Sessions</div>
</div>
<div class="stat-item">
<div class="stat-value" id="dash-win-rate"></div>
<div class="stat-label">Match Win Rate</div>
</div>
</div>
</div>
<!-- Daily challenge card -->
<div class="card card-accent">
<h3 class="card-title">⚡ DAILY CHALLENGE</h3>
<div class="daily-stratagem">
<div class="daily-name" id="dash-daily-name"></div>
<div class="daily-category" id="dash-daily-category"></div>
<div class="daily-best">
Best time: <span id="dash-daily-best"></span>
</div>
<button class="btn btn-accent" onclick="startDailyChallenge()">Practice this stratagem</button>
</div>
</div>
<!-- Online users card -->
<div class="card">
<h3 class="card-title">ONLINE HELLDIVERS</h3>
<div id="dash-online" class="online-list">
<span class="muted">Loading...</span>
</div>
</div>
<!-- Recent sessions card -->
<div class="card">
<h3 class="card-title">RECENT SESSIONS</h3>
<table class="data-table">
<thead>
<tr><th>Stratagem</th><th>Score</th><th>Time</th></tr>
</thead>
<tbody id="dash-recent">
<tr><td colspan="3" class="muted">No sessions yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── PRACTICE ───────────────────────────────────────────────── -->
<div id="view-practice" class="view hidden">
<div class="page-header">
<h2 class="page-title">TRAINING PROTOCOL</h2>
</div>
<!-- Category filters -->
<div class="category-row" id="practice-categories"></div>
<!-- Idle (start screen) -->
<div id="practice-idle" class="practice-idle">
<div class="idle-hint">Select categories above, then start training</div>
<button class="btn btn-accent btn-lg" onclick="startPractice()">⚡ START TRAINING</button>
</div>
<!-- Active training -->
<div id="practice-active" class="practice-active hidden">
<div class="stratagem-display card">
<div class="stratagem-category" id="practice-category"></div>
<div class="stratagem-name" id="practice-name"></div>
<div class="arrow-sequence" id="practice-sequence"></div>
<div class="practice-hint">Use Arrow Keys or D-Pad</div>
</div>
<div class="practice-hud">
<div class="hud-item">
<div class="hud-label">TIME</div>
<div class="timer" id="practice-timer">30</div>
</div>
<div class="hud-item">
<div class="hud-label">SCORE</div>
<div class="hud-value" id="practice-score">0</div>
</div>
<div class="hud-item">
<div class="hud-label">STREAK</div>
<div class="hud-value accent" id="practice-streak">0</div>
</div>
</div>
<!-- D-Pad (mobile) -->
<div class="dpad">
<div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button>
<div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button>
</div>
</div>
<button class="btn btn-muted" onclick="stopPracticeUI()">Stop Training</button>
</div>
</div>
<!-- ── LOBBY ──────────────────────────────────────────────────── -->
<div id="view-lobby" class="view hidden">
<div class="page-header">
<h2 class="page-title">1v1 ARENA</h2>
<p class="page-sub">Challenge a fellow Helldiver to a stratagem duel</p>
</div>
<div class="lobby-layout">
<div class="card">
<h3 class="card-title">ONLINE HELLDIVERS</h3>
<div id="lobby-players" class="player-list">
<p class="muted">Loading...</p>
</div>
</div>
<div id="lobby-challenges" class="challenge-list"></div>
</div>
</div>
<!-- ── MATCH ──────────────────────────────────────────────────── -->
<div id="view-match" class="view hidden">
<div class="match-header">
<div class="match-status-text" id="match-status">Waiting...</div>
<div class="match-category" id="match-category"></div>
</div>
<div class="match-scoreboard">
<div class="match-player me">
<div class="match-player-name" id="match-me-name"></div>
<div class="match-wins" id="match-me-wins">0</div>
</div>
<div class="match-vs">VS</div>
<div class="match-player opp">
<div class="match-player-name" id="match-opp-name"></div>
<div class="match-wins" id="match-opp-wins">0</div>
</div>
</div>
<!-- Round area -->
<div id="match-round-area" class="match-round-area hidden">
<div class="match-sequences">
<div class="match-seq-col">
<div class="match-seq-label">YOU</div>
<div class="arrow-sequence" id="match-me-sequence"></div>
</div>
<div class="match-seq-col">
<div class="match-seq-label">OPPONENT</div>
<div class="arrow-sequence" id="match-opp-sequence"></div>
</div>
</div>
<!-- D-Pad (mobile) -->
<div class="dpad">
<div class="dpad-row">
<button class="dpad-btn dpad-up" onclick="dpadInput('up')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-left" onclick="dpadInput('left')"></button>
<div class="dpad-center"></div>
<button class="dpad-btn dpad-right" onclick="dpadInput('right')"></button>
</div>
<div class="dpad-row">
<button class="dpad-btn dpad-down" onclick="dpadInput('down')"></button>
</div>
</div>
</div>
<div class="match-actions">
<button class="btn btn-accent" id="match-ready-btn" onclick="setReady()" style="display:none">READY</button>
<button class="btn btn-muted btn-sm" onclick="leaveMatch()">Leave Match</button>
</div>
</div>
<!-- ── LEADERBOARD ────────────────────────────────────────────── -->
<div id="view-leaderboard" class="view hidden">
<div class="page-header">
<h2 class="page-title">HALL OF HEROES</h2>
<p class="page-sub">Top Helldivers ranked by total practice score</p>
</div>
<div class="card">
<table class="data-table leaderboard-table">
<thead>
<tr>
<th>#</th>
<th>Helldiver</th>
<th>Total Score</th>
<th>Sessions</th>
<th>Match W/Total</th>
</tr>
</thead>
<tbody id="leaderboard-table-body">
<tr><td colspan="5" class="muted">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── ADMIN ──────────────────────────────────────────────────── -->
<div id="view-admin" class="view hidden">
<div class="page-header">
<h2 class="page-title">ADMIN PANEL</h2>
</div>
<div class="admin-layout">
<!-- Create user -->
<div class="card">
<h3 class="card-title">CREATE HELLDIVER</h3>
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" type="text" placeholder="helldiver_name" pattern="[a-zA-Z0-9_-]{2,32}">
</div>
<div class="field">
<label for="new-role">Role</label>
<select id="new-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p id="admin-error" class="error hidden"></p>
<button class="btn btn-accent" onclick="createUser()">Create User</button>
<div id="new-pw-display" class="pw-display hidden"></div>
</div>
<!-- User list -->
<div class="card">
<h3 class="card-title">ACTIVE HELLDIVERS</h3>
<div id="admin-users" class="admin-user-list">Loading...</div>
</div>
</div>
</div>
<!-- Toast notifications -->
<div id="toast-container"></div>
<script src="stratagems.js"></script>
<script src="app.js"></script>
</body>
</html>
+87
View File
@@ -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'] },
];
+833
View File
@@ -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; }
}
+639
View File
@@ -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); });