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