Files
helldivers/public/app.js
T

780 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
user: null, // { user, role, mustChange }
currentView: 'login',
stratagems: [],
practice: {
active: false,
current: null,
progress: 0,
timeLeft: 30,
timerHandle: null,
startTime: null,
score: 0,
streak: 0,
selectedCats: new Set(), // empty = all categories
dailyTarget: null, // set when using daily challenge shortcut
},
lobby: {
online: [],
incoming: [], // usernames who challenged me
},
match: {
roomId: null,
opponent: null,
matchScores: {},
current: null,
myProgress: 0,
oppProgress: 0,
roundActive: false,
},
ws: null,
wsReconnectTimer: null,
};
// ── API helpers ───────────────────────────────────────────────────────────────
async function api(method, endpoint, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + endpoint, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
// ── View system ───────────────────────────────────────────────────────────────
function showView(name) {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
const el = document.getElementById('view-' + name);
if (el) el.classList.remove('hidden');
state.currentView = name;
// Highlight active nav button
document.querySelectorAll('.nav-btn').forEach(b => {
b.classList.toggle('active', b.dataset.view === name);
});
// Stop practice timer when navigating away
if (name !== 'practice') stopPracticeTimer();
// View-specific init
if (name === 'dashboard') loadDashboard();
if (name === 'leaderboard') loadLeaderboard();
if (name === 'admin') loadAdmin();
if (name === 'practice') initPracticeView();
if (name === 'lobby') updateLobbyView();
}
// ── Authentication ────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const data = await api('GET', '/me');
if (data.user) {
state.user = data;
if (data.mustChange) {
showView('change-password');
} else {
onLoggedIn();
}
} else {
showView('login');
}
} catch {
showView('login');
}
}
async function onLoggedIn() {
document.getElementById('main-nav').classList.remove('hidden');
document.getElementById('nav-username').textContent = state.user.user;
document.getElementById('nav-admin').classList.toggle('hidden', state.user.role !== 'admin');
// Stratagems are served via authenticated API not as a public static file
state.stratagems = await api('GET', '/stratagems').catch(() => []);
connectWS();
showView('dashboard');
}
async function logout() {
stopPracticeTimer();
if (state.ws) state.ws.close();
clearTimeout(state.wsReconnectTimer);
await api('POST', '/logout').catch(() => {});
state.user = null;
document.getElementById('main-nav').classList.add('hidden');
showView('login');
}
// Login form
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const el = document.getElementById('login-error');
el.classList.add('hidden');
try {
await api('POST', '/login', {
username: document.getElementById('login-username').value.trim(),
password: document.getElementById('login-password').value,
});
await checkAuth();
} catch (err) {
el.textContent = err.message;
el.classList.remove('hidden');
}
});
// Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errEl = document.getElementById('cp-error');
const newPw = document.getElementById('cp-new').value;
const confPw = document.getElementById('cp-confirm').value;
errEl.classList.add('hidden');
if (newPw !== confPw) {
errEl.textContent = 'Passwords do not match';
errEl.classList.remove('hidden');
return;
}
try {
await api('POST', '/change-password', {
oldPassword: document.getElementById('cp-old').value,
newPassword: newPw,
});
state.user.mustChange = false;
onLoggedIn();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
// Nav buttons
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.view));
});
// ── WebSocket ─────────────────────────────────────────────────────────────────
function connectWS() {
if (state.ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
state.ws = new WebSocket(proto + '//' + location.host);
state.ws.onopen = () => { clearTimeout(state.wsReconnectTimer); };
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
state.ws.onerror = () => state.ws.close();
state.ws.onclose = () => {
state.ws = null;
if (state.user) {
state.wsReconnectTimer = setTimeout(connectWS, 3000);
}
};
}
function wsSend(type, payload) {
if (state.ws?.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({ type, payload: payload || {} }));
}
}
function handleWSMessage({ type, payload }) {
switch (type) {
case 'lobby-update':
state.lobby.online = payload.online || [];
state.lobby.incoming = payload.incoming || [];
if (state.currentView === 'lobby') updateLobbyView();
if (state.currentView === 'dashboard') updateDashboardOnline(payload.online);
updateChallengeBadge();
break;
case 'challenge-received':
if (!state.lobby.incoming.includes(payload.from)) state.lobby.incoming.push(payload.from);
updateChallengeBadge();
if (state.currentView === 'lobby') updateLobbyView();
showToast(esc(payload.from) + ' challenges you! Go to 1v1 to respond.');
break;
case 'challenge-declined':
showToast(esc(payload.by) + ' declined your challenge.');
break;
case 'room-joined':
state.match.roomId = payload.roomId;
state.match.opponent = payload.opponent;
state.match.matchScores = payload.matchScores;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = false;
showView('match');
renderMatchWaiting();
break;
case 'round-start':
state.match.current = payload.stratagem;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = true;
renderMatchRound();
break;
case 'input-result':
if (payload.userId === state.user.user) {
state.match.myProgress = payload.progress;
updateMyArrows(payload.correct);
} else {
state.match.oppProgress = payload.progress;
updateOppArrows(payload.correct);
}
break;
case 'round-complete':
state.match.roundActive = false;
state.match.matchScores = payload.matchScores;
renderRoundResult(payload.winner);
break;
case 'match-end':
state.match.matchScores = payload.matchScores;
renderMatchEnd(payload.winner);
break;
case 'opponent-left':
showToast('Opponent left the match.');
setTimeout(() => showView('lobby'), 1800);
break;
}
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
try {
const data = await api('GET', '/dashboard');
renderDashboard(data);
} catch {
/* silently ignore dashboard is cosmetic */
}
}
function renderDashboard({ stats, rank, online, recent, daily }) {
setText('dash-total-score', stats.totalScore || 0);
setText('dash-rank', rank ? '#' + rank.position : 'Unranked');
setText('dash-sessions', stats.sessions || 0);
const wr = (stats.matches > 0) ? Math.round((stats.wins / stats.matches) * 100) + '%' : '—';
setText('dash-win-rate', wr);
if (daily) {
setText('dash-daily-name', daily.stratagem.name);
setText('dash-daily-category', daily.stratagem.category);
setText('dash-daily-best', daily.bestTime ? (daily.bestTime / 1000).toFixed(2) + 's' : 'No record yet');
// Store for the "Practice this" shortcut
state.practice.dailyTarget = daily.stratagem.name;
}
const tbody = document.getElementById('dash-recent');
if (recent.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="muted">No sessions yet</td></tr>';
} else {
tbody.innerHTML = recent.map(r =>
`<tr><td>${esc(r.stratagem)}</td><td>${r.score}</td><td>${(r.time_ms / 1000).toFixed(2)}s</td></tr>`
).join('');
}
updateDashboardOnline(online);
}
function updateDashboardOnline(online) {
const el = document.getElementById('dash-online');
if (!el) return;
const others = (online || []).filter(u => u !== state.user?.user);
if (others.length === 0) {
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
} else {
el.innerHTML = others.map(u =>
`<div class="online-user">
<span class="online-dot"></span>
<span style="flex:1;font-family:var(--font-mono)">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).join('');
}
}
function startDailyChallenge() {
if (!state.practice.dailyTarget) return;
state.practice.selectedCats.clear();
showView('practice');
// start a practice session focused on the daily stratagem
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
if (strat) {
state.practice.selectedCats.add(strat.category);
startPractice();
}
}
// ── Practice mode ─────────────────────────────────────────────────────────────
function initPracticeView() {
renderCategoryFilters();
if (!state.practice.active) showPracticeIdle();
}
function renderCategoryFilters() {
const cats = [...new Set(state.stratagems.map(s => s.category))];
const el = document.getElementById('practice-categories');
el.innerHTML = cats.map(cat => {
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
return `<button class="cat-btn ${active ? 'active' : ''}"
onclick="toggleCategory('${esc(cat)}')">${esc(cat)}</button>`;
}).join('');
}
function toggleCategory(cat) {
if (state.practice.selectedCats.has(cat)) {
state.practice.selectedCats.delete(cat);
} else {
state.practice.selectedCats.add(cat);
}
renderCategoryFilters();
}
function showPracticeIdle() {
document.getElementById('practice-idle').classList.remove('hidden');
document.getElementById('practice-active').classList.add('hidden');
state.practice.active = false;
}
function startPractice() {
if (getPool().length === 0) { showToast('No stratagems match the selected filters'); return; }
state.practice.active = true;
state.practice.score = 0;
state.practice.streak = 0;
document.getElementById('practice-idle').classList.add('hidden');
document.getElementById('practice-active').classList.remove('hidden');
nextStratagem();
}
function stopPracticeUI() {
stopPracticeTimer();
showPracticeIdle();
}
function stopPracticeTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = null;
state.practice.active = false;
}
function getPool() {
const cats = state.practice.selectedCats;
if (cats.size === 0) return state.stratagems;
return state.stratagems.filter(s => cats.has(s.category));
}
function nextStratagem() {
const pool = getPool();
state.practice.current = pool[Math.floor(Math.random() * pool.length)];
state.practice.progress = 0;
state.practice.timeLeft = 30;
state.practice.startTime = Date.now();
renderPracticeStratagem();
startPracticeTimer();
}
function startPracticeTimer() {
clearInterval(state.practice.timerHandle);
state.practice.timerHandle = setInterval(() => {
state.practice.timeLeft--;
updateTimerDisplay();
if (state.practice.timeLeft <= 0) {
clearInterval(state.practice.timerHandle);
state.practice.streak = 0;
updateStreakDisplay();
// Flash timer to signal timeout
const timerEl = document.getElementById('practice-timer');
timerEl.classList.add('flash-wrong');
setTimeout(() => { timerEl.classList.remove('flash-wrong'); nextStratagem(); }, 700);
}
}, 1000);
}
function renderPracticeStratagem() {
const s = state.practice.current;
setText('practice-category', s.category);
setText('practice-name', s.name);
renderArrows('practice-sequence', s.sequence, state.practice.progress);
updateTimerDisplay();
updateScoreDisplay();
updateStreakDisplay();
}
function renderArrows(containerId, sequence, progress) {
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = sequence.map((dir, i) => {
let cls = 'arrow-key';
if (i < progress) cls += ' completed';
if (i === progress) cls += ' active';
return `<div class="${cls}">${ARROW[dir]}</div>`;
}).join('');
}
function updateTimerDisplay() {
const el = document.getElementById('practice-timer');
if (!el) return;
el.textContent = state.practice.timeLeft;
el.className = 'timer' + (state.practice.timeLeft <= 5 ? ' timer-danger' : '');
}
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
function updateStreakDisplay() { setText('practice-streak', state.practice.streak); }
function handlePracticeInput(dir) {
const p = state.practice;
if (!p.active || !p.current) return;
const seq = p.current.sequence;
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
const curArrow = arrows[p.progress];
if (dir === seq[p.progress]) {
// Correct input
curArrow?.classList.add('flash-correct');
p.progress++;
if (p.progress === seq.length) {
// Stratagem completed!
clearInterval(p.timerHandle);
const elapsed = Date.now() - p.startTime;
const secs = Math.min(30, elapsed / 1000);
const pts = Math.round((100 + (30 - secs) * 3) * (1 + p.streak * 0.1));
p.score += pts;
p.streak++;
updateScoreDisplay();
updateStreakDisplay();
// Flash all arrows green
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
el.classList.remove('flash-correct');
el.classList.add('completed');
});
// Save result (fire-and-forget)
api('POST', '/scores/practice', {
stratagem: p.current.name,
category: p.current.category,
time_ms: elapsed,
score: pts,
}).catch(() => {});
setTimeout(nextStratagem, 600);
} else {
// Re-render with updated progress (highlights next arrow)
renderArrows('practice-sequence', seq, p.progress);
}
} else {
// Wrong input reset progress
curArrow?.classList.add('flash-wrong');
p.progress = 0;
p.streak = 0;
updateStreakDisplay();
setTimeout(() => {
renderArrows('practice-sequence', seq, 0);
}, 350);
}
}
// ── Lobby ─────────────────────────────────────────────────────────────────────
function updateLobbyView() {
const others = state.lobby.online.filter(u => u !== state.user?.user);
const el = document.getElementById('lobby-players');
if (!el) return;
if (others.length === 0) {
el.innerHTML = '<p class="muted">No other Helldivers online. Waiting for reinforcements...</p>';
} else {
el.innerHTML = others.map(u =>
`<div class="lobby-player">
<span class="online-dot"></span>
<span class="player-name">${esc(u)}</span>
<button class="btn btn-sm btn-accent" onclick="sendChallenge('${esc(u)}')">⚔ Challenge</button>
</div>`
).join('');
}
// Incoming challenges
const challEl = document.getElementById('lobby-challenges');
if (!challEl) return;
const inc = state.lobby.incoming;
if (inc.length === 0) {
challEl.innerHTML = '';
} else {
challEl.innerHTML = inc.map(from =>
`<div class="challenge-item">
<span style="flex:1"><strong>${esc(from)}</strong> challenges you to a duel!</span>
<button class="btn btn-sm btn-accent" onclick="acceptChallenge('${esc(from)}')">Accept</button>
<button class="btn btn-sm btn-muted" onclick="declineChallenge('${esc(from)}')">Decline</button>
</div>`
).join('');
}
}
function sendChallenge(target) {
wsSend('challenge-user', { targetUser: target });
showToast('Challenge sent to ' + esc(target));
}
function acceptChallenge(from) {
wsSend('accept-challenge', { challengerId: from });
// Remove from incoming list
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
updateChallengeBadge();
}
function declineChallenge(from) {
wsSend('decline-challenge', { challengerId: from });
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
updateChallengeBadge();
if (state.currentView === 'lobby') updateLobbyView();
}
function updateChallengeBadge() {
const badge = document.getElementById('challenge-badge');
if (!badge) return;
const count = state.lobby.incoming.length;
if (count > 0) {
badge.textContent = count + ' Challenge' + (count > 1 ? 's' : '') + ' Go to 1v1';
badge.classList.remove('hidden');
badge.onclick = () => showView('lobby');
} else {
badge.classList.add('hidden');
}
}
// ── Match ─────────────────────────────────────────────────────────────────────
function renderMatchWaiting() {
const m = state.match;
setText('match-me-name', state.user.user);
setText('match-opp-name', m.opponent);
setText('match-status', 'Waiting for both players...');
setText('match-category', '');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
const readyBtn = document.getElementById('match-ready-btn');
readyBtn.textContent = 'READY';
readyBtn.disabled = false;
readyBtn.style.display = 'inline-flex';
}
function renderMatchScores() {
const m = state.match;
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
}
function setReady() {
wsSend('player-ready');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready waiting for opponent...';
btn.disabled = true;
}
function renderMatchRound() {
const m = state.match;
setText('match-status', m.current.name);
setText('match-category', m.current.category);
document.getElementById('match-round-area').classList.remove('hidden');
document.getElementById('match-ready-btn').style.display = 'none';
renderArrows('match-me-sequence', m.current.sequence, 0);
renderArrows('match-opp-sequence', m.current.sequence, 0);
}
function updateMyArrows(correct) {
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
if (!correct) {
const el = document.getElementById('match-me-sequence');
el?.classList.add('flash-wrong-seq');
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
}
}
function updateOppArrows(correct) {
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
}
function handleMatchInput(dir) {
if (!state.match.roundActive) return;
wsSend('input-arrow', { direction: dir });
}
function renderRoundResult(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
renderMatchScores();
// Short pause then show ready button for next round
setTimeout(() => {
document.getElementById('match-round-area').classList.add('hidden');
const btn = document.getElementById('match-ready-btn');
btn.textContent = 'Ready for next round';
btn.disabled = false;
btn.style.display = 'inline-flex';
setText('match-category', '');
}, 1600);
}
function renderMatchEnd(winner) {
const won = winner === state.user.user;
setText('match-status', won ? '🏆 MATCH WON!' : '☠ MATCH LOST');
renderMatchScores();
document.getElementById('match-round-area').classList.add('hidden');
document.getElementById('match-ready-btn').style.display = 'none';
setTimeout(() => showView('lobby'), 3000);
}
function leaveMatch() {
wsSend('leave-room');
showView('lobby');
}
// ── Leaderboard ───────────────────────────────────────────────────────────────
async function loadLeaderboard() {
const tbody = document.getElementById('leaderboard-table-body');
try {
const rows = await api('GET', '/scores/leaderboard');
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No scores yet. Start practicing!</td></tr>';
} else {
tbody.innerHTML = rows.map((r, i) =>
`<tr class="${r.username === state.user?.user ? 'row-me' : ''}">
<td class="rank">${i + 1}</td>
<td style="font-family:var(--font-mono)">${esc(r.username)}</td>
<td style="font-family:var(--font-mono)">${r.totalScore}</td>
<td style="font-family:var(--font-mono)">${r.sessions}</td>
<td style="font-family:var(--font-mono)">${r.wins}/${r.matches}</td>
</tr>`
).join('');
}
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="muted">Error loading leaderboard</td></tr>';
}
}
// ── Admin panel ───────────────────────────────────────────────────────────────
async function loadAdmin() {
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
try {
const users = await api('GET', '/users');
renderAdminUsers(users);
} catch {
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
}
}
function renderAdminUsers(users) {
const el = document.getElementById('admin-users');
el.innerHTML = users.map(u =>
`<div class="admin-user-row">
<span class="user-name">${esc(u.username)}</span>
<span class="user-role badge-${u.role}">${u.role}</span>
${u.mustChange ? '<span class="badge-warning">temp pw</span>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.username)}')">Delete</button>`
: ''}
</div>`
).join('');
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const role = document.getElementById('new-role').value;
const errEl = document.getElementById('admin-error');
const pwEl = document.getElementById('new-pw-display');
errEl.classList.add('hidden');
pwEl.classList.add('hidden');
if (!username) return;
try {
const result = await api('POST', '/users', { username, role });
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
pwEl.classList.remove('hidden');
document.getElementById('new-username').value = '';
loadAdmin();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
async function deleteUser(username) {
if (!confirm('Delete "' + username + '"? This cannot be undone.')) return;
try {
await api('DELETE', '/users/' + encodeURIComponent(username));
loadAdmin();
} catch (err) {
showToast('Error: ' + err.message);
}
}
// ── Keyboard input ────────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
const MAP = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
const dir = MAP[e.key];
if (!dir) return;
if (state.currentView === 'practice' || state.currentView === 'match') {
e.preventDefault(); // prevent page scroll
dpadInput(dir);
}
});
// Called by on-screen D-pad and keyboard handler
function dpadInput(dir) {
if (state.currentView === 'practice') handlePracticeInput(dir);
if (state.currentView === 'match') handleMatchInput(dir);
}
// ── Utils ─────────────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function showToast(msg) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = msg;
container.appendChild(toast);
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('show'));
});
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3200);
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', checkAuth);