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)
+
+
+
+
+ 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);
}
});