1843 lines
70 KiB
JavaScript
1843 lines
70 KiB
JavaScript
'use strict';
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
const RING_CIRCUMFERENCE = 219.9; // 2π × r(35)
|
||
const ELO_RANKS = [
|
||
{ label: 'PRIVATE', min: 0, icon: '⚡' },
|
||
{ label: 'SERGEANT', min: 1100, icon: '★' },
|
||
{ label: 'LIEUTENANT', min: 1300, icon: '☆' },
|
||
{ label: 'CAPTAIN', min: 1500, icon: '⚔' },
|
||
{ label: 'GENERAL', min: 1700, icon: '🏆' },
|
||
];
|
||
|
||
function eloRankFor(elo) {
|
||
for (let i = ELO_RANKS.length - 1; i >= 0; i--) {
|
||
if (elo >= ELO_RANKS[i].min) return ELO_RANKS[i];
|
||
}
|
||
return ELO_RANKS[0];
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const state = {
|
||
user: null,
|
||
currentView: 'login',
|
||
stratagems: [],
|
||
|
||
settings: {
|
||
timerDuration: 30, // 15 | 30 | 45
|
||
difficulty: 'normal', // 'easy' | 'normal' | 'hard'
|
||
},
|
||
|
||
practice: {
|
||
active: false,
|
||
mode: 'timed',
|
||
current: null,
|
||
queue: [], // upcoming stratagems (for queue preview)
|
||
progress: 0,
|
||
timeLeft: 30,
|
||
timerHandle: null,
|
||
startTime: null,
|
||
score: 0,
|
||
streak: 0,
|
||
selectedCats: new Set(),
|
||
dailyTarget: null,
|
||
|
||
// Endless mode
|
||
lives: 3,
|
||
|
||
// Drill mode
|
||
drillPool: [],
|
||
drillCompleted: 0,
|
||
drillTotal: 0,
|
||
|
||
// Speedrun mode
|
||
speedrunStart: null,
|
||
speedrunPool: [],
|
||
speedrunElapsed: 0,
|
||
|
||
// Session stats
|
||
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 },
|
||
},
|
||
|
||
lobby: {
|
||
online: [],
|
||
incoming: [],
|
||
pendingChallenge: null,
|
||
},
|
||
|
||
match: {
|
||
roomId: null,
|
||
opponent: null,
|
||
matchScores: {},
|
||
current: null,
|
||
myProgress: 0,
|
||
oppProgress: 0,
|
||
roundActive: false,
|
||
roundHistory: [],
|
||
},
|
||
|
||
leaderboard: { activeTab: 'practice' },
|
||
|
||
history: { page: 1, total: 0 },
|
||
|
||
ws: null,
|
||
wsReconnectTimer: null,
|
||
};
|
||
|
||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||
function loadSettings() {
|
||
try {
|
||
const raw = localStorage.getItem('hd2-settings');
|
||
if (raw) {
|
||
const s = JSON.parse(raw);
|
||
if ([15, 30, 45].includes(s.timerDuration)) state.settings.timerDuration = s.timerDuration;
|
||
if (['easy', 'normal', 'hard'].includes(s.difficulty)) state.settings.difficulty = s.difficulty;
|
||
}
|
||
} catch { /* ignore */ }
|
||
applySettingsToUI();
|
||
}
|
||
|
||
function saveSettings() {
|
||
localStorage.setItem('hd2-settings', JSON.stringify(state.settings));
|
||
}
|
||
|
||
function applySettingsToUI() {
|
||
document.querySelectorAll('[data-setting="timer"]').forEach(btn => {
|
||
btn.classList.toggle('active', Number(btn.dataset.value) === state.settings.timerDuration);
|
||
});
|
||
document.querySelectorAll('[data-setting="difficulty"]').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.value === state.settings.difficulty);
|
||
});
|
||
}
|
||
|
||
// ── API ───────────────────────────────────────────────────────────────────────
|
||
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');
|
||
v.classList.remove('view-fade-in');
|
||
});
|
||
const el = document.getElementById('view-' + name);
|
||
if (el) {
|
||
el.classList.remove('hidden');
|
||
requestAnimationFrame(() => el.classList.add('view-fade-in'));
|
||
}
|
||
state.currentView = name;
|
||
|
||
document.querySelectorAll('.nav-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === name);
|
||
});
|
||
|
||
if (name !== 'practice') stopPracticeTimer();
|
||
if (name === 'dashboard') loadDashboard();
|
||
if (name === 'leaderboard') loadLeaderboard();
|
||
if (name === 'admin') loadAdmin();
|
||
if (name === 'practice') initPracticeView();
|
||
if (name === 'lobby') updateLobbyView();
|
||
if (name === 'history') loadHistory();
|
||
|
||
if (name !== 'practice') document.body.classList.remove('in-practice-session');
|
||
if (name !== 'match') document.body.classList.remove('in-match-round');
|
||
}
|
||
|
||
function focusGameplayArea(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
requestAnimationFrame(() => {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function runGameplayCountdown(label = 'Deploying', steps = ['3', '2', '1', 'GO']) {
|
||
const el = document.getElementById('gameplay-countdown');
|
||
if (!el) return;
|
||
el.classList.remove('hidden');
|
||
for (const step of steps) {
|
||
el.innerHTML = `<span class="countdown-label">${esc(label)}</span><strong>${esc(step)}</strong>`;
|
||
el.classList.remove('countdown-pop');
|
||
void el.offsetWidth;
|
||
el.classList.add('countdown-pop');
|
||
await sleep(step === 'GO' ? 380 : 520);
|
||
}
|
||
el.classList.add('hidden');
|
||
}
|
||
|
||
function showGameplayFeedback(id, text, tone = 'info', duration = 900) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.className = `gameplay-feedback gameplay-feedback-${tone}`;
|
||
clearTimeout(el._hideTimer);
|
||
el._hideTimer = setTimeout(() => {
|
||
el.className = 'gameplay-feedback hidden';
|
||
}, duration);
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
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');
|
||
document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin');
|
||
state.stratagems = await api('GET', '/stratagems').catch(() => []);
|
||
populateCategoryFilter();
|
||
loadSettings();
|
||
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');
|
||
}
|
||
|
||
function populateCategoryFilter() {
|
||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||
const sel = document.getElementById('history-filter-cat');
|
||
if (!sel) return;
|
||
// Remove old options except the first
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
cats.forEach(cat => {
|
||
const opt = document.createElement('option');
|
||
opt.value = cat;
|
||
opt.textContent = cat;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
});
|
||
|
||
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');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.nav-btn[data-view]').forEach(btn => {
|
||
btn.addEventListener('click', () => showView(btn.dataset.view));
|
||
});
|
||
document.getElementById('btn-briefing-practice')?.addEventListener('click', () => showView('practice'));
|
||
document.getElementById('btn-briefing-lobby')?.addEventListener('click', () => showView('lobby'));
|
||
document.getElementById('btn-briefing-leaderboard')?.addEventListener('click', () => showView('leaderboard'));
|
||
|
||
// ── Hamburger nav ─────────────────────────────────────────────────────────────
|
||
function openDrawer() {
|
||
document.getElementById('nav-drawer').classList.add('open');
|
||
document.getElementById('nav-overlay').classList.add('open');
|
||
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'true');
|
||
}
|
||
|
||
function closeDrawer() {
|
||
document.getElementById('nav-drawer').classList.remove('open');
|
||
document.getElementById('nav-overlay').classList.remove('open');
|
||
document.getElementById('nav-hamburger').setAttribute('aria-expanded', 'false');
|
||
}
|
||
|
||
document.getElementById('nav-hamburger').addEventListener('click', openDrawer);
|
||
document.getElementById('nav-overlay').addEventListener('click', closeDrawer);
|
||
document.querySelectorAll('.drawer-btn[data-view]').forEach(btn => {
|
||
btn.addEventListener('click', () => { showView(btn.dataset.view); closeDrawer(); });
|
||
});
|
||
document.getElementById('btn-logout-drawer')?.addEventListener('click', () => { closeDrawer(); logout(); });
|
||
|
||
// ── 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();
|
||
openChallengeModal(payload.from, payload.elo ?? '?');
|
||
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;
|
||
state.match.roundHistory = [];
|
||
closeChallengeModal();
|
||
showView('match');
|
||
renderMatchWaiting();
|
||
break;
|
||
|
||
case 'round-start':
|
||
state.match.current = payload.stratagem;
|
||
state.match.myProgress = 0;
|
||
state.match.oppProgress = 0;
|
||
state.match.roundActive = false;
|
||
renderMatchRound();
|
||
beginMatchRound();
|
||
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();
|
||
}
|
||
break;
|
||
|
||
case 'round-complete':
|
||
state.match.roundActive = false;
|
||
state.match.matchScores = payload.matchScores;
|
||
state.match.roundHistory.push({ stratagem: state.match.current?.name, winner: payload.winner });
|
||
renderRoundResult(payload.winner);
|
||
break;
|
||
|
||
case 'match-end':
|
||
state.match.matchScores = payload.matchScores;
|
||
openMatchResultModal({
|
||
winner: payload.winner,
|
||
eloChanges: payload.eloChanges,
|
||
roundHistory: payload.roundHistory || state.match.roundHistory,
|
||
});
|
||
break;
|
||
|
||
case 'opponent-left':
|
||
showToast('Opponent left the match.');
|
||
setTimeout(() => showView('lobby'), 1800);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
setText('dash-hero-name', state.user?.user || '—');
|
||
try {
|
||
const data = await api('GET', '/dashboard');
|
||
renderDashboard(data);
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
|
||
const myRank = eloRankFor(elo || 1000);
|
||
const onlineOthers = (online || []).filter(u => u.name !== state.user?.user);
|
||
setText('dash-hero-name', state.user.user);
|
||
setText('dash-rank-label', rankLabel || myRank.label);
|
||
setText('dash-elo', elo || 1000);
|
||
setText('dash-rank-icon', myRank.icon);
|
||
|
||
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 || 0) / stats.matches) * 100) + '%' : '—';
|
||
setText('dash-win-rate', wr);
|
||
setText('dash-online-count', String(onlineOthers.length));
|
||
|
||
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');
|
||
setText('dash-daily-focus', daily.stratagem.category);
|
||
setText('dash-status-line', daily.bestTime
|
||
? `Daily focus is ${daily.stratagem.name}. Your best run is ${(daily.bestTime / 1000).toFixed(2)}s, so a cleaner sequence could move you up fast.`
|
||
: `Daily focus is ${daily.stratagem.name}. No record logged yet, so this is a clean chance to set the pace for today.`);
|
||
state.practice.dailyTarget = daily.stratagem.name;
|
||
renderDailySequencePreview(daily.stratagem.sequence);
|
||
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
|
||
} else {
|
||
setText('dash-daily-focus', 'Stand By');
|
||
setText('dash-status-line', 'Systems are online. Review recent runs, sharpen your execution, and push your rank before heading into the arena.');
|
||
}
|
||
|
||
const tbody = document.getElementById('dash-recent');
|
||
if (!recent?.length) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = recent.map(r => {
|
||
const icon = state.stratagems.find(s => s.name === r.stratagem)?.icon || '';
|
||
return `<tr>
|
||
<td><img class="stratagem-icon-sm" src="${esc(icon)}" alt="" ${icon ? '' : 'style="display:none"'}>${esc(r.stratagem)}</td>
|
||
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
|
||
<td>${r.score}</td>
|
||
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
updateDashboardOnline(online);
|
||
}
|
||
|
||
function renderDailySequencePreview(sequence) {
|
||
const ARROW = { up: '↑', down: '↓', left: '←', right: '→' };
|
||
const el = document.getElementById('dash-daily-sequence');
|
||
if (!el) return;
|
||
el.innerHTML = sequence.map(d => `<div class="daily-arrow">${ARROW[d]}</div>`).join('');
|
||
}
|
||
|
||
function updateDashboardOnline(online) {
|
||
const el = document.getElementById('dash-online');
|
||
if (!el) return;
|
||
const players = (online || []).filter(u => u.name !== state.user?.user);
|
||
if (!players.length) {
|
||
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
|
||
} else {
|
||
el.innerHTML = players.map(u =>
|
||
`<div class="online-user">
|
||
<span class="online-dot"></span>
|
||
<span style="flex:1;font-family:var(--font-mono)">${esc(u.name)}</span>
|
||
${u.elo ? `<span class="player-elo">${u.elo}</span>` : ''}
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
}
|
||
|
||
function startDailyChallenge() {
|
||
if (!state.practice.dailyTarget) return;
|
||
state.practice.selectedCats.clear();
|
||
showView('practice');
|
||
const strat = state.stratagems.find(s => s.name === state.practice.dailyTarget);
|
||
if (strat) {
|
||
state.practice.selectedCats.add(strat.category);
|
||
startPractice();
|
||
}
|
||
}
|
||
|
||
// ── Practice ──────────────────────────────────────────────────────────────────
|
||
function initPracticeView() {
|
||
renderCategoryFilters();
|
||
if (!state.practice.active) showPracticeIdle();
|
||
updateModeLabel();
|
||
}
|
||
|
||
function renderCategoryFilters() {
|
||
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
||
const el = document.getElementById('practice-categories');
|
||
if (!el) return;
|
||
el.innerHTML = cats.map(cat => {
|
||
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
|
||
return `<button class="cat-btn ${active ? 'active' : ''}" data-action="toggle-cat" data-cat="${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');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
document.body.classList.remove('in-practice-session');
|
||
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 resetSessionStats() {
|
||
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 };
|
||
}
|
||
|
||
async function startPractice() {
|
||
const pool = getPool();
|
||
if (!pool.length) { showToast('No stratagems match the selected filters'); return; }
|
||
|
||
const mode = state.practice.mode;
|
||
state.practice.active = true;
|
||
state.practice.score = 0;
|
||
state.practice.streak = 0;
|
||
state.practice.lives = 3;
|
||
state.practice.progress = 0;
|
||
resetSessionStats();
|
||
|
||
document.getElementById('practice-idle').classList.add('hidden');
|
||
document.getElementById('practice-active').classList.remove('hidden');
|
||
document.body.classList.add('in-practice-session');
|
||
|
||
if (mode === 'drill') {
|
||
state.practice.drillPool = shuffleArray([...pool]);
|
||
state.practice.drillCompleted = 0;
|
||
state.practice.drillTotal = pool.length;
|
||
document.getElementById('drill-progress-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'TIME');
|
||
} else if (mode === 'endless') {
|
||
document.getElementById('hud-lives-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-timer-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
updateLivesDisplay();
|
||
} else if (mode === 'speedrun') {
|
||
state.practice.speedrunPool = shuffleArray([...state.stratagems]);
|
||
state.practice.speedrunStart = Date.now();
|
||
state.practice.speedrunElapsed = 0;
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'ELAPSED');
|
||
} else {
|
||
// Timed
|
||
document.getElementById('hud-timer-wrap').classList.remove('hidden');
|
||
document.getElementById('hud-lives-wrap').classList.add('hidden');
|
||
document.getElementById('drill-progress-wrap').classList.add('hidden');
|
||
setText('hud-timer-label', 'TIME');
|
||
}
|
||
|
||
focusGameplayArea('practice-active');
|
||
showGameplayFeedback('practice-feedback', 'Stand by. Deployment starting.', 'info', 1500);
|
||
await runGameplayCountdown('Deployment');
|
||
nextStratagem();
|
||
}
|
||
|
||
function shuffleArray(arr) {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
function stopPracticeUI() {
|
||
const p = state.practice;
|
||
if (p.active && (p.sessionStats.completed > 0 || p.sessionStats.missed > 0)) {
|
||
openSessionSummary();
|
||
} else {
|
||
stopPracticeTimer();
|
||
showPracticeIdle();
|
||
}
|
||
}
|
||
|
||
function stopPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = null;
|
||
state.practice.active = false;
|
||
}
|
||
|
||
function buildQueue() {
|
||
const p = state.practice;
|
||
const mode = p.mode;
|
||
const pool = mode === 'speedrun' ? p.speedrunPool.slice(1, 4)
|
||
: mode === 'drill' ? p.drillPool.slice(1, 4)
|
||
: (() => {
|
||
const fullPool = getPool().filter(s => s !== p.current);
|
||
return shuffleArray([...fullPool]).slice(0, 3);
|
||
})();
|
||
p.queue = pool;
|
||
renderQueue(pool);
|
||
}
|
||
|
||
function renderQueue(queue) {
|
||
const el = document.getElementById('practice-queue');
|
||
if (!el) return;
|
||
if (!queue?.length) { el.innerHTML = ''; return; }
|
||
el.innerHTML = queue.map(s => {
|
||
const iconHtml = s.icon
|
||
? `<img class="queue-icon" src="${esc(s.icon)}" alt="${esc(s.name)}">`
|
||
: `<div class="queue-icon-fallback">⚡</div>`;
|
||
return `<div class="queue-item">
|
||
${iconHtml}
|
||
<div class="queue-label">${esc(s.name)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function nextStratagem() {
|
||
const p = state.practice;
|
||
const mode = p.mode;
|
||
let strat;
|
||
|
||
if (mode === 'drill') {
|
||
if (!p.drillPool.length) {
|
||
clearInterval(p.timerHandle);
|
||
showToast('Drill complete! All stratagems mastered.');
|
||
openSessionSummary();
|
||
return;
|
||
}
|
||
strat = p.drillPool[0];
|
||
} else if (mode === 'speedrun') {
|
||
if (!p.speedrunPool.length) {
|
||
const totalMs = Date.now() - p.speedrunStart;
|
||
clearInterval(p.timerHandle);
|
||
showToast(`Speedrun complete! ${(totalMs / 1000).toFixed(2)}s`);
|
||
openSessionSummary();
|
||
return;
|
||
}
|
||
strat = p.speedrunPool[0];
|
||
} else {
|
||
// timed & endless: pick random from pool
|
||
const pool = getPool();
|
||
if (!pool.length) { showPracticeIdle(); return; }
|
||
strat = pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
p.current = strat;
|
||
p.progress = 0;
|
||
p.startTime = Date.now();
|
||
|
||
if (mode === 'timed') {
|
||
p.timeLeft = state.settings.timerDuration;
|
||
startPracticeTimer();
|
||
} else if (mode === 'drill') {
|
||
p.timeLeft = 60;
|
||
startPracticeTimer();
|
||
updateDrillProgress();
|
||
} else if (mode === 'speedrun') {
|
||
startSpeedrunTimer();
|
||
}
|
||
|
||
renderPracticeStratagem();
|
||
buildQueue();
|
||
}
|
||
|
||
function startPracticeTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
const total = state.practice.mode === 'drill' ? 60 : state.settings.timerDuration;
|
||
state.practice.timerHandle = setInterval(() => {
|
||
state.practice.timeLeft--;
|
||
updateTimerDisplay(total);
|
||
const showVignette = state.practice.timeLeft <= 5 && state.practice.timeLeft > 0;
|
||
document.getElementById('danger-vignette').classList.toggle('hidden', !showVignette);
|
||
if (state.practice.timeLeft <= 0) {
|
||
clearInterval(state.practice.timerHandle);
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
state.practice.streak = 0;
|
||
state.practice.sessionStats.missed++;
|
||
trackPracticeMistake();
|
||
updateStreakDisplay();
|
||
shakeIcon();
|
||
showGameplayFeedback('practice-feedback', 'Timer expired. Next stratagem.', 'danger', 1000);
|
||
setTimeout(nextStratagem, 500);
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function startSpeedrunTimer() {
|
||
clearInterval(state.practice.timerHandle);
|
||
state.practice.timerHandle = setInterval(() => {
|
||
state.practice.speedrunElapsed = Date.now() - state.practice.speedrunStart;
|
||
const secs = (state.practice.speedrunElapsed / 1000).toFixed(1);
|
||
setText('practice-timer', secs + 's');
|
||
const ring = document.getElementById('timer-ring-fill');
|
||
if (ring) ring.style.strokeDashoffset = '0';
|
||
}, 100);
|
||
}
|
||
|
||
function renderPracticeStratagem() {
|
||
const s = state.practice.current;
|
||
const diff = state.settings.difficulty;
|
||
|
||
setText('practice-category', diff === 'easy' ? s.category : '');
|
||
if (diff === 'hard') {
|
||
setText('practice-name', '— ' + '● '.repeat(s.sequence.length).trim() + ' —');
|
||
} else {
|
||
setText('practice-name', s.name);
|
||
}
|
||
|
||
renderArrows('practice-sequence', s.sequence, 0);
|
||
updateTimerDisplay(state.settings.timerDuration);
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
|
||
// Show icon
|
||
const iconEl = document.getElementById('practice-icon');
|
||
const fallbackEl = document.getElementById('practice-icon-fallback');
|
||
if (s.icon) {
|
||
setIcon(iconEl, s.icon);
|
||
iconEl.classList.remove('icon-complete', 'icon-wrong');
|
||
if (fallbackEl) fallbackEl.style.display = 'none';
|
||
} else {
|
||
if (iconEl) iconEl.style.display = 'none';
|
||
if (fallbackEl) fallbackEl.style.display = '';
|
||
}
|
||
showGameplayFeedback('practice-feedback', `${s.name} ready. Execute the sequence.`, 'info', 900);
|
||
}
|
||
|
||
function setIcon(imgEl, src) {
|
||
if (!imgEl || !src) return;
|
||
imgEl.src = src;
|
||
imgEl.alt = '';
|
||
imgEl.style.display = '';
|
||
imgEl.onerror = () => { imgEl.style.display = 'none'; };
|
||
}
|
||
|
||
function renderArrows(containerId, sequence, progress) {
|
||
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}">${renderDirGlyph(dir)}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderDirGlyph(dir) {
|
||
return `<span class="dir-glyph dir-${esc(dir)}" aria-hidden="true">
|
||
<svg viewBox="0 0 64 64" focusable="false">
|
||
<path class="dir-line" d="M32 50 L32 18" />
|
||
<path class="dir-head" d="M32 12 L20 24 H28 V50 H36 V24 H44 Z" />
|
||
</svg>
|
||
</span>`;
|
||
}
|
||
|
||
function updateTimerDisplay(total) {
|
||
if (state.practice.mode === 'speedrun') return;
|
||
const el = document.getElementById('practice-timer');
|
||
if (!el) return;
|
||
const t = state.practice.timeLeft;
|
||
const tot = total || state.settings.timerDuration;
|
||
el.textContent = t;
|
||
const isDanger = t <= 5 && t > 0;
|
||
el.className = 'timer-ring-val' + (isDanger ? ' danger' : '');
|
||
|
||
const ring = document.getElementById('timer-ring-fill');
|
||
if (ring) {
|
||
const fraction = Math.max(0, t / tot);
|
||
ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - fraction));
|
||
ring.classList.toggle('danger', isDanger);
|
||
}
|
||
}
|
||
|
||
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
|
||
|
||
function updateStreakDisplay() {
|
||
setText('practice-streak', state.practice.streak);
|
||
const streakItem = document.getElementById('hud-streak-item');
|
||
const comboBadge = document.getElementById('practice-combo');
|
||
const streak = state.practice.streak;
|
||
|
||
if (streakItem) streakItem.classList.toggle('streak-fire', streak >= 5);
|
||
if (comboBadge) {
|
||
if (streak >= 2) {
|
||
comboBadge.textContent = '×' + (1 + streak * 0.1).toFixed(1);
|
||
comboBadge.classList.remove('hidden');
|
||
} else {
|
||
comboBadge.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateLivesDisplay() {
|
||
const el = document.getElementById('practice-lives');
|
||
if (!el) return;
|
||
el.innerHTML = Array.from({ length: 3 }, (_, i) =>
|
||
`<span class="life-icon${i >= state.practice.lives ? ' lost' : ''}">❤</span>`
|
||
).join('');
|
||
}
|
||
|
||
function trackPracticeMistake() {
|
||
const current = state.practice.current;
|
||
if (!current) return;
|
||
const mistakes = state.practice.sessionStats.mistakes;
|
||
mistakes[current.name] = (mistakes[current.name] || 0) + 1;
|
||
}
|
||
|
||
function updateDrillProgress() {
|
||
const p = state.practice;
|
||
setText('drill-progress-text', p.drillCompleted + ' / ' + p.drillTotal);
|
||
const fill = document.getElementById('drill-progress-fill');
|
||
if (fill) fill.style.width = p.drillTotal > 0 ? Math.round((p.drillCompleted / p.drillTotal) * 100) + '%' : '0%';
|
||
}
|
||
|
||
function shakeIcon() {
|
||
const iconEl = document.getElementById('practice-icon');
|
||
if (!iconEl || iconEl.style.display === 'none') return;
|
||
iconEl.classList.remove('icon-wrong');
|
||
requestAnimationFrame(() => iconEl.classList.add('icon-wrong'));
|
||
setTimeout(() => iconEl.classList.remove('icon-wrong'), 400);
|
||
}
|
||
|
||
function handlePracticeInput(dir) {
|
||
const p = state.practice;
|
||
if (!p.active || !p.current) return;
|
||
|
||
const mode = p.mode;
|
||
const seq = p.current.sequence;
|
||
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
|
||
const cur = arrows[p.progress];
|
||
|
||
if (dir === seq[p.progress]) {
|
||
cur?.classList.add('flash-correct');
|
||
p.progress++;
|
||
|
||
if (p.progress === seq.length) {
|
||
clearInterval(p.timerHandle);
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
|
||
const elapsed = Date.now() - p.startTime;
|
||
const mult = 1 + p.streak * 0.1;
|
||
let pts = 0;
|
||
|
||
if (mode === 'timed' || mode === 'drill') {
|
||
const secs = Math.min(state.settings.timerDuration, elapsed / 1000);
|
||
pts = Math.round((100 + (state.settings.timerDuration - secs) * 3) * mult);
|
||
} else if (mode === 'endless') {
|
||
pts = Math.round(100 * mult);
|
||
} else if (mode === 'speedrun') {
|
||
pts = Math.round(50 * mult);
|
||
}
|
||
|
||
p.score += pts;
|
||
p.streak++;
|
||
p.sessionStats.maxStreak = Math.max(p.sessionStats.maxStreak, p.streak);
|
||
p.sessionStats.completed++;
|
||
if (elapsed < p.sessionStats.bestTime) p.sessionStats.bestTime = elapsed;
|
||
|
||
// Track per-stratagem stats
|
||
if (!p.sessionStats.stratagems[p.current.name]) {
|
||
p.sessionStats.stratagems[p.current.name] = { count: 0, totalMs: 0 };
|
||
}
|
||
p.sessionStats.stratagems[p.current.name].count++;
|
||
p.sessionStats.stratagems[p.current.name].totalMs += elapsed;
|
||
|
||
updateScoreDisplay();
|
||
updateStreakDisplay();
|
||
|
||
// Visual: all arrows complete + icon flash
|
||
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
|
||
el.classList.remove('flash-correct');
|
||
el.classList.add('completed');
|
||
});
|
||
const iconEl = document.getElementById('practice-icon');
|
||
iconEl?.classList.add('icon-complete');
|
||
setTimeout(() => iconEl?.classList.remove('icon-complete'), 300);
|
||
|
||
// Score popup
|
||
showScorePopup('+' + pts);
|
||
showGameplayFeedback('practice-feedback', `${p.streak >= 5 ? 'Perfect chain' : 'Confirmed'} +${pts}`, 'success', 1100);
|
||
|
||
api('POST', '/scores/practice', {
|
||
stratagem: p.current.name,
|
||
category: p.current.category,
|
||
time_ms: elapsed,
|
||
score: pts,
|
||
mode: mode,
|
||
}).catch(() => {});
|
||
|
||
if (mode === 'drill') {
|
||
p.drillPool.shift();
|
||
p.drillCompleted++;
|
||
updateDrillProgress();
|
||
} else if (mode === 'speedrun') {
|
||
p.speedrunPool.shift();
|
||
}
|
||
|
||
setTimeout(nextStratagem, 550);
|
||
} else {
|
||
renderArrows('practice-sequence', seq, p.progress);
|
||
}
|
||
|
||
} else {
|
||
// Wrong input
|
||
cur?.classList.add('flash-wrong');
|
||
p.progress = 0;
|
||
shakeIcon();
|
||
trackPracticeMistake();
|
||
showGameplayFeedback('practice-feedback', 'Wrong input. Sequence reset.', 'danger', 1000);
|
||
|
||
if (mode === 'endless') {
|
||
p.lives--;
|
||
p.streak = 0;
|
||
updateLivesDisplay();
|
||
updateStreakDisplay();
|
||
p.sessionStats.missed++;
|
||
if (p.lives <= 0) {
|
||
setTimeout(() => openSessionSummary(), 600);
|
||
return;
|
||
}
|
||
} else {
|
||
p.streak = 0;
|
||
updateStreakDisplay();
|
||
}
|
||
|
||
setTimeout(() => renderArrows('practice-sequence', seq, 0), 350);
|
||
}
|
||
}
|
||
|
||
// ── Settings modal ────────────────────────────────────────────────────────────
|
||
function openSettingsModal() {
|
||
applySettingsToUI();
|
||
document.getElementById('modal-settings').classList.remove('hidden');
|
||
}
|
||
|
||
function closeSettingsModal() {
|
||
document.getElementById('modal-settings').classList.add('hidden');
|
||
}
|
||
|
||
document.getElementById('btn-practice-settings')?.addEventListener('click', openSettingsModal);
|
||
document.getElementById('btn-settings-close')?.addEventListener('click', closeSettingsModal);
|
||
document.getElementById('modal-settings')?.addEventListener('click', (e) => {
|
||
if (e.target === document.getElementById('modal-settings')) closeSettingsModal();
|
||
});
|
||
|
||
// Settings option click (delegation via main delegation handler)
|
||
// handled in the data-action delegation below
|
||
|
||
// ── Mode card selection ───────────────────────────────────────────────────────
|
||
document.getElementById('practice-mode-grid')?.addEventListener('click', (e) => {
|
||
const card = e.target.closest('[data-mode]');
|
||
if (!card || state.practice.active) return;
|
||
state.practice.mode = card.dataset.mode;
|
||
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active'));
|
||
card.classList.add('active');
|
||
updateModeLabel();
|
||
});
|
||
|
||
function updateModeLabel() {
|
||
const dur = state.settings.timerDuration;
|
||
const labels = {
|
||
timed: `Timed mode — ${dur}s per stratagem`,
|
||
endless: 'Endless mode — 3 lives, no timer',
|
||
drill: 'Category Drill — master your selection',
|
||
speedrun: 'Speed Run — all stratagems, fastest time',
|
||
};
|
||
setText('practice-mode-label', labels[state.practice.mode] || '');
|
||
}
|
||
|
||
// ── Session summary modal ─────────────────────────────────────────────────────
|
||
function openSessionSummary() {
|
||
stopPracticeTimer();
|
||
document.getElementById('danger-vignette').classList.add('hidden');
|
||
|
||
const p = state.practice;
|
||
const s = p.sessionStats;
|
||
|
||
// Stats grid
|
||
const accuracy = (s.completed + s.missed) > 0
|
||
? Math.round(s.completed / (s.completed + s.missed) * 100) + '%'
|
||
: '—';
|
||
const bestTimeStr = s.bestTime < Infinity ? (s.bestTime / 1000).toFixed(2) + 's' : '—';
|
||
|
||
const grid = document.getElementById('summary-grid');
|
||
if (grid) {
|
||
grid.innerHTML = [
|
||
{ label: 'Score', val: p.score },
|
||
{ label: 'Completed', val: s.completed },
|
||
{ label: 'Streak Max',val: s.maxStreak },
|
||
{ label: 'Accuracy', val: accuracy },
|
||
{ label: 'Best Time', val: bestTimeStr },
|
||
{ label: 'Mode', val: p.mode },
|
||
].map(x => `<div class="summary-stat">
|
||
<div class="summary-stat-val">${esc(String(x.val))}</div>
|
||
<div class="summary-stat-label">${esc(x.label)}</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
const insightsEl = document.getElementById('summary-insights');
|
||
if (insightsEl) {
|
||
const mistakeEntries = Object.entries(s.mistakes).sort((a, b) => b[1] - a[1]);
|
||
const stratEntries = Object.entries(s.stratagems)
|
||
.map(([name, stat]) => ({ name, avg: stat.totalMs / stat.count, count: stat.count }))
|
||
.sort((a, b) => b.avg - a.avg);
|
||
const insights = [
|
||
`Accuracy landed at ${accuracy}${s.maxStreak >= 5 ? ` with a peak streak of ${s.maxStreak}.` : '.'}`,
|
||
stratEntries[0] ? `Slowest repeated stratagem was ${stratEntries[0].name} at ${(stratEntries[0].avg / 1000).toFixed(2)}s average.` : 'Run more sessions to identify your slowest stratagems.',
|
||
mistakeEntries[0] ? `Most common reset came from ${mistakeEntries[0][0]} with ${mistakeEntries[0][1]} mistake${mistakeEntries[0][1] > 1 ? 's' : ''}.` : 'No input resets recorded in this session.',
|
||
];
|
||
insightsEl.innerHTML = insights.map(line => `<div class="summary-insight">${esc(line)}</div>`).join('');
|
||
}
|
||
|
||
// Top stratagems by count
|
||
const topEl = document.getElementById('summary-top-stratagems');
|
||
if (topEl) {
|
||
const tops = Object.entries(s.stratagems)
|
||
.sort((a, b) => b[1].count - a[1].count)
|
||
.slice(0, 5);
|
||
if (tops.length) {
|
||
topEl.innerHTML = tops.map(([name, stat], i) => {
|
||
const strat = state.stratagems.find(x => x.name === name);
|
||
const avgMs = (stat.totalMs / stat.count).toFixed(0);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<div class="summary-top-item">
|
||
<span class="summary-top-rank">${i + 1}.</span>
|
||
${iconHtml}
|
||
<span class="summary-top-name">${esc(name)}</span>
|
||
<span class="summary-top-time">${(avgMs / 1000).toFixed(2)}s avg</span>
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
topEl.innerHTML = '<p class="muted" style="font-size:0.85rem">No data</p>';
|
||
}
|
||
}
|
||
|
||
document.getElementById('modal-session-summary').classList.remove('hidden');
|
||
}
|
||
|
||
function closeSessionSummary() {
|
||
document.getElementById('modal-session-summary').classList.add('hidden');
|
||
showPracticeIdle();
|
||
}
|
||
|
||
document.getElementById('btn-summary-dashboard')?.addEventListener('click', () => {
|
||
closeSessionSummary();
|
||
showView('dashboard');
|
||
});
|
||
document.getElementById('btn-summary-restart')?.addEventListener('click', () => {
|
||
document.getElementById('modal-session-summary').classList.add('hidden');
|
||
startPractice();
|
||
});
|
||
|
||
// ── Lobby ─────────────────────────────────────────────────────────────────────
|
||
function updateLobbyView() {
|
||
const others = state.lobby.online.filter(u => u.name !== state.user?.user);
|
||
const el = document.getElementById('lobby-players');
|
||
if (!el) return;
|
||
|
||
if (!others.length) {
|
||
el.innerHTML = `<div class="lobby-empty">
|
||
<div class="lobby-empty-icon">📡</div>
|
||
<p>No other Helldivers online.<br>Waiting for reinforcements...</p>
|
||
</div>`;
|
||
} else {
|
||
el.innerHTML = others.map(u =>
|
||
`<div class="lobby-player">
|
||
<span class="online-dot"></span>
|
||
<span class="player-name">${esc(u.name)}</span>
|
||
${u.elo ? `<span class="player-elo">${esc(u.rank)} · ${u.elo}</span>` : ''}
|
||
<button class="btn btn-sm btn-accent" data-action="challenge" data-user="${esc(u.name)}">⚔ Challenge</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
const challEl = document.getElementById('lobby-challenges');
|
||
if (!challEl) return;
|
||
const inc = state.lobby.incoming;
|
||
if (!inc.length) {
|
||
challEl.innerHTML = '<p class="muted">No incoming challenges</p>';
|
||
} else {
|
||
challEl.innerHTML = inc.map(from =>
|
||
`<div class="challenge-item">
|
||
<span style="flex:1"><strong>${esc(from)}</strong> challenges you!</span>
|
||
<button class="btn btn-sm btn-accent" data-action="accept" data-user="${esc(from)}">Accept</button>
|
||
<button class="btn btn-sm btn-muted" data-action="decline" data-user="${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 });
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
closeChallengeModal();
|
||
updateChallengeBadge();
|
||
}
|
||
|
||
function declineChallenge(from) {
|
||
wsSend('decline-challenge', { challengerId: from });
|
||
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
||
closeChallengeModal();
|
||
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');
|
||
}
|
||
}
|
||
|
||
function openChallengeModal(from, elo) {
|
||
state.lobby.pendingChallenge = { from, elo };
|
||
setText('modal-challenger-name', from);
|
||
setText('modal-challenger-elo', elo);
|
||
document.getElementById('modal-challenge').classList.remove('hidden');
|
||
}
|
||
|
||
function closeChallengeModal() {
|
||
document.getElementById('modal-challenge').classList.add('hidden');
|
||
state.lobby.pendingChallenge = null;
|
||
}
|
||
|
||
document.getElementById('btn-accept-challenge')?.addEventListener('click', () => {
|
||
if (state.lobby.pendingChallenge) acceptChallenge(state.lobby.pendingChallenge.from);
|
||
});
|
||
document.getElementById('btn-decline-challenge')?.addEventListener('click', () => {
|
||
if (state.lobby.pendingChallenge) declineChallenge(state.lobby.pendingChallenge.from);
|
||
});
|
||
document.getElementById('modal-challenge')?.addEventListener('click', (e) => {
|
||
if (e.target === document.getElementById('modal-challenge')) closeChallengeModal();
|
||
});
|
||
|
||
// ── 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 btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'READY';
|
||
btn.disabled = false;
|
||
btn.classList.remove('hidden');
|
||
document.body.classList.remove('in-match-round');
|
||
showGameplayFeedback('match-feedback', 'Waiting for both divers to ready up.', 'info', 1200);
|
||
updateMatchProgressUI();
|
||
// Hide match icon
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (matchIcon) matchIcon.style.display = 'none';
|
||
}
|
||
|
||
function renderMatchScores() {
|
||
const m = state.match;
|
||
setText('match-me-wins', m.matchScores[state.user.user] ?? 0);
|
||
setText('match-opp-wins', m.matchScores[m.opponent] ?? 0);
|
||
}
|
||
|
||
function setReady() {
|
||
wsSend('player-ready');
|
||
const btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'Ready – waiting for opponent...';
|
||
btn.disabled = true;
|
||
}
|
||
|
||
function renderMatchRound() {
|
||
const m = state.match;
|
||
setText('match-status', m.current.name);
|
||
setText('match-category', m.current.category);
|
||
document.getElementById('match-round-area').classList.remove('hidden');
|
||
document.getElementById('match-ready-btn').classList.add('hidden');
|
||
document.body.classList.add('in-match-round');
|
||
renderArrows('match-me-sequence', m.current.sequence, 0);
|
||
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
||
updateMatchProgressUI();
|
||
|
||
// Show stratagem icon in match
|
||
const strat = state.stratagems.find(s => s.name === m.current.name);
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (strat?.icon) setIcon(matchIcon, strat.icon);
|
||
else if (matchIcon) matchIcon.style.display = 'none';
|
||
focusGameplayArea('match-round-area');
|
||
}
|
||
|
||
function updateMyArrows(correct) {
|
||
renderArrows('match-me-sequence', state.match.current.sequence, state.match.myProgress);
|
||
updateMatchProgressUI();
|
||
if (!correct) {
|
||
const el = document.getElementById('match-me-sequence');
|
||
el?.classList.add('flash-wrong-seq');
|
||
setTimeout(() => el?.classList.remove('flash-wrong-seq'), 350);
|
||
showGameplayFeedback('match-feedback', 'Input rejected. Recover now.', 'danger', 900);
|
||
} else {
|
||
showGameplayFeedback('match-feedback', 'Confirmed. Keep pushing.', 'success', 550);
|
||
}
|
||
}
|
||
|
||
function updateOppArrows() {
|
||
renderArrows('match-opp-sequence', state.match.current.sequence, state.match.oppProgress);
|
||
updateMatchProgressUI();
|
||
}
|
||
|
||
function handleMatchInput(dir) {
|
||
if (!state.match.roundActive) return;
|
||
wsSend('input-arrow', { direction: dir });
|
||
}
|
||
|
||
function updateMatchProgressUI() {
|
||
const total = state.match.current?.sequence?.length || 0;
|
||
const myFill = document.getElementById('match-me-progress-fill');
|
||
const oppFill = document.getElementById('match-opp-progress-fill');
|
||
const myText = document.getElementById('match-me-progress-text');
|
||
const oppText = document.getElementById('match-opp-progress-text');
|
||
if (myFill) myFill.style.width = total ? `${(state.match.myProgress / total) * 100}%` : '0%';
|
||
if (oppFill) oppFill.style.width = total ? `${(state.match.oppProgress / total) * 100}%` : '0%';
|
||
if (myText) myText.textContent = `${state.match.myProgress} / ${total}`;
|
||
if (oppText) oppText.textContent = `${state.match.oppProgress} / ${total}`;
|
||
}
|
||
|
||
async function beginMatchRound() {
|
||
focusGameplayArea('match-round-area');
|
||
showGameplayFeedback('match-feedback', 'Round locked. Prepare to input.', 'info', 1200);
|
||
await runGameplayCountdown('Round Start');
|
||
state.match.roundActive = true;
|
||
showGameplayFeedback('match-feedback', 'Go go go.', 'success', 700);
|
||
}
|
||
|
||
function renderRoundResult(winner) {
|
||
const won = winner === state.user.user;
|
||
setText('match-status', won ? '✓ ROUND WON' : '✗ ROUND LOST');
|
||
renderMatchScores();
|
||
showGameplayFeedback('match-feedback', won ? 'Round secured.' : 'Opponent took the round.', won ? 'success' : 'danger', 1200);
|
||
const matchIcon = document.getElementById('match-icon');
|
||
if (matchIcon) matchIcon.style.display = 'none';
|
||
setTimeout(() => {
|
||
document.getElementById('match-round-area').classList.add('hidden');
|
||
document.body.classList.remove('in-match-round');
|
||
const btn = document.getElementById('match-ready-btn');
|
||
btn.textContent = 'Ready for next round';
|
||
btn.disabled = false;
|
||
btn.classList.remove('hidden');
|
||
setText('match-category', '');
|
||
}, 1600);
|
||
}
|
||
|
||
function openMatchResultModal({ winner, eloChanges, roundHistory }) {
|
||
const isWinner = winner === state.user.user;
|
||
const resultEl = document.getElementById('result-winner-text');
|
||
if (resultEl) {
|
||
resultEl.textContent = isWinner ? '🏆 VICTORY' : '☠ DEFEAT';
|
||
resultEl.className = 'result-winner ' + (isWinner ? 'win' : 'loss');
|
||
}
|
||
|
||
if (eloChanges && state.user?.user) {
|
||
const myChange = eloChanges[state.user.user];
|
||
if (myChange) {
|
||
setText('result-elo-old', myChange.old);
|
||
setText('result-elo-new', myChange.new);
|
||
const delta = myChange.delta;
|
||
const deltaEl = document.getElementById('result-elo-delta');
|
||
if (deltaEl) {
|
||
deltaEl.textContent = (delta >= 0 ? '+' : '') + delta;
|
||
deltaEl.className = 'elo-delta-val ' + (delta >= 0 ? 'positive' : 'negative');
|
||
}
|
||
}
|
||
}
|
||
|
||
const histEl = document.getElementById('result-round-history');
|
||
if (histEl && roundHistory?.length) {
|
||
histEl.innerHTML = roundHistory.map((r, i) => {
|
||
const iMine = r.winner === state.user.user;
|
||
const stratName = r.stratagem || '—';
|
||
const strat = state.stratagems.find(s => s.name === stratName);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<div class="round-row ${iMine ? 'won' : 'lost'}">
|
||
<span class="round-num">R${r.round || (i + 1)}</span>
|
||
${iconHtml}
|
||
<span class="round-strat">${esc(stratName)}</span>
|
||
<span class="round-result">${iMine ? '✓ Won' : '✗ Lost'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} else if (histEl) {
|
||
histEl.innerHTML = '';
|
||
}
|
||
|
||
document.getElementById('modal-match-result').classList.remove('hidden');
|
||
}
|
||
|
||
function closeMatchResultModal() {
|
||
document.getElementById('modal-match-result').classList.add('hidden');
|
||
}
|
||
|
||
function leaveMatch() {
|
||
wsSend('leave-room');
|
||
closeMatchResultModal();
|
||
showView('lobby');
|
||
}
|
||
|
||
document.getElementById('btn-result-lobby')?.addEventListener('click', () => {
|
||
closeMatchResultModal();
|
||
showView('lobby');
|
||
});
|
||
document.getElementById('btn-result-rematch')?.addEventListener('click', () => {
|
||
closeMatchResultModal();
|
||
const opp = state.match.opponent;
|
||
showView('lobby');
|
||
if (opp) sendChallenge(opp);
|
||
});
|
||
|
||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||
async function loadLeaderboard() {
|
||
const tab = state.leaderboard.activeTab;
|
||
const tbody = document.getElementById('leaderboard-table-body');
|
||
const thead = document.getElementById('leaderboard-thead');
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
|
||
|
||
try {
|
||
if (tab === 'practice') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Rank</th><th>Total Score</th><th>Sessions</th><th>Match W/Total</th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No scores yet. Start practicing!</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map((r, i) => {
|
||
const rank = eloRankFor(r.elo || 1000);
|
||
return `<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><span class="badge badge-rank">${rank.icon} ${rank.label}</span></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('');
|
||
}
|
||
} else if (tab === 'elo') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>ELO</th><th>Rank</th><th colspan="2">Matches W/Total</th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard/elo');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No ELO data yet. Play some 1v1 matches!</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map((r, i) => {
|
||
const rank = eloRankFor(r.elo);
|
||
return `<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);color:var(--accent)">${r.elo}</td>
|
||
<td><span class="badge badge-rank">${rank.icon} ${rank.label}</span></td>
|
||
<td colspan="2" style="font-family:var(--font-mono)">${r.wins || 0}/${r.matches || 0}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
} else if (tab === 'speedrun') {
|
||
if (thead) thead.innerHTML = '<tr><th>#</th><th>Helldiver</th><th>Total Time</th><th colspan="3"></th></tr>';
|
||
const rows = await api('GET', '/scores/leaderboard/speedrun');
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No speedrun data yet. Try Speed Run mode!</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);color:var(--accent)">${(r.totalTime / 1000).toFixed(2)}s</td>
|
||
<td colspan="3"></td>
|
||
</tr>`
|
||
).join('');
|
||
}
|
||
}
|
||
} catch {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading leaderboard</td></tr>';
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.leaderboard.activeTab = btn.dataset.tab;
|
||
loadLeaderboard();
|
||
});
|
||
});
|
||
|
||
// ── History ───────────────────────────────────────────────────────────────────
|
||
async function loadHistory() {
|
||
const h = state.history;
|
||
const tbody = document.getElementById('history-table-body');
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
|
||
|
||
const mode = document.getElementById('history-filter-mode')?.value || '';
|
||
const cat = document.getElementById('history-filter-cat')?.value || '';
|
||
const limit = 10;
|
||
|
||
try {
|
||
let url = `/history?page=${h.page}&limit=${limit}`;
|
||
if (mode) url += '&mode=' + encodeURIComponent(mode);
|
||
if (cat) url += '&cat=' + encodeURIComponent(cat);
|
||
|
||
const data = await api('GET', url);
|
||
h.total = data.total || 0;
|
||
|
||
if (!data.rows?.length) {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">No sessions yet</td></tr>';
|
||
} else {
|
||
if (tbody) {
|
||
tbody.innerHTML = data.rows.map(r => {
|
||
const strat = state.stratagems.find(s => s.name === r.stratagem);
|
||
const date = new Date(r.created_at || Date.now()).toLocaleDateString('de-DE');
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<tr>
|
||
<td>${iconHtml}${esc(r.stratagem)}</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(r.category || '—')}</td>
|
||
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
|
||
<td>${r.score}</td>
|
||
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${date}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
renderHistoryChart(data.rows);
|
||
}
|
||
|
||
renderHistoryPagination(limit);
|
||
loadStratagemStats();
|
||
} catch {
|
||
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading history</td></tr>';
|
||
}
|
||
}
|
||
|
||
function renderHistoryChart(sessions) {
|
||
const svg = document.getElementById('history-chart-svg');
|
||
if (!svg || sessions.length < 2) {
|
||
if (svg) svg.innerHTML = '<text x="50%" y="50%" text-anchor="middle" fill="var(--text-muted)" font-size="13">Not enough data</text>';
|
||
return;
|
||
}
|
||
const W = 800, H = 160, PAD = 20;
|
||
const scores = sessions.map(s => s.score || 0);
|
||
const maxScore = Math.max(...scores, 1);
|
||
const minScore = Math.min(...scores);
|
||
const range = maxScore - minScore || 1;
|
||
const points = scores.map((s, i) => {
|
||
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
|
||
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
|
||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||
svg.innerHTML = `<polyline fill="none" stroke="var(--accent)" stroke-width="2"
|
||
stroke-linejoin="round" stroke-linecap="round" points="${points}"/>
|
||
${scores.map((s, i) => {
|
||
const x = PAD + (i / (scores.length - 1)) * (W - PAD * 2);
|
||
const y = H - PAD - ((s - minScore) / range) * (H - PAD * 2);
|
||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent)" opacity="0.7"/>`;
|
||
}).join('')}`;
|
||
}
|
||
|
||
function renderHistoryPagination(limit) {
|
||
const el = document.getElementById('history-pagination');
|
||
if (!el) return;
|
||
const total = state.history.total;
|
||
const page = state.history.page;
|
||
const pages = Math.ceil(total / limit);
|
||
if (pages <= 1) { el.innerHTML = ''; return; }
|
||
el.innerHTML = `
|
||
<button class="page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">← Prev</button>
|
||
<span class="page-info">Page ${page} / ${pages}</span>
|
||
<button class="page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">Next →</button>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('history-pagination')?.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-page]');
|
||
if (!btn) return;
|
||
state.history.page = Number(btn.dataset.page);
|
||
loadHistory();
|
||
});
|
||
|
||
document.getElementById('history-filter-mode')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||
document.getElementById('history-filter-cat')?.addEventListener('change', () => { state.history.page = 1; loadHistory(); });
|
||
document.getElementById('admin-user-search')?.addEventListener('input', () => {
|
||
renderAdminUsers(state.adminUsers || []);
|
||
});
|
||
|
||
async function loadStratagemStats() {
|
||
try {
|
||
const rows = await api('GET', '/stats/stratagems');
|
||
const tbody = document.getElementById('best-per-stratagem-body');
|
||
if (!tbody) return;
|
||
if (!rows?.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No data yet</td></tr>';
|
||
} else {
|
||
tbody.innerHTML = rows.map(r => {
|
||
const strat = state.stratagems.find(s => s.name === r.stratagem);
|
||
const iconHtml = strat?.icon ? `<img class="stratagem-icon-sm" src="${esc(strat.icon)}" alt="">` : '';
|
||
return `<tr>
|
||
<td>${iconHtml}</td>
|
||
<td>${esc(r.stratagem)}</td>
|
||
<td style="font-size:0.8rem;color:var(--text-muted)">${esc(strat?.category || '—')}</td>
|
||
<td style="color:var(--accent)">${r.best_time ? (r.best_time / 1000).toFixed(2) + 's' : '—'}</td>
|
||
<td>${r.attempts || 0}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||
async function loadAdmin() {
|
||
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
|
||
try {
|
||
const [users, overview, activity] = await Promise.all([
|
||
api('GET', '/users'),
|
||
api('GET', '/admin/overview'),
|
||
api('GET', '/admin/activity'),
|
||
]);
|
||
state.adminUsers = users;
|
||
renderAdminOverview(overview);
|
||
renderAdminUsers(users);
|
||
renderAdminActivity(activity);
|
||
} catch {
|
||
document.getElementById('admin-users').innerHTML = '<span class="muted">Error loading users</span>';
|
||
}
|
||
}
|
||
|
||
function renderAdminOverview(data = {}) {
|
||
setText('admin-total-users', String(data.totals?.users ?? 0));
|
||
setText('admin-total-admins', String(data.totals?.admins ?? 0));
|
||
setText('admin-temp-passwords', String(data.totals?.tempPasswords ?? 0));
|
||
setText('admin-practice-sessions', String(data.activity?.practiceSessions ?? 0));
|
||
|
||
const topUser = data.topUser?.username || 'No data yet';
|
||
const topMeta = data.topUser
|
||
? `Score ${Number(data.topUser.totalScore || 0).toLocaleString()} across ${data.topUser.sessions || 0} sessions`
|
||
: 'Waiting for enough runs to identify a standout Helldiver.';
|
||
|
||
setText('admin-top-user', topUser);
|
||
setText('admin-top-user-meta', topMeta);
|
||
}
|
||
|
||
function renderAdminUsers(users) {
|
||
const el = document.getElementById('admin-users');
|
||
const search = document.getElementById('admin-user-search')?.value.trim().toLowerCase() || '';
|
||
const filtered = users.filter((u) => {
|
||
if (!search) return true;
|
||
return [
|
||
u.username,
|
||
u.role,
|
||
String(u.elo ?? ''),
|
||
String(u.sessions ?? ''),
|
||
].join(' ').toLowerCase().includes(search);
|
||
});
|
||
|
||
if (!filtered.length) {
|
||
el.innerHTML = '<div class="admin-empty">No matching users found.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = filtered.map((u) => {
|
||
const isSelf = u.username === state.user.user;
|
||
const nextRole = u.role === 'admin' ? 'user' : 'admin';
|
||
const lastPlayed = u.lastPlayed ? new Date(u.lastPlayed).toLocaleString() : 'No activity yet';
|
||
return `<div class="admin-user-row">
|
||
<div class="admin-user-main">
|
||
<div class="admin-user-name-row">
|
||
<span class="user-name">${esc(u.username)}</span>
|
||
<span class="badge badge-${u.role}">${u.role}</span>
|
||
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
|
||
${isSelf ? '<span class="badge">you</span>' : ''}
|
||
</div>
|
||
<div class="admin-user-meta">
|
||
<span>ELO ${Number(u.elo ?? 1000)}</span>
|
||
<span>${Number(u.sessions ?? 0)} sessions</span>
|
||
<span>${esc(lastPlayed)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="admin-user-actions">
|
||
<button class="btn btn-muted btn-sm" data-action="reset-password" data-user="${esc(u.username)}">Reset password</button>
|
||
${isSelf ? '' : `<button class="btn btn-sm" data-action="toggle-role" data-user="${esc(u.username)}" data-role="${nextRole}">${u.role === 'admin' ? 'Make user' : 'Make admin'}</button>`}
|
||
${isSelf ? '' : `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderAdminActivity(data = {}) {
|
||
const practiceEl = document.getElementById('admin-recent-practice');
|
||
const matchesEl = document.getElementById('admin-recent-matches');
|
||
|
||
const practiceRows = data.practice || [];
|
||
const matchRows = data.matches || [];
|
||
|
||
practiceEl.innerHTML = practiceRows.length
|
||
? practiceRows.map((row) => `
|
||
<div class="admin-activity-item">
|
||
<div class="admin-activity-head">
|
||
<strong>${esc(row.username)}</strong>
|
||
<span>${esc(row.mode || 'practice')}</span>
|
||
</div>
|
||
<div class="admin-activity-body">${esc(row.stratagem || 'Unknown stratagem')}</div>
|
||
<div class="admin-activity-meta">
|
||
<span>${Number(row.score || 0)} pts</span>
|
||
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
|
||
</div>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="admin-empty">No recent practice activity.</div>';
|
||
|
||
matchesEl.innerHTML = matchRows.length
|
||
? matchRows.map((row) => `
|
||
<div class="admin-activity-item">
|
||
<div class="admin-activity-head">
|
||
<strong>${esc(row.winner || 'Pending')}</strong>
|
||
<span>${esc(row.winner || 'Pending')} vs ${esc(row.loser || 'Unknown')}</span>
|
||
</div>
|
||
<div class="admin-activity-body">Scoreline: ${row.winner_rounds ?? 0} : ${row.loser_rounds ?? 0}</div>
|
||
<div class="admin-activity-meta">
|
||
<span>${row.created_at ? esc(new Date(row.created_at).toLocaleString()) : '—'}</span>
|
||
</div>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="admin-empty">No recent match activity.</div>';
|
||
}
|
||
|
||
async function resetUserPassword(username) {
|
||
if (!confirm(`Reset password for "${username}" and require a password change on next login?`)) return;
|
||
try {
|
||
const result = await api('POST', `/users/${encodeURIComponent(username)}/reset-password`);
|
||
const pwEl = document.getElementById('new-pw-display');
|
||
pwEl.textContent = `Temp password for ${username}: ${result.tempPassword}`;
|
||
pwEl.classList.remove('hidden');
|
||
showToast(`Password reset for ${username}`);
|
||
loadAdmin();
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function updateUserRole(username, role) {
|
||
const label = role === 'admin' ? 'promote' : 'demote';
|
||
if (!confirm(`Really ${label} "${username}"?`)) return;
|
||
try {
|
||
await api('PATCH', `/users/${encodeURIComponent(username)}`, { role });
|
||
showToast(`Role updated for ${username}`);
|
||
loadAdmin();
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function createUser() {
|
||
const username = document.getElementById('new-username').value.trim();
|
||
const role = document.getElementById('new-role').value;
|
||
const errEl = document.getElementById('admin-error');
|
||
const pwEl = document.getElementById('new-pw-display');
|
||
errEl.classList.add('hidden');
|
||
pwEl.classList.add('hidden');
|
||
if (!username) return;
|
||
try {
|
||
const result = await api('POST', '/users', { username, role });
|
||
pwEl.textContent = 'Temp password for ' + esc(username) + ': ' + esc(result.tempPassword);
|
||
pwEl.classList.remove('hidden');
|
||
document.getElementById('new-username').value = '';
|
||
loadAdmin();
|
||
} catch (err) {
|
||
errEl.textContent = err.message;
|
||
errEl.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function deleteUser(username) {
|
||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||
try {
|
||
await api('DELETE', '/users/' + encodeURIComponent(username));
|
||
loadAdmin();
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ── Event delegation ──────────────────────────────────────────────────────────
|
||
document.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.dataset.action;
|
||
const user = btn.dataset.user;
|
||
const cat = btn.dataset.cat;
|
||
|
||
if (action === 'challenge' && user) sendChallenge(user);
|
||
if (action === 'accept' && user) acceptChallenge(user);
|
||
if (action === 'decline' && user) declineChallenge(user);
|
||
if (action === 'reset-password' && user) resetUserPassword(user);
|
||
if (action === 'toggle-role' && user) updateUserRole(user, btn.dataset.role);
|
||
if (action === 'delete-user' && user) deleteUser(user);
|
||
if (action === 'toggle-cat' && cat) toggleCategory(cat);
|
||
|
||
// Settings options
|
||
const settingBtn = e.target.closest('[data-setting]');
|
||
if (settingBtn) {
|
||
const setting = settingBtn.dataset.setting;
|
||
const value = settingBtn.dataset.value;
|
||
if (setting === 'timer') state.settings.timerDuration = Number(value);
|
||
else if (setting === 'difficulty') state.settings.difficulty = value;
|
||
saveSettings();
|
||
applySettingsToUI();
|
||
updateModeLabel();
|
||
}
|
||
});
|
||
|
||
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeSettingsModal();
|
||
closeChallengeModal();
|
||
closeMatchResultModal();
|
||
if (document.getElementById('modal-session-summary')?.classList.contains('hidden') === false) {
|
||
closeSessionSummary();
|
||
}
|
||
if (state.currentView === 'practice' && state.practice.active) {
|
||
stopPracticeUI();
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && state.currentView === 'practice' && !state.practice.active) {
|
||
startPractice();
|
||
return;
|
||
}
|
||
|
||
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();
|
||
dpadInput(dir);
|
||
}
|
||
});
|
||
|
||
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');
|
||
if (container.children.length >= 3) container.firstChild?.remove();
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast';
|
||
toast.textContent = msg;
|
||
container.appendChild(toast);
|
||
requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show')));
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||
}, 3200);
|
||
}
|
||
|
||
function showScorePopup(text) {
|
||
const el = document.getElementById('score-popup');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.remove('show', 'hidden');
|
||
requestAnimationFrame(() => {
|
||
el.classList.add('show');
|
||
el.addEventListener('animationend', () => {
|
||
el.classList.remove('show');
|
||
}, { once: true });
|
||
});
|
||
}
|
||
|
||
// ── Static button bindings ───────────────────────────────────────────────────
|
||
document.getElementById('btn-logout') ?.addEventListener('click', logout);
|
||
document.getElementById('btn-daily-challenge') ?.addEventListener('click', startDailyChallenge);
|
||
document.getElementById('btn-start-practice') ?.addEventListener('click', startPractice);
|
||
document.getElementById('btn-stop-practice') ?.addEventListener('click', stopPracticeUI);
|
||
document.getElementById('match-ready-btn') ?.addEventListener('click', setReady);
|
||
document.getElementById('btn-leave-match') ?.addEventListener('click', leaveMatch);
|
||
document.getElementById('btn-create-user') ?.addEventListener('click', createUser);
|
||
|
||
document.getElementById('practice-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
document.getElementById('match-dpad')?.addEventListener('click', (e) => {
|
||
const dir = e.target.closest('[data-dir]')?.dataset.dir;
|
||
if (dir) dpadInput(dir);
|
||
});
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', checkAuth);
|