You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2175 lines
80 KiB

const Database = require('better-sqlite3');
const bcrypt = require('bcrypt');
const path = require('path');
const BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS) || 12;
class HikeMapDB {
constructor(dbPath) {
this.dbPath = dbPath || path.join(__dirname, 'data', 'hikemap.db');
this.db = null;
}
init() {
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
this.createTables();
console.log(`Database initialized at ${this.dbPath}`);
return this;
}
createTables() {
// Users table
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
total_points INTEGER DEFAULT 0,
finds_count INTEGER DEFAULT 0,
avatar_icon TEXT DEFAULT 'account',
avatar_color TEXT DEFAULT '#4CAF50',
is_admin BOOLEAN DEFAULT 0
)
`);
// Geocache finds table
this.db.exec(`
CREATE TABLE IF NOT EXISTS geocache_finds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
geocache_id TEXT NOT NULL,
found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
points_earned INTEGER NOT NULL,
is_first_finder BOOLEAN DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, geocache_id)
)
`);
// Refresh tokens table for logout/token invalidation
this.db.exec(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// RPG stats table
this.db.exec(`
CREATE TABLE IF NOT EXISTS rpg_stats (
user_id INTEGER PRIMARY KEY,
character_name TEXT,
race TEXT DEFAULT 'human',
class TEXT NOT NULL DEFAULT 'trail_runner',
level INTEGER DEFAULT 1,
xp INTEGER DEFAULT 0,
hp INTEGER DEFAULT 100,
max_hp INTEGER DEFAULT 100,
mp INTEGER DEFAULT 50,
max_mp INTEGER DEFAULT 50,
atk INTEGER DEFAULT 12,
def INTEGER DEFAULT 8,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Migration: Add character_name and race columns if they don't exist
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN character_name TEXT`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN race TEXT DEFAULT 'human'`);
} catch (e) { /* Column already exists */ }
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(`
CREATE TABLE IF NOT EXISTS monster_entourage (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
monster_type TEXT NOT NULL,
level INTEGER NOT NULL,
hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
atk INTEGER NOT NULL,
def INTEGER NOT NULL,
position_lat REAL,
position_lng REAL,
spawn_time INTEGER NOT NULL,
last_dialogue_time INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Monster types table - defines available monster types
this.db.exec(`
CREATE TABLE IF NOT EXISTS monster_types (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL,
base_hp INTEGER NOT NULL,
base_atk INTEGER NOT NULL,
base_def INTEGER NOT NULL,
xp_reward INTEGER NOT NULL,
level_scale_hp INTEGER NOT NULL,
level_scale_atk INTEGER NOT NULL,
level_scale_def INTEGER NOT NULL,
min_level INTEGER DEFAULT 1,
max_level INTEGER DEFAULT 5,
spawn_weight INTEGER DEFAULT 100,
dialogues TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Add columns if they don't exist (migration for existing databases)
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN min_level INTEGER DEFAULT 1`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN max_level INTEGER DEFAULT 5`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`);
} catch (e) { /* Column already exists */ }
// Skills table - defines available skills/spells
this.db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
type TEXT NOT NULL,
mp_cost INTEGER DEFAULT 0,
base_power INTEGER DEFAULT 0,
accuracy INTEGER DEFAULT 100,
hit_count INTEGER DEFAULT 1,
target TEXT DEFAULT 'enemy',
status_effect TEXT,
player_usable BOOLEAN DEFAULT 1,
monster_usable BOOLEAN DEFAULT 1,
enabled BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Classes table - defines playable classes
this.db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
base_hp INTEGER DEFAULT 100,
base_mp INTEGER DEFAULT 50,
base_atk INTEGER DEFAULT 12,
base_def INTEGER DEFAULT 8,
base_accuracy INTEGER DEFAULT 90,
base_dodge INTEGER DEFAULT 10,
hp_per_level INTEGER DEFAULT 10,
mp_per_level INTEGER DEFAULT 5,
atk_per_level INTEGER DEFAULT 2,
def_per_level INTEGER DEFAULT 1,
enabled BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Class skills - assigns skills to classes with unlock levels and choice groups
this.db.exec(`
CREATE TABLE IF NOT EXISTS class_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
unlock_level INTEGER DEFAULT 1,
choice_group INTEGER,
custom_name TEXT,
custom_description TEXT,
UNIQUE(class_id, skill_id)
)
`);
// Class skill names - class-specific naming for skills (legacy, replaced by class_skills)
this.db.exec(`
CREATE TABLE IF NOT EXISTS class_skill_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
skill_id TEXT NOT NULL,
class_id TEXT NOT NULL,
custom_name TEXT NOT NULL,
custom_description TEXT,
UNIQUE(skill_id, class_id)
)
`);
// Monster skills - skills assigned to monster types
this.db.exec(`
CREATE TABLE IF NOT EXISTS monster_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monster_type_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
weight INTEGER DEFAULT 10,
min_level INTEGER DEFAULT 1,
UNIQUE(monster_type_id, skill_id)
)
`);
// Migration: Add accuracy/dodge to rpg_stats
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN accuracy INTEGER DEFAULT 90`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN dodge INTEGER DEFAULT 10`);
} catch (e) { /* Column already exists */ }
// Migration: Add accuracy/dodge to monster_types
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN accuracy INTEGER DEFAULT 85`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN dodge INTEGER DEFAULT 5`);
} catch (e) { /* Column already exists */ }
// Migration: Add MP to monster_types
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN base_mp INTEGER DEFAULT 20`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN level_scale_mp INTEGER DEFAULT 5`);
} catch (e) { /* Column already exists */ }
// Migration: Add custom_name to monster_skills for per-monster skill renaming
try {
this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_name TEXT`);
} catch (e) { /* Column already exists */ }
// Migration: Add targeting_mode to skills for multi-hit targeting behavior
try {
this.db.exec(`ALTER TABLE skills ADD COLUMN targeting_mode TEXT DEFAULT 'same_target'`);
} catch (e) { /* Column already exists */ }
// Migration: Add home base and death system columns to rpg_stats
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_lat REAL`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_lng REAL`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN last_home_set TEXT`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN is_dead INTEGER DEFAULT 0`);
} catch (e) { /* Column already exists */ }
try {
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_icon TEXT DEFAULT '00'`);
} catch (e) { /* Column already exists */ }
// Game settings table - key/value store for game configuration
this.db.exec(`
CREATE TABLE IF NOT EXISTS game_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 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);
CREATE INDEX IF NOT EXISTS idx_geocache_finds_geocache ON geocache_finds(geocache_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_monster_entourage_user ON monster_entourage(user_id);
CREATE INDEX IF NOT EXISTS idx_class_skill_names_skill ON class_skill_names(skill_id);
CREATE INDEX IF NOT EXISTS idx_class_skill_names_class ON class_skill_names(class_id);
CREATE INDEX IF NOT EXISTS idx_monster_skills_monster ON monster_skills(monster_type_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_skill ON class_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_player_buffs_player ON player_buffs(player_id);
`);
}
// User methods
async createUser(username, email, password) {
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
try {
const stmt = this.db.prepare(`
INSERT INTO users (username, email, password_hash)
VALUES (?, ?, ?)
`);
const result = stmt.run(username.toLowerCase(), email.toLowerCase(), passwordHash);
return { id: result.lastInsertRowid, username, email };
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
if (err.message.includes('username')) {
throw new Error('Username already exists');
}
if (err.message.includes('email')) {
throw new Error('Email already exists');
}
}
throw err;
}
}
async validateUser(usernameOrEmail, password) {
const stmt = this.db.prepare(`
SELECT * FROM users
WHERE username = ? OR email = ?
`);
const user = stmt.get(usernameOrEmail.toLowerCase(), usernameOrEmail.toLowerCase());
if (!user) {
return null;
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return null;
}
// Don't return password hash
const { password_hash, ...safeUser } = user;
return safeUser;
}
getUserById(userId) {
const stmt = this.db.prepare(`
SELECT id, username, email, created_at, total_points, finds_count,
avatar_icon, avatar_color, is_admin
FROM users WHERE id = ?
`);
return stmt.get(userId);
}
getUserByUsername(username) {
const stmt = this.db.prepare(`
SELECT id, username, email, created_at, total_points, finds_count,
avatar_icon, avatar_color, is_admin
FROM users WHERE username = ?
`);
return stmt.get(username.toLowerCase());
}
updateUserAvatar(userId, icon, color) {
const stmt = this.db.prepare(`
UPDATE users SET avatar_icon = ?, avatar_color = ?
WHERE id = ?
`);
return stmt.run(icon, color, userId);
}
setUserAdmin(userId, isAdmin) {
const stmt = this.db.prepare(`
UPDATE users SET is_admin = ? WHERE id = ?
`);
return stmt.run(isAdmin ? 1 : 0, userId);
}
setUserAdminByUsername(username, isAdmin) {
const stmt = this.db.prepare(`
UPDATE users SET is_admin = ? WHERE username = ?
`);
return stmt.run(isAdmin ? 1 : 0, username.toLowerCase());
}
// Geocache find methods
recordFind(userId, geocacheId, pointsEarned, isFirstFinder = false) {
const transaction = this.db.transaction(() => {
// Insert the find record
const insertStmt = this.db.prepare(`
INSERT INTO geocache_finds (user_id, geocache_id, points_earned, is_first_finder)
VALUES (?, ?, ?, ?)
`);
try {
insertStmt.run(userId, geocacheId, pointsEarned, isFirstFinder ? 1 : 0);
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('You have already found this geocache');
}
throw err;
}
// Update user's total points and finds count
const updateStmt = this.db.prepare(`
UPDATE users
SET total_points = total_points + ?,
finds_count = finds_count + 1
WHERE id = ?
`);
updateStmt.run(pointsEarned, userId);
return { success: true, pointsEarned };
});
return transaction();
}
hasUserFoundGeocache(userId, geocacheId) {
const stmt = this.db.prepare(`
SELECT 1 FROM geocache_finds
WHERE user_id = ? AND geocache_id = ?
`);
return !!stmt.get(userId, geocacheId);
}
isFirstFinder(geocacheId) {
const stmt = this.db.prepare(`
SELECT 1 FROM geocache_finds
WHERE geocache_id = ?
LIMIT 1
`);
return !stmt.get(geocacheId);
}
getGeocacheFinders(geocacheId) {
const stmt = this.db.prepare(`
SELECT u.id, u.username, u.avatar_icon, u.avatar_color,
gf.found_at, gf.points_earned, gf.is_first_finder
FROM geocache_finds gf
JOIN users u ON gf.user_id = u.id
WHERE gf.geocache_id = ?
ORDER BY gf.found_at ASC
`);
return stmt.all(geocacheId);
}
getUserFinds(userId, limit = 50) {
const stmt = this.db.prepare(`
SELECT geocache_id, found_at, points_earned, is_first_finder
FROM geocache_finds
WHERE user_id = ?
ORDER BY found_at DESC
LIMIT ?
`);
return stmt.all(userId, limit);
}
// Leaderboard methods
getLeaderboard(period = 'all', limit = 50) {
let whereClause = '';
if (period === 'weekly') {
whereClause = "WHERE gf.found_at >= datetime('now', '-7 days')";
} else if (period === 'monthly') {
whereClause = "WHERE gf.found_at >= datetime('now', '-30 days')";
}
if (period === 'all') {
// For all-time, use the cached total_points
const stmt = this.db.prepare(`
SELECT id, username, avatar_icon, avatar_color, total_points, finds_count
FROM users
ORDER BY total_points DESC
LIMIT ?
`);
return stmt.all(limit);
} else {
// For weekly/monthly, calculate from finds
const stmt = this.db.prepare(`
SELECT u.id, u.username, u.avatar_icon, u.avatar_color,
SUM(gf.points_earned) as total_points,
COUNT(gf.id) as finds_count
FROM users u
JOIN geocache_finds gf ON u.id = gf.user_id
${whereClause}
GROUP BY u.id
ORDER BY total_points DESC
LIMIT ?
`);
return stmt.all(limit);
}
}
// Refresh token methods
async storeRefreshToken(userId, tokenHash, expiresAt) {
const stmt = this.db.prepare(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES (?, ?, ?)
`);
return stmt.run(userId, tokenHash, expiresAt);
}
getRefreshToken(tokenHash) {
const stmt = this.db.prepare(`
SELECT * FROM refresh_tokens
WHERE token_hash = ? AND expires_at > datetime('now')
`);
return stmt.get(tokenHash);
}
deleteRefreshToken(tokenHash) {
const stmt = this.db.prepare(`
DELETE FROM refresh_tokens WHERE token_hash = ?
`);
return stmt.run(tokenHash);
}
deleteUserRefreshTokens(userId) {
const stmt = this.db.prepare(`
DELETE FROM refresh_tokens WHERE user_id = ?
`);
return stmt.run(userId);
}
cleanExpiredTokens() {
const stmt = this.db.prepare(`
DELETE FROM refresh_tokens WHERE expires_at <= datetime('now')
`);
return stmt.run();
}
clearAllRefreshTokens() {
const stmt = this.db.prepare(`DELETE FROM refresh_tokens`);
const result = stmt.run();
console.log(`Cleared ${result.changes} refresh tokens on startup`);
return result;
}
// 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, 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
`);
return !!stmt.get(userId);
}
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, active_skills, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
character_name = excluded.character_name,
race = excluded.race,
class = excluded.class,
level = excluded.level,
xp = excluded.xp,
hp = excluded.hp,
max_hp = excluded.max_hp,
mp = excluded.mp,
max_mp = excluded.max_mp,
atk = excluded.atk,
def = excluded.def,
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 (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,
characterData.race || 'human',
characterData.class || 'trail_runner',
characterData.level || 1,
characterData.xp || 0,
characterData.hp || 100,
characterData.maxHp || 100,
characterData.mp || 50,
characterData.maxMp || 50,
characterData.atk || 12,
characterData.def || 8,
characterData.accuracy || 90,
characterData.dodge || 10,
unlockedSkillsJson,
activeSkillsJson
);
}
// 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, 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),
class = excluded.class,
level = excluded.level,
xp = excluded.xp,
hp = excluded.hp,
max_hp = excluded.max_hp,
mp = excluded.mp,
max_mp = excluded.max_mp,
atk = excluded.atk,
def = excluded.def,
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 skills arrays to JSON strings for storage
const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null;
const activeSkillsJson = stats.activeSkills ? JSON.stringify(stats.activeSkills) : null;
stmt.run(
userId,
stats.name || null,
stats.race || null,
stats.class || 'trail_runner',
stats.level || 1,
stats.xp || 0,
stats.hp || 100,
stats.maxHp || 100,
stats.mp || 50,
stats.maxMp || 50,
stats.atk || 12,
stats.def || 8,
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,
newVersion
);
console.log(`[VERSION CHECK] Saved user ${userId} stats, version ${currentVersion} -> ${newVersion}`);
return { success: true, newVersion };
}
// Set home base location
setHomeBase(userId, lat, lng) {
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
home_base_lat = ?,
home_base_lng = ?,
last_home_set = datetime('now'),
updated_at = datetime('now')
WHERE user_id = ?
`);
return stmt.run(lat, lng, userId);
}
// Update home base icon
setHomeBaseIcon(userId, iconId) {
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
home_base_icon = ?,
updated_at = datetime('now')
WHERE user_id = ?
`);
return stmt.run(iconId, userId);
}
// Check if user can set home base (once per day)
canSetHomeBase(userId) {
const stmt = this.db.prepare(`
SELECT last_home_set FROM rpg_stats WHERE user_id = ?
`);
const result = stmt.get(userId);
if (!result || !result.last_home_set) return true;
const lastSet = new Date(result.last_home_set);
const now = new Date();
const hoursSince = (now - lastSet) / (1000 * 60 * 60);
return hoursSince >= 24;
}
// Handle player death
handlePlayerDeath(userId, xpPenaltyPercent = 10) {
// Get current stats to calculate XP penalty
const stats = this.getRpgStats(userId);
if (!stats) return null;
// Calculate XP loss - can't drop below current level threshold
const currentLevel = stats.level;
const levelThresholds = [0, 100, 250, 500, 800, 1200]; // XP needed for each level
const minXp = levelThresholds[currentLevel - 1] || 0;
const xpLoss = Math.floor(stats.xp * (xpPenaltyPercent / 100));
const newXp = Math.max(minXp, stats.xp - xpLoss);
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
is_dead = 1,
hp = 0,
xp = ?,
updated_at = datetime('now')
WHERE user_id = ?
`);
stmt.run(newXp, userId);
return { xpLost: stats.xp - newXp, newXp };
}
// Respawn player at home base
respawnPlayer(userId) {
const stats = this.getRpgStats(userId);
if (!stats) return null;
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
is_dead = 0,
hp = max_hp,
mp = max_mp,
updated_at = datetime('now')
WHERE user_id = ?
`);
return stmt.run(userId);
}
// Monster entourage methods
getMonsterEntourage(userId) {
const stmt = this.db.prepare(`
SELECT id, monster_type, level, hp, max_hp, atk, def,
position_lat, position_lng, spawn_time, last_dialogue_time
FROM monster_entourage WHERE user_id = ?
`);
return stmt.all(userId);
}
saveMonsterEntourage(userId, monsters) {
const deleteStmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`);
const insertStmt = this.db.prepare(`
INSERT INTO monster_entourage
(id, user_id, monster_type, level, hp, max_hp, atk, def, position_lat, position_lng, spawn_time, last_dialogue_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const transaction = this.db.transaction(() => {
deleteStmt.run(userId);
for (const monster of monsters) {
insertStmt.run(
monster.id,
userId,
monster.type,
monster.level,
monster.hp,
monster.maxHp,
monster.atk,
monster.def,
monster.position?.lat || null,
monster.position?.lng || null,
monster.spawnTime,
monster.lastDialogueTime || 0
);
}
});
return transaction();
}
removeMonster(userId, monsterId) {
const stmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ? AND id = ?`);
return stmt.run(userId, monsterId);
}
clearMonsterEntourage(userId) {
const stmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`);
return stmt.run(userId);
}
// Monster type methods
getAllMonsterTypes(enabledOnly = true) {
const stmt = enabledOnly
? this.db.prepare(`SELECT * FROM monster_types WHERE enabled = 1`)
: this.db.prepare(`SELECT * FROM monster_types`);
return stmt.all();
}
getMonsterType(id) {
const stmt = this.db.prepare(`SELECT * FROM monster_types WHERE id = ?`);
return stmt.get(id);
}
createMonsterType(monsterData) {
const stmt = this.db.prepare(`
INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward,
level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled,
base_mp, level_scale_mp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// Support both camelCase (legacy) and snake_case (new admin UI) field names
const baseHp = monsterData.baseHp || monsterData.base_hp;
const baseAtk = monsterData.baseAtk || monsterData.base_atk;
const baseDef = monsterData.baseDef || monsterData.base_def;
const baseMp = monsterData.baseMp || monsterData.base_mp || 20;
const xpReward = monsterData.xpReward || monsterData.base_xp;
// Merge levelScale with defaults for any missing properties
const levelScaleInput = monsterData.levelScale || {};
const levelScale = {
hp: levelScaleInput.hp ?? monsterData.level_scale_hp ?? 10,
atk: levelScaleInput.atk ?? monsterData.level_scale_atk ?? 2,
def: levelScaleInput.def ?? monsterData.level_scale_def ?? 1,
mp: levelScaleInput.mp ?? monsterData.level_scale_mp ?? 5
};
const minLevel = monsterData.minLevel || monsterData.min_level || 1;
const maxLevel = monsterData.maxLevel || monsterData.max_level || 5;
const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100;
const dialogues = typeof monsterData.dialogues === 'string'
? monsterData.dialogues
: JSON.stringify(monsterData.dialogues);
return stmt.run(
monsterData.id || monsterData.key,
monsterData.name,
monsterData.icon || '🟢',
baseHp,
baseAtk,
baseDef,
xpReward,
levelScale.hp,
levelScale.atk,
levelScale.def,
minLevel,
maxLevel,
spawnWeight,
dialogues,
monsterData.enabled !== false ? 1 : 0,
baseMp,
levelScale.mp || 5
);
}
updateMonsterType(id, monsterData) {
const stmt = this.db.prepare(`
UPDATE monster_types SET
name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?,
xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?,
min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?,
base_mp = ?, level_scale_mp = ?
WHERE id = ?
`);
// Support both camelCase (legacy) and snake_case (new admin UI) field names
const baseHp = monsterData.baseHp || monsterData.base_hp;
const baseAtk = monsterData.baseAtk || monsterData.base_atk;
const baseDef = monsterData.baseDef || monsterData.base_def;
const baseMp = monsterData.baseMp || monsterData.base_mp || 20;
const xpReward = monsterData.xpReward || monsterData.base_xp;
// Merge levelScale with defaults for any missing properties
const levelScaleInput = monsterData.levelScale || {};
const levelScale = {
hp: levelScaleInput.hp ?? monsterData.level_scale_hp ?? 10,
atk: levelScaleInput.atk ?? monsterData.level_scale_atk ?? 2,
def: levelScaleInput.def ?? monsterData.level_scale_def ?? 1,
mp: levelScaleInput.mp ?? monsterData.level_scale_mp ?? 5
};
const minLevel = monsterData.minLevel || monsterData.min_level || 1;
const maxLevel = monsterData.maxLevel || monsterData.max_level || 5;
const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100;
const dialogues = typeof monsterData.dialogues === 'string'
? monsterData.dialogues
: JSON.stringify(monsterData.dialogues);
return stmt.run(
monsterData.name,
monsterData.icon || '🟢',
baseHp,
baseAtk,
baseDef,
xpReward,
levelScale.hp,
levelScale.atk,
levelScale.def,
minLevel,
maxLevel,
spawnWeight,
dialogues,
monsterData.enabled !== false ? 1 : 0,
baseMp,
levelScale.mp || 5,
id
);
}
deleteMonsterType(id) {
const stmt = this.db.prepare(`DELETE FROM monster_types WHERE id = ?`);
return stmt.run(id);
}
toggleMonsterEnabled(id, enabled) {
const stmt = this.db.prepare(`UPDATE monster_types SET enabled = ? WHERE id = ?`);
return stmt.run(enabled ? 1 : 0, id);
}
seedDefaultMonsters() {
// Check if Moop already exists
const existing = this.getMonsterType('moop');
if (existing) return;
// Seed Moop - Matter Out Of Place
this.createMonsterType({
id: 'moop',
name: 'Moop',
icon: '🟢',
baseHp: 30,
baseAtk: 5,
baseDef: 2,
xpReward: 15,
levelScale: { hp: 10, atk: 2, def: 1 },
dialogues: {
annoyed: [
"Hey! HEY! I don't belong here!",
"Excuse me, I'm MATTER OUT OF PLACE!",
"This is a Leave No Trace trail!",
"I was in your pocket! YOUR POCKET!",
"Pack it in, pack it out! Remember?!"
],
frustrated: [
"STOP IGNORING ME!",
"I don't belong here and YOU know it!",
"I'm literally the definition of litter!",
"MOOP! MATTER! OUT! OF! PLACE!",
"Fine! Just keep walking! SEE IF I CARE!"
],
desperate: [
"Please... just pick me up...",
"I promise I'll fit in your pocket!",
"What if I promised to be biodegradable?",
"I just want to go to a proper bin...",
"I didn't ask to be abandoned here!"
],
philosophical: [
"What even IS place, when you think about it?",
"If matter is out of place, is place out of matter?",
"Perhaps being misplaced is the true journey.",
"Am I out of place, or is place out of me?",
"We're not so different, you and I..."
],
existential: [
"I have accepted my displacement.",
"All matter is eventually out of place.",
"I've made peace with being moop.",
"The trail will reclaim me eventually.",
"It's actually kind of nice out here. Good views."
]
},
enabled: true
});
console.log('Seeded default monster: Moop');
}
// =====================
// SKILLS METHODS
// =====================
getAllSkills(enabledOnly = false) {
const stmt = enabledOnly
? this.db.prepare(`SELECT * FROM skills WHERE enabled = 1`)
: this.db.prepare(`SELECT * FROM skills`);
return stmt.all();
}
getSkill(id) {
const stmt = this.db.prepare(`SELECT * FROM skills WHERE id = ?`);
return stmt.get(id);
}
// Get utility skill configuration from status_effect JSON
// Returns { effectType, effectValue, durationHours, cooldownHours } or null
getUtilitySkillConfig(skillId) {
const skill = this.getSkill(skillId);
if (!skill || skill.type !== 'utility') return null;
if (!skill.status_effect) return null;
try {
return JSON.parse(skill.status_effect);
} catch {
return null;
}
}
createSkill(skillData) {
const stmt = this.db.prepare(`
INSERT INTO skills (id, name, description, type, mp_cost, base_power, accuracy, hit_count, target, targeting_mode, status_effect, player_usable, monster_usable, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const statusEffect = skillData.statusEffect
? (typeof skillData.statusEffect === 'string' ? skillData.statusEffect : JSON.stringify(skillData.statusEffect))
: null;
return stmt.run(
skillData.id,
skillData.name,
skillData.description,
skillData.type || 'damage',
skillData.mpCost || skillData.mp_cost || 0,
skillData.basePower || skillData.base_power || 0,
skillData.accuracy || 100,
skillData.hitCount || skillData.hit_count || 1,
skillData.target || 'enemy',
skillData.targetingMode || skillData.targeting_mode || 'same_target',
statusEffect,
skillData.playerUsable !== false ? 1 : 0,
skillData.monsterUsable !== false ? 1 : 0,
skillData.enabled !== false ? 1 : 0
);
}
updateSkill(id, skillData) {
const stmt = this.db.prepare(`
UPDATE skills SET
name = ?, description = ?, type = ?, mp_cost = ?, base_power = ?,
accuracy = ?, hit_count = ?, target = ?, targeting_mode = ?, status_effect = ?,
player_usable = ?, monster_usable = ?, enabled = ?
WHERE id = ?
`);
// Handle both camelCase and snake_case field names
const rawStatusEffect = skillData.statusEffect || skillData.status_effect;
const statusEffect = rawStatusEffect
? (typeof rawStatusEffect === 'string' ? rawStatusEffect : JSON.stringify(rawStatusEffect))
: null;
// Handle player_usable/playerUsable (check for explicit false)
const playerUsable = skillData.player_usable !== undefined
? (skillData.player_usable ? 1 : 0)
: (skillData.playerUsable !== false ? 1 : 0);
const monsterUsable = skillData.monster_usable !== undefined
? (skillData.monster_usable ? 1 : 0)
: (skillData.monsterUsable !== false ? 1 : 0);
const enabled = skillData.enabled !== undefined
? (skillData.enabled ? 1 : 0)
: 1;
return stmt.run(
skillData.name,
skillData.description,
skillData.type || 'damage',
skillData.mpCost || skillData.mp_cost || 0,
skillData.basePower || skillData.base_power || 0,
skillData.accuracy || 100,
skillData.hitCount || skillData.hit_count || 1,
skillData.target || 'enemy',
skillData.targetingMode || skillData.targeting_mode || 'same_target',
statusEffect,
playerUsable,
monsterUsable,
enabled,
id
);
}
deleteSkill(id) {
// Also delete related class skill names and monster skills
this.db.prepare(`DELETE FROM class_skill_names WHERE skill_id = ?`).run(id);
this.db.prepare(`DELETE FROM monster_skills WHERE skill_id = ?`).run(id);
const stmt = this.db.prepare(`DELETE FROM skills WHERE id = ?`);
return stmt.run(id);
}
// =====================
// CLASS SKILL NAMES METHODS
// =====================
// Get custom names from class_skills table (primary source - what admin panel edits)
getAllClassSkillNamesFromClassSkills() {
const stmt = this.db.prepare(`
SELECT cs.id, cs.skill_id, cs.class_id, cs.custom_name, cs.custom_description
FROM class_skills cs
WHERE cs.custom_name IS NOT NULL AND cs.custom_name != ''
`);
return stmt.all();
}
// Legacy: Get from class_skill_names table (deprecated)
getAllClassSkillNames() {
const stmt = this.db.prepare(`SELECT * FROM class_skill_names`);
return stmt.all();
}
getClassSkillNames(classId) {
const stmt = this.db.prepare(`SELECT * FROM class_skill_names WHERE class_id = ?`);
return stmt.all(classId);
}
getSkillNameForClass(skillId, classId) {
const stmt = this.db.prepare(`SELECT * FROM class_skill_names WHERE skill_id = ? AND class_id = ?`);
return stmt.get(skillId, classId);
}
createClassSkillName(data) {
const stmt = this.db.prepare(`
INSERT INTO class_skill_names (skill_id, class_id, custom_name, custom_description)
VALUES (?, ?, ?, ?)
`);
return stmt.run(
data.skillId || data.skill_id,
data.classId || data.class_id,
data.customName || data.custom_name,
data.customDescription || data.custom_description || null
);
}
updateClassSkillName(id, data) {
const stmt = this.db.prepare(`
UPDATE class_skill_names SET
skill_id = ?, class_id = ?, custom_name = ?, custom_description = ?
WHERE id = ?
`);
return stmt.run(
data.skillId || data.skill_id,
data.classId || data.class_id,
data.customName || data.custom_name,
data.customDescription || data.custom_description || null,
id
);
}
deleteClassSkillName(id) {
const stmt = this.db.prepare(`DELETE FROM class_skill_names WHERE id = ?`);
return stmt.run(id);
}
// =====================
// MONSTER SKILLS METHODS
// =====================
getAllMonsterSkills() {
const stmt = this.db.prepare(`SELECT * FROM monster_skills`);
return stmt.all();
}
getMonsterTypeSkills(monsterTypeId) {
const stmt = this.db.prepare(`
SELECT ms.*, s.name, s.description, s.type, s.mp_cost, s.base_power,
s.accuracy, s.hit_count, s.target, s.status_effect
FROM monster_skills ms
JOIN skills s ON ms.skill_id = s.id
WHERE ms.monster_type_id = ? AND s.enabled = 1 AND s.monster_usable = 1
`);
return stmt.all(monsterTypeId);
}
createMonsterSkill(data) {
const stmt = this.db.prepare(`
INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level, custom_name)
VALUES (?, ?, ?, ?, ?)
`);
return stmt.run(
data.monsterTypeId || data.monster_type_id,
data.skillId || data.skill_id,
data.weight || 10,
data.minLevel || data.min_level || 1,
data.customName || data.custom_name || null
);
}
updateMonsterSkill(id, data) {
// Build dynamic update for partial updates
const updates = [];
const values = [];
if (data.weight !== undefined) {
updates.push('weight = ?');
values.push(data.weight);
}
if (data.min_level !== undefined || data.minLevel !== undefined) {
updates.push('min_level = ?');
values.push(data.min_level || data.minLevel);
}
if (data.custom_name !== undefined || data.customName !== undefined) {
updates.push('custom_name = ?');
values.push(data.custom_name || data.customName || null);
}
if (updates.length === 0) return;
values.push(id);
const stmt = this.db.prepare(`UPDATE monster_skills SET ${updates.join(', ')} WHERE id = ?`);
return stmt.run(...values);
}
deleteMonsterSkill(id) {
const stmt = this.db.prepare(`DELETE FROM monster_skills WHERE id = ?`);
return stmt.run(id);
}
// =====================
// SKILL SEEDING
// =====================
seedDefaultSkills() {
console.log('Checking/seeding default skills...');
const defaultSkills = [
{
id: 'basic_attack',
name: 'Attack',
description: 'A basic physical attack',
type: 'damage',
mpCost: 0,
basePower: 100,
accuracy: 95,
hitCount: 1,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: true
},
{
id: 'double_attack',
name: 'Double Attack',
description: 'Strike twice in quick succession',
type: 'damage',
mpCost: 5,
basePower: 60,
accuracy: 85,
hitCount: 2,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: true
},
{
id: 'heal',
name: 'Heal',
description: 'Restore HP',
type: 'heal',
mpCost: 8,
basePower: 50,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: null,
playerUsable: true,
monsterUsable: false
},
{
id: 'power_strike',
name: 'Power Strike',
description: 'A powerful blow with extra force',
type: 'damage',
mpCost: 10,
basePower: 180,
accuracy: 80,
hitCount: 1,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: true
},
{
id: 'defend',
name: 'Defend',
description: 'Raise defense temporarily',
type: 'buff',
mpCost: 3,
basePower: 50,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: { type: 'defense_up', percent: 50, duration: 2 },
playerUsable: true,
monsterUsable: true
},
{
id: 'poison',
name: 'Poison',
description: 'Inflict poison that deals damage over time',
type: 'status',
mpCost: 0,
basePower: 20,
accuracy: 75,
hitCount: 1,
target: 'enemy',
statusEffect: { type: 'poison', damage: 5, duration: 3 },
playerUsable: false,
monsterUsable: true
},
{
id: 'whirlwind',
name: 'Whirlwind',
description: 'A spinning attack that hits all enemies',
type: 'damage',
mpCost: 12,
basePower: 75,
accuracy: 85,
hitCount: 1,
target: 'all_enemies',
statusEffect: null,
playerUsable: true,
monsterUsable: false
},
{
id: 'quick_strike',
name: 'Quick Strike',
description: 'A fast attack with high accuracy',
type: 'damage',
mpCost: 4,
basePower: 80,
accuracy: 98,
hitCount: 1,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: true
},
{
id: 'focus',
name: 'Focus',
description: 'Concentrate to boost accuracy for the next few turns',
type: 'buff',
mpCost: 6,
basePower: 20,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: { type: 'accuracy_up', percent: 20, duration: 3 },
playerUsable: true,
monsterUsable: false
},
{
id: 'second_wind',
name: 'Second Wind',
description: 'Double your MP regeneration while walking for 1 hour. Once per day.',
type: 'utility',
mpCost: 0,
basePower: 0,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: {
effectType: 'mp_regen_multiplier',
effectValue: 2.0,
durationHours: 1,
cooldownHours: 24
},
playerUsable: false, // Not usable in combat - it's a utility skill
monsterUsable: false
},
{
id: 'heavy_blow',
name: 'Heavy Blow',
description: 'A devastating attack that is hard to land',
type: 'damage',
mpCost: 15,
basePower: 250,
accuracy: 60,
hitCount: 1,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: true
},
{
id: 'quick_heal',
name: 'Quick Heal',
description: 'A small but efficient heal',
type: 'heal',
mpCost: 4,
basePower: 25,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: null,
playerUsable: true,
monsterUsable: false
},
{
id: 'triple_strike',
name: 'Triple Strike',
description: 'Strike three times in rapid succession',
type: 'damage',
mpCost: 18,
basePower: 50,
accuracy: 80,
hitCount: 3,
target: 'enemy',
statusEffect: null,
playerUsable: true,
monsterUsable: false
},
{
id: 'full_restore',
name: 'Full Restore',
description: 'Fully restore HP at great MP cost',
type: 'heal',
mpCost: 30,
basePower: 100,
accuracy: 100,
hitCount: 1,
target: 'self',
statusEffect: null,
playerUsable: true,
monsterUsable: false
}
];
for (const skill of defaultSkills) {
// Only seed if skill doesn't exist
const existing = this.getSkill(skill.id);
if (existing) continue;
this.createSkill(skill);
console.log(` Seeded skill: ${skill.name}`);
}
// Seed Trail Runner class skill names
const trailRunnerSkillNames = [
{ skillId: 'basic_attack', classId: 'trail_runner', customName: 'Kickems', customDescription: 'A swift kick from your trusty trail shoes!' },
{ skillId: 'double_attack', classId: 'trail_runner', customName: 'Brand New Hokas', customDescription: 'Break in those fresh kicks with two quick strikes!' },
{ skillId: 'power_strike', classId: 'trail_runner', customName: 'Downhill Sprint', customDescription: 'Use gravity to deliver a devastating blow!' },
{ skillId: 'heal', classId: 'trail_runner', customName: 'Gel Pack', customDescription: 'Quick energy gel restores your stamina' },
{ skillId: 'defend', classId: 'trail_runner', customName: 'Pace Yourself', customDescription: 'Slow down to conserve energy' }
];
for (const name of trailRunnerSkillNames) {
try {
this.createClassSkillName(name);
console.log(` Seeded class skill name: ${name.customName} for ${name.classId}`);
} catch (err) {
// Skip if already exists
}
}
// Assign poison skill to Moop monster
const moop = this.getMonsterType('moop');
if (moop) {
try {
this.createMonsterSkill({
monsterTypeId: 'moop',
skillId: 'poison',
weight: 30,
minLevel: 1
});
console.log(' Assigned poison skill to Moop');
} catch (err) {
// Skip if already exists
}
}
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);
}
}
// =====================
// CLASSES METHODS
// =====================
getAllClasses(enabledOnly = false) {
const stmt = enabledOnly
? this.db.prepare(`SELECT * FROM classes WHERE enabled = 1`)
: this.db.prepare(`SELECT * FROM classes`);
return stmt.all();
}
getClass(id) {
const stmt = this.db.prepare(`SELECT * FROM classes WHERE id = ?`);
return stmt.get(id);
}
createClass(classData) {
const stmt = this.db.prepare(`
INSERT INTO classes (id, name, description, base_hp, base_mp, base_atk, base_def,
base_accuracy, base_dodge, hp_per_level, mp_per_level, atk_per_level, def_per_level, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(
classData.id,
classData.name,
classData.description || '',
classData.base_hp || classData.baseHp || 100,
classData.base_mp || classData.baseMp || 50,
classData.base_atk || classData.baseAtk || 12,
classData.base_def || classData.baseDef || 8,
classData.base_accuracy || classData.baseAccuracy || 90,
classData.base_dodge || classData.baseDodge || 10,
classData.hp_per_level || classData.hpPerLevel || 10,
classData.mp_per_level || classData.mpPerLevel || 5,
classData.atk_per_level || classData.atkPerLevel || 2,
classData.def_per_level || classData.defPerLevel || 1,
classData.enabled ? 1 : 0
);
}
updateClass(id, classData) {
const stmt = this.db.prepare(`
UPDATE classes SET
name = ?, description = ?, base_hp = ?, base_mp = ?, base_atk = ?, base_def = ?,
base_accuracy = ?, base_dodge = ?, hp_per_level = ?, mp_per_level = ?,
atk_per_level = ?, def_per_level = ?, enabled = ?
WHERE id = ?
`);
return stmt.run(
classData.name,
classData.description || '',
classData.base_hp || classData.baseHp || 100,
classData.base_mp || classData.baseMp || 50,
classData.base_atk || classData.baseAtk || 12,
classData.base_def || classData.baseDef || 8,
classData.base_accuracy || classData.baseAccuracy || 90,
classData.base_dodge || classData.baseDodge || 10,
classData.hp_per_level || classData.hpPerLevel || 10,
classData.mp_per_level || classData.mpPerLevel || 5,
classData.atk_per_level || classData.atkPerLevel || 2,
classData.def_per_level || classData.defPerLevel || 1,
classData.enabled ? 1 : 0,
id
);
}
deleteClass(id) {
// Also delete related class_skills
this.db.prepare(`DELETE FROM class_skills WHERE class_id = ?`).run(id);
this.db.prepare(`DELETE FROM class_skill_names WHERE class_id = ?`).run(id);
const stmt = this.db.prepare(`DELETE FROM classes WHERE id = ?`);
return stmt.run(id);
}
toggleClassEnabled(id, enabled) {
const stmt = this.db.prepare(`UPDATE classes SET enabled = ? WHERE id = ?`);
return stmt.run(enabled ? 1 : 0, id);
}
// =====================
// CLASS SKILLS METHODS
// =====================
getClassSkills(classId) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
s.mp_cost, s.base_power, s.accuracy, s.hit_count, s.target, s.status_effect
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND s.enabled = 1
ORDER BY cs.unlock_level ASC, cs.choice_group ASC
`);
return stmt.all(classId);
}
getAllClassSkills() {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
LEFT JOIN skills s ON cs.skill_id = s.id
`);
return stmt.all();
}
getClassSkill(classId, skillId) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.skill_id = ?
`);
return stmt.get(classId, skillId);
}
createClassSkill(data) {
const stmt = this.db.prepare(`
INSERT INTO class_skills (class_id, skill_id, unlock_level, choice_group, custom_name, custom_description)
VALUES (?, ?, ?, ?, ?, ?)
`);
return stmt.run(
data.class_id || data.classId,
data.skill_id || data.skillId,
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null
);
}
updateClassSkill(id, data) {
const stmt = this.db.prepare(`
UPDATE class_skills SET
unlock_level = ?, choice_group = ?, custom_name = ?, custom_description = ?
WHERE id = ?
`);
return stmt.run(
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null,
id
);
}
deleteClassSkill(id) {
const stmt = this.db.prepare(`DELETE FROM class_skills WHERE id = ?`);
return stmt.run(id);
}
// Get skills available for level-up choice at a specific level
getSkillChoicesForLevel(classId, level) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
s.mp_cost, s.base_power, s.accuracy, s.hit_count
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.unlock_level = ? AND cs.choice_group IS NOT NULL
ORDER BY cs.choice_group ASC
`);
return stmt.all(classId, level);
}
// Get auto-learned skills up to a specific level (no choice_group)
getAutoLearnedSkills(classId, maxLevel) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.unlock_level <= ? AND cs.choice_group IS NULL
ORDER BY cs.unlock_level ASC
`);
return stmt.all(classId, maxLevel);
}
// =====================
// CLASS SEEDING
// =====================
seedDefaultClasses() {
// Check if Trail Runner already exists
const existing = this.getClass('trail_runner');
if (existing) return;
console.log('Seeding default classes...');
// Trail Runner - the initial available class
this.createClass({
id: 'trail_runner',
name: 'Trail Runner',
description: 'A swift adventurer who conquers trails with endurance and agility. Uses running-themed skills.',
base_hp: 100,
base_mp: 50,
base_atk: 12,
base_def: 8,
base_accuracy: 90,
base_dodge: 15, // Trail runners are agile
hp_per_level: 10,
mp_per_level: 5,
atk_per_level: 2,
def_per_level: 1,
enabled: true
});
console.log(' Seeded class: Trail Runner');
// Gym Bro - coming soon
this.createClass({
id: 'gym_bro',
name: 'Gym Bro',
description: 'A powerhouse of strength who never skips leg day. High attack and defense.',
base_hp: 120,
base_mp: 30,
base_atk: 15,
base_def: 12,
base_accuracy: 85,
base_dodge: 5,
hp_per_level: 15,
mp_per_level: 3,
atk_per_level: 3,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: Gym Bro (disabled)');
// Yoga Master - coming soon
this.createClass({
id: 'yoga_master',
name: 'Yoga Master',
description: 'A balanced warrior with high dodge and healing abilities. Mind over matter.',
base_hp: 80,
base_mp: 80,
base_atk: 10,
base_def: 10,
base_accuracy: 95,
base_dodge: 20,
hp_per_level: 8,
mp_per_level: 8,
atk_per_level: 1,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: Yoga Master (disabled)');
// CrossFit Crusader - coming soon
this.createClass({
id: 'crossfit_crusader',
name: 'CrossFit Crusader',
description: 'An all-around athlete with balanced stats and versatile skills.',
base_hp: 100,
base_mp: 50,
base_atk: 13,
base_def: 10,
base_accuracy: 88,
base_dodge: 12,
hp_per_level: 12,
mp_per_level: 5,
atk_per_level: 2,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: CrossFit Crusader (disabled)');
// Seed Trail Runner skills
this.seedTrailRunnerSkills();
console.log('Default classes seeded successfully');
}
seedTrailRunnerSkills() {
// Check if already seeded
const existing = this.getClassSkills('trail_runner');
if (existing.length > 0) return;
console.log(' Seeding Trail Runner skills...');
// Level 1 - Basic Attack (auto-learned)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'basic_attack',
unlock_level: 1,
choice_group: null,
custom_name: 'Trail Kick',
custom_description: 'A swift kick perfected on countless trails'
});
// Level 2 - Choice: Brand New Hokas (double_attack) OR Runner\'s High (heal)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'double_attack',
unlock_level: 2,
choice_group: 1,
custom_name: 'Brand New Hokas',
custom_description: 'Break in those fresh kicks with two quick strikes!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'heal',
unlock_level: 2,
choice_group: 1,
custom_name: 'Gel Pack',
custom_description: 'Quick energy gel restores your stamina'
});
// Level 3 - Choice: Downhill Sprint (power_strike) OR Pace Yourself (defend)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'power_strike',
unlock_level: 3,
choice_group: 2,
custom_name: 'Downhill Sprint',
custom_description: 'Use gravity to deliver a devastating blow!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'defend',
unlock_level: 3,
choice_group: 2,
custom_name: 'Pace Yourself',
custom_description: 'Slow down to conserve energy and reduce damage'
});
// Level 4 - Choice: Trail Blaze (whirlwind) OR Quick Feet (quick_strike)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'whirlwind',
unlock_level: 4,
choice_group: 3,
custom_name: 'Trail Blaze',
custom_description: 'Spin through the pack hitting everyone in your path!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'quick_strike',
unlock_level: 4,
choice_group: 3,
custom_name: 'Quick Feet',
custom_description: 'Lightning fast footwork for a guaranteed hit'
});
// Level 5 - Choice: Second Wind OR Trail Mix (quick_heal)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'second_wind',
unlock_level: 5,
choice_group: 4,
custom_name: 'Second Wind',
custom_description: 'Catch your breath and restore your energy reserves'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'quick_heal',
unlock_level: 5,
choice_group: 4,
custom_name: 'Trail Mix',
custom_description: 'A quick snack to keep you going'
});
// Level 6 - Choice: Finish Line Sprint (triple_strike) OR Focus Zone
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'triple_strike',
unlock_level: 6,
choice_group: 5,
custom_name: 'Finish Line Sprint',
custom_description: 'Give it everything for the final stretch - 3 rapid strikes!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'focus',
unlock_level: 6,
choice_group: 5,
custom_name: 'Zone In',
custom_description: 'Enter the zone - boost your accuracy for the next few turns'
});
// Level 7 - Choice: Ultra Marathon (full_restore) OR Bonk (heavy_blow)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'full_restore',
unlock_level: 7,
choice_group: 6,
custom_name: 'Ultra Marathon',
custom_description: 'Dig deep and fully restore your HP'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'heavy_blow',
unlock_level: 7,
choice_group: 6,
custom_name: 'The Bonk',
custom_description: 'Hit the wall... then hit them with it! Devastating but hard to land.'
});
console.log(' Trail Runner skills seeded');
}
// Admin: Get all users with their RPG stats
getAllUsers() {
const stmt = this.db.prepare(`
SELECT u.id, u.username, u.email, u.created_at, u.total_points, u.finds_count,
u.avatar_icon, u.avatar_color, u.is_admin,
r.character_name, r.race, r.class, r.level, r.xp, r.hp, r.max_hp,
r.mp, r.max_mp, r.atk, r.def, r.accuracy, r.dodge, r.unlocked_skills
FROM users u
LEFT JOIN rpg_stats r ON u.id = r.user_id
ORDER BY u.created_at DESC
`);
return stmt.all();
}
// Admin: Update user RPG stats
updateUserRpgStats(userId, stats) {
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
character_name = COALESCE(?, character_name),
level = ?, xp = ?, hp = ?, max_hp = ?, mp = ?, max_mp = ?,
atk = ?, def = ?, accuracy = ?, dodge = ?, unlocked_skills = ?, updated_at = datetime('now')
WHERE user_id = ?
`);
const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null;
// Support both camelCase (from app) and snake_case (from admin)
const params = [
stats.character_name || stats.name || null,
stats.level || 1,
stats.xp || 0,
stats.hp || 100,
stats.maxHp || stats.max_hp || 100,
stats.mp || 50,
stats.maxMp || stats.max_mp || 50,
stats.atk || 12,
stats.def || 8,
stats.accuracy || 90,
stats.dodge || 10,
unlockedSkillsJson,
userId
];
console.log('DB updateUserRpgStats params:', JSON.stringify(params));
const result = stmt.run(...params);
// Verify the update
const verify = this.db.prepare('SELECT atk FROM rpg_stats WHERE user_id = ?').get(userId);
console.log('DB verify after update - atk:', verify ? verify.atk : 'NO ROW');
return result;
}
// Admin: Reset user RPG progress
resetUserProgress(userId) {
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"]',
active_skills = '["basic_attack"]',
is_dead = 0,
updated_at = datetime('now')
WHERE user_id = ?
`);
const result = stmt.run(userId);
// 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;
}
// Admin: Reset user home base
resetUserHomeBase(userId) {
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
home_base_lat = NULL,
home_base_lng = NULL,
last_home_set = NULL,
updated_at = datetime('now')
WHERE user_id = ?
`);
return stmt.run(userId);
}
// Admin: Delete user completely
deleteUser(userId) {
const transaction = this.db.transaction(() => {
// Delete in order to respect foreign key constraints (if any)
// Delete monster entourage
this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId);
// Delete player buffs
this.db.prepare(`DELETE FROM player_buffs WHERE player_id = ?`).run(userId);
// Delete refresh tokens
this.db.prepare(`DELETE FROM refresh_tokens WHERE user_id = ?`).run(userId);
// Delete RPG stats
this.db.prepare(`DELETE FROM rpg_stats WHERE user_id = ?`).run(userId);
// Delete user account
this.db.prepare(`DELETE FROM users WHERE id = ?`).run(userId);
});
return transaction();
}
// Game settings methods
getSetting(key) {
const stmt = this.db.prepare(`SELECT value FROM game_settings WHERE key = ?`);
const row = stmt.get(key);
return row ? row.value : null;
}
setSetting(key, value) {
const stmt = this.db.prepare(`
INSERT INTO game_settings (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
`);
return stmt.run(key, value);
}
getAllSettings() {
const stmt = this.db.prepare(`SELECT key, value FROM game_settings`);
const rows = stmt.all();
const settings = {};
rows.forEach(row => {
// Try to parse JSON values
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
});
return settings;
}
seedDefaultSettings() {
const defaults = {
monsterSpawnInterval: 20000, // Timer interval in ms (20 seconds)
monsterSpawnChance: 50, // Percent chance per interval (50%)
monsterSpawnDistance: 10, // Meters player must move for new spawns (10m)
maxMonstersPerPlayer: 10,
xpMultiplier: 1.0,
combatEnabled: true,
mpRegenDistance: 5 // Meters walked per 1 MP regenerated
};
for (const [key, value] of Object.entries(defaults)) {
const existing = this.getSetting(key);
if (existing === null) {
this.setSetting(key, JSON.stringify(value));
}
}
}
// =====================
// 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();
}
}
}
module.exports = HikeMapDB;