From 07b9cb80411f310bb172faa62e835e1a8d13ecf3 Mon Sep 17 00:00:00 2001 From: HikeMap User Date: Sun, 4 Jan 2026 23:23:25 -0600 Subject: [PATCH] Add toast notifications, admin broadcast system, and regen settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add toast notification system with CSS animations for user feedback - Add WebSocket broadcast for admin panel changes (monsters, skills, settings) - Client auto-refreshes when admin makes changes - Add HP/MP regen settings to admin panel (intervals, percentages, home multipliers) - Fix SQL parameter mismatch in saveRpgStats (data_version = excluded.data_version) - Implement optimistic updates in admin panel for immediate feedback - Add broadcastAdminChange helper with client count logging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- admin.html | 178 +++++++++++++++++++++++---- database.js | 204 ++++++++++++++++++++++++++++--- index.html | 244 +++++++++++++++++++++++++++++++------ server.js | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 885 insertions(+), 80 deletions(-) diff --git a/admin.html b/admin.html index a1d5875..ca763bc 100644 --- a/admin.html +++ b/admin.html @@ -842,6 +842,46 @@ Meters walked to regen 1 MP (0 = disabled) +
+ + + MP gained per distance threshold +
+ + +

HP Regeneration

+
+
+ + + Time between HP regen ticks +
+
+ + + % of max HP restored per tick +
+
+ +

Home Base Bonuses

