Browse Source

Add inactivity logout system and combat SFX

- Add automatic logout after configurable inactivity period (default 10 min)
- Show warning banner before logout occurs
- Add inactivity timeout settings to admin panel
- Add combat sound effects (player/monster attack, skills, miss, death)
- Add login music track
- Update moop_fanciest monster artwork
- Add artwork_todo.md for tracking emoji replacements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
d36e3519aa
  1. 28
      admin.html
  2. 150
      artwork_todo.md
  3. 1
      docker-compose.yml
  4. 163
      index.html
  5. BIN
      mapgameimgs/monsters/moop_fanciest100.png
  6. BIN
      mapgameimgs/monsters/moop_fanciest50.png
  7. BIN
      mapgamemusic/login.mp3
  8. 7
      server.js
  9. 7
      service-worker.js
  10. BIN
      sfx/missed.mp3
  11. BIN
      sfx/monster_attack.mp3
  12. BIN
      sfx/monster_death.mp3
  13. BIN
      sfx/monster_skill.mp3
  14. BIN
      sfx/player_attack.mp3
  15. BIN
      sfx/player_skill.mp3
  16. 34
      to_do.md

28
admin.html

@ -919,7 +919,21 @@
<small style="color: #888; font-size: 11px;">Distance from home to get bonuses</small>
</div>
<div class="form-group">
<!-- Placeholder for future settings -->
<!-- empty for alignment -->
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Session Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Inactivity Timeout (minutes)</label>
<input type="number" id="setting-inactivityTimeout" placeholder="10" min="1" step="1">
<small style="color: #888; font-size: 11px;">Minutes of inactivity before auto-logout</small>
</div>
<div class="form-group">
<label>Logout Warning Time (seconds)</label>
<input type="number" id="setting-inactivityWarningTime" placeholder="60" min="10" step="5">
<small style="color: #888; font-size: 11px;">Warning shown this many seconds before logout</small>
</div>
</div>
</div>
@ -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 {

150
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 `<img>` tags, add CSS for inline sizing
2. **HUD/Buttons**: Replace innerHTML emoji with background-image or `<img>`
3. **Combat UI**: Player portrait already has placeholder div (`#playerCombatIcon`)
4. **Fallback**: Keep emoji as fallback if image fails to load

1
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

163
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;

BIN
mapgameimgs/monsters/moop_fanciest100.png

Before

Width: 102  |  Height: 100  |  Size: 15 KiB

After

Width: 102  |  Height: 100  |  Size: 9.1 KiB

BIN
mapgameimgs/monsters/moop_fanciest50.png

Before

Width: 50  |  Height: 50  |  Size: 5.7 KiB

After

Width: 50  |  Height: 50  |  Size: 4.8 KiB

BIN
mapgamemusic/login.mp3

7
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) {

7
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') ||

BIN
sfx/missed.mp3

BIN
sfx/monster_attack.mp3

BIN
sfx/monster_death.mp3

BIN
sfx/monster_skill.mp3

BIN
sfx/player_attack.mp3

BIN
sfx/player_skill.mp3

34
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
---

Loading…
Cancel
Save