feat: initial Helldivers 2 Stratagem Trainer (practice, 1v1, leaderboard, dashboard)
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'helldivers',
|
||||||
|
script: 'server.js',
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3012,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
@@ -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
@@ -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, '&')
|
||||||
|
.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);
|
||||||
@@ -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>
|
||||||
@@ -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'] },
|
||||||
|
];
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); });
|
||||||
Reference in New Issue
Block a user