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
+
@@ -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
---