+
+
+ + + HP regen multiplier when at home (e.g., 3 = 3x faster) +
+
+ + + % of max HP/MP per tick when at home base +
+
+
+
+ + + Distance from home to get bonuses +
@@ -1454,7 +1494,7 @@ } try { - await api('/api/admin/monster-skills', { + const result = await api('/api/admin/monster-skills', { method: 'POST', body: JSON.stringify({ monster_type_id: monsterId, @@ -1464,8 +1504,20 @@ min_level: minLevel }) }); + // Add to local array immediately (optimistic update) + const skill = allSkills.find(s => s.id === skillId); + currentMonsterSkills.push({ + id: result?.id || Date.now(), // Use response ID or temp ID + monster_type_id: monsterId, + skill_id: skillId, + skill_name: skill?.name || skillId, + custom_name: customName || null, + weight: weight, + min_level: minLevel + }); showToast('Skill added'); - await loadMonsterSkills(monsterId); + renderMonsterSkills(); + loadMonsterSkills(monsterId); // Background refresh for consistency document.getElementById('addSkillSelect').value = ''; document.getElementById('addSkillCustomName').value = ''; } catch (e) { @@ -1493,6 +1545,9 @@ method: 'PUT', body: JSON.stringify({ [field]: parseInt(value) }) }); + // Update local state immediately + const ms = currentMonsterSkills.find(s => s.id === id); + if (ms) ms[field] = parseInt(value); } catch (e) { showToast('Failed to update skill: ' + e.message, 'error'); } @@ -1501,9 +1556,12 @@ async function removeMonsterSkill(id) { try { await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' }); + // Remove from local array immediately + currentMonsterSkills = currentMonsterSkills.filter(ms => ms.id !== id); showToast('Skill removed'); + renderMonsterSkills(); const monsterId = document.getElementById('monsterId').value; - await loadMonsterSkills(monsterId); + loadMonsterSkills(monsterId); // Background refresh for consistency } catch (e) { showToast('Failed to remove skill: ' + e.message, 'error'); } @@ -1560,8 +1618,11 @@ try { await api(`/api/admin/monster-types/${id}`, { method: 'DELETE' }); + // Remove from local array immediately + monsters = monsters.filter(m => m.id !== id); showToast('Monster deleted'); - loadMonsters(); + renderMonsterTable(); + loadMonsters(); // Background refresh for consistency } catch (e) { showToast('Failed to delete monster: ' + e.message, 'error'); } @@ -1686,16 +1747,26 @@ method: 'PUT', body: JSON.stringify(data) }); + // Update local array immediately (optimistic update) + const idx = monsters.findIndex(m => m.id === id); + if (idx !== -1) { + monsters[idx] = { ...monsters[idx], ...data }; + } showToast('Monster updated'); } else { - await api('/api/admin/monster-types', { + const result = await api('/api/admin/monster-types', { method: 'POST', body: JSON.stringify(data) }); + // Add to local array immediately + if (result && result.id) { + monsters.push({ id: result.id, ...data }); + } showToast('Monster created'); } + renderMonsterTable(); closeMonsterModal(); - loadMonsters(); + loadMonsters(); // Background refresh for consistency } catch (e) { showToast('Failed to save monster: ' + e.message, 'error'); } @@ -1767,8 +1838,11 @@ try { await api(`/api/admin/skills/${id}`, { method: 'DELETE' }); + // Remove from local array immediately + allSkills = allSkills.filter(s => s.id !== id); showToast('Skill deleted'); - loadSkillsAdmin(); + renderSkillTable(); + loadSkillsAdmin(); // Background refresh for consistency } catch (e) { showToast('Failed to delete skill: ' + e.message, 'error'); } @@ -1868,16 +1942,24 @@ method: 'PUT', body: JSON.stringify(data) }); + // Update local array immediately (optimistic update) + const idx = allSkills.findIndex(s => s.id === editId); + if (idx !== -1) { + allSkills[idx] = { ...allSkills[idx], ...data }; + } showToast('Skill updated'); } else { await api('/api/admin/skills', { method: 'POST', body: JSON.stringify(data) }); + // Add to local array immediately + allSkills.push({ ...data }); showToast('Skill created'); } + renderSkillTable(); closeSkillModal(); - loadSkillsAdmin(); + loadSkillsAdmin(); // Background refresh for consistency } catch (e) { showToast('Failed to save skill: ' + e.message, 'error'); } @@ -1953,8 +2035,11 @@ try { await api(`/api/admin/classes/${id}`, { method: 'DELETE' }); + // Remove from local array immediately + allClasses = allClasses.filter(c => c.id !== id); showToast('Class deleted'); - loadClasses(); + renderClassTable(); + loadClasses(); // Background refresh for consistency } catch (e) { showToast('Failed to delete class: ' + e.message, 'error'); } @@ -2037,16 +2122,24 @@ method: 'PUT', body: JSON.stringify(data) }); + // Update local array immediately (optimistic update) + const idx = allClasses.findIndex(c => c.id === editId); + if (idx !== -1) { + allClasses[idx] = { ...allClasses[idx], ...data }; + } showToast('Class updated'); } else { await api('/api/admin/classes', { method: 'POST', body: JSON.stringify(data) }); + // Add to local array immediately + allClasses.push({ ...data }); showToast('Class created'); } + renderClassTable(); closeClassModal(); - loadClasses(); + loadClasses(); // Background refresh for consistency } catch (e) { showToast('Failed to save class: ' + e.message, 'error'); } @@ -2158,7 +2251,7 @@ } try { - await api('/api/admin/class-skills', { + const result = await api('/api/admin/class-skills', { method: 'POST', body: JSON.stringify({ class_id: classId, @@ -2168,8 +2261,20 @@ custom_name: customName || null }) }); + // Add to local array immediately (optimistic update) + const skill = allSkills.find(s => s.id === skillId); + currentClassSkills.push({ + id: result?.id || Date.now(), // Use response ID or temp ID + class_id: classId, + skill_id: skillId, + skill_name: skill?.name || skillId, + unlock_level: unlockLevel, + choice_group: choiceGroup, + custom_name: customName || null + }); showToast('Skill added'); - await loadClassSkills(classId); + renderClassSkills(); + loadClassSkills(classId); // Background refresh for consistency document.getElementById('addClassSkillSelect').value = ''; document.getElementById('addClassSkillName').value = ''; document.getElementById('addClassSkillLevel').value = '1'; @@ -2193,9 +2298,9 @@ method: 'PUT', body: JSON.stringify(data) }); - // Reload to update display - const classId = document.getElementById('classEditId').value; - await loadClassSkills(classId); + // Update local state immediately + const cs = currentClassSkills.find(s => s.id === id); + if (cs) cs[field] = data[field]; } catch (e) { showToast('Failed to update skill: ' + e.message, 'error'); } @@ -2204,9 +2309,12 @@ async function removeClassSkill(id) { try { await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' }); + // Remove from local array immediately + currentClassSkills = currentClassSkills.filter(cs => cs.id !== id); showToast('Skill removed'); + renderClassSkills(); const classId = document.getElementById('classEditId').value; - await loadClassSkills(classId); + loadClassSkills(classId); // Background refresh for consistency } catch (e) { showToast('Failed to remove skill: ' + e.message, 'error'); } @@ -2264,8 +2372,14 @@ method: 'PUT', body: JSON.stringify({ is_admin: isAdmin }) }); + // Update local array immediately (optimistic update) + const idx = users.findIndex(u => u.id == id); + if (idx !== -1) { + users[idx].is_admin = isAdmin; + } showToast(isAdmin ? 'Admin granted' : 'Admin revoked'); - loadUsers(); + renderUserTable(); + loadUsers(); // Background refresh for consistency } catch (e) { showToast('Failed to update admin status: ' + e.message, 'error'); } @@ -2343,9 +2457,15 @@ method: 'PUT', body: JSON.stringify(data) }); + // Update local array immediately (optimistic update) + const idx = users.findIndex(u => u.id == id); + if (idx !== -1) { + users[idx] = { ...users[idx], ...data }; + } showToast('User updated'); + renderUserTable(); closeUserModal(); - loadUsers(); + loadUsers(); // Background refresh for consistency } catch (e) { showToast('Failed to update user: ' + e.message, 'error'); } @@ -2366,6 +2486,15 @@ document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0; document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false; document.getElementById('setting-mpRegenDistance').value = settings.mpRegenDistance || 5; + document.getElementById('setting-mpRegenAmount').value = settings.mpRegenAmount || 1; + // HP Regen settings (convert interval from ms to seconds) + const hpIntervalMs = settings.hpRegenInterval || 10000; + document.getElementById('setting-hpRegenInterval').value = Math.round(hpIntervalMs / 1000); + document.getElementById('setting-hpRegenPercent').value = settings.hpRegenPercent || 1; + // Home base settings + document.getElementById('setting-homeHpMultiplier').value = settings.homeHpMultiplier || 3; + document.getElementById('setting-homeRegenPercent').value = settings.homeRegenPercent || 5; + document.getElementById('setting-homeBaseRadius').value = settings.homeBaseRadius || 20; } catch (e) { showToast('Failed to load settings: ' + e.message, 'error'); } @@ -2374,6 +2503,7 @@ document.getElementById('saveSettingsBtn').addEventListener('click', async () => { // 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; const newSettings = { monsterSpawnInterval: intervalSeconds * 1000, monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50, @@ -2381,7 +2511,13 @@ maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10, xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0, combatEnabled: document.getElementById('setting-combatEnabled').checked, - mpRegenDistance: parseInt(document.getElementById('setting-mpRegenDistance').value) || 5 + mpRegenDistance: parseInt(document.getElementById('setting-mpRegenDistance').value) || 5, + mpRegenAmount: parseInt(document.getElementById('setting-mpRegenAmount').value) || 1, + hpRegenInterval: hpIntervalSeconds * 1000, + 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 }; try { @@ -2389,8 +2525,10 @@ method: 'PUT', body: JSON.stringify(newSettings) }); + // Update local settings immediately + settings = { ...settings, ...newSettings }; showToast('Settings saved'); - loadSettings(); + loadSettings(); // Background refresh for consistency } catch (e) { showToast('Failed to save settings: ' + e.message, 'error'); } diff --git a/database.js b/database.js index 727da12..859cf75 100644 --- a/database.js +++ b/database.js @@ -91,6 +91,12 @@ class HikeMapDB { try { this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN unlocked_skills TEXT DEFAULT '["basic_attack"]'`); } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN active_skills TEXT DEFAULT '["basic_attack"]'`); + } catch (e) { /* Column already exists */ } + try { + this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN data_version INTEGER DEFAULT 1`); + } catch (e) { /* Column already exists */ } // Monster entourage table - stores monsters following the player this.db.exec(` @@ -270,6 +276,22 @@ class HikeMapDB { ) `); + // Player buffs table - for utility skills like Second Wind + this.db.exec(` + CREATE TABLE IF NOT EXISTS player_buffs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_id INTEGER NOT NULL, + buff_type TEXT NOT NULL, + effect_type TEXT NOT NULL, + effect_value REAL DEFAULT 1.0, + activated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + cooldown_hours INTEGER DEFAULT 24, + FOREIGN KEY (player_id) REFERENCES users(id), + UNIQUE(player_id, buff_type) + ) + `); + // Create indexes for performance this.db.exec(` CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); @@ -284,6 +306,7 @@ class HikeMapDB { CREATE INDEX IF NOT EXISTS idx_monster_skills_skill ON monster_skills(skill_id); CREATE INDEX IF NOT EXISTS idx_class_skills_class ON class_skills(class_id); CREATE INDEX IF NOT EXISTS idx_class_skills_skill ON class_skills(skill_id); + CREATE INDEX IF NOT EXISTS idx_player_buffs_player ON player_buffs(player_id); `); } @@ -522,13 +545,21 @@ class HikeMapDB { // RPG Stats methods getRpgStats(userId) { const stmt = this.db.prepare(` - SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, - home_base_lat, home_base_lng, last_home_set, is_dead, home_base_icon + SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, + unlocked_skills, active_skills, + home_base_lat, home_base_lng, last_home_set, is_dead, home_base_icon, data_version FROM rpg_stats WHERE user_id = ? `); return stmt.get(userId); } + // Get current data version for a user + getDataVersion(userId) { + const stmt = this.db.prepare(`SELECT data_version FROM rpg_stats WHERE user_id = ?`); + const result = stmt.get(userId); + return result ? (result.data_version || 1) : 1; + } + hasCharacter(userId) { const stmt = this.db.prepare(` SELECT 1 FROM rpg_stats WHERE user_id = ? AND character_name IS NOT NULL @@ -538,8 +569,8 @@ class HikeMapDB { createCharacter(userId, characterData) { const stmt = this.db.prepare(` - INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, active_skills, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET character_name = excluded.character_name, race = excluded.race, @@ -555,10 +586,12 @@ class HikeMapDB { accuracy = excluded.accuracy, dodge = excluded.dodge, unlocked_skills = excluded.unlocked_skills, + active_skills = excluded.active_skills, updated_at = datetime('now') `); - // New characters start with only basic_attack + // New characters start with only basic_attack (both unlocked and active) const unlockedSkillsJson = JSON.stringify(characterData.unlockedSkills || ['basic_attack']); + const activeSkillsJson = JSON.stringify(characterData.activeSkills || ['basic_attack']); return stmt.run( userId, characterData.name, @@ -574,14 +607,29 @@ class HikeMapDB { characterData.def || 8, characterData.accuracy || 90, characterData.dodge || 10, - unlockedSkillsJson + unlockedSkillsJson, + activeSkillsJson ); } - saveRpgStats(userId, stats) { + // Save RPG stats with version checking to prevent stale data overwrites + // Returns { success: true, newVersion } or { success: false, reason, currentVersion } + saveRpgStats(userId, stats, clientVersion = null) { + // Get current version in database + const currentVersion = this.getDataVersion(userId); + + // If client sent a version, check it's not stale + if (clientVersion !== null && clientVersion < currentVersion) { + console.log(`[VERSION CHECK] Rejecting save for user ${userId}: client version ${clientVersion} < server version ${currentVersion}`); + return { success: false, reason: 'stale_data', currentVersion }; + } + + // Increment version for this save + const newVersion = currentVersion + 1; + const stmt = this.db.prepare(` - INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, home_base_lat, home_base_lng, last_home_set, is_dead, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, active_skills, home_base_lat, home_base_lng, last_home_set, is_dead, data_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET character_name = COALESCE(excluded.character_name, rpg_stats.character_name), race = COALESCE(excluded.race, rpg_stats.race), @@ -597,15 +645,18 @@ class HikeMapDB { accuracy = excluded.accuracy, dodge = excluded.dodge, unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills), + active_skills = COALESCE(excluded.active_skills, rpg_stats.active_skills), home_base_lat = COALESCE(excluded.home_base_lat, rpg_stats.home_base_lat), home_base_lng = COALESCE(excluded.home_base_lng, rpg_stats.home_base_lng), last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set), is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead), + data_version = excluded.data_version, updated_at = datetime('now') `); - // Convert unlockedSkills array to JSON string for storage + // Convert skills arrays to JSON strings for storage const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null; - return stmt.run( + const activeSkillsJson = stats.activeSkills ? JSON.stringify(stats.activeSkills) : null; + stmt.run( userId, stats.name || null, stats.race || null, @@ -621,11 +672,16 @@ class HikeMapDB { stats.accuracy || 90, stats.dodge || 10, unlockedSkillsJson, + activeSkillsJson, stats.homeBaseLat || null, stats.homeBaseLng || null, stats.lastHomeSet || null, - stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null + stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null, + newVersion ); + + console.log(`[VERSION CHECK] Saved user ${userId} stats, version ${currentVersion} -> ${newVersion}`); + return { success: true, newVersion }; } // Set home base location @@ -1247,15 +1303,15 @@ class HikeMapDB { { id: 'second_wind', name: 'Second Wind', - description: 'Catch your breath to restore MP', - type: 'restore', + description: 'Double your MP regeneration while walking for 1 hour. Once per day.', + type: 'utility', mpCost: 0, - basePower: 30, + basePower: 0, accuracy: 100, hitCount: 1, target: 'self', - statusEffect: null, - playerUsable: true, + statusEffect: { type: 'mp_regen_multiplier', value: 2.0, duration: 3600 }, + playerUsable: false, // Not usable in combat - it's a utility skill monsterUsable: false }, { @@ -1359,6 +1415,23 @@ class HikeMapDB { } console.log('Default skills seeded successfully'); + + // Update second_wind to be a utility skill (migration for existing databases) + try { + const updateStmt = this.db.prepare(` + UPDATE skills SET + type = 'utility', + player_usable = 0, + description = 'Double your MP regeneration while walking for 1 hour. Once per day.' + WHERE id = 'second_wind' AND type != 'utility' + `); + const result = updateStmt.run(); + if (result.changes > 0) { + console.log(' Migrated second_wind to utility skill type'); + } + } catch (err) { + console.error(' Failed to migrate second_wind:', err.message); + } } // ===================== @@ -1811,7 +1884,10 @@ class HikeMapDB { const stmt = this.db.prepare(` UPDATE rpg_stats SET level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50, - atk = 12, def = 8, accuracy = 90, dodge = 10, unlocked_skills = '["basic_attack"]', + atk = 12, def = 8, accuracy = 90, dodge = 10, + unlocked_skills = '["basic_attack"]', + active_skills = '["basic_attack"]', + is_dead = 0, updated_at = datetime('now') WHERE user_id = ? `); @@ -1820,6 +1896,9 @@ class HikeMapDB { // Also clear their monster entourage this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId); + // Clear buffs too + this.db.prepare(`DELETE FROM player_buffs WHERE player_id = ?`).run(userId); + return result; } @@ -1886,6 +1965,95 @@ class HikeMapDB { } } + // ===================== + // PLAYER BUFFS METHODS + // ===================== + + // Get all buffs for a player (including expired for cooldown check) + getPlayerBuffs(userId) { + const stmt = this.db.prepare(` + SELECT * FROM player_buffs WHERE player_id = ? + `); + return stmt.all(userId); + } + + // Get active buffs only (not expired) + getActiveBuffs(userId) { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare(` + SELECT * FROM player_buffs + WHERE player_id = ? AND expires_at > ? + `); + return stmt.all(userId, now); + } + + // Get a specific buff with cooldown info + getBuffWithCooldown(userId, buffType) { + const stmt = this.db.prepare(` + SELECT * FROM player_buffs + WHERE player_id = ? AND buff_type = ? + `); + const buff = stmt.get(userId, buffType); + if (!buff) return null; + + const now = Math.floor(Date.now() / 1000); + const cooldownEnds = buff.activated_at + (buff.cooldown_hours * 3600); + + return { + ...buff, + isActive: buff.expires_at > now, + isOnCooldown: cooldownEnds > now, + expiresIn: Math.max(0, buff.expires_at - now), + cooldownEndsIn: Math.max(0, cooldownEnds - now) + }; + } + + // Activate a buff (creates or updates) + activateBuff(userId, buffType, effectType, effectValue, durationHours, cooldownHours) { + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + (durationHours * 3600); + + const stmt = this.db.prepare(` + INSERT INTO player_buffs (player_id, buff_type, effect_type, effect_value, activated_at, expires_at, cooldown_hours) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(player_id, buff_type) DO UPDATE SET + effect_type = excluded.effect_type, + effect_value = excluded.effect_value, + activated_at = excluded.activated_at, + expires_at = excluded.expires_at, + cooldown_hours = excluded.cooldown_hours + `); + return stmt.run(userId, buffType, effectType, effectValue, now, expiresAt, cooldownHours); + } + + // Check if a buff can be activated (not on cooldown) + canActivateBuff(userId, buffType) { + const buff = this.getBuffWithCooldown(userId, buffType); + if (!buff) return true; // Never used before + return !buff.isOnCooldown; + } + + // Get the current multiplier for an effect type (returns 1.0 if no active buff) + getBuffMultiplier(userId, effectType) { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare(` + SELECT effect_value FROM player_buffs + WHERE player_id = ? AND effect_type = ? AND expires_at > ? + `); + const buff = stmt.get(userId, effectType, now); + return buff ? buff.effect_value : 1.0; + } + + // Clean up old buff records (optional maintenance) + cleanupExpiredBuffs(olderThanDays = 7) { + const threshold = Math.floor(Date.now() / 1000) - (olderThanDays * 24 * 3600); + const stmt = this.db.prepare(` + DELETE FROM player_buffs + WHERE expires_at < ? AND (activated_at + cooldown_hours * 3600) < ? + `); + return stmt.run(threshold, threshold); + } + close() { if (this.db) { this.db.close(); diff --git a/index.html b/index.html index fa05987..4a3cd5a 100644 --- a/index.html +++ b/index.html @@ -2985,6 +2985,53 @@ padding: 12px; } + /* Toast notifications */ + .toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; + } + .toast { + background: #333; + color: white; + padding: 12px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-size: 14px; + max-width: 300px; + animation: toastSlideIn 0.3s ease-out; + pointer-events: auto; + } + .toast.success { + background: #28a745; + } + .toast.error { + background: #dc3545; + } + .toast.warning { + background: #ffc107; + color: #333; + } + .toast.info { + background: #17a2b8; + } + .toast.fade-out { + animation: toastFadeOut 0.3s ease-out forwards; + } + @keyframes toastSlideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes toastFadeOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + @@ -4083,6 +4130,12 @@ spawnSettings.spawnChance = settings.spawnChance || 50; spawnSettings.spawnDistance = settings.spawnDistance || 10; spawnSettings.mpRegenDistance = settings.mpRegenDistance || 5; + spawnSettings.mpRegenAmount = settings.mpRegenAmount || 1; + spawnSettings.hpRegenInterval = settings.hpRegenInterval || 10000; + spawnSettings.hpRegenPercent = settings.hpRegenPercent || 1; + spawnSettings.homeHpMultiplier = settings.homeHpMultiplier || 3; + spawnSettings.homeRegenPercent = settings.homeRegenPercent || 5; + spawnSettings.homeBaseRadius = settings.homeBaseRadius || 20; console.log('Loaded spawn settings:', spawnSettings); } } catch (err) { @@ -4333,18 +4386,21 @@ spawnInterval: 20000, // Timer interval in ms spawnChance: 50, // Percent chance per interval spawnDistance: 10, // Meters player must move - mpRegenDistance: 5 // Meters per 1 MP regen + mpRegenDistance: 5, // Meters per 1 MP regen + mpRegenAmount: 1, // MP gained per distance threshold + hpRegenInterval: 10000, // HP regens every 10 seconds + hpRegenPercent: 1, // Base: 1% of max HP per tick + homeHpMultiplier: 3, // HP regens 3x faster at home base + homeRegenPercent: 5, // Regen 5% of max HP/MP per tick at home + homeBaseRadius: 20 // Meters - radius for home base effects }; // MP regen tracking (distance-based) let lastMpRegenLocation = null; // Track location for MP regen distance let mpRegenAccumulator = 0; // Accumulated distance for MP regen - // HP regen settings (time-based) + // HP regen settings (time-based) - now use spawnSettings values let hpRegenTimer = null; // Timer for passive HP regen - const HP_REGEN_INTERVAL = 10000; // HP regens every 10 seconds - const HP_REGEN_PERCENT = 1; // Base: 1% of max HP per tick - const HOME_HP_MULTIPLIER = 3; // HP regens 3x faster at home base // Home Base state variables let homeBaseMarker = null; // Leaflet marker for home base @@ -4352,9 +4408,7 @@ let xpLostOnDeath = 0; // Track XP lost for display let lastHomeRegenTime = 0; // Track last HP/MP regen at home base let wasAtHomeBase = false; // Track if player was at home base last check - const HOME_REGEN_INTERVAL = 3000; // Regen every 3 seconds when at home - const HOME_REGEN_PERCENT = 5; // Regen 5% of max HP/MP per tick - const HOME_BASE_RADIUS = 20; // Meters - radius for home base effects + const HOME_REGEN_INTERVAL = 3000; // Regen every 3 seconds when at home (this stays constant) // Player buffs state (loaded from server) let playerBuffs = {}; // Buff status keyed by buffType @@ -4413,7 +4467,7 @@ if (!combatState && playerStats && !playerStats.isDead) { // Check if at home base const distToHome = getDistanceToHome(); - if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) { + if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) { playMusic('homebase'); } else { playMusic('overworld'); @@ -4500,7 +4554,7 @@ } else { // Check if at home base const distToHome = getDistanceToHome(); - if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) { + if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) { playMusic('homebase'); } else { playMusic('overworld'); @@ -6419,9 +6473,8 @@ }; ws.onmessage = (event) => { - console.log('Raw WebSocket message:', event.data); const data = JSON.parse(event.data); - console.log('Parsed message type:', data.type); + console.log('[WS] Received message type:', data.type); switch (data.type) { case 'init': @@ -6450,6 +6503,67 @@ removeOtherUser(data.userId); break; + case 'force_logout': + // Another session has started - log out this tab + console.log('Force logout received:', data.reason); + // Mark that we were force-logged out to prevent auto-reconnect + sessionStorage.setItem('forceLoggedOut', 'true'); + // Clear auth tokens + localStorage.removeItem('accessToken'); + localStorage.removeItem('currentUser'); + // Show login screen + currentUser = null; + playerStats = null; + statsLoadedFromServer = false; + document.getElementById('rpgHud').style.display = 'none'; + document.getElementById('deathOverlay').style.display = 'none'; + stopMonsterSpawning(); + if (homeBaseMarker) { + map.removeLayer(homeBaseMarker); + homeBaseMarker = null; + } + // Close WebSocket to prevent reconnection + if (ws) { + ws.close(); + ws = null; + } + // Show auth modal with message + document.getElementById('authModal').style.display = 'flex'; + alert('You were logged out because another session started.\n\nPlease log in again on THIS tab, or close it.'); + break; + + case 'settings_updated': + // Admin updated game settings - reload them + console.log('Settings updated by admin:', data.settings); + // Update local spawnSettings with the new values + if (data.settings) { + if (data.settings.monsterSpawnInterval !== undefined) spawnSettings.spawnInterval = data.settings.monsterSpawnInterval; + if (data.settings.monsterSpawnChance !== undefined) spawnSettings.spawnChance = data.settings.monsterSpawnChance; + if (data.settings.monsterSpawnDistance !== undefined) spawnSettings.spawnDistance = data.settings.monsterSpawnDistance; + if (data.settings.mpRegenDistance !== undefined) spawnSettings.mpRegenDistance = data.settings.mpRegenDistance; + if (data.settings.mpRegenAmount !== undefined) spawnSettings.mpRegenAmount = data.settings.mpRegenAmount; + if (data.settings.hpRegenInterval !== undefined) spawnSettings.hpRegenInterval = data.settings.hpRegenInterval; + if (data.settings.hpRegenPercent !== undefined) spawnSettings.hpRegenPercent = data.settings.hpRegenPercent; + if (data.settings.homeHpMultiplier !== undefined) spawnSettings.homeHpMultiplier = data.settings.homeHpMultiplier; + if (data.settings.homeRegenPercent !== undefined) spawnSettings.homeRegenPercent = data.settings.homeRegenPercent; + if (data.settings.homeBaseRadius !== undefined) spawnSettings.homeBaseRadius = data.settings.homeBaseRadius; + } + // Restart HP regen timer with new interval if it changed + if (data.settings.hpRegenInterval !== undefined) { + stopHpRegenTimer(); + startHpRegenTimer(); + } + showNotification('Game settings updated - refreshing...', 'info'); + setTimeout(() => location.reload(), 1500); + break; + + case 'admin_update': + // Admin made a change - refresh the page + console.log('Admin update:', data.changeType, data.details); + showNotification(`Game data updated: ${data.changeType} - refreshing...`, 'info'); + setTimeout(() => location.reload(), 1500); + break; + case 'geocacheUpdate': // Another user or device added or updated a geocache if (data.geocache) { @@ -9301,6 +9415,31 @@ statusEl.className = 'status' + (type ? ' ' + type : ''); } + // Toast notification system + function showNotification(message, type = 'info', duration = 8000) { + // Get or create toast container + let container = document.querySelector('.toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'toast-container'; + document.body.appendChild(container); + } + + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + // Auto-remove after duration + setTimeout(() => { + toast.classList.add('fade-out'); + setTimeout(() => toast.remove(), 300); + }, duration); + + return toast; + } + function updateTrackList() { const listEl = document.getElementById('trackList'); const countEl = document.getElementById('trackCount'); @@ -10430,6 +10569,7 @@ // Initialize RPG with the new character playerStats = characterData; + playerStats.dataVersion = 1; // Initial version for new character // Mark as loaded from server (we just created it there) statsLoadedFromServer = true; @@ -10682,10 +10822,16 @@ // Activate Second Wind buff async function activateSecondWind() { + console.log('activateSecondWind called'); const token = localStorage.getItem('accessToken'); - if (!token) return; + if (!token) { + console.error('No access token for Second Wind activation'); + showNotification('Please log in to use skills', 'error'); + return; + } try { + console.log('Sending buff activation request...'); const response = await fetch('/api/user/buffs/activate', { method: 'POST', headers: { @@ -11138,8 +11284,24 @@ }, body: JSON.stringify(playerStats) }) - .then(response => { - if (!response.ok) { + .then(async response => { + if (response.ok) { + const data = await response.json(); + // Update local version after successful save + if (data.dataVersion) { + playerStats.dataVersion = data.dataVersion; + console.log('Stats saved, version now:', data.dataVersion); + } + } else if (response.status === 409) { + // Data conflict - our data is stale + const error = await response.json(); + console.error('Data conflict detected! Our version is stale.', error); + showNotification('⚠️ Data out of sync - refreshing...', 'error'); + // Reload from server to get fresh data + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { console.error('Server rejected stats save:', response.status); response.json().then(err => console.error('Server error:', err)); showNotification('⚠️ Failed to save progress', 'error'); @@ -11200,7 +11362,7 @@ // Create or update home base marker on map function updateHomeBaseMarker() { - if (!playerStats || !playerStats.homeBaseLat || !playerStats.homeBaseLng) { + if (!playerStats || playerStats.homeBaseLat == null || playerStats.homeBaseLng == null) { if (homeBaseMarker) { map.removeLayer(homeBaseMarker); homeBaseMarker = null; @@ -11244,7 +11406,7 @@ // Update home base button text based on whether home is set function updateHomeBaseButton() { const btn = document.getElementById('homeBaseBtn'); - if (playerStats && playerStats.homeBaseLat && playerStats.homeBaseLng) { + if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) { btn.innerHTML = '🏠'; btn.title = 'Homebase Settings'; } else { @@ -11264,7 +11426,7 @@ } // If home base is already set, open the customization modal - if (playerStats && playerStats.homeBaseLat && playerStats.homeBaseLng) { + if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) { openHomebaseModal(); return; } @@ -11554,8 +11716,13 @@ // Select a homebase icon async function selectHomebaseIcon(iconId) { + console.log('selectHomebaseIcon called with:', iconId); const token = localStorage.getItem('accessToken'); - if (!token) return; + if (!token) { + console.error('No access token for icon selection'); + showNotification('Please log in to change icon', 'error'); + return; + } try { const response = await fetch('/api/user/home-base/icon', { @@ -11577,11 +11744,17 @@ // Update the marker on the map updateHomeBaseMarker(); + savePlayerStats(); + showNotification('Base icon updated!', 'success'); console.log('Home base icon updated to:', iconId); + } else { + const error = await response.json(); + showNotification(error.error || 'Failed to update icon', 'error'); } } catch (err) { console.error('Failed to update homebase icon:', err); + showNotification('Failed to update icon', 'error'); } } @@ -11663,7 +11836,7 @@ // Calculate distance to home base in meters function getDistanceToHome() { - if (!userLocation || !playerStats || !playerStats.homeBaseLat) return null; + if (!userLocation || !playerStats || playerStats.homeBaseLat == null) return null; const metersPerDegLat = 111320; const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180); @@ -11677,7 +11850,7 @@ // Check if player has reached home base for respawn function checkHomeBaseRespawn() { if (!playerStats || !playerStats.isDead) return; - if (!playerStats.homeBaseLat) return; + if (playerStats.homeBaseLat == null) return; const distance = getDistanceToHome(); if (distance === null) return; @@ -11689,7 +11862,7 @@ document.getElementById('homeDistanceText').textContent = distanceText; // Respawn if within home base radius - if (distance <= HOME_BASE_RADIUS) { + if (distance <= spawnSettings.homeBaseRadius) { respawnPlayer(); } } @@ -11698,21 +11871,21 @@ function checkHomeBaseRegen() { // Skip if dead, no home base, or no player stats if (!playerStats || playerStats.isDead) return; - if (!playerStats.homeBaseLat) return; + if (playerStats.homeBaseLat == null) return; // Skip if already at max MP (HP is handled by time-based regen) if (playerStats.mp >= playerStats.maxMp) return; // Check distance to home const distance = getDistanceToHome(); - if (distance === null || distance > HOME_BASE_RADIUS) return; + if (distance === null || distance > spawnSettings.homeBaseRadius) return; // Check if enough time has passed since last regen const now = Date.now(); if (now - lastHomeRegenTime < HOME_REGEN_INTERVAL) return; // Regenerate MP only (HP is handled by time-based regen with 3x at home) - const mpRegen = Math.ceil(playerStats.maxMp * (HOME_REGEN_PERCENT / 100)); + const mpRegen = Math.ceil(playerStats.maxMp * (spawnSettings.homeRegenPercent / 100)); if (playerStats.mp < playerStats.maxMp) { const oldMp = playerStats.mp; @@ -11779,7 +11952,8 @@ // Check if we've walked enough for MP regen if (mpRegenAccumulator >= regenDistance) { // Apply MP regen multiplier (e.g., 2x with Second Wind active) - const baseMpToRegen = Math.floor(mpRegenAccumulator / regenDistance); + const regenTicks = Math.floor(mpRegenAccumulator / regenDistance); + const baseMpToRegen = regenTicks * (spawnSettings.mpRegenAmount || 1); const mpToRegen = Math.floor(baseMpToRegen * mpRegenMultiplier); mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder @@ -11807,11 +11981,11 @@ // Check if at home base for 3x boost const distanceToHome = getDistanceToHome(); - const isAtHome = distanceToHome !== null && distanceToHome <= HOME_BASE_RADIUS; + const isAtHome = distanceToHome !== null && distanceToHome <= spawnSettings.homeBaseRadius; // Calculate HP to regen (1% base, 3% at home) - const multiplier = isAtHome ? HOME_HP_MULTIPLIER : 1; - const hpToRegen = Math.max(1, Math.ceil(playerStats.maxHp * (HP_REGEN_PERCENT / 100) * multiplier)); + const multiplier = isAtHome ? spawnSettings.homeHpMultiplier : 1; + const hpToRegen = Math.max(1, Math.ceil(playerStats.maxHp * (spawnSettings.hpRegenPercent / 100) * multiplier)); const oldHp = playerStats.hp; playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpToRegen); @@ -11832,7 +12006,7 @@ // HP regens every 10 seconds hpRegenTimer = setInterval(() => { checkTimeBasedHpRegen(); - }, HP_REGEN_INTERVAL); + }, spawnSettings.hpRegenInterval); } // Stop the HP regen timer @@ -11870,7 +12044,7 @@ // Check if at home base and clear monsters if entering function checkHomeBaseMonsterClear() { const distance = getDistanceToHome(); - const isAtHome = distance !== null && distance <= HOME_BASE_RADIUS; + const isAtHome = distance !== null && distance <= spawnSettings.homeBaseRadius; // Check if player just entered home base if (isAtHome && !wasAtHomeBase) { @@ -12210,7 +12384,7 @@ // Don't spawn monsters at home base const distanceToHome = getDistanceToHome(); - if (distanceToHome !== null && distanceToHome <= HOME_BASE_RADIUS) return; + if (distanceToHome !== null && distanceToHome <= spawnSettings.homeBaseRadius) return; // Movement-based spawning: first monster can spawn standing still, // but subsequent monsters require player to move the configured distance @@ -13180,7 +13354,7 @@ const monsterCount = combatState.monsters.filter(m => m.hp > 0).length; // If player has a home base, trigger death system - if (playerStats.homeBaseLat && playerStats.homeBaseLng) { + if (playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) { addCombatLog(`💀 You have been slain! Return to your home base to respawn.`, 'damage'); playerStats.mp = combatState.player.mp; @@ -13225,7 +13399,7 @@ // If victory music isn't playing, switch to appropriate ambient music if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) { const distToHome = getDistanceToHome(); - if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) { + if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) { playMusic('homebase'); } else { playMusic('overworld'); @@ -13287,7 +13461,7 @@ playMusic('death'); } else { const distToHome = getDistanceToHome(); - if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) { + if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) { playMusic('homebase'); } else { playMusic('overworld'); diff --git a/server.js b/server.js index 2521082..5c0b7e2 100644 --- a/server.js +++ b/server.js @@ -765,6 +765,16 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { } } + // Parse active_skills from JSON string (default to unlockedSkills for migration) + let activeSkills = unlockedSkills; // Default: use unlocked skills for existing users + if (stats.active_skills) { + try { + activeSkills = JSON.parse(stats.active_skills); + } catch (e) { + console.error('Failed to parse active_skills:', e); + } + } + // Convert snake_case from DB to camelCase for client res.json({ name: stats.character_name, @@ -781,11 +791,13 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { accuracy: stats.accuracy || 90, dodge: stats.dodge || 10, unlockedSkills: unlockedSkills, + activeSkills: activeSkills, homeBaseLat: stats.home_base_lat, homeBaseLng: stats.home_base_lng, lastHomeSet: stats.last_home_set, isDead: !!stats.is_dead, - homeBaseIcon: stats.home_base_icon || '00' + homeBaseIcon: stats.home_base_icon || '00', + dataVersion: stats.data_version || 1 }); } else { // No stats yet - return null so client creates defaults @@ -849,14 +861,121 @@ app.put('/api/user/rpg-stats', authenticateToken, (req, res) => { return res.status(400).json({ error: 'Invalid stats data' }); } - db.saveRpgStats(req.user.userId, stats); - res.json({ success: true }); + // Pass client's data version for checking + const clientVersion = stats.dataVersion || null; + const result = db.saveRpgStats(req.user.userId, stats, clientVersion); + + if (result.success) { + res.json({ success: true, dataVersion: result.newVersion }); + } else { + // Stale data - client needs to reload + console.log(`[STALE DATA] User ${req.user.userId} tried to save version ${clientVersion}, server has ${result.currentVersion}`); + res.status(409).json({ + error: 'Data conflict - your data is out of date', + reason: result.reason, + currentVersion: result.currentVersion + }); + } } catch (err) { console.error('Save RPG stats error:', err); res.status(500).json({ error: 'Failed to save RPG stats' }); } }); +// Beacon endpoint for saving stats on page close (no response needed) +app.post('/api/user/rpg-stats-beacon', (req, res) => { + try { + const { token, stats } = req.body; + + if (!token || !stats) { + return res.status(400).end(); + } + + // Verify token manually + let decoded; + try { + decoded = jwt.verify(token, JWT_SECRET); + } catch (err) { + return res.status(401).end(); + } + + // Use version checking to prevent stale data overwrites + const clientVersion = stats.dataVersion || null; + const result = db.saveRpgStats(decoded.userId, stats, clientVersion); + + if (!result.success) { + console.log(`[BEACON STALE] User ${decoded.userId} beacon rejected: version ${clientVersion} < ${result.currentVersion}`); + } + + res.status(200).end(); + } catch (err) { + console.error('Beacon save error:', err); + res.status(500).end(); + } +}); + +// Swap active skill (for skill loadout at home base) +app.post('/api/user/swap-skill', authenticateToken, (req, res) => { + try { + const { tier, newSkillId, currentActiveSkills, unlockedSkills } = req.body; + + // Validate inputs + if (tier === undefined || !newSkillId) { + return res.status(400).json({ error: 'Tier and skill ID are required' }); + } + + // Validate skill is unlocked + if (!unlockedSkills || !unlockedSkills.includes(newSkillId)) { + return res.status(400).json({ error: 'Skill is not unlocked' }); + } + + // Build new active skills array + // Remove any existing skill from the same tier, add new skill + let newActiveSkills = currentActiveSkills ? [...currentActiveSkills] : ['basic_attack']; + + // Filter out the old skill from this tier (client sends the old skill ID via tier mapping) + // Since we don't have skill tier info on server, trust client's currentActiveSkills + // and just ensure the new skill replaces the old one from same tier + + // Add the new skill if not already present + if (!newActiveSkills.includes(newSkillId)) { + newActiveSkills.push(newSkillId); + } + + // Save to database + const stats = db.getRpgStats(req.user.userId); + if (!stats) { + return res.status(404).json({ error: 'Character not found' }); + } + + // Parse existing data + let existingUnlocked = ['basic_attack']; + if (stats.unlocked_skills) { + try { + existingUnlocked = JSON.parse(stats.unlocked_skills); + } catch (e) {} + } + + db.saveRpgStats(req.user.userId, { + ...stats, + name: stats.character_name, + maxHp: stats.max_hp, + maxMp: stats.max_mp, + unlockedSkills: existingUnlocked, + activeSkills: newActiveSkills, + homeBaseLat: stats.home_base_lat, + homeBaseLng: stats.home_base_lng, + lastHomeSet: stats.last_home_set, + isDead: !!stats.is_dead + }); + + res.json({ success: true, activeSkills: newActiveSkills }); + } catch (err) { + console.error('Swap skill error:', err); + res.status(500).json({ error: 'Failed to swap skill' }); + } +}); + // Check if user can set home base (once per day) app.get('/api/user/can-set-home', authenticateToken, (req, res) => { try { @@ -930,7 +1049,13 @@ app.get('/api/spawn-settings', (req, res) => { spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'), spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'), spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10'), - mpRegenDistance: JSON.parse(db.getSetting('mpRegenDistance') || '5') + mpRegenDistance: JSON.parse(db.getSetting('mpRegenDistance') || '5'), + mpRegenAmount: JSON.parse(db.getSetting('mpRegenAmount') || '1'), + hpRegenInterval: JSON.parse(db.getSetting('hpRegenInterval') || '10000'), + 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') }; res.json(settings); } catch (err) { @@ -1002,6 +1127,144 @@ app.post('/api/user/respawn', authenticateToken, (req, res) => { } }); +// ============================================ +// Player Buff Endpoints +// ============================================ + +// Get all buffs for current user (with status info) +app.get('/api/user/buffs', authenticateToken, (req, res) => { + try { + const buffs = db.getPlayerBuffs(req.user.userId); + const now = Math.floor(Date.now() / 1000); + + // Format buffs with status info + const formatted = buffs.map(b => { + const cooldownEnds = b.activated_at + (b.cooldown_hours * 3600); + return { + buffType: b.buff_type, + effectType: b.effect_type, + effectValue: b.effect_value, + activatedAt: b.activated_at, + expiresAt: b.expires_at, + cooldownHours: b.cooldown_hours, + isActive: b.expires_at > now, + isOnCooldown: cooldownEnds > now, + expiresIn: Math.max(0, b.expires_at - now), + cooldownEndsIn: Math.max(0, cooldownEnds - now) + }; + }); + + res.json(formatted); + } catch (err) { + console.error('Get buffs error:', err); + res.status(500).json({ error: 'Failed to get buffs' }); + } +}); + +// Get specific buff status (for checking before activation) +app.get('/api/user/buffs/:buffType', authenticateToken, (req, res) => { + try { + const buff = db.getBuffWithCooldown(req.user.userId, req.params.buffType); + + if (!buff) { + // Never used - can activate + res.json({ + buffType: req.params.buffType, + canActivate: true, + isActive: false, + isOnCooldown: false + }); + } else { + res.json({ + buffType: buff.buff_type, + effectType: buff.effect_type, + effectValue: buff.effect_value, + canActivate: !buff.isOnCooldown, + isActive: buff.isActive, + isOnCooldown: buff.isOnCooldown, + expiresIn: buff.expiresIn, + cooldownEndsIn: buff.cooldownEndsIn + }); + } + } catch (err) { + console.error('Get buff status error:', err); + res.status(500).json({ error: 'Failed to get buff status' }); + } +}); + +// Activate a buff (utility skill) +app.post('/api/user/buffs/activate', authenticateToken, (req, res) => { + try { + const { buffType } = req.body; + + if (!buffType) { + return res.status(400).json({ error: 'Buff type is required' }); + } + + // Define buff configurations + const BUFF_CONFIGS = { + 'second_wind': { + effectType: 'mp_regen_multiplier', + effectValue: 2.0, // Double MP regen + durationHours: 1, // 1 hour + cooldownHours: 24 // 24 hour cooldown + } + // Add more buff types here as needed + }; + + const config = BUFF_CONFIGS[buffType]; + if (!config) { + return res.status(400).json({ error: 'Unknown buff type' }); + } + + // Check if buff can be activated (not on cooldown) + if (!db.canActivateBuff(req.user.userId, buffType)) { + const buff = db.getBuffWithCooldown(req.user.userId, buffType); + return res.status(400).json({ + error: 'Buff is on cooldown', + cooldownEndsIn: buff.cooldownEndsIn + }); + } + + // Activate the buff + db.activateBuff( + req.user.userId, + buffType, + config.effectType, + config.effectValue, + config.durationHours, + config.cooldownHours + ); + + const buff = db.getBuffWithCooldown(req.user.userId, buffType); + + console.log(`User ${req.user.username} activated ${buffType} buff`); + + res.json({ + success: true, + buffType: buffType, + effectType: config.effectType, + effectValue: config.effectValue, + expiresIn: buff.expiresIn, + cooldownEndsIn: buff.cooldownEndsIn + }); + } catch (err) { + console.error('Activate buff error:', err); + res.status(500).json({ error: 'Failed to activate buff' }); + } +}); + +// Get MP regen multiplier for current user (used by client for walking regen) +app.get('/api/user/mp-regen-multiplier', authenticateToken, (req, res) => { + try { + const multiplier = db.getBuffMultiplier(req.user.userId, 'mp_regen_multiplier'); + res.json({ multiplier }); + } catch (err) { + console.error('Get MP regen multiplier error:', err); + res.status(500).json({ error: 'Failed to get multiplier' }); + } +}); + // Get all monster types (public endpoint - needed for game rendering) app.get('/api/monster-types', (req, res) => { try { @@ -1239,6 +1502,7 @@ app.post('/api/admin/monster-types', adminOnly, async (req, res) => { } } + broadcastAdminChange('monster', { action: 'created' }); res.json({ success: true }); } catch (err) { console.error('Admin create monster type error:', err); @@ -1251,6 +1515,7 @@ app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateMonsterType(req.params.id, data); + broadcastAdminChange('monster', { action: 'updated', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin update monster type error:', err); @@ -1263,6 +1528,7 @@ app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => { try { const { enabled } = req.body; db.toggleMonsterEnabled(req.params.id, enabled); + broadcastAdminChange('monster', { action: 'toggled', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin toggle monster error:', err); @@ -1274,6 +1540,7 @@ app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => { app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => { try { db.deleteMonsterType(req.params.id); + broadcastAdminChange('monster', { action: 'deleted', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin delete monster type error:', err); @@ -1391,11 +1658,23 @@ app.get('/api/admin/settings', adminOnly, (req, res) => { // Update game settings app.put('/api/admin/settings', adminOnly, (req, res) => { + console.log('[SETTINGS] Admin settings update received'); try { const settings = req.body; + console.log('[SETTINGS] Settings to save:', Object.keys(settings)); for (const [key, value] of Object.entries(settings)) { db.setSetting(key, JSON.stringify(value)); } + + // Broadcast settings update to all connected clients + console.log('[SETTINGS] Broadcasting settings update to all clients'); + const clientCount = [...wss.clients].filter(c => c.readyState === 1).length; + console.log(`[SETTINGS] Active WebSocket clients: ${clientCount}`); + broadcast({ + type: 'settings_updated', + settings: settings + }, null); // null = send to ALL clients including sender + res.json({ success: true }); } catch (err) { console.error('Admin update settings error:', err); @@ -1442,6 +1721,7 @@ app.post('/api/admin/skills', adminOnly, (req, res) => { return res.status(400).json({ error: 'Missing required fields (id and name)' }); } db.createSkill(data); + broadcastAdminChange('skill', { action: 'created' }); res.json({ success: true }); } catch (err) { console.error('Admin create skill error:', err); @@ -1454,6 +1734,7 @@ app.put('/api/admin/skills/:id', adminOnly, (req, res) => { try { const data = req.body; db.updateSkill(req.params.id, data); + broadcastAdminChange('skill', { action: 'updated', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin update skill error:', err); @@ -1465,6 +1746,7 @@ app.put('/api/admin/skills/:id', adminOnly, (req, res) => { app.delete('/api/admin/skills/:id', adminOnly, (req, res) => { try { db.deleteSkill(req.params.id); + broadcastAdminChange('skill', { action: 'deleted', id: req.params.id }); res.json({ success: true }); } catch (err) { console.error('Admin delete skill error:', err); @@ -1816,6 +2098,19 @@ function broadcast(data, senderId) { }); } +// Broadcast admin changes to all clients +function broadcastAdminChange(changeType, details = {}) { + let clientCount = 0; + wss.clients.forEach(c => { if (c.readyState === WebSocket.OPEN) clientCount++; }); + console.log(`[ADMIN] Broadcasting ${changeType} change to ${clientCount} clients`); + broadcast({ + type: 'admin_update', + changeType: changeType, + details: details, + timestamp: Date.now() + }, null); +} + // Map authenticated user IDs to WebSocket connections for targeted messages const authUserConnections = new Map(); // authUserId (number) -> ws connection @@ -1876,6 +2171,33 @@ wss.on('connection', (ws) => { if (data.type === 'auth') { // Register authenticated user's WebSocket connection if (data.authUserId) { + // Check if this user already has an active connection (old tab) + const existingConnection = authUserConnections.get(data.authUserId); + console.log(`[SESSION] User ${data.authUserId} auth - existing connection:`, existingConnection ? 'yes' : 'no'); + if (existingConnection && existingConnection !== ws) { + console.log(`[SESSION] Existing connection state: ${existingConnection.readyState} (OPEN=${WebSocket.OPEN})`); + if (existingConnection.readyState === WebSocket.OPEN) { + // Force logout the old tab + console.log(`[SESSION] Kicking old session for user ${data.authUserId}`); + try { + existingConnection.send(JSON.stringify({ + type: 'force_logout', + reason: 'Another session has started' + })); + console.log(`[SESSION] Sent force_logout to old connection`); + } catch (e) { + console.error(`[SESSION] Failed to send force_logout:`, e); + } + // Close the old connection after a brief delay + setTimeout(() => { + if (existingConnection.readyState === WebSocket.OPEN) { + existingConnection.close(4000, 'Replaced by new session'); + console.log(`[SESSION] Closed old connection`); + } + }, 1000); + } + } + ws.authUserId = data.authUserId; authUserConnections.set(data.authUserId, ws); console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`); @@ -1981,16 +2303,19 @@ wss.on('connection', (ws) => { ws.on('close', () => { removeUser(userId); - // Clean up auth user mapping - if (ws.authUserId) { + // Clean up auth user mapping - but only if THIS connection is still the active one + // (don't remove if a newer connection replaced us) + if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) { authUserConnections.delete(ws.authUserId); + console.log(`[SESSION] Removed auth mapping for user ${ws.authUserId} (connection closed)`); } }); ws.on('error', (err) => { console.error(`WebSocket error for user ${userId}:`, err); removeUser(userId); - if (ws.authUserId) { + // Same check - only remove if we're still the active connection + if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) { authUserConnections.delete(ws.authUserId); } });