diff --git a/admin.html b/admin.html index e49f82e..e5552a8 100644 --- a/admin.html +++ b/admin.html @@ -919,7 +919,21 @@ Distance from home to get bonuses
- + +
+ + +

Session Settings

+
+
+ + + Minutes of inactivity before auto-logout +
+
+ + + Warning shown this many seconds before logout
@@ -2728,6 +2742,11 @@ document.getElementById('setting-homeHpMultiplier').value = settings.homeHpMultiplier || 3; document.getElementById('setting-homeRegenPercent').value = settings.homeRegenPercent || 5; document.getElementById('setting-homeBaseRadius').value = settings.homeBaseRadius || 20; + // Session settings (convert inactivity timeout from ms to minutes) + const inactivityMs = settings.inactivityTimeout || 600000; + document.getElementById('setting-inactivityTimeout').value = Math.round(inactivityMs / 60000); + const warningMs = settings.inactivityWarningTime || 60000; + document.getElementById('setting-inactivityWarningTime').value = Math.round(warningMs / 1000); } catch (e) { showToast('Failed to load settings: ' + e.message, 'error'); } @@ -2737,6 +2756,9 @@ // Convert interval from seconds to ms for storage const intervalSeconds = parseInt(document.getElementById('setting-monsterSpawnInterval').value) || 20; const hpIntervalSeconds = parseInt(document.getElementById('setting-hpRegenInterval').value) || 10; + // Convert inactivity timeout from minutes to ms, warning from seconds to ms + const inactivityMinutes = parseInt(document.getElementById('setting-inactivityTimeout').value) || 10; + const warningSeconds = parseInt(document.getElementById('setting-inactivityWarningTime').value) || 60; const newSettings = { monsterSpawnInterval: intervalSeconds * 1000, monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50, @@ -2750,7 +2772,9 @@ hpRegenPercent: parseFloat(document.getElementById('setting-hpRegenPercent').value) || 1, homeHpMultiplier: parseFloat(document.getElementById('setting-homeHpMultiplier').value) || 3, homeRegenPercent: parseFloat(document.getElementById('setting-homeRegenPercent').value) || 5, - homeBaseRadius: parseInt(document.getElementById('setting-homeBaseRadius').value) || 20 + homeBaseRadius: parseInt(document.getElementById('setting-homeBaseRadius').value) || 20, + inactivityTimeout: inactivityMinutes * 60000, + inactivityWarningTime: warningSeconds * 1000 }; try { diff --git a/artwork_todo.md b/artwork_todo.md new file mode 100644 index 0000000..f2c1cc9 --- /dev/null +++ b/artwork_todo.md @@ -0,0 +1,150 @@ +# HikeMap Artwork Todo + +Track all emoji replacements and custom artwork needed. All icons should follow the existing `50.png` / `100.png` sizing convention. + +--- + +## Combat Log / Battle Events + +| Emoji | Current Usage | Art File | Status | +|-------|---------------|----------|--------| +| ⚔️ | Player attack hit, generic combat | `icons/attack.png` | [ ] | +| ✨ | Multi-hit skill damage | `icons/multi_hit.png` | [ ] | +| 🌟 | Multi-target skill hit | `icons/aoe_hit.png` | [ ] | +| 🔥 | Monster skill / status effect damage | `icons/fire_attack.png` | [ ] | +| ❌ | Miss (player or monster) | `icons/miss.png` | [ ] | +| 💀 | Enemy defeated / Player death | `icons/skull.png` | [ ] | +| ☠️ | Poison tick damage | `icons/poison.png` | [ ] | +| 💚 | Heal skill used | `icons/heal.png` | [ ] | +| 🛡️ | Defense buff activated | `icons/shield_buff.png` | [ ] | +| ⚡ | Player turn / dodge buff / quick skills | `icons/lightning.png` | [ ] | + +--- + +## Stats & Character Sheet + +| Emoji | Current Usage | Art File | Status | +|-------|---------------|----------|--------| +| ❤️ | HP stat label | `icons/stat_hp.png` | [ ] | +| 💙 | MP stat label | `icons/stat_mp.png` | [ ] | +| ⚔️ | ATK stat label | `icons/stat_atk.png` | [ ] | +| 🛡️ | DEF stat label | `icons/stat_def.png` | [ ] | + +--- + +## Class Icons (for HUD, combat, character sheet) + +| Emoji | Class | Art Files | Status | +|-------|-------|-----------|--------| +| 🏃 | Trail Runner | `classes/trail_runner50.png`, `classes/trail_runner100.png` | [ ] | +| 💪 | Gym Bro | `classes/gym_bro50.png`, `classes/gym_bro100.png` | [ ] | +| 🧘 | Yoga Master | `classes/yoga_master50.png`, `classes/yoga_master100.png` | [ ] | +| 🏋️ | CrossFit Crusader | `classes/crossfit50.png`, `classes/crossfit100.png` | [ ] | + +--- + +## Race Icons (Character Creator) + +| Emoji | Race | Art File | Status | +|-------|------|----------|--------| +| 👤 | Human | `races/human.png` | [ ] | +| 🧝 | Elf | `races/elf.png` | [ ] | +| ⛏️ | Dwarf | `races/dwarf.png` | [ ] | +| 🦶 | Halfling | `races/halfling.png` | [ ] | + +--- + +## UI Elements + +| Emoji | Current Usage | Art File | Status | +|-------|---------------|----------|--------| +| 🏠 | Home base button / entered home base | `icons/home.png` | [ ] | +| 📍 | Geocache marker / location pin | `icons/pin.png` | [ ] | +| 🎯 | Destination reached notification | `icons/target.png` | [ ] | +| 🎵 | Music on button | `icons/music_on.png` | [ ] | +| 🔇 | Music muted button | `icons/music_off.png` | [ ] | +| ⚙️ | Settings header | `icons/settings.png` | [ ] | +| ✏️ | Edit tools header | `icons/pencil.png` | [ ] | +| 🛠️ | Developer tools header | `icons/tools.png` | [ ] | +| ⚠️ | Warning / error notification | `icons/warning.png` | [ ] | + +--- + +## Player Portraits (Combat UI) + +Need player character art to display in combat instead of class emoji. + +| Class | Art Files | Status | +|-------|-----------|--------| +| Trail Runner | `players/trail_runner50.png`, `players/trail_runner100.png` | [ ] | +| Gym Bro | `players/gym_bro50.png`, `players/gym_bro100.png` | [ ] | +| Yoga Master | `players/yoga_master50.png`, `players/yoga_master100.png` | [ ] | +| CrossFit Crusader | `players/crossfit50.png`, `players/crossfit100.png` | [ ] | + +--- + +## Skill Icons (Optional - currently use class icons or ⚔️) + +Could add unique icons per skill for the combat UI skill buttons. + +| Skill ID | Skill Name | Art File | Status | +|----------|------------|----------|--------| +| basic_attack | Attack / Kickems | `skills/basic_attack.png` | [ ] | +| double_attack | Double Attack / Brand New Hokas | `skills/double_attack.png` | [ ] | +| power_strike | Power Strike / Downhill Sprint | `skills/power_strike.png` | [ ] | +| heal | Heal / Gel Pack | `skills/heal.png` | [ ] | +| defend | Defend / Pace Yourself | `skills/defend.png` | [ ] | +| quick_step | Quick Step | `skills/quick_step.png` | [ ] | +| second_wind | Second Wind | `skills/second_wind.png` | [ ] | +| finish_line_sprint | Finish Line Sprint | `skills/finish_line_sprint.png` | [ ] | + +--- + +## Summary + +| Category | Count | Priority | +|----------|-------|----------| +| Combat Log Icons | 10 | High | +| Stat Icons | 4 | High | +| Class Icons | 4 (x2 sizes) | High | +| Race Icons | 4 | Medium | +| UI Elements | 9 | Medium | +| Player Portraits | 4 (x2 sizes) | High | +| Skill Icons | 8+ | Low | + +**Total unique artwork pieces needed: ~35-45** + +--- + +## Directory Structure + +``` +mapgameimgs/ +├── monsters/ # (existing) +├── bases/ # (existing - home base icons) +├── icons/ # NEW - UI and combat log icons +│ ├── attack50.png +│ ├── attack100.png +│ ├── heal50.png +│ └── ... +├── classes/ # NEW - class portraits +│ ├── trail_runner50.png +│ ├── trail_runner100.png +│ └── ... +├── races/ # NEW - race icons for character creator +│ ├── human.png +│ └── ... +├── players/ # NEW - player combat portraits +│ └── ... +└── skills/ # NEW - skill button icons (optional) + └── ... +``` + +--- + +## Implementation Notes + +1. **Combat Log**: Replace emoji strings with `` tags, add CSS for inline sizing +2. **HUD/Buttons**: Replace innerHTML emoji with background-image or `` +3. **Combat UI**: Player portrait already has placeholder div (`#playerCombatIcon`) +4. **Fallback**: Keep emoji as fallback if image fails to load diff --git a/docker-compose.yml b/docker-compose.yml index fda8e0d..fd159a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./:/app/data - ./mapgameimgs:/app/mapgameimgs - ./mapgamemusic:/app/mapgamemusic + - ./sfx:/app/sfx restart: unless-stopped environment: - NODE_ENV=production diff --git a/index.html b/index.html index ea63b5c..392ce5b 100644 --- a/index.html +++ b/index.html @@ -4154,6 +4154,15 @@ spawnSettings.homeHpMultiplier = settings.homeHpMultiplier || 3; spawnSettings.homeRegenPercent = settings.homeRegenPercent || 5; spawnSettings.homeBaseRadius = settings.homeBaseRadius || 20; + + // Load inactivity settings + if (settings.inactivityTimeout) { + inactivityTimeout = settings.inactivityTimeout; + } + if (settings.inactivityWarningTime) { + inactivityWarningTime = settings.inactivityWarningTime; + } + console.log('Loaded spawn settings:', spawnSettings); } } catch (err) { @@ -4450,6 +4459,45 @@ pausedTracks: {} // Store paused positions for resumable tracks }; + // ========================================== + // SOUND EFFECTS SYSTEM + // ========================================== + const gameSfx = { + missed: new Audio('/sfx/missed.mp3'), + player_attack: new Audio('/sfx/player_attack.mp3'), + player_skill: new Audio('/sfx/player_skill.mp3'), + monster_attack: new Audio('/sfx/monster_attack.mp3'), + monster_skill: new Audio('/sfx/monster_skill.mp3'), + monster_death: new Audio('/sfx/monster_death.mp3'), + volume: parseFloat(localStorage.getItem('sfxVolume') || '0.5'), + muted: localStorage.getItem('sfxMuted') === 'true' + }; + + // Initialize SFX + function initSfx() { + const sfxNames = ['missed', 'player_attack', 'player_skill', 'monster_attack', 'monster_skill', 'monster_death']; + sfxNames.forEach(sfx => { + const audio = gameSfx[sfx]; + audio.preload = 'auto'; + audio.volume = gameSfx.volume; + audio.load(); + }); + } + + // Play a sound effect (doesn't interrupt music) + function playSfx(sfxName) { + if (gameSfx.muted) return; + const audio = gameSfx[sfxName]; + if (!audio) { + console.error('SFX not found:', sfxName); + return; + } + // Clone and play so multiple can overlap + const clone = audio.cloneNode(); + clone.volume = gameSfx.volume; + clone.play().catch(e => console.log('SFX play failed:', e)); + } + // Initialize music settings function initMusic() { // Set up looping for ambient tracks @@ -10348,7 +10396,101 @@ localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); localStorage.removeItem('hikemap_rpg_stats'); // Clear cached RPG stats to prevent stale data + stopInactivityTimer(); updateAuthUI(); + + // Show login modal to force re-authentication + showAuthModal(); + } + + // ========================================== + // INACTIVITY LOGOUT SYSTEM + // ========================================== + let inactivityTimeout = 10 * 60 * 1000; // Default 10 minutes, can be overridden by server settings + let inactivityWarningTime = 60 * 1000; // Warning 60 seconds before logout + let inactivityTimer = null; + let inactivityWarningTimer = null; + + function resetInactivityTimer() { + // Only track if user is logged in + if (!accessToken) { + console.log('[Inactivity] No access token, skipping timer'); + return; + } + + // Clear existing timers + if (inactivityTimer) clearTimeout(inactivityTimer); + if (inactivityWarningTimer) clearTimeout(inactivityWarningTimer); + + // Hide warning if showing + const warningEl = document.getElementById('inactivityWarning'); + if (warningEl) warningEl.style.display = 'none'; + + // Set warning timer + const warningTime = Math.max(0, inactivityTimeout - inactivityWarningTime); + console.log('[Inactivity] Timer reset. Warning in', warningTime/1000, 's, logout in', inactivityTimeout/1000, 's'); + + inactivityWarningTimer = setTimeout(() => { + console.log('[Inactivity] Showing warning'); + showInactivityWarning(); + }, warningTime); + + // Set logout timer + inactivityTimer = setTimeout(() => { + console.log('[Inactivity] Logging out due to inactivity'); + logout(); + }, inactivityTimeout); + } + + function showInactivityWarning() { + let warningEl = document.getElementById('inactivityWarning'); + if (!warningEl) { + warningEl = document.createElement('div'); + warningEl.id = 'inactivityWarning'; + warningEl.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 152, 0, 0.95); + color: #000; + padding: 12px 24px; + border-radius: 8px; + z-index: 10000; + font-weight: bold; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + `; + document.body.appendChild(warningEl); + } + const seconds = Math.round(inactivityWarningTime / 1000); + warningEl.textContent = `You will be logged out in ${seconds} seconds due to inactivity`; + warningEl.style.display = 'block'; + } + + function stopInactivityTimer() { + if (inactivityTimer) { + clearTimeout(inactivityTimer); + inactivityTimer = null; + } + if (inactivityWarningTimer) { + clearTimeout(inactivityWarningTimer); + inactivityWarningTimer = null; + } + const warningEl = document.getElementById('inactivityWarning'); + if (warningEl) warningEl.style.display = 'none'; + } + + function startInactivityTracking() { + // Note: mousemove excluded - too sensitive, resets on every tiny movement + const activityEvents = ['mousedown', 'keypress', 'scroll', 'touchstart', 'click']; + activityEvents.forEach(event => { + document.addEventListener(event, () => { + console.log('[Inactivity] Activity detected:', event); + resetInactivityTimer(); + }, { passive: true }); + }); + console.log('[Inactivity] *** TRACKING STARTED *** timeout:', inactivityTimeout / 1000, 'seconds'); + resetInactivityTimer(); } async function loadCurrentUser() { @@ -10362,6 +10504,10 @@ registerWebSocketAuth(); // Initialize RPG system for eligible users await initializePlayerStats(currentUser.username); + // Start inactivity tracking + console.log('[DEBUG] About to call startInactivityTracking'); + startInactivityTracking(); + console.log('[DEBUG] Called startInactivityTracking'); } else { // Token invalid logout(); @@ -12203,7 +12349,7 @@ // Show notification if (count > 0) { - showToast(`🏠 Entered home base - ${count} monster${count > 1 ? 's' : ''} fled!`, 'info'); + console.log(`🏠 Entered home base - ${count} monster${count > 1 ? 's' : ''} fled!`); } // Update HUD @@ -13203,6 +13349,7 @@ if (!rollHit(hitChance)) { if (targets.length === 1) { addCombatLog(`❌ ${displayName} missed ${currentTarget.data.name}! (${hitChance}% chance)`, 'miss'); + playSfx('missed'); } continue; // Miss this target, continue to next } @@ -13230,14 +13377,17 @@ if (targets.length === 1) { if (hitCount > 1) { addCombatLog(`✨ ${displayName} hits ${currentTarget.data.name} ${hitCount} times for ${totalDamage} total damage!`, 'damage'); + playSfx('player_skill'); } else { addCombatLog(`⚔️ ${displayName} hits ${currentTarget.data.name} for ${totalDamage} damage!`, 'damage'); + playSfx('player_attack'); } } // Check if this monster died if (currentTarget.hp <= 0) { monstersKilled++; + playSfx('monster_death'); // Award XP immediately for this kill const xpReward = (currentTarget.data?.xpReward || 10) * currentTarget.level; playerStats.xp += xpReward; @@ -13261,8 +13411,10 @@ if (targets.length > 1) { if (monstersHit === 0) { addCombatLog(`❌ ${displayName} missed all enemies!`, 'miss'); + playSfx('missed'); } else { addCombatLog(`🌟 ${displayName} hits ${monstersHit} enemies for ${grandTotalDamage} total damage!`, 'damage'); + playSfx('player_skill'); if (monstersKilled > 0) { const totalXpGained = combatState.player.xpGained || 0; addCombatLog(`💀 ${monstersKilled} enemy${monstersKilled > 1 ? 'ies' : ''} defeated! +${totalXpGained} XP`, 'victory'); @@ -13413,6 +13565,7 @@ // Roll for hit if (!rollHit(hitChance)) { addCombatLog(`❌ ${monster.data.name}'s ${selectedSkill.name} missed! (${hitChance}% chance)`, 'miss'); + playSfx('missed'); combatState.currentMonsterTurn++; setTimeout(executeMonsterTurns, 800); return; @@ -13464,8 +13617,10 @@ turnsLeft: effect.duration || 3 }); addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} applied!`, 'damage'); + playSfx('monster_skill'); } else { addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage'); + playSfx('monster_skill'); } } } else { @@ -13490,10 +13645,13 @@ if (isGenericAttack) { addCombatLog(`⚔️ ${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage'); + playSfx('monster_attack'); } else if (hitCount > 1) { addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage'); + playSfx('monster_skill'); } else { addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! You take ${totalDamage} damage!`, 'damage'); + playSfx('monster_skill'); } } @@ -13640,8 +13798,9 @@ loadCurrentUser(); }); - // Initialize music system + // Initialize music and sound effects systems initMusic(); + initSfx(); // Start appropriate music on first user interaction (required due to autoplay restrictions) let musicStarted = false; diff --git a/mapgameimgs/monsters/moop_fanciest100.png b/mapgameimgs/monsters/moop_fanciest100.png index d08e384..b0c5fea 100755 Binary files a/mapgameimgs/monsters/moop_fanciest100.png and b/mapgameimgs/monsters/moop_fanciest100.png differ diff --git a/mapgameimgs/monsters/moop_fanciest50.png b/mapgameimgs/monsters/moop_fanciest50.png index 5283d19..f2a8545 100755 Binary files a/mapgameimgs/monsters/moop_fanciest50.png and b/mapgameimgs/monsters/moop_fanciest50.png differ diff --git a/mapgamemusic/login.mp3 b/mapgamemusic/login.mp3 new file mode 100755 index 0000000..46b6670 Binary files /dev/null and b/mapgamemusic/login.mp3 differ diff --git a/server.js b/server.js index 1dbe250..bbb092d 100644 --- a/server.js +++ b/server.js @@ -103,6 +103,9 @@ app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs'))); // Serve game music app.use('/mapgamemusic', express.static(path.join(__dirname, 'mapgamemusic'))); +// Serve sound effects +app.use('/sfx', express.static(path.join(__dirname, 'sfx'))); + // Serve other static files app.use(express.static(path.join(__dirname))); @@ -1073,7 +1076,9 @@ app.get('/api/spawn-settings', (req, res) => { hpRegenPercent: JSON.parse(db.getSetting('hpRegenPercent') || '1'), homeHpMultiplier: JSON.parse(db.getSetting('homeHpMultiplier') || '3'), homeRegenPercent: JSON.parse(db.getSetting('homeRegenPercent') || '5'), - homeBaseRadius: JSON.parse(db.getSetting('homeBaseRadius') || '20') + homeBaseRadius: JSON.parse(db.getSetting('homeBaseRadius') || '20'), + inactivityTimeout: JSON.parse(db.getSetting('inactivityTimeout') || '600000'), // 10 minutes default + inactivityWarningTime: JSON.parse(db.getSetting('inactivityWarningTime') || '60000') // 60 seconds default }; res.json(settings); } catch (err) { diff --git a/service-worker.js b/service-worker.js index 0f94a22..d0ac4ef 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ // HikeMap Service Worker // Increment version to force cache refresh -const CACHE_NAME = 'hikemap-v1.0.1'; +const CACHE_NAME = 'hikemap-v1.0.2'; const urlsToCache = [ '/', '/index.html', @@ -52,6 +52,11 @@ self.addEventListener('activate', event => { self.addEventListener('fetch', event => { const url = new URL(event.request.url); + // Skip non-http(s) requests (chrome-extension://, etc.) + if (!url.protocol.startsWith('http')) { + return; + } + // Handle map tiles with cache-first strategy if (url.hostname.includes('tile.openstreetmap.org') || url.hostname.includes('mt0.google.com') || diff --git a/sfx/missed.mp3 b/sfx/missed.mp3 new file mode 100755 index 0000000..df90757 Binary files /dev/null and b/sfx/missed.mp3 differ diff --git a/sfx/monster_attack.mp3 b/sfx/monster_attack.mp3 new file mode 100755 index 0000000..19bd54e Binary files /dev/null and b/sfx/monster_attack.mp3 differ diff --git a/sfx/monster_death.mp3 b/sfx/monster_death.mp3 new file mode 100755 index 0000000..75f9773 Binary files /dev/null and b/sfx/monster_death.mp3 differ diff --git a/sfx/monster_skill.mp3 b/sfx/monster_skill.mp3 new file mode 100755 index 0000000..7ecb505 Binary files /dev/null and b/sfx/monster_skill.mp3 differ diff --git a/sfx/player_attack.mp3 b/sfx/player_attack.mp3 new file mode 100755 index 0000000..55325a0 Binary files /dev/null and b/sfx/player_attack.mp3 differ diff --git a/sfx/player_skill.mp3 b/sfx/player_skill.mp3 new file mode 100755 index 0000000..be425c8 Binary files /dev/null and b/sfx/player_skill.mp3 differ diff --git a/to_do.md b/to_do.md index 6d254cf..6b4a310 100644 --- a/to_do.md +++ b/to_do.md @@ -54,8 +54,22 @@ - [x] Monster cloning - [x] Monster enable/disable toggle - [x] Auto-copy default images for new monsters +- [x] Utility skill management (buffs like Second Wind) - [ ] Spawn control (manual monster spawning) - [ ] Game balance settings +- [ ] Class skill names admin editor + +## Phase 7: Skill Database System - COMPLETE +- [x] Skills table in database +- [x] Skills admin page (CRUD) +- [x] Hit/miss mechanics (accuracy vs dodge) +- [x] Monster skills with weighted random selection +- [x] Custom skill names per monster +- [x] Status effects (poison) with turn-based damage +- [x] Buff skills (defend) working properly +- [x] Status effect visual overlays (100x100px) +- [x] Monster min/max level spawning +- [x] Class-specific skill names (getSkillForClass) - working via class_skill_names table ## Phase 8: Home Base / Death System - COMPLETE - [x] Add home_base_lat, home_base_lng, last_home_set, is_dead columns to rpg_stats @@ -72,18 +86,14 @@ - [x] Respawn player with full HP and MP when they reach home - [x] If no home base set, old behavior (restore 50% HP on defeat) -## Phase 7: Skill Database System - COMPLETE -- [x] Skills table in database -- [x] Skills admin page (CRUD) -- [x] Hit/miss mechanics (accuracy vs dodge) -- [x] Monster skills with weighted random selection -- [x] Custom skill names per monster -- [x] Status effects (poison) with turn-based damage -- [x] Buff skills (defend) working properly -- [x] Status effect visual overlays (100x100px) -- [x] Monster min/max level spawning -- [ ] Class-specific skill names (getSkillForClass) -- [ ] Class skill names admin editor +## Phase 9: Polish & QoL - NEW +- [x] Combat SFX (player attack, monster attack, miss, death sounds) +- [x] Background music system (overworld, battle, victory, death, homebase) +- [x] Cross-device sync fixes (visibilitychange, pagehide handlers) +- [x] Service worker caching (network-first for API/HTML) +- [x] Server restart detection (force logout on session mismatch) +- [ ] SFX volume control in settings +- [ ] Music volume control in settings ---