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
2026-04-03 11:59:24 +02:00
sessionStats : { completed : 0 , missed : 0 , bestTime : Infinity , stratagems : { } , mistakes : { } , maxStreak : 0 } ,
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-04-03 11:59:24 +02:00
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 ) ;
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-04-03 11:59:24 +02:00
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' ) ) ;
2026-03-30 13:32:55 +02:00
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 ;
2026-04-03 11:59:24 +02:00
state . match . roundActive = false ;
2026-03-30 13:32:55 +02:00
renderMatchRound ( ) ;
2026-04-03 11:59:24 +02:00
beginMatchRound ( ) ;
2026-03-30 13:32:55 +02:00
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-04-03 11:59:24 +02:00
const onlineOthers = ( online || [ ] ) . filter ( u => u . name !== state . user ? . user ) ;
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 ) ;
2026-04-03 11:59:24 +02:00
setText ( 'dash-online-count' , String ( onlineOthers . length ) ) ;
2026-03-30 13:32:55 +02:00
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' ) ;
2026-04-03 11:59:24 +02:00
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. ` ) ;
2026-03-30 13:32:55 +02:00
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-04-03 11:59:24 +02:00
} 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.' ) ;
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-04-03 11:59:24 +02:00
document . body . classList . remove ( 'in-practice-session' ) ;
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 ( ) {
2026-04-03 11:59:24 +02:00
state . practice . sessionStats = { completed : 0 , missed : 0 , bestTime : Infinity , stratagems : { } , mistakes : { } , maxStreak : 0 } ;
2026-03-31 08:48:56 +02:00
}
2026-04-03 11:59:24 +02:00
async 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-04-03 11:59:24 +02:00
document . body . classList . add ( 'in-practice-session' ) ;
2026-03-30 13:32:55 +02:00
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-04-03 11:59:24 +02:00
focusGameplayArea ( 'practice-active' ) ;
showGameplayFeedback ( 'practice-feedback' , 'Stand by. Deployment starting.' , 'info' , 1500 ) ;
await runGameplayCountdown ( 'Deployment' ) ;
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-04-03 11:59:24 +02:00
trackPracticeMistake ( ) ;
2026-03-30 13:32:55 +02:00
updateStreakDisplay ( ) ;
2026-03-31 08:48:56 +02:00
shakeIcon ( ) ;
2026-04-03 11:59:24 +02:00
showGameplayFeedback ( 'practice-feedback' , 'Timer expired. Next stratagem.' , 'danger' , 1000 ) ;
2026-03-31 08:48:56 +02:00
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 = '' ;
}
2026-04-03 11:59:24 +02:00
showGameplayFeedback ( 'practice-feedback' , ` ${ s . name } ready. Execute the sequence. ` , 'info' , 900 ) ;
2026-03-31 08:48:56 +02:00
}
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 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' ;
2026-04-03 11:59:24 +02:00
return ` <div class=" ${ cls } "> ${ renderDirGlyph ( dir ) } </div> ` ;
2026-03-30 13:32:55 +02:00
} ) . join ( '' ) ;
}
2026-04-03 11:59:24 +02:00
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> ` ;
}
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 ( '' ) ;
}
2026-04-03 11:59:24 +02:00
function trackPracticeMistake ( ) {
const current = state . practice . current ;
if ( ! current ) return ;
const mistakes = state . practice . sessionStats . mistakes ;
mistakes [ current . name ] = ( mistakes [ current . name ] || 0 ) + 1 ;
}
2026-03-31 08:48:56 +02:00
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-04-03 11:59:24 +02:00
p . sessionStats . maxStreak = Math . max ( p . sessionStats . maxStreak , 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-04-03 11:59:24 +02:00
showGameplayFeedback ( 'practice-feedback' , ` ${ p . streak >= 5 ? 'Perfect chain' : 'Confirmed' } + ${ pts } ` , 'success' , 1100 ) ;
2026-03-31 08:48:56 +02:00
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 ( ) ;
2026-04-03 11:59:24 +02:00
trackPracticeMistake ( ) ;
showGameplayFeedback ( 'practice-feedback' , 'Wrong input. Sequence reset.' , 'danger' , 1000 ) ;
2026-03-31 08:48:56 +02:00
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 } ,
2026-04-03 11:59:24 +02:00
{ label : 'Streak Max' , val : s . maxStreak } ,
2026-03-31 08:48:56 +02:00
{ 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 ( '' ) ;
}
2026-04-03 11:59:24 +02:00
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 ( '' ) ;
}
2026-03-31 08:48:56 +02:00
// 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' ) ;
2026-04-03 11:59:24 +02:00
document . body . classList . remove ( 'in-match-round' ) ;
showGameplayFeedback ( 'match-feedback' , 'Waiting for both divers to ready up.' , 'info' , 1200 ) ;
updateMatchProgressUI ( ) ;
2026-03-31 08:48:56 +02:00
// 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-04-03 11:59:24 +02:00
document . body . classList . add ( 'in-match-round' ) ;
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-04-03 11:59:24 +02:00
updateMatchProgressUI ( ) ;
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-04-03 11:59:24 +02:00
focusGameplayArea ( 'match-round-area' ) ;
2026-03-30 13:32:55 +02:00
}
function updateMyArrows ( correct ) {
renderArrows ( 'match-me-sequence' , state . match . current . sequence , state . match . myProgress ) ;
2026-04-03 11:59:24 +02:00
updateMatchProgressUI ( ) ;
2026-03-30 13:32:55 +02:00
if ( ! correct ) {
const el = document . getElementById ( 'match-me-sequence' ) ;
el ? . classList . add ( 'flash-wrong-seq' ) ;
setTimeout ( ( ) => el ? . classList . remove ( 'flash-wrong-seq' ) , 350 ) ;
2026-04-03 11:59:24 +02:00
showGameplayFeedback ( 'match-feedback' , 'Input rejected. Recover now.' , 'danger' , 900 ) ;
} else {
showGameplayFeedback ( 'match-feedback' , 'Confirmed. Keep pushing.' , 'success' , 550 ) ;
2026-03-30 13:32:55 +02:00
}
}
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 ) ;
2026-04-03 11:59:24 +02:00
updateMatchProgressUI ( ) ;
2026-03-30 13:32:55 +02:00
}
function handleMatchInput ( dir ) {
if ( ! state . match . roundActive ) return ;
wsSend ( 'input-arrow' , { direction : dir } ) ;
}
2026-04-03 11:59:24 +02:00
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 ) ;
}
2026-03-30 13:32:55 +02:00
function renderRoundResult ( winner ) {
const won = winner === state . user . user ;
setText ( 'match-status' , won ? '✓ ROUND WON' : '✗ ROUND LOST' ) ;
renderMatchScores ( ) ;
2026-04-03 11:59:24 +02:00
showGameplayFeedback ( 'match-feedback' , won ? 'Round secured.' : 'Opponent took the round.' , won ? 'success' : 'danger' , 1200 ) ;
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' ) ;
2026-04-03 11:59:24 +02:00
document . body . classList . remove ( 'in-match-round' ) ;
2026-03-30 13:32:55 +02:00
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 ( ) ; } ) ;
2026-04-03 11:59:24 +02:00
document . getElementById ( 'admin-user-search' ) ? . addEventListener ( 'input' , ( ) => {
renderAdminUsers ( state . adminUsers || [ ] ) ;
} ) ;
2026-03-31 08:48:56 +02:00
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 {
2026-04-03 11:59:24 +02:00
const [ users , overview , activity ] = await Promise . all ( [
api ( 'GET' , '/users' ) ,
api ( 'GET' , '/admin/overview' ) ,
api ( 'GET' , '/admin/activity' ) ,
] ) ;
state . adminUsers = users ;
renderAdminOverview ( overview ) ;
2026-03-30 13:32:55 +02:00
renderAdminUsers ( users ) ;
2026-04-03 11:59:24 +02:00
renderAdminActivity ( activity ) ;
2026-03-30 13:32:55 +02:00
} catch {
document . getElementById ( 'admin-users' ) . innerHTML = '<span class="muted">Error loading users</span>' ;
}
}
2026-04-03 11:59:24 +02:00
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 ) ;
}
2026-03-30 13:32:55 +02:00
function renderAdminUsers ( users ) {
const el = document . getElementById ( 'admin-users' ) ;
2026-04-03 11:59:24 +02:00
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 ) ;
}
2026-03-30 13:32:55 +02:00
}
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 ) ;
2026-04-03 11:59:24 +02:00
if ( action === 'reset-password' && user ) resetUserPassword ( user ) ;
if ( action === 'toggle-role' && user ) updateUserRole ( user , btn . dataset . role ) ;
2026-03-30 14:07:36 +02:00
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 ) ;