2026-03-30 13:32:55 +02:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const state = {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
user: null,
|
2026-03-30 13:32:55 +02:00
|
|
|
|
currentView: 'login',
|
|
|
|
|
|
stratagems: [],
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
settings: {
|
|
|
|
|
|
timerDuration: 30, // 15 | 30 | 45
|
|
|
|
|
|
difficulty: 'normal', // 'easy' | 'normal' | 'hard'
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
practice: {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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: {} },
|
2026-03-30 13:32:55 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
lobby: {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
online: [],
|
|
|
|
|
|
incoming: [],
|
|
|
|
|
|
pendingChallenge: null,
|
2026-03-30 13:32:55 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
match: {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
roomId: null,
|
|
|
|
|
|
opponent: null,
|
|
|
|
|
|
matchScores: {},
|
|
|
|
|
|
current: null,
|
|
|
|
|
|
myProgress: 0,
|
|
|
|
|
|
oppProgress: 0,
|
|
|
|
|
|
roundActive: false,
|
|
|
|
|
|
roundHistory: [],
|
2026-03-30 13:32:55 +02:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
leaderboard: { activeTab: 'practice' },
|
|
|
|
|
|
|
|
|
|
|
|
history: { page: 1, total: 0 },
|
|
|
|
|
|
|
|
|
|
|
|
ws: null,
|
2026-03-30 13:32:55 +02:00
|
|
|
|
wsReconnectTimer: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── 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 ───────────────────────────────────────────────────────────────────────
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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) {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
document.querySelectorAll('.view').forEach(v => {
|
|
|
|
|
|
v.classList.add('hidden');
|
|
|
|
|
|
v.classList.remove('view-fade-in');
|
|
|
|
|
|
});
|
2026-03-30 13:32:55 +02:00
|
|
|
|
const el = document.getElementById('view-' + name);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (el) {
|
|
|
|
|
|
el.classList.remove('hidden');
|
|
|
|
|
|
requestAnimationFrame(() => el.classList.add('view-fade-in'));
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
state.currentView = name;
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.nav-btn').forEach(b => {
|
|
|
|
|
|
b.classList.toggle('active', b.dataset.view === name);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (name !== 'practice') stopPracticeTimer();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (name === 'dashboard') loadDashboard();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
if (name === 'leaderboard') loadLeaderboard();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (name === 'admin') loadAdmin();
|
|
|
|
|
|
if (name === 'practice') initPracticeView();
|
|
|
|
|
|
if (name === 'lobby') updateLobbyView();
|
|
|
|
|
|
if (name === 'history') loadHistory();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
2026-03-30 13:32:55 +02:00
|
|
|
|
async function checkAuth() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api('GET', '/me');
|
|
|
|
|
|
if (data.user) {
|
|
|
|
|
|
state.user = data;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (data.mustChange) showView('change-password');
|
|
|
|
|
|
else onLoggedIn();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
showView('login');
|
|
|
|
|
|
}
|
2026-03-31 08:48:56 +02:00
|
|
|
|
} catch { showView('login'); }
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:39:28 +02:00
|
|
|
|
async function onLoggedIn() {
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
document.getElementById('drawer-admin')?.classList.toggle('hidden', state.user.role !== 'admin');
|
2026-03-30 13:39:28 +02:00
|
|
|
|
state.stratagems = await api('GET', '/stratagems').catch(() => []);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
populateCategoryFilter();
|
|
|
|
|
|
loadSettings();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── 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(); });
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function connectWS() {
|
|
|
|
|
|
if (state.ws) return;
|
|
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
|
state.ws = new WebSocket(proto + '//' + location.host);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
state.ws.onopen = () => clearTimeout(state.wsReconnectTimer);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
state.ws.onmessage = (e) => { try { handleWSMessage(JSON.parse(e.data)); } catch {} };
|
|
|
|
|
|
state.ws.onerror = () => state.ws.close();
|
|
|
|
|
|
state.ws.onclose = () => {
|
|
|
|
|
|
state.ws = null;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (state.user) state.wsReconnectTimer = setTimeout(connectWS, 3000);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
openChallengeModal(payload.from, payload.elo ?? '?');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
state.match.roundHistory = [];
|
|
|
|
|
|
closeChallengeModal();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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) {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
state.match.myProgress = payload.progress;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
updateMyArrows(payload.correct);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
state.match.oppProgress = payload.progress;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
updateOppArrows();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'round-complete':
|
|
|
|
|
|
state.match.roundActive = false;
|
|
|
|
|
|
state.match.matchScores = payload.matchScores;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
state.match.roundHistory.push({ stratagem: state.match.current?.name, winner: payload.winner });
|
2026-03-30 13:32:55 +02:00
|
|
|
|
renderRoundResult(payload.winner);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'match-end':
|
|
|
|
|
|
state.match.matchScores = payload.matchScores;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
openMatchResultModal({
|
|
|
|
|
|
winner: payload.winner,
|
|
|
|
|
|
eloChanges: payload.eloChanges,
|
|
|
|
|
|
roundHistory: payload.roundHistory || state.match.roundHistory,
|
|
|
|
|
|
});
|
2026-03-30 13:32:55 +02:00
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'opponent-left':
|
|
|
|
|
|
showToast('Opponent left the match.');
|
|
|
|
|
|
setTimeout(() => showView('lobby'), 1800);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async function loadDashboard() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
setText('dash-hero-name', state.user?.user || '—');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const data = await api('GET', '/dashboard');
|
|
|
|
|
|
renderDashboard(data);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
} catch { /* ignore */ }
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent, daily }) {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
const myRank = eloRankFor(elo || 1000);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
setText('dash-hero-name', state.user.user);
|
2026-03-31 09:05:33 +02:00
|
|
|
|
setText('dash-rank-label', rankLabel || myRank.label);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
setText('dash-elo', elo || 1000);
|
2026-03-31 09:05:33 +02:00
|
|
|
|
setText('dash-rank-icon', myRank.icon);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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) + '%' : '—';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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');
|
|
|
|
|
|
state.practice.dailyTarget = daily.stratagem.name;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
renderDailySequencePreview(daily.stratagem.sequence);
|
|
|
|
|
|
setIcon(document.getElementById('dash-daily-icon'), daily.stratagem.icon);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tbody = document.getElementById('dash-recent');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (!recent?.length) {
|
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="4" class="muted">No sessions yet</td></tr>';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
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>
|
2026-03-31 08:48:56 +02:00
|
|
|
|
<td><span class="badge">${esc(r.mode || 'timed')}</span></td>
|
|
|
|
|
|
<td>${r.score}</td>
|
|
|
|
|
|
<td>${(r.time_ms / 1000).toFixed(2)}s</td>
|
2026-03-31 09:05:33 +02:00
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateDashboardOnline(online);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
function updateDashboardOnline(online) {
|
|
|
|
|
|
const el = document.getElementById('dash-online');
|
|
|
|
|
|
if (!el) return;
|
2026-03-31 09:05:33 +02:00
|
|
|
|
const players = (online || []).filter(u => u.name !== state.user?.user);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (!players.length) {
|
2026-03-30 13:32:55 +02:00
|
|
|
|
el.innerHTML = '<span class="muted">No other Helldivers online</span>';
|
|
|
|
|
|
} else {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
el.innerHTML = players.map(u =>
|
|
|
|
|
|
`<div class="online-user">
|
2026-03-30 13:32:55 +02:00
|
|
|
|
<span class="online-dot"></span>
|
2026-03-31 09:05:33 +02:00
|
|
|
|
<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('');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── Practice ──────────────────────────────────────────────────────────────────
|
2026-03-30 13:32:55 +02:00
|
|
|
|
function initPracticeView() {
|
|
|
|
|
|
renderCategoryFilters();
|
|
|
|
|
|
if (!state.practice.active) showPracticeIdle();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
updateModeLabel();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCategoryFilters() {
|
|
|
|
|
|
const cats = [...new Set(state.stratagems.map(s => s.category))];
|
|
|
|
|
|
const el = document.getElementById('practice-categories');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (!el) return;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
el.innerHTML = cats.map(cat => {
|
|
|
|
|
|
const active = state.practice.selectedCats.size === 0 || state.practice.selectedCats.has(cat);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
return `<button class="cat-btn ${active ? 'active' : ''}" data-action="toggle-cat" data-cat="${esc(cat)}">${esc(cat)}</button>`;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleCategory(cat) {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (state.practice.selectedCats.has(cat)) state.practice.selectedCats.delete(cat);
|
|
|
|
|
|
else state.practice.selectedCats.add(cat);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
renderCategoryFilters();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showPracticeIdle() {
|
|
|
|
|
|
document.getElementById('practice-idle').classList.remove('hidden');
|
|
|
|
|
|
document.getElementById('practice-active').classList.add('hidden');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
state.practice.active = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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: {} };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
function startPractice() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
|
|
|
|
|
document.getElementById('practice-idle').classList.add('hidden');
|
|
|
|
|
|
document.getElementById('practice-active').classList.remove('hidden');
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
nextStratagem();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
function stopPracticeUI() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const p = state.practice;
|
|
|
|
|
|
if (p.active && (p.sessionStats.completed > 0 || p.sessionStats.missed > 0)) {
|
|
|
|
|
|
openSessionSummary();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopPracticeTimer();
|
|
|
|
|
|
showPracticeIdle();
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopPracticeTimer() {
|
|
|
|
|
|
clearInterval(state.practice.timerHandle);
|
|
|
|
|
|
state.practice.timerHandle = null;
|
|
|
|
|
|
state.practice.active = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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('');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function nextStratagem() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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 {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
// timed & endless: pick random from pool
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
|
|
|
|
|
renderPracticeStratagem();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
buildQueue();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startPracticeTimer() {
|
|
|
|
|
|
clearInterval(state.practice.timerHandle);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const total = state.practice.mode === 'drill' ? 60 : state.settings.timerDuration;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
state.practice.timerHandle = setInterval(() => {
|
|
|
|
|
|
state.practice.timeLeft--;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
updateTimerDisplay(total);
|
|
|
|
|
|
const showVignette = state.practice.timeLeft <= 5 && state.practice.timeLeft > 0;
|
|
|
|
|
|
document.getElementById('danger-vignette').classList.toggle('hidden', !showVignette);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
if (state.practice.timeLeft <= 0) {
|
|
|
|
|
|
clearInterval(state.practice.timerHandle);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
document.getElementById('danger-vignette').classList.add('hidden');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
state.practice.streak = 0;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
state.practice.sessionStats.missed++;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
updateStreakDisplay();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
shakeIcon();
|
|
|
|
|
|
setTimeout(nextStratagem, 500);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
function renderPracticeStratagem() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
updateScoreDisplay();
|
|
|
|
|
|
updateStreakDisplay();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
// 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 = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setIcon(imgEl, src) {
|
|
|
|
|
|
if (!imgEl || !src) return;
|
|
|
|
|
|
imgEl.src = src;
|
|
|
|
|
|
imgEl.alt = '';
|
|
|
|
|
|
imgEl.style.display = '';
|
|
|
|
|
|
imgEl.onerror = () => { imgEl.style.display = 'none'; };
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
function updateTimerDisplay(total) {
|
|
|
|
|
|
if (state.practice.mode === 'speedrun') return;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
const el = document.getElementById('practice-timer');
|
|
|
|
|
|
if (!el) return;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateScoreDisplay() { setText('practice-score', state.practice.score); }
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
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 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);
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
|
|
|
|
|
function handlePracticeInput(dir) {
|
|
|
|
|
|
const p = state.practice;
|
|
|
|
|
|
if (!p.active || !p.current) return;
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const mode = p.mode;
|
|
|
|
|
|
const seq = p.current.sequence;
|
|
|
|
|
|
const arrows = document.querySelectorAll('#practice-sequence .arrow-key');
|
|
|
|
|
|
const cur = arrows[p.progress];
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
|
|
|
|
|
if (dir === seq[p.progress]) {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
cur?.classList.add('flash-correct');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
p.progress++;
|
|
|
|
|
|
|
|
|
|
|
|
if (p.progress === seq.length) {
|
|
|
|
|
|
clearInterval(p.timerHandle);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
document.getElementById('danger-vignette').classList.add('hidden');
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
const elapsed = Date.now() - p.startTime;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
|
|
|
|
|
p.score += pts;
|
|
|
|
|
|
p.streak++;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
updateScoreDisplay();
|
|
|
|
|
|
updateStreakDisplay();
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// Visual: all arrows complete + icon flash
|
2026-03-30 13:32:55 +02:00
|
|
|
|
document.querySelectorAll('#practice-sequence .arrow-key').forEach(el => {
|
|
|
|
|
|
el.classList.remove('flash-correct');
|
|
|
|
|
|
el.classList.add('completed');
|
|
|
|
|
|
});
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const iconEl = document.getElementById('practice-icon');
|
|
|
|
|
|
iconEl?.classList.add('icon-complete');
|
|
|
|
|
|
setTimeout(() => iconEl?.classList.remove('icon-complete'), 300);
|
|
|
|
|
|
|
|
|
|
|
|
// Score popup
|
|
|
|
|
|
showScorePopup('+' + pts);
|
|
|
|
|
|
|
2026-03-31 09:05:33 +02:00
|
|
|
|
api('POST', '/scores/practice', {
|
|
|
|
|
|
stratagem: p.current.name,
|
|
|
|
|
|
category: p.current.category,
|
|
|
|
|
|
time_ms: elapsed,
|
|
|
|
|
|
score: pts,
|
|
|
|
|
|
mode: mode,
|
|
|
|
|
|
}).catch(() => {});
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (mode === 'drill') {
|
|
|
|
|
|
p.drillPool.shift();
|
|
|
|
|
|
p.drillCompleted++;
|
|
|
|
|
|
updateDrillProgress();
|
|
|
|
|
|
} else if (mode === 'speedrun') {
|
|
|
|
|
|
p.speedrunPool.shift();
|
|
|
|
|
|
}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
setTimeout(nextStratagem, 550);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
renderArrows('practice-sequence', seq, p.progress);
|
|
|
|
|
|
}
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// Wrong input
|
|
|
|
|
|
cur?.classList.add('flash-wrong');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
p.progress = 0;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
shakeIcon();
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── 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: p.streak },
|
|
|
|
|
|
{ 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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── Lobby ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function updateLobbyView() {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
const others = state.lobby.online.filter(u => u.name !== state.user?.user);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const el = document.getElementById('lobby-players');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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>`;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
2026-03-31 09:05:33 +02:00
|
|
|
|
el.innerHTML = others.map(u =>
|
|
|
|
|
|
`<div class="lobby-player">
|
2026-03-30 13:32:55 +02:00
|
|
|
|
<span class="online-dot"></span>
|
2026-03-31 09:05:33 +02:00
|
|
|
|
<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('');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const challEl = document.getElementById('lobby-challenges');
|
|
|
|
|
|
if (!challEl) return;
|
|
|
|
|
|
const inc = state.lobby.incoming;
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (!inc.length) {
|
|
|
|
|
|
challEl.innerHTML = '<p class="muted">No incoming challenges</p>';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
challEl.innerHTML = inc.map(from =>
|
|
|
|
|
|
`<div class="challenge-item">
|
2026-03-31 08:48:56 +02:00
|
|
|
|
<span style="flex:1"><strong>${esc(from)}</strong> challenges you!</span>
|
2026-03-30 14:07:36 +02:00
|
|
|
|
<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>
|
2026-03-30 13:32:55 +02:00
|
|
|
|
</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);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
closeChallengeModal();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
updateChallengeBadge();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function declineChallenge(from) {
|
|
|
|
|
|
wsSend('decline-challenge', { challengerId: from });
|
|
|
|
|
|
state.lobby.incoming = state.lobby.incoming.filter(u => u !== from);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
closeChallengeModal();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── 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');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const btn = document.getElementById('match-ready-btn');
|
|
|
|
|
|
btn.textContent = 'READY';
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.classList.remove('hidden');
|
|
|
|
|
|
// Hide match icon
|
|
|
|
|
|
const matchIcon = document.getElementById('match-icon');
|
|
|
|
|
|
if (matchIcon) matchIcon.style.display = 'none';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2026-03-30 14:07:36 +02:00
|
|
|
|
document.getElementById('match-ready-btn').classList.add('hidden');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
renderArrows('match-me-sequence', m.current.sequence, 0);
|
|
|
|
|
|
renderArrows('match-opp-sequence', m.current.sequence, 0);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
// 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';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
function updateOppArrows() {
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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();
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const matchIcon = document.getElementById('match-icon');
|
|
|
|
|
|
if (matchIcon) matchIcon.style.display = 'none';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
document.getElementById('match-round-area').classList.add('hidden');
|
|
|
|
|
|
const btn = document.getElementById('match-ready-btn');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
btn.textContent = 'Ready for next round';
|
|
|
|
|
|
btn.disabled = false;
|
2026-03-30 14:07:36 +02:00
|
|
|
|
btn.classList.remove('hidden');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
setText('match-category', '');
|
|
|
|
|
|
}, 1600);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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');
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function leaveMatch() {
|
|
|
|
|
|
wsSend('leave-room');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
closeMatchResultModal();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
showView('lobby');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
async function loadLeaderboard() {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const tab = state.leaderboard.activeTab;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
const tbody = document.getElementById('leaderboard-table-body');
|
2026-03-31 08:48:56 +02:00
|
|
|
|
const thead = document.getElementById('leaderboard-thead');
|
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="6" class="muted">Loading...</td></tr>';
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
try {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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>';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} else {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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);
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
renderHistoryPagination(limit);
|
|
|
|
|
|
loadStratagemStats();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
} catch {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">Error loading history</td></tr>';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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(); });
|
|
|
|
|
|
|
|
|
|
|
|
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 ─────────────────────────────────────────────────────────────────────
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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">
|
2026-03-31 08:48:56 +02:00
|
|
|
|
<span style="font-family:var(--font-mono);flex:1">${esc(u.username)}</span>
|
|
|
|
|
|
<span class="badge badge-${u.role}">${u.role}</span>
|
|
|
|
|
|
${u.mustChange ? '<span class="badge badge-warning">temp pw</span>' : ''}
|
2026-03-30 13:32:55 +02:00
|
|
|
|
${u.username !== state.user.user
|
2026-03-30 14:07:36 +02:00
|
|
|
|
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
|
2026-03-30 13:32:55 +02:00
|
|
|
|
: ''}
|
|
|
|
|
|
</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) {
|
2026-03-30 14:07:36 +02:00
|
|
|
|
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
2026-03-30 13:32:55 +02:00
|
|
|
|
try {
|
|
|
|
|
|
await api('DELETE', '/users/' + encodeURIComponent(username));
|
|
|
|
|
|
loadAdmin();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
showToast('Error: ' + err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── Event delegation ──────────────────────────────────────────────────────────
|
2026-03-30 14:07:36 +02:00
|
|
|
|
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 === 'delete-user' && user) deleteUser(user);
|
|
|
|
|
|
if (action === 'toggle-cat' && cat) toggleCategory(cat);
|
2026-03-31 08:48:56 +02:00
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-03-30 14:07:36 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
// ── Keyboard ──────────────────────────────────────────────────────────────────
|
2026-03-30 13:32:55 +02:00
|
|
|
|
document.addEventListener('keydown', (e) => {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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') {
|
2026-03-31 08:48:56 +02:00
|
|
|
|
e.preventDefault();
|
2026-03-30 13:32:55 +02:00
|
|
|
|
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');
|
2026-03-30 14:07:36 +02:00
|
|
|
|
if (container.children.length >= 3) container.firstChild?.remove();
|
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
|
toast.className = 'toast';
|
2026-03-30 13:32:55 +02:00
|
|
|
|
toast.textContent = msg;
|
|
|
|
|
|
container.appendChild(toast);
|
2026-03-30 14:07:36 +02:00
|
|
|
|
requestAnimationFrame(() => requestAnimationFrame(() => toast.classList.add('show')));
|
2026-03-30 13:32:55 +02:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
toast.classList.remove('show');
|
2026-03-30 14:07:36 +02:00
|
|
|
|
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
2026-03-30 13:32:55 +02:00
|
|
|
|
}, 3200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 08:48:56 +02:00
|
|
|
|
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 ───────────────────────────────────────────────────
|
2026-03-30 18:31:46 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 13:32:55 +02:00
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', checkAuth);
|