Browse Source

Add toast notifications, admin broadcast system, and regen settings

- 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 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
07b9cb8041
  1. 178
      admin.html
  2. 204
      database.js
  3. 244
      index.html
  4. 339
      server.js

178
admin.html

@ -842,6 +842,46 @@
<input type="number" id="setting-mpRegenDistance" placeholder="5" min="0" step="1"> <input type="number" id="setting-mpRegenDistance" placeholder="5" min="0" step="1">
<small style="color: #888; font-size: 11px;">Meters walked to regen 1 MP (0 = disabled)</small> <small style="color: #888; font-size: 11px;">Meters walked to regen 1 MP (0 = disabled)</small>
</div> </div>
<div class="form-group">
<label>MP Regen Amount</label>
<input type="number" id="setting-mpRegenAmount" placeholder="1" min="1" step="1">
<small style="color: #888; font-size: 11px;">MP gained per distance threshold</small>
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">HP Regeneration</h3>
<div class="form-row">
<div class="form-group">
<label>HP Regen Interval (seconds)</label>
<input type="number" id="setting-hpRegenInterval" placeholder="10" min="1" step="1">
<small style="color: #888; font-size: 11px;">Time between HP regen ticks</small>
</div>
<div class="form-group">
<label>HP Regen Percent</label>
<input type="number" step="0.1" id="setting-hpRegenPercent" placeholder="1" min="0.1">
<small style="color: #888; font-size: 11px;">% of max HP restored per tick</small>
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Home Base Bonuses</h3>
<div class="form-row">
<div class="form-group">
<label>Home HP Regen Multiplier</label>
<input type="number" step="0.5" id="setting-homeHpMultiplier" placeholder="3" min="1">
<small style="color: #888; font-size: 11px;">HP regen multiplier when at home (e.g., 3 = 3x faster)</small>
</div>
<div class="form-group">
<label>Home Regen Percent</label>
<input type="number" step="1" id="setting-homeRegenPercent" placeholder="5" min="1">
<small style="color: #888; font-size: 11px;">% of max HP/MP per tick when at home base</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Home Base Radius (meters)</label>
<input type="number" id="setting-homeBaseRadius" placeholder="20" min="5" step="1">
<small style="color: #888; font-size: 11px;">Distance from home to get bonuses</small>
</div>
<div class="form-group"> <div class="form-group">
<!-- Placeholder for future settings --> <!-- Placeholder for future settings -->
</div> </div>
@ -1454,7 +1494,7 @@
} }
try { try {
await api('/api/admin/monster-skills', {
const result = await api('/api/admin/monster-skills', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
monster_type_id: monsterId, monster_type_id: monsterId,
@ -1464,8 +1504,20 @@
min_level: minLevel 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'); showToast('Skill added');
await loadMonsterSkills(monsterId);
renderMonsterSkills();
loadMonsterSkills(monsterId); // Background refresh for consistency
document.getElementById('addSkillSelect').value = ''; document.getElementById('addSkillSelect').value = '';
document.getElementById('addSkillCustomName').value = ''; document.getElementById('addSkillCustomName').value = '';
} catch (e) { } catch (e) {
@ -1493,6 +1545,9 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify({ [field]: parseInt(value) }) 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) { } catch (e) {
showToast('Failed to update skill: ' + e.message, 'error'); showToast('Failed to update skill: ' + e.message, 'error');
} }
@ -1501,9 +1556,12 @@
async function removeMonsterSkill(id) { async function removeMonsterSkill(id) {
try { try {
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' }); await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
currentMonsterSkills = currentMonsterSkills.filter(ms => ms.id !== id);
showToast('Skill removed'); showToast('Skill removed');
renderMonsterSkills();
const monsterId = document.getElementById('monsterId').value; const monsterId = document.getElementById('monsterId').value;
await loadMonsterSkills(monsterId);
loadMonsterSkills(monsterId); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error'); showToast('Failed to remove skill: ' + e.message, 'error');
} }
@ -1560,8 +1618,11 @@
try { try {
await api(`/api/admin/monster-types/${id}`, { method: 'DELETE' }); await api(`/api/admin/monster-types/${id}`, { method: 'DELETE' });
// Remove from local array immediately
monsters = monsters.filter(m => m.id !== id);
showToast('Monster deleted'); showToast('Monster deleted');
loadMonsters();
renderMonsterTable();
loadMonsters(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to delete monster: ' + e.message, 'error'); showToast('Failed to delete monster: ' + e.message, 'error');
} }
@ -1686,16 +1747,26 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) 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'); showToast('Monster updated');
} else { } else {
await api('/api/admin/monster-types', {
const result = await api('/api/admin/monster-types', {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
// Add to local array immediately
if (result && result.id) {
monsters.push({ id: result.id, ...data });
}
showToast('Monster created'); showToast('Monster created');
} }
renderMonsterTable();
closeMonsterModal(); closeMonsterModal();
loadMonsters();
loadMonsters(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to save monster: ' + e.message, 'error'); showToast('Failed to save monster: ' + e.message, 'error');
} }
@ -1767,8 +1838,11 @@
try { try {
await api(`/api/admin/skills/${id}`, { method: 'DELETE' }); await api(`/api/admin/skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
allSkills = allSkills.filter(s => s.id !== id);
showToast('Skill deleted'); showToast('Skill deleted');
loadSkillsAdmin();
renderSkillTable();
loadSkillsAdmin(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to delete skill: ' + e.message, 'error'); showToast('Failed to delete skill: ' + e.message, 'error');
} }
@ -1868,16 +1942,24 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) 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'); showToast('Skill updated');
} else { } else {
await api('/api/admin/skills', { await api('/api/admin/skills', {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
// Add to local array immediately
allSkills.push({ ...data });
showToast('Skill created'); showToast('Skill created');
} }
renderSkillTable();
closeSkillModal(); closeSkillModal();
loadSkillsAdmin();
loadSkillsAdmin(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to save skill: ' + e.message, 'error'); showToast('Failed to save skill: ' + e.message, 'error');
} }
@ -1953,8 +2035,11 @@
try { try {
await api(`/api/admin/classes/${id}`, { method: 'DELETE' }); await api(`/api/admin/classes/${id}`, { method: 'DELETE' });
// Remove from local array immediately
allClasses = allClasses.filter(c => c.id !== id);
showToast('Class deleted'); showToast('Class deleted');
loadClasses();
renderClassTable();
loadClasses(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to delete class: ' + e.message, 'error'); showToast('Failed to delete class: ' + e.message, 'error');
} }
@ -2037,16 +2122,24 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) 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'); showToast('Class updated');
} else { } else {
await api('/api/admin/classes', { await api('/api/admin/classes', {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
// Add to local array immediately
allClasses.push({ ...data });
showToast('Class created'); showToast('Class created');
} }
renderClassTable();
closeClassModal(); closeClassModal();
loadClasses();
loadClasses(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to save class: ' + e.message, 'error'); showToast('Failed to save class: ' + e.message, 'error');
} }
@ -2158,7 +2251,7 @@
} }
try { try {
await api('/api/admin/class-skills', {
const result = await api('/api/admin/class-skills', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
class_id: classId, class_id: classId,
@ -2168,8 +2261,20 @@
custom_name: customName || null 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'); showToast('Skill added');
await loadClassSkills(classId);
renderClassSkills();
loadClassSkills(classId); // Background refresh for consistency
document.getElementById('addClassSkillSelect').value = ''; document.getElementById('addClassSkillSelect').value = '';
document.getElementById('addClassSkillName').value = ''; document.getElementById('addClassSkillName').value = '';
document.getElementById('addClassSkillLevel').value = '1'; document.getElementById('addClassSkillLevel').value = '1';
@ -2193,9 +2298,9 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) 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) { } catch (e) {
showToast('Failed to update skill: ' + e.message, 'error'); showToast('Failed to update skill: ' + e.message, 'error');
} }
@ -2204,9 +2309,12 @@
async function removeClassSkill(id) { async function removeClassSkill(id) {
try { try {
await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' }); await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
currentClassSkills = currentClassSkills.filter(cs => cs.id !== id);
showToast('Skill removed'); showToast('Skill removed');
renderClassSkills();
const classId = document.getElementById('classEditId').value; const classId = document.getElementById('classEditId').value;
await loadClassSkills(classId);
loadClassSkills(classId); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error'); showToast('Failed to remove skill: ' + e.message, 'error');
} }
@ -2264,8 +2372,14 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify({ is_admin: isAdmin }) 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'); showToast(isAdmin ? 'Admin granted' : 'Admin revoked');
loadUsers();
renderUserTable();
loadUsers(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to update admin status: ' + e.message, 'error'); showToast('Failed to update admin status: ' + e.message, 'error');
} }
@ -2343,9 +2457,15 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) 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'); showToast('User updated');
renderUserTable();
closeUserModal(); closeUserModal();
loadUsers();
loadUsers(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to update user: ' + e.message, 'error'); showToast('Failed to update user: ' + e.message, 'error');
} }
@ -2366,6 +2486,15 @@
document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0; document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0;
document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false; document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false;
document.getElementById('setting-mpRegenDistance').value = settings.mpRegenDistance || 5; 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) { } catch (e) {
showToast('Failed to load settings: ' + e.message, 'error'); showToast('Failed to load settings: ' + e.message, 'error');
} }
@ -2374,6 +2503,7 @@
document.getElementById('saveSettingsBtn').addEventListener('click', async () => { document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
// Convert interval from seconds to ms for storage // Convert interval from seconds to ms for storage
const intervalSeconds = parseInt(document.getElementById('setting-monsterSpawnInterval').value) || 20; const intervalSeconds = parseInt(document.getElementById('setting-monsterSpawnInterval').value) || 20;
const hpIntervalSeconds = parseInt(document.getElementById('setting-hpRegenInterval').value) || 10;
const newSettings = { const newSettings = {
monsterSpawnInterval: intervalSeconds * 1000, monsterSpawnInterval: intervalSeconds * 1000,
monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50, monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50,
@ -2381,7 +2511,13 @@
maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10, maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10,
xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0, xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0,
combatEnabled: document.getElementById('setting-combatEnabled').checked, 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 { try {
@ -2389,8 +2525,10 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify(newSettings) body: JSON.stringify(newSettings)
}); });
// Update local settings immediately
settings = { ...settings, ...newSettings };
showToast('Settings saved'); showToast('Settings saved');
loadSettings();
loadSettings(); // Background refresh for consistency
} catch (e) { } catch (e) {
showToast('Failed to save settings: ' + e.message, 'error'); showToast('Failed to save settings: ' + e.message, 'error');
} }

204
database.js

@ -91,6 +91,12 @@ class HikeMapDB {
try { try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN unlocked_skills TEXT DEFAULT '["basic_attack"]'`); this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN unlocked_skills TEXT DEFAULT '["basic_attack"]'`);
} catch (e) { /* Column already exists */ } } 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 // Monster entourage table - stores monsters following the player
this.db.exec(` 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 // Create indexes for performance
this.db.exec(` this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id); 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_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_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_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 // RPG Stats methods
getRpgStats(userId) { getRpgStats(userId) {
const stmt = this.db.prepare(` 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 = ? FROM rpg_stats WHERE user_id = ?
`); `);
return stmt.get(userId); 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) { hasCharacter(userId) {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT 1 FROM rpg_stats WHERE user_id = ? AND character_name IS NOT NULL SELECT 1 FROM rpg_stats WHERE user_id = ? AND character_name IS NOT NULL
@ -538,8 +569,8 @@ class HikeMapDB {
createCharacter(userId, characterData) { createCharacter(userId, characterData) {
const stmt = this.db.prepare(` 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 ON CONFLICT(user_id) DO UPDATE SET
character_name = excluded.character_name, character_name = excluded.character_name,
race = excluded.race, race = excluded.race,
@ -555,10 +586,12 @@ class HikeMapDB {
accuracy = excluded.accuracy, accuracy = excluded.accuracy,
dodge = excluded.dodge, dodge = excluded.dodge,
unlocked_skills = excluded.unlocked_skills, unlocked_skills = excluded.unlocked_skills,
active_skills = excluded.active_skills,
updated_at = datetime('now') 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 unlockedSkillsJson = JSON.stringify(characterData.unlockedSkills || ['basic_attack']);
const activeSkillsJson = JSON.stringify(characterData.activeSkills || ['basic_attack']);
return stmt.run( return stmt.run(
userId, userId,
characterData.name, characterData.name,
@ -574,14 +607,29 @@ class HikeMapDB {
characterData.def || 8, characterData.def || 8,
characterData.accuracy || 90, characterData.accuracy || 90,
characterData.dodge || 10, 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(` 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 ON CONFLICT(user_id) DO UPDATE SET
character_name = COALESCE(excluded.character_name, rpg_stats.character_name), character_name = COALESCE(excluded.character_name, rpg_stats.character_name),
race = COALESCE(excluded.race, rpg_stats.race), race = COALESCE(excluded.race, rpg_stats.race),
@ -597,15 +645,18 @@ class HikeMapDB {
accuracy = excluded.accuracy, accuracy = excluded.accuracy,
dodge = excluded.dodge, dodge = excluded.dodge,
unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills), 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_lat = COALESCE(excluded.home_base_lat, rpg_stats.home_base_lat),
home_base_lng = COALESCE(excluded.home_base_lng, rpg_stats.home_base_lng), 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), last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set),
is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead), is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead),
data_version = excluded.data_version,
updated_at = datetime('now') 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; const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null;
return stmt.run(
const activeSkillsJson = stats.activeSkills ? JSON.stringify(stats.activeSkills) : null;
stmt.run(
userId, userId,
stats.name || null, stats.name || null,
stats.race || null, stats.race || null,
@ -621,11 +672,16 @@ class HikeMapDB {
stats.accuracy || 90, stats.accuracy || 90,
stats.dodge || 10, stats.dodge || 10,
unlockedSkillsJson, unlockedSkillsJson,
activeSkillsJson,
stats.homeBaseLat || null, stats.homeBaseLat || null,
stats.homeBaseLng || null, stats.homeBaseLng || null,
stats.lastHomeSet || 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 // Set home base location
@ -1247,15 +1303,15 @@ class HikeMapDB {
{ {
id: 'second_wind', id: 'second_wind',
name: '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, mpCost: 0,
basePower: 30,
basePower: 0,
accuracy: 100, accuracy: 100,
hitCount: 1, hitCount: 1,
target: 'self', 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 monsterUsable: false
}, },
{ {
@ -1359,6 +1415,23 @@ class HikeMapDB {
} }
console.log('Default skills seeded successfully'); 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(` const stmt = this.db.prepare(`
UPDATE rpg_stats SET UPDATE rpg_stats SET
level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50, 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') updated_at = datetime('now')
WHERE user_id = ? WHERE user_id = ?
`); `);
@ -1820,6 +1896,9 @@ class HikeMapDB {
// Also clear their monster entourage // Also clear their monster entourage
this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId); 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; 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() { close() {
if (this.db) { if (this.db) {
this.db.close(); this.db.close();

244
index.html

@ -2985,6 +2985,53 @@
padding: 12px; 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; }
}
</style> </style>
</head> </head>
<body> <body>
@ -4083,6 +4130,12 @@
spawnSettings.spawnChance = settings.spawnChance || 50; spawnSettings.spawnChance = settings.spawnChance || 50;
spawnSettings.spawnDistance = settings.spawnDistance || 10; spawnSettings.spawnDistance = settings.spawnDistance || 10;
spawnSettings.mpRegenDistance = settings.mpRegenDistance || 5; 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); console.log('Loaded spawn settings:', spawnSettings);
} }
} catch (err) { } catch (err) {
@ -4333,18 +4386,21 @@
spawnInterval: 20000, // Timer interval in ms spawnInterval: 20000, // Timer interval in ms
spawnChance: 50, // Percent chance per interval spawnChance: 50, // Percent chance per interval
spawnDistance: 10, // Meters player must move 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) // MP regen tracking (distance-based)
let lastMpRegenLocation = null; // Track location for MP regen distance let lastMpRegenLocation = null; // Track location for MP regen distance
let mpRegenAccumulator = 0; // Accumulated distance for MP regen 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 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 // Home Base state variables
let homeBaseMarker = null; // Leaflet marker for home base let homeBaseMarker = null; // Leaflet marker for home base
@ -4352,9 +4408,7 @@
let xpLostOnDeath = 0; // Track XP lost for display let xpLostOnDeath = 0; // Track XP lost for display
let lastHomeRegenTime = 0; // Track last HP/MP regen at home base let lastHomeRegenTime = 0; // Track last HP/MP regen at home base
let wasAtHomeBase = false; // Track if player was at home base last check 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) // Player buffs state (loaded from server)
let playerBuffs = {}; // Buff status keyed by buffType let playerBuffs = {}; // Buff status keyed by buffType
@ -4413,7 +4467,7 @@
if (!combatState && playerStats && !playerStats.isDead) { if (!combatState && playerStats && !playerStats.isDead) {
// Check if at home base // Check if at home base
const distToHome = getDistanceToHome(); const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase'); playMusic('homebase');
} else { } else {
playMusic('overworld'); playMusic('overworld');
@ -4500,7 +4554,7 @@
} else { } else {
// Check if at home base // Check if at home base
const distToHome = getDistanceToHome(); const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase'); playMusic('homebase');
} else { } else {
playMusic('overworld'); playMusic('overworld');
@ -6419,9 +6473,8 @@
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
console.log('Raw WebSocket message:', event.data);
const data = JSON.parse(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) { switch (data.type) {
case 'init': case 'init':
@ -6450,6 +6503,67 @@
removeOtherUser(data.userId); removeOtherUser(data.userId);
break; 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': case 'geocacheUpdate':
// Another user or device added or updated a geocache // Another user or device added or updated a geocache
if (data.geocache) { if (data.geocache) {
@ -9301,6 +9415,31 @@
statusEl.className = 'status' + (type ? ' ' + type : ''); 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() { function updateTrackList() {
const listEl = document.getElementById('trackList'); const listEl = document.getElementById('trackList');
const countEl = document.getElementById('trackCount'); const countEl = document.getElementById('trackCount');
@ -10430,6 +10569,7 @@
// Initialize RPG with the new character // Initialize RPG with the new character
playerStats = characterData; playerStats = characterData;
playerStats.dataVersion = 1; // Initial version for new character
// Mark as loaded from server (we just created it there) // Mark as loaded from server (we just created it there)
statsLoadedFromServer = true; statsLoadedFromServer = true;
@ -10682,10 +10822,16 @@
// Activate Second Wind buff // Activate Second Wind buff
async function activateSecondWind() { async function activateSecondWind() {
console.log('activateSecondWind called');
const token = localStorage.getItem('accessToken'); 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 { try {
console.log('Sending buff activation request...');
const response = await fetch('/api/user/buffs/activate', { const response = await fetch('/api/user/buffs/activate', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -11138,8 +11284,24 @@
}, },
body: JSON.stringify(playerStats) 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); console.error('Server rejected stats save:', response.status);
response.json().then(err => console.error('Server error:', err)); response.json().then(err => console.error('Server error:', err));
showNotification('⚠️ Failed to save progress', 'error'); showNotification('⚠️ Failed to save progress', 'error');
@ -11200,7 +11362,7 @@
// Create or update home base marker on map // Create or update home base marker on map
function updateHomeBaseMarker() { function updateHomeBaseMarker() {
if (!playerStats || !playerStats.homeBaseLat || !playerStats.homeBaseLng) {
if (!playerStats || playerStats.homeBaseLat == null || playerStats.homeBaseLng == null) {
if (homeBaseMarker) { if (homeBaseMarker) {
map.removeLayer(homeBaseMarker); map.removeLayer(homeBaseMarker);
homeBaseMarker = null; homeBaseMarker = null;
@ -11244,7 +11406,7 @@
// Update home base button text based on whether home is set // Update home base button text based on whether home is set
function updateHomeBaseButton() { function updateHomeBaseButton() {
const btn = document.getElementById('homeBaseBtn'); const btn = document.getElementById('homeBaseBtn');
if (playerStats && playerStats.homeBaseLat && playerStats.homeBaseLng) {
if (playerStats && playerStats.homeBaseLat != null && playerStats.homeBaseLng != null) {
btn.innerHTML = '🏠'; btn.innerHTML = '🏠';
btn.title = 'Homebase Settings'; btn.title = 'Homebase Settings';
} else { } else {
@ -11264,7 +11426,7 @@
} }
// If home base is already set, open the customization modal // 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(); openHomebaseModal();
return; return;
} }
@ -11554,8 +11716,13 @@
// Select a homebase icon // Select a homebase icon
async function selectHomebaseIcon(iconId) { async function selectHomebaseIcon(iconId) {
console.log('selectHomebaseIcon called with:', iconId);
const token = localStorage.getItem('accessToken'); 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 { try {
const response = await fetch('/api/user/home-base/icon', { const response = await fetch('/api/user/home-base/icon', {
@ -11577,11 +11744,17 @@
// Update the marker on the map // Update the marker on the map
updateHomeBaseMarker(); updateHomeBaseMarker();
savePlayerStats();
showNotification('Base icon updated!', 'success');
console.log('Home base icon updated to:', iconId); console.log('Home base icon updated to:', iconId);
} else {
const error = await response.json();
showNotification(error.error || 'Failed to update icon', 'error');
} }
} catch (err) { } catch (err) {
console.error('Failed to update homebase icon:', 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 // Calculate distance to home base in meters
function getDistanceToHome() { function getDistanceToHome() {
if (!userLocation || !playerStats || !playerStats.homeBaseLat) return null;
if (!userLocation || !playerStats || playerStats.homeBaseLat == null) return null;
const metersPerDegLat = 111320; const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180); const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
@ -11677,7 +11850,7 @@
// Check if player has reached home base for respawn // Check if player has reached home base for respawn
function checkHomeBaseRespawn() { function checkHomeBaseRespawn() {
if (!playerStats || !playerStats.isDead) return; if (!playerStats || !playerStats.isDead) return;
if (!playerStats.homeBaseLat) return;
if (playerStats.homeBaseLat == null) return;
const distance = getDistanceToHome(); const distance = getDistanceToHome();
if (distance === null) return; if (distance === null) return;
@ -11689,7 +11862,7 @@
document.getElementById('homeDistanceText').textContent = distanceText; document.getElementById('homeDistanceText').textContent = distanceText;
// Respawn if within home base radius // Respawn if within home base radius
if (distance <= HOME_BASE_RADIUS) {
if (distance <= spawnSettings.homeBaseRadius) {
respawnPlayer(); respawnPlayer();
} }
} }
@ -11698,21 +11871,21 @@
function checkHomeBaseRegen() { function checkHomeBaseRegen() {
// Skip if dead, no home base, or no player stats // Skip if dead, no home base, or no player stats
if (!playerStats || playerStats.isDead) return; 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) // Skip if already at max MP (HP is handled by time-based regen)
if (playerStats.mp >= playerStats.maxMp) return; if (playerStats.mp >= playerStats.maxMp) return;
// Check distance to home // Check distance to home
const distance = getDistanceToHome(); 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 // Check if enough time has passed since last regen
const now = Date.now(); const now = Date.now();
if (now - lastHomeRegenTime < HOME_REGEN_INTERVAL) return; if (now - lastHomeRegenTime < HOME_REGEN_INTERVAL) return;
// Regenerate MP only (HP is handled by time-based regen with 3x at home) // 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) { if (playerStats.mp < playerStats.maxMp) {
const oldMp = playerStats.mp; const oldMp = playerStats.mp;
@ -11779,7 +11952,8 @@
// Check if we've walked enough for MP regen // Check if we've walked enough for MP regen
if (mpRegenAccumulator >= regenDistance) { if (mpRegenAccumulator >= regenDistance) {
// Apply MP regen multiplier (e.g., 2x with Second Wind active) // 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); const mpToRegen = Math.floor(baseMpToRegen * mpRegenMultiplier);
mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder mpRegenAccumulator = mpRegenAccumulator % regenDistance; // Keep remainder
@ -11807,11 +11981,11 @@
// Check if at home base for 3x boost // Check if at home base for 3x boost
const distanceToHome = getDistanceToHome(); 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) // 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; const oldHp = playerStats.hp;
playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpToRegen); playerStats.hp = Math.min(playerStats.maxHp, playerStats.hp + hpToRegen);
@ -11832,7 +12006,7 @@
// HP regens every 10 seconds // HP regens every 10 seconds
hpRegenTimer = setInterval(() => { hpRegenTimer = setInterval(() => {
checkTimeBasedHpRegen(); checkTimeBasedHpRegen();
}, HP_REGEN_INTERVAL);
}, spawnSettings.hpRegenInterval);
} }
// Stop the HP regen timer // Stop the HP regen timer
@ -11870,7 +12044,7 @@
// Check if at home base and clear monsters if entering // Check if at home base and clear monsters if entering
function checkHomeBaseMonsterClear() { function checkHomeBaseMonsterClear() {
const distance = getDistanceToHome(); 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 // Check if player just entered home base
if (isAtHome && !wasAtHomeBase) { if (isAtHome && !wasAtHomeBase) {
@ -12210,7 +12384,7 @@
// Don't spawn monsters at home base // Don't spawn monsters at home base
const distanceToHome = getDistanceToHome(); 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, // Movement-based spawning: first monster can spawn standing still,
// but subsequent monsters require player to move the configured distance // but subsequent monsters require player to move the configured distance
@ -13180,7 +13354,7 @@
const monsterCount = combatState.monsters.filter(m => m.hp > 0).length; const monsterCount = combatState.monsters.filter(m => m.hp > 0).length;
// If player has a home base, trigger death system // 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'); addCombatLog(`💀 You have been slain! Return to your home base to respawn.`, 'damage');
playerStats.mp = combatState.player.mp; playerStats.mp = combatState.player.mp;
@ -13225,7 +13399,7 @@
// If victory music isn't playing, switch to appropriate ambient music // If victory music isn't playing, switch to appropriate ambient music
if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) { if (gameMusic.currentTrack !== 'victory' || gameMusic.victory.paused) {
const distToHome = getDistanceToHome(); const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase'); playMusic('homebase');
} else { } else {
playMusic('overworld'); playMusic('overworld');
@ -13287,7 +13461,7 @@
playMusic('death'); playMusic('death');
} else { } else {
const distToHome = getDistanceToHome(); const distToHome = getDistanceToHome();
if (distToHome !== null && distToHome <= HOME_BASE_RADIUS) {
if (distToHome !== null && distToHome <= spawnSettings.homeBaseRadius) {
playMusic('homebase'); playMusic('homebase');
} else { } else {
playMusic('overworld'); playMusic('overworld');

339
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 // Convert snake_case from DB to camelCase for client
res.json({ res.json({
name: stats.character_name, name: stats.character_name,
@ -781,11 +791,13 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
accuracy: stats.accuracy || 90, accuracy: stats.accuracy || 90,
dodge: stats.dodge || 10, dodge: stats.dodge || 10,
unlockedSkills: unlockedSkills, unlockedSkills: unlockedSkills,
activeSkills: activeSkills,
homeBaseLat: stats.home_base_lat, homeBaseLat: stats.home_base_lat,
homeBaseLng: stats.home_base_lng, homeBaseLng: stats.home_base_lng,
lastHomeSet: stats.last_home_set, lastHomeSet: stats.last_home_set,
isDead: !!stats.is_dead, isDead: !!stats.is_dead,
homeBaseIcon: stats.home_base_icon || '00'
homeBaseIcon: stats.home_base_icon || '00',
dataVersion: stats.data_version || 1
}); });
} else { } else {
// No stats yet - return null so client creates defaults // 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' }); 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) { } catch (err) {
console.error('Save RPG stats error:', err); console.error('Save RPG stats error:', err);
res.status(500).json({ error: 'Failed to save RPG stats' }); 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) // Check if user can set home base (once per day)
app.get('/api/user/can-set-home', authenticateToken, (req, res) => { app.get('/api/user/can-set-home', authenticateToken, (req, res) => {
try { try {
@ -930,7 +1049,13 @@ app.get('/api/spawn-settings', (req, res) => {
spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'), spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'),
spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'), spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'),
spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10'), 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); res.json(settings);
} catch (err) { } 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) // Get all monster types (public endpoint - needed for game rendering)
app.get('/api/monster-types', (req, res) => { app.get('/api/monster-types', (req, res) => {
try { try {
@ -1239,6 +1502,7 @@ app.post('/api/admin/monster-types', adminOnly, async (req, res) => {
} }
} }
broadcastAdminChange('monster', { action: 'created' });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin create monster type error:', err); console.error('Admin create monster type error:', err);
@ -1251,6 +1515,7 @@ app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try { try {
const data = req.body; const data = req.body;
db.updateMonsterType(req.params.id, data); db.updateMonsterType(req.params.id, data);
broadcastAdminChange('monster', { action: 'updated', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin update monster type error:', 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 { try {
const { enabled } = req.body; const { enabled } = req.body;
db.toggleMonsterEnabled(req.params.id, enabled); db.toggleMonsterEnabled(req.params.id, enabled);
broadcastAdminChange('monster', { action: 'toggled', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin toggle monster error:', 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) => { app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try { try {
db.deleteMonsterType(req.params.id); db.deleteMonsterType(req.params.id);
broadcastAdminChange('monster', { action: 'deleted', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin delete monster type error:', err); console.error('Admin delete monster type error:', err);
@ -1391,11 +1658,23 @@ app.get('/api/admin/settings', adminOnly, (req, res) => {
// Update game settings // Update game settings
app.put('/api/admin/settings', adminOnly, (req, res) => { app.put('/api/admin/settings', adminOnly, (req, res) => {
console.log('[SETTINGS] Admin settings update received');
try { try {
const settings = req.body; const settings = req.body;
console.log('[SETTINGS] Settings to save:', Object.keys(settings));
for (const [key, value] of Object.entries(settings)) { for (const [key, value] of Object.entries(settings)) {
db.setSetting(key, JSON.stringify(value)); 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 }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin update settings error:', 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)' }); return res.status(400).json({ error: 'Missing required fields (id and name)' });
} }
db.createSkill(data); db.createSkill(data);
broadcastAdminChange('skill', { action: 'created' });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin create skill error:', err); console.error('Admin create skill error:', err);
@ -1454,6 +1734,7 @@ app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
try { try {
const data = req.body; const data = req.body;
db.updateSkill(req.params.id, data); db.updateSkill(req.params.id, data);
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin update skill error:', 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) => { app.delete('/api/admin/skills/:id', adminOnly, (req, res) => {
try { try {
db.deleteSkill(req.params.id); db.deleteSkill(req.params.id);
broadcastAdminChange('skill', { action: 'deleted', id: req.params.id });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin delete skill error:', 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 // Map authenticated user IDs to WebSocket connections for targeted messages
const authUserConnections = new Map(); // authUserId (number) -> ws connection const authUserConnections = new Map(); // authUserId (number) -> ws connection
@ -1876,6 +2171,33 @@ wss.on('connection', (ws) => {
if (data.type === 'auth') { if (data.type === 'auth') {
// Register authenticated user's WebSocket connection // Register authenticated user's WebSocket connection
if (data.authUserId) { 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; ws.authUserId = data.authUserId;
authUserConnections.set(data.authUserId, ws); authUserConnections.set(data.authUserId, ws);
console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`); console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`);
@ -1981,16 +2303,19 @@ wss.on('connection', (ws) => {
ws.on('close', () => { ws.on('close', () => {
removeUser(userId); 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); authUserConnections.delete(ws.authUserId);
console.log(`[SESSION] Removed auth mapping for user ${ws.authUserId} (connection closed)`);
} }
}); });
ws.on('error', (err) => { ws.on('error', (err) => {
console.error(`WebSocket error for user ${userId}:`, err); console.error(`WebSocket error for user ${userId}:`, err);
removeUser(userId); 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); authUserConnections.delete(ws.authUserId);
} }
}); });

Loading…
Cancel
Save