feat: polish gameplay and admin flow

This commit is contained in:
Jeremy Brandenburger
2026-04-03 11:59:24 +02:00
parent c8003cc77c
commit f5f57c3e4d
72 changed files with 2286 additions and 502 deletions
+268 -18
View File
@@ -56,7 +56,7 @@ const state = {
speedrunElapsed: 0,
// Session stats
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} },
sessionStats: { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 },
},
lobby: {
@@ -144,6 +144,46 @@ function showView(name) {
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 ──────────────────────────────────────────────────────────────────────
@@ -239,6 +279,9 @@ document.getElementById('change-password-form').addEventListener('submit', async
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() {
@@ -317,8 +360,9 @@ function handleWSMessage({ type, payload }) {
state.match.current = payload.stratagem;
state.match.myProgress = 0;
state.match.oppProgress = 0;
state.match.roundActive = true;
state.match.roundActive = false;
renderMatchRound();
beginMatchRound();
break;
case 'input-result':
@@ -365,6 +409,7 @@ async function loadDashboard() {
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);
@@ -375,14 +420,22 @@ function renderDashboard({ stats, rank, elo, eloRank: rankLabel, online, recent,
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');
@@ -469,6 +522,7 @@ function showPracticeIdle() {
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;
}
@@ -479,10 +533,10 @@ function getPool() {
}
function resetSessionStats() {
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {} };
state.practice.sessionStats = { completed: 0, missed: 0, bestTime: Infinity, stratagems: {}, mistakes: {}, maxStreak: 0 };
}
function startPractice() {
async function startPractice() {
const pool = getPool();
if (!pool.length) { showToast('No stratagems match the selected filters'); return; }
@@ -496,6 +550,7 @@ function startPractice() {
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]);
@@ -526,6 +581,9 @@ function startPractice() {
setText('hud-timer-label', 'TIME');
}
focusGameplayArea('practice-active');
showGameplayFeedback('practice-feedback', 'Stand by. Deployment starting.', 'info', 1500);
await runGameplayCountdown('Deployment');
nextStratagem();
}
@@ -642,8 +700,10 @@ function startPracticeTimer() {
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);
@@ -687,6 +747,7 @@ function renderPracticeStratagem() {
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) {
@@ -698,17 +759,25 @@ function setIcon(imgEl, src) {
}
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>`;
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');
@@ -754,6 +823,13 @@ function updateLivesDisplay() {
).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);
@@ -801,6 +877,7 @@ function handlePracticeInput(dir) {
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;
@@ -825,6 +902,7 @@ function handlePracticeInput(dir) {
// Score popup
showScorePopup('+' + pts);
showGameplayFeedback('practice-feedback', `${p.streak >= 5 ? 'Perfect chain' : 'Confirmed'} +${pts}`, 'success', 1100);
api('POST', '/scores/practice', {
stratagem: p.current.name,
@@ -852,6 +930,8 @@ function handlePracticeInput(dir) {
cur?.classList.add('flash-wrong');
p.progress = 0;
shakeIcon();
trackPracticeMistake();
showGameplayFeedback('practice-feedback', 'Wrong input. Sequence reset.', 'danger', 1000);
if (mode === 'endless') {
p.lives--;
@@ -931,7 +1011,7 @@ function openSessionSummary() {
grid.innerHTML = [
{ label: 'Score', val: p.score },
{ label: 'Completed', val: s.completed },
{ label: 'Streak Max',val: p.streak },
{ label: 'Streak Max',val: s.maxStreak },
{ label: 'Accuracy', val: accuracy },
{ label: 'Best Time', val: bestTimeStr },
{ label: 'Mode', val: p.mode },
@@ -941,6 +1021,20 @@ function openSessionSummary() {
</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) {
@@ -1087,6 +1181,9 @@ function renderMatchWaiting() {
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';
@@ -1111,27 +1208,35 @@ function renderMatchRound() {
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) {
@@ -1139,14 +1244,36 @@ function handleMatchInput(dir) {
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;
@@ -1388,6 +1515,9 @@ document.getElementById('history-pagination')?.addEventListener('click', (e) =>
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 {
@@ -1416,25 +1546,143 @@ async function loadStratagemStats() {
async function loadAdmin() {
if (state.user?.role !== 'admin') { showView('dashboard'); return; }
try {
const users = await api('GET', '/users');
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');
el.innerHTML = users.map(u =>
`<div class="admin-user-row">
<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>' : ''}
${u.username !== state.user.user
? `<button class="btn btn-sm btn-danger" data-action="delete-user" data-user="${esc(u.username)}">Delete</button>`
: ''}
</div>`
).join('');
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() {
@@ -1478,6 +1726,8 @@ document.addEventListener('click', (e) => {
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);