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 */ } // 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 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 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 ) `); // 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); `); } // 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(); } // 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 FROM rpg_stats WHERE user_id = ? `); return stmt.get(userId); } 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, 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, updated_at = datetime('now') `); // New characters start with only basic_attack const unlockedSkillsJson = JSON.stringify(characterData.unlockedSkills || ['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 ); } saveRpgStats(userId, stats) { 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')) 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), 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), updated_at = datetime('now') `); // Convert unlockedSkills array to JSON string for storage const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null; return 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, stats.homeBaseLat || null, stats.homeBaseLng || null, stats.lastHomeSet || null, stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null ); } // 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) 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 xpReward = monsterData.xpReward || monsterData.base_xp; const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; 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 ); } 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 = ? 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 xpReward = monsterData.xpReward || monsterData.base_xp; const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 }; 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, 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); } createSkill(skillData) { const stmt = this.db.prepare(` INSERT INTO skills (id, name, description, type, mp_cost, base_power, accuracy, hit_count, target, 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', 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 = ?, status_effect = ?, player_usable = ?, monster_usable = ?, enabled = ? WHERE id = ? `); const statusEffect = skillData.statusEffect ? (typeof skillData.statusEffect === 'string' ? skillData.statusEffect : JSON.stringify(skillData.statusEffect)) : null; 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', statusEffect, skillData.playerUsable !== false ? 1 : 0, skillData.monsterUsable !== false ? 1 : 0, skillData.enabled !== false ? 1 : 0, 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 // ===================== 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 } ]; 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: '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'); } // ===================== // 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' }); 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"]', 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); return result; } // 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 }; for (const [key, value] of Object.entries(defaults)) { const existing = this.getSetting(key); if (existing === null) { this.setSetting(key, JSON.stringify(value)); } } } close() { if (this.db) { this.db.close(); } } } module.exports = HikeMapDB;