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, dialogues TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, created_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); `); } // 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, unlocked_skills 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, 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, 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, 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, unlocked_skills, 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, unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills), 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, unlockedSkillsJson ); } // 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); } // 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, dialogues, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); return stmt.run( monsterData.id, monsterData.name, monsterData.icon, monsterData.baseHp, monsterData.baseAtk, monsterData.baseDef, monsterData.xpReward, monsterData.levelScale.hp, monsterData.levelScale.atk, monsterData.levelScale.def, JSON.stringify(monsterData.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 = ?, dialogues = ?, enabled = ? WHERE id = ? `); return stmt.run( monsterData.name, monsterData.icon, monsterData.baseHp, monsterData.baseAtk, monsterData.baseDef, monsterData.xpReward, monsterData.levelScale.hp, monsterData.levelScale.atk, monsterData.levelScale.def, JSON.stringify(monsterData.dialogues), monsterData.enabled !== false ? 1 : 0, id ); } deleteMonsterType(id) { const stmt = this.db.prepare(`DELETE FROM monster_types WHERE id = ?`); return stmt.run(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'); } close() { if (this.db) { this.db.close(); } } } module.exports = HikeMapDB;