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.
798 lines
29 KiB
798 lines
29 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 */ }
|
|
|
|
// 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);
|
|
`);
|
|
}
|
|
|
|
// 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, 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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// 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.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
|
|
level = ?, xp = ?, hp = ?, max_hp = ?, mp = ?, max_mp = ?,
|
|
atk = ?, def = ?, unlocked_skills = ?, updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : '["basic_attack"]';
|
|
return stmt.run(
|
|
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,
|
|
userId
|
|
);
|
|
}
|
|
|
|
// 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, 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: 30000,
|
|
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;
|