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.
 
 
 
 
 

1330 lines
48 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 */ }
// 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
)
`);
// Class skill names - class-specific naming for 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);
`);
}
// 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() {
// Check if skills already exist
const existing = this.getSkill('basic_attack');
if (existing) return;
console.log('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
}
];
for (const skill of defaultSkills) {
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) {
this.createClassSkillName(name);
console.log(` Seeded class skill name: ${name.customName} for ${name.classId}`);
}
// Assign poison skill to Moop monster
const moop = this.getMonsterType('moop');
if (moop) {
this.createMonsterSkill({
monsterTypeId: 'moop',
skillId: 'poison',
weight: 30,
minLevel: 1
});
console.log(' Assigned poison skill to Moop');
}
console.log('Default skills seeded successfully');
}
// 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;