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.
1882 lines
68 KiB
1882 lines
68 KiB
const Database = require('better-sqlite3');
|
|
const bcrypt = require('bcrypt');
|
|
const path = require('path');
|
|
|
|
const BCRYPT_ROUNDS = parseInt(process.env.BCRYPT_ROUNDS) || 12;
|
|
|
|
class HikeMapDB {
|
|
constructor(dbPath) {
|
|
this.dbPath = dbPath || path.join(__dirname, 'data', 'hikemap.db');
|
|
this.db = null;
|
|
}
|
|
|
|
init() {
|
|
this.db = new Database(this.dbPath);
|
|
this.db.pragma('journal_mode = WAL');
|
|
this.createTables();
|
|
console.log(`Database initialized at ${this.dbPath}`);
|
|
return this;
|
|
}
|
|
|
|
createTables() {
|
|
// Users table
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
total_points INTEGER DEFAULT 0,
|
|
finds_count INTEGER DEFAULT 0,
|
|
avatar_icon TEXT DEFAULT 'account',
|
|
avatar_color TEXT DEFAULT '#4CAF50',
|
|
is_admin BOOLEAN DEFAULT 0
|
|
)
|
|
`);
|
|
|
|
// Geocache finds table
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS geocache_finds (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
geocache_id TEXT NOT NULL,
|
|
found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
points_earned INTEGER NOT NULL,
|
|
is_first_finder BOOLEAN DEFAULT 0,
|
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
|
UNIQUE(user_id, geocache_id)
|
|
)
|
|
`);
|
|
|
|
// Refresh tokens table for logout/token invalidation
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)
|
|
`);
|
|
|
|
// RPG stats table
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS rpg_stats (
|
|
user_id INTEGER PRIMARY KEY,
|
|
character_name TEXT,
|
|
race TEXT DEFAULT 'human',
|
|
class TEXT NOT NULL DEFAULT 'trail_runner',
|
|
level INTEGER DEFAULT 1,
|
|
xp INTEGER DEFAULT 0,
|
|
hp INTEGER DEFAULT 100,
|
|
max_hp INTEGER DEFAULT 100,
|
|
mp INTEGER DEFAULT 50,
|
|
max_mp INTEGER DEFAULT 50,
|
|
atk INTEGER DEFAULT 12,
|
|
def INTEGER DEFAULT 8,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)
|
|
`);
|
|
|
|
// Migration: Add character_name and race columns if they don't exist
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN character_name TEXT`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN race TEXT DEFAULT 'human'`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN unlocked_skills TEXT DEFAULT '["basic_attack"]'`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Monster entourage table - stores monsters following the player
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monster_entourage (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
monster_type TEXT NOT NULL,
|
|
level INTEGER NOT NULL,
|
|
hp INTEGER NOT NULL,
|
|
max_hp INTEGER NOT NULL,
|
|
atk INTEGER NOT NULL,
|
|
def INTEGER NOT NULL,
|
|
position_lat REAL,
|
|
position_lng REAL,
|
|
spawn_time INTEGER NOT NULL,
|
|
last_dialogue_time INTEGER DEFAULT 0,
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
)
|
|
`);
|
|
|
|
// Monster types table - defines available monster types
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monster_types (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
icon TEXT NOT NULL,
|
|
base_hp INTEGER NOT NULL,
|
|
base_atk INTEGER NOT NULL,
|
|
base_def INTEGER NOT NULL,
|
|
xp_reward INTEGER NOT NULL,
|
|
level_scale_hp INTEGER NOT NULL,
|
|
level_scale_atk INTEGER NOT NULL,
|
|
level_scale_def INTEGER NOT NULL,
|
|
min_level INTEGER DEFAULT 1,
|
|
max_level INTEGER DEFAULT 5,
|
|
spawn_weight INTEGER DEFAULT 100,
|
|
dialogues TEXT NOT NULL,
|
|
enabled BOOLEAN DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Add columns if they don't exist (migration for existing databases)
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_types ADD COLUMN min_level INTEGER DEFAULT 1`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_types ADD COLUMN max_level INTEGER DEFAULT 5`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Skills table - defines available skills/spells
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS skills (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
mp_cost INTEGER DEFAULT 0,
|
|
base_power INTEGER DEFAULT 0,
|
|
accuracy INTEGER DEFAULT 100,
|
|
hit_count INTEGER DEFAULT 1,
|
|
target TEXT DEFAULT 'enemy',
|
|
status_effect TEXT,
|
|
player_usable BOOLEAN DEFAULT 1,
|
|
monster_usable BOOLEAN DEFAULT 1,
|
|
enabled BOOLEAN DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Classes table - defines playable classes
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS classes (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
base_hp INTEGER DEFAULT 100,
|
|
base_mp INTEGER DEFAULT 50,
|
|
base_atk INTEGER DEFAULT 12,
|
|
base_def INTEGER DEFAULT 8,
|
|
base_accuracy INTEGER DEFAULT 90,
|
|
base_dodge INTEGER DEFAULT 10,
|
|
hp_per_level INTEGER DEFAULT 10,
|
|
mp_per_level INTEGER DEFAULT 5,
|
|
atk_per_level INTEGER DEFAULT 2,
|
|
def_per_level INTEGER DEFAULT 1,
|
|
enabled BOOLEAN DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Class skills - assigns skills to classes with unlock levels and choice groups
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS class_skills (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
class_id TEXT NOT NULL,
|
|
skill_id TEXT NOT NULL,
|
|
unlock_level INTEGER DEFAULT 1,
|
|
choice_group INTEGER,
|
|
custom_name TEXT,
|
|
custom_description TEXT,
|
|
UNIQUE(class_id, skill_id)
|
|
)
|
|
`);
|
|
|
|
// Class skill names - class-specific naming for skills (legacy, replaced by class_skills)
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS class_skill_names (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
skill_id TEXT NOT NULL,
|
|
class_id TEXT NOT NULL,
|
|
custom_name TEXT NOT NULL,
|
|
custom_description TEXT,
|
|
UNIQUE(skill_id, class_id)
|
|
)
|
|
`);
|
|
|
|
// Monster skills - skills assigned to monster types
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monster_skills (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
monster_type_id TEXT NOT NULL,
|
|
skill_id TEXT NOT NULL,
|
|
weight INTEGER DEFAULT 10,
|
|
min_level INTEGER DEFAULT 1,
|
|
UNIQUE(monster_type_id, skill_id)
|
|
)
|
|
`);
|
|
|
|
// Migration: Add accuracy/dodge to rpg_stats
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN accuracy INTEGER DEFAULT 90`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN dodge INTEGER DEFAULT 10`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Migration: Add accuracy/dodge to monster_types
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_types ADD COLUMN accuracy INTEGER DEFAULT 85`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_types ADD COLUMN dodge INTEGER DEFAULT 5`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Migration: Add custom_name to monster_skills for per-monster skill renaming
|
|
try {
|
|
this.db.exec(`ALTER TABLE monster_skills ADD COLUMN custom_name TEXT`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Migration: Add home base and death system columns to rpg_stats
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_lat REAL`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_lng REAL`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN last_home_set TEXT`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN is_dead INTEGER DEFAULT 0`);
|
|
} catch (e) { /* Column already exists */ }
|
|
try {
|
|
this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN home_base_icon TEXT DEFAULT '00'`);
|
|
} catch (e) { /* Column already exists */ }
|
|
|
|
// Game settings table - key/value store for game configuration
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS game_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Create indexes for performance
|
|
this.db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_geocache_finds_user ON geocache_finds(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_geocache_finds_geocache ON geocache_finds(geocache_id);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
CREATE INDEX IF NOT EXISTS idx_monster_entourage_user ON monster_entourage(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_class_skill_names_skill ON class_skill_names(skill_id);
|
|
CREATE INDEX IF NOT EXISTS idx_class_skill_names_class ON class_skill_names(class_id);
|
|
CREATE INDEX IF NOT EXISTS idx_monster_skills_monster ON monster_skills(monster_type_id);
|
|
CREATE INDEX IF NOT EXISTS idx_monster_skills_skill ON monster_skills(skill_id);
|
|
CREATE INDEX IF NOT EXISTS idx_class_skills_class ON class_skills(class_id);
|
|
CREATE INDEX IF NOT EXISTS idx_class_skills_skill ON class_skills(skill_id);
|
|
`);
|
|
}
|
|
|
|
// User methods
|
|
async createUser(username, email, password) {
|
|
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
|
|
try {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO users (username, email, password_hash)
|
|
VALUES (?, ?, ?)
|
|
`);
|
|
const result = stmt.run(username.toLowerCase(), email.toLowerCase(), passwordHash);
|
|
return { id: result.lastInsertRowid, username, email };
|
|
} catch (err) {
|
|
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
if (err.message.includes('username')) {
|
|
throw new Error('Username already exists');
|
|
}
|
|
if (err.message.includes('email')) {
|
|
throw new Error('Email already exists');
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async validateUser(usernameOrEmail, password) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT * FROM users
|
|
WHERE username = ? OR email = ?
|
|
`);
|
|
const user = stmt.get(usernameOrEmail.toLowerCase(), usernameOrEmail.toLowerCase());
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
if (!valid) {
|
|
return null;
|
|
}
|
|
|
|
// Don't return password hash
|
|
const { password_hash, ...safeUser } = user;
|
|
return safeUser;
|
|
}
|
|
|
|
getUserById(userId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT id, username, email, created_at, total_points, finds_count,
|
|
avatar_icon, avatar_color, is_admin
|
|
FROM users WHERE id = ?
|
|
`);
|
|
return stmt.get(userId);
|
|
}
|
|
|
|
getUserByUsername(username) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT id, username, email, created_at, total_points, finds_count,
|
|
avatar_icon, avatar_color, is_admin
|
|
FROM users WHERE username = ?
|
|
`);
|
|
return stmt.get(username.toLowerCase());
|
|
}
|
|
|
|
updateUserAvatar(userId, icon, color) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE users SET avatar_icon = ?, avatar_color = ?
|
|
WHERE id = ?
|
|
`);
|
|
return stmt.run(icon, color, userId);
|
|
}
|
|
|
|
setUserAdmin(userId, isAdmin) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE users SET is_admin = ? WHERE id = ?
|
|
`);
|
|
return stmt.run(isAdmin ? 1 : 0, userId);
|
|
}
|
|
|
|
setUserAdminByUsername(username, isAdmin) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE users SET is_admin = ? WHERE username = ?
|
|
`);
|
|
return stmt.run(isAdmin ? 1 : 0, username.toLowerCase());
|
|
}
|
|
|
|
// Geocache find methods
|
|
recordFind(userId, geocacheId, pointsEarned, isFirstFinder = false) {
|
|
const transaction = this.db.transaction(() => {
|
|
// Insert the find record
|
|
const insertStmt = this.db.prepare(`
|
|
INSERT INTO geocache_finds (user_id, geocache_id, points_earned, is_first_finder)
|
|
VALUES (?, ?, ?, ?)
|
|
`);
|
|
|
|
try {
|
|
insertStmt.run(userId, geocacheId, pointsEarned, isFirstFinder ? 1 : 0);
|
|
} catch (err) {
|
|
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|
throw new Error('You have already found this geocache');
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Update user's total points and finds count
|
|
const updateStmt = this.db.prepare(`
|
|
UPDATE users
|
|
SET total_points = total_points + ?,
|
|
finds_count = finds_count + 1
|
|
WHERE id = ?
|
|
`);
|
|
updateStmt.run(pointsEarned, userId);
|
|
|
|
return { success: true, pointsEarned };
|
|
});
|
|
|
|
return transaction();
|
|
}
|
|
|
|
hasUserFoundGeocache(userId, geocacheId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT 1 FROM geocache_finds
|
|
WHERE user_id = ? AND geocache_id = ?
|
|
`);
|
|
return !!stmt.get(userId, geocacheId);
|
|
}
|
|
|
|
isFirstFinder(geocacheId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT 1 FROM geocache_finds
|
|
WHERE geocache_id = ?
|
|
LIMIT 1
|
|
`);
|
|
return !stmt.get(geocacheId);
|
|
}
|
|
|
|
getGeocacheFinders(geocacheId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT u.id, u.username, u.avatar_icon, u.avatar_color,
|
|
gf.found_at, gf.points_earned, gf.is_first_finder
|
|
FROM geocache_finds gf
|
|
JOIN users u ON gf.user_id = u.id
|
|
WHERE gf.geocache_id = ?
|
|
ORDER BY gf.found_at ASC
|
|
`);
|
|
return stmt.all(geocacheId);
|
|
}
|
|
|
|
getUserFinds(userId, limit = 50) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT geocache_id, found_at, points_earned, is_first_finder
|
|
FROM geocache_finds
|
|
WHERE user_id = ?
|
|
ORDER BY found_at DESC
|
|
LIMIT ?
|
|
`);
|
|
return stmt.all(userId, limit);
|
|
}
|
|
|
|
// Leaderboard methods
|
|
getLeaderboard(period = 'all', limit = 50) {
|
|
let whereClause = '';
|
|
|
|
if (period === 'weekly') {
|
|
whereClause = "WHERE gf.found_at >= datetime('now', '-7 days')";
|
|
} else if (period === 'monthly') {
|
|
whereClause = "WHERE gf.found_at >= datetime('now', '-30 days')";
|
|
}
|
|
|
|
if (period === 'all') {
|
|
// For all-time, use the cached total_points
|
|
const stmt = this.db.prepare(`
|
|
SELECT id, username, avatar_icon, avatar_color, total_points, finds_count
|
|
FROM users
|
|
ORDER BY total_points DESC
|
|
LIMIT ?
|
|
`);
|
|
return stmt.all(limit);
|
|
} else {
|
|
// For weekly/monthly, calculate from finds
|
|
const stmt = this.db.prepare(`
|
|
SELECT u.id, u.username, u.avatar_icon, u.avatar_color,
|
|
SUM(gf.points_earned) as total_points,
|
|
COUNT(gf.id) as finds_count
|
|
FROM users u
|
|
JOIN geocache_finds gf ON u.id = gf.user_id
|
|
${whereClause}
|
|
GROUP BY u.id
|
|
ORDER BY total_points DESC
|
|
LIMIT ?
|
|
`);
|
|
return stmt.all(limit);
|
|
}
|
|
}
|
|
|
|
// Refresh token methods
|
|
async storeRefreshToken(userId, tokenHash, expiresAt) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
|
VALUES (?, ?, ?)
|
|
`);
|
|
return stmt.run(userId, tokenHash, expiresAt);
|
|
}
|
|
|
|
getRefreshToken(tokenHash) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT * FROM refresh_tokens
|
|
WHERE token_hash = ? AND expires_at > datetime('now')
|
|
`);
|
|
return stmt.get(tokenHash);
|
|
}
|
|
|
|
deleteRefreshToken(tokenHash) {
|
|
const stmt = this.db.prepare(`
|
|
DELETE FROM refresh_tokens WHERE token_hash = ?
|
|
`);
|
|
return stmt.run(tokenHash);
|
|
}
|
|
|
|
deleteUserRefreshTokens(userId) {
|
|
const stmt = this.db.prepare(`
|
|
DELETE FROM refresh_tokens WHERE user_id = ?
|
|
`);
|
|
return stmt.run(userId);
|
|
}
|
|
|
|
cleanExpiredTokens() {
|
|
const stmt = this.db.prepare(`
|
|
DELETE FROM refresh_tokens WHERE expires_at <= datetime('now')
|
|
`);
|
|
return stmt.run();
|
|
}
|
|
|
|
// RPG Stats methods
|
|
getRpgStats(userId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills,
|
|
home_base_lat, home_base_lng, last_home_set, is_dead, home_base_icon
|
|
FROM rpg_stats WHERE user_id = ?
|
|
`);
|
|
return stmt.get(userId);
|
|
}
|
|
|
|
hasCharacter(userId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT 1 FROM rpg_stats WHERE user_id = ? AND character_name IS NOT NULL
|
|
`);
|
|
return !!stmt.get(userId);
|
|
}
|
|
|
|
createCharacter(userId, characterData) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
character_name = excluded.character_name,
|
|
race = excluded.race,
|
|
class = excluded.class,
|
|
level = excluded.level,
|
|
xp = excluded.xp,
|
|
hp = excluded.hp,
|
|
max_hp = excluded.max_hp,
|
|
mp = excluded.mp,
|
|
max_mp = excluded.max_mp,
|
|
atk = excluded.atk,
|
|
def = excluded.def,
|
|
accuracy = excluded.accuracy,
|
|
dodge = excluded.dodge,
|
|
unlocked_skills = excluded.unlocked_skills,
|
|
updated_at = datetime('now')
|
|
`);
|
|
// New characters start with only basic_attack
|
|
const unlockedSkillsJson = JSON.stringify(characterData.unlockedSkills || ['basic_attack']);
|
|
return stmt.run(
|
|
userId,
|
|
characterData.name,
|
|
characterData.race || 'human',
|
|
characterData.class || 'trail_runner',
|
|
characterData.level || 1,
|
|
characterData.xp || 0,
|
|
characterData.hp || 100,
|
|
characterData.maxHp || 100,
|
|
characterData.mp || 50,
|
|
characterData.maxMp || 50,
|
|
characterData.atk || 12,
|
|
characterData.def || 8,
|
|
characterData.accuracy || 90,
|
|
characterData.dodge || 10,
|
|
unlockedSkillsJson
|
|
);
|
|
}
|
|
|
|
saveRpgStats(userId, stats) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills, home_base_lat, home_base_lng, last_home_set, is_dead, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
character_name = COALESCE(excluded.character_name, rpg_stats.character_name),
|
|
race = COALESCE(excluded.race, rpg_stats.race),
|
|
class = excluded.class,
|
|
level = excluded.level,
|
|
xp = excluded.xp,
|
|
hp = excluded.hp,
|
|
max_hp = excluded.max_hp,
|
|
mp = excluded.mp,
|
|
max_mp = excluded.max_mp,
|
|
atk = excluded.atk,
|
|
def = excluded.def,
|
|
accuracy = excluded.accuracy,
|
|
dodge = excluded.dodge,
|
|
unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills),
|
|
home_base_lat = COALESCE(excluded.home_base_lat, rpg_stats.home_base_lat),
|
|
home_base_lng = COALESCE(excluded.home_base_lng, rpg_stats.home_base_lng),
|
|
last_home_set = COALESCE(excluded.last_home_set, rpg_stats.last_home_set),
|
|
is_dead = COALESCE(excluded.is_dead, rpg_stats.is_dead),
|
|
updated_at = datetime('now')
|
|
`);
|
|
// Convert unlockedSkills array to JSON string for storage
|
|
const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : null;
|
|
return stmt.run(
|
|
userId,
|
|
stats.name || null,
|
|
stats.race || null,
|
|
stats.class || 'trail_runner',
|
|
stats.level || 1,
|
|
stats.xp || 0,
|
|
stats.hp || 100,
|
|
stats.maxHp || 100,
|
|
stats.mp || 50,
|
|
stats.maxMp || 50,
|
|
stats.atk || 12,
|
|
stats.def || 8,
|
|
stats.accuracy || 90,
|
|
stats.dodge || 10,
|
|
unlockedSkillsJson,
|
|
stats.homeBaseLat || null,
|
|
stats.homeBaseLng || null,
|
|
stats.lastHomeSet || null,
|
|
stats.isDead !== undefined ? (stats.isDead ? 1 : 0) : null
|
|
);
|
|
}
|
|
|
|
// Set home base location
|
|
setHomeBase(userId, lat, lng) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE rpg_stats SET
|
|
home_base_lat = ?,
|
|
home_base_lng = ?,
|
|
last_home_set = datetime('now'),
|
|
updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
return stmt.run(lat, lng, userId);
|
|
}
|
|
|
|
// Update home base icon
|
|
setHomeBaseIcon(userId, iconId) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE rpg_stats SET
|
|
home_base_icon = ?,
|
|
updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
return stmt.run(iconId, userId);
|
|
}
|
|
|
|
// Check if user can set home base (once per day)
|
|
canSetHomeBase(userId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT last_home_set FROM rpg_stats WHERE user_id = ?
|
|
`);
|
|
const result = stmt.get(userId);
|
|
if (!result || !result.last_home_set) return true;
|
|
|
|
const lastSet = new Date(result.last_home_set);
|
|
const now = new Date();
|
|
const hoursSince = (now - lastSet) / (1000 * 60 * 60);
|
|
return hoursSince >= 24;
|
|
}
|
|
|
|
// Handle player death
|
|
handlePlayerDeath(userId, xpPenaltyPercent = 10) {
|
|
// Get current stats to calculate XP penalty
|
|
const stats = this.getRpgStats(userId);
|
|
if (!stats) return null;
|
|
|
|
// Calculate XP loss - can't drop below current level threshold
|
|
const currentLevel = stats.level;
|
|
const levelThresholds = [0, 100, 250, 500, 800, 1200]; // XP needed for each level
|
|
const minXp = levelThresholds[currentLevel - 1] || 0;
|
|
const xpLoss = Math.floor(stats.xp * (xpPenaltyPercent / 100));
|
|
const newXp = Math.max(minXp, stats.xp - xpLoss);
|
|
|
|
const stmt = this.db.prepare(`
|
|
UPDATE rpg_stats SET
|
|
is_dead = 1,
|
|
hp = 0,
|
|
xp = ?,
|
|
updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
stmt.run(newXp, userId);
|
|
|
|
return { xpLost: stats.xp - newXp, newXp };
|
|
}
|
|
|
|
// Respawn player at home base
|
|
respawnPlayer(userId) {
|
|
const stats = this.getRpgStats(userId);
|
|
if (!stats) return null;
|
|
|
|
const stmt = this.db.prepare(`
|
|
UPDATE rpg_stats SET
|
|
is_dead = 0,
|
|
hp = max_hp,
|
|
mp = max_mp,
|
|
updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
return stmt.run(userId);
|
|
}
|
|
|
|
// Monster entourage methods
|
|
getMonsterEntourage(userId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT id, monster_type, level, hp, max_hp, atk, def,
|
|
position_lat, position_lng, spawn_time, last_dialogue_time
|
|
FROM monster_entourage WHERE user_id = ?
|
|
`);
|
|
return stmt.all(userId);
|
|
}
|
|
|
|
saveMonsterEntourage(userId, monsters) {
|
|
const deleteStmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`);
|
|
const insertStmt = this.db.prepare(`
|
|
INSERT INTO monster_entourage
|
|
(id, user_id, monster_type, level, hp, max_hp, atk, def, position_lat, position_lng, spawn_time, last_dialogue_time)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
const transaction = this.db.transaction(() => {
|
|
deleteStmt.run(userId);
|
|
for (const monster of monsters) {
|
|
insertStmt.run(
|
|
monster.id,
|
|
userId,
|
|
monster.type,
|
|
monster.level,
|
|
monster.hp,
|
|
monster.maxHp,
|
|
monster.atk,
|
|
monster.def,
|
|
monster.position?.lat || null,
|
|
monster.position?.lng || null,
|
|
monster.spawnTime,
|
|
monster.lastDialogueTime || 0
|
|
);
|
|
}
|
|
});
|
|
|
|
return transaction();
|
|
}
|
|
|
|
removeMonster(userId, monsterId) {
|
|
const stmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ? AND id = ?`);
|
|
return stmt.run(userId, monsterId);
|
|
}
|
|
|
|
clearMonsterEntourage(userId) {
|
|
const stmt = this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`);
|
|
return stmt.run(userId);
|
|
}
|
|
|
|
// Monster type methods
|
|
getAllMonsterTypes(enabledOnly = true) {
|
|
const stmt = enabledOnly
|
|
? this.db.prepare(`SELECT * FROM monster_types WHERE enabled = 1`)
|
|
: this.db.prepare(`SELECT * FROM monster_types`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getMonsterType(id) {
|
|
const stmt = this.db.prepare(`SELECT * FROM monster_types WHERE id = ?`);
|
|
return stmt.get(id);
|
|
}
|
|
|
|
createMonsterType(monsterData) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO monster_types (id, name, icon, base_hp, base_atk, base_def, xp_reward,
|
|
level_scale_hp, level_scale_atk, level_scale_def, min_level, max_level, spawn_weight, dialogues, enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
// Support both camelCase (legacy) and snake_case (new admin UI) field names
|
|
const baseHp = monsterData.baseHp || monsterData.base_hp;
|
|
const baseAtk = monsterData.baseAtk || monsterData.base_atk;
|
|
const baseDef = monsterData.baseDef || monsterData.base_def;
|
|
const xpReward = monsterData.xpReward || monsterData.base_xp;
|
|
const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 };
|
|
const minLevel = monsterData.minLevel || monsterData.min_level || 1;
|
|
const maxLevel = monsterData.maxLevel || monsterData.max_level || 5;
|
|
const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100;
|
|
const dialogues = typeof monsterData.dialogues === 'string'
|
|
? monsterData.dialogues
|
|
: JSON.stringify(monsterData.dialogues);
|
|
|
|
return stmt.run(
|
|
monsterData.id || monsterData.key,
|
|
monsterData.name,
|
|
monsterData.icon || '🟢',
|
|
baseHp,
|
|
baseAtk,
|
|
baseDef,
|
|
xpReward,
|
|
levelScale.hp,
|
|
levelScale.atk,
|
|
levelScale.def,
|
|
minLevel,
|
|
maxLevel,
|
|
spawnWeight,
|
|
dialogues,
|
|
monsterData.enabled !== false ? 1 : 0
|
|
);
|
|
}
|
|
|
|
updateMonsterType(id, monsterData) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE monster_types SET
|
|
name = ?, icon = ?, base_hp = ?, base_atk = ?, base_def = ?,
|
|
xp_reward = ?, level_scale_hp = ?, level_scale_atk = ?, level_scale_def = ?,
|
|
min_level = ?, max_level = ?, spawn_weight = ?, dialogues = ?, enabled = ?
|
|
WHERE id = ?
|
|
`);
|
|
// Support both camelCase (legacy) and snake_case (new admin UI) field names
|
|
const baseHp = monsterData.baseHp || monsterData.base_hp;
|
|
const baseAtk = monsterData.baseAtk || monsterData.base_atk;
|
|
const baseDef = monsterData.baseDef || monsterData.base_def;
|
|
const xpReward = monsterData.xpReward || monsterData.base_xp;
|
|
const levelScale = monsterData.levelScale || { hp: 10, atk: 2, def: 1 };
|
|
const minLevel = monsterData.minLevel || monsterData.min_level || 1;
|
|
const maxLevel = monsterData.maxLevel || monsterData.max_level || 5;
|
|
const spawnWeight = monsterData.spawnWeight || monsterData.spawn_weight || 100;
|
|
const dialogues = typeof monsterData.dialogues === 'string'
|
|
? monsterData.dialogues
|
|
: JSON.stringify(monsterData.dialogues);
|
|
|
|
return stmt.run(
|
|
monsterData.name,
|
|
monsterData.icon || '🟢',
|
|
baseHp,
|
|
baseAtk,
|
|
baseDef,
|
|
xpReward,
|
|
levelScale.hp,
|
|
levelScale.atk,
|
|
levelScale.def,
|
|
minLevel,
|
|
maxLevel,
|
|
spawnWeight,
|
|
dialogues,
|
|
monsterData.enabled !== false ? 1 : 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
deleteMonsterType(id) {
|
|
const stmt = this.db.prepare(`DELETE FROM monster_types WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
toggleMonsterEnabled(id, enabled) {
|
|
const stmt = this.db.prepare(`UPDATE monster_types SET enabled = ? WHERE id = ?`);
|
|
return stmt.run(enabled ? 1 : 0, id);
|
|
}
|
|
|
|
seedDefaultMonsters() {
|
|
// Check if Moop already exists
|
|
const existing = this.getMonsterType('moop');
|
|
if (existing) return;
|
|
|
|
// Seed Moop - Matter Out Of Place
|
|
this.createMonsterType({
|
|
id: 'moop',
|
|
name: 'Moop',
|
|
icon: '🟢',
|
|
baseHp: 30,
|
|
baseAtk: 5,
|
|
baseDef: 2,
|
|
xpReward: 15,
|
|
levelScale: { hp: 10, atk: 2, def: 1 },
|
|
dialogues: {
|
|
annoyed: [
|
|
"Hey! HEY! I don't belong here!",
|
|
"Excuse me, I'm MATTER OUT OF PLACE!",
|
|
"This is a Leave No Trace trail!",
|
|
"I was in your pocket! YOUR POCKET!",
|
|
"Pack it in, pack it out! Remember?!"
|
|
],
|
|
frustrated: [
|
|
"STOP IGNORING ME!",
|
|
"I don't belong here and YOU know it!",
|
|
"I'm literally the definition of litter!",
|
|
"MOOP! MATTER! OUT! OF! PLACE!",
|
|
"Fine! Just keep walking! SEE IF I CARE!"
|
|
],
|
|
desperate: [
|
|
"Please... just pick me up...",
|
|
"I promise I'll fit in your pocket!",
|
|
"What if I promised to be biodegradable?",
|
|
"I just want to go to a proper bin...",
|
|
"I didn't ask to be abandoned here!"
|
|
],
|
|
philosophical: [
|
|
"What even IS place, when you think about it?",
|
|
"If matter is out of place, is place out of matter?",
|
|
"Perhaps being misplaced is the true journey.",
|
|
"Am I out of place, or is place out of me?",
|
|
"We're not so different, you and I..."
|
|
],
|
|
existential: [
|
|
"I have accepted my displacement.",
|
|
"All matter is eventually out of place.",
|
|
"I've made peace with being moop.",
|
|
"The trail will reclaim me eventually.",
|
|
"It's actually kind of nice out here. Good views."
|
|
]
|
|
},
|
|
enabled: true
|
|
});
|
|
console.log('Seeded default monster: Moop');
|
|
}
|
|
|
|
// =====================
|
|
// SKILLS METHODS
|
|
// =====================
|
|
|
|
getAllSkills(enabledOnly = false) {
|
|
const stmt = enabledOnly
|
|
? this.db.prepare(`SELECT * FROM skills WHERE enabled = 1`)
|
|
: this.db.prepare(`SELECT * FROM skills`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getSkill(id) {
|
|
const stmt = this.db.prepare(`SELECT * FROM skills WHERE id = ?`);
|
|
return stmt.get(id);
|
|
}
|
|
|
|
createSkill(skillData) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO skills (id, name, description, type, mp_cost, base_power, accuracy, hit_count, target, status_effect, player_usable, monster_usable, enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const statusEffect = skillData.statusEffect
|
|
? (typeof skillData.statusEffect === 'string' ? skillData.statusEffect : JSON.stringify(skillData.statusEffect))
|
|
: null;
|
|
return stmt.run(
|
|
skillData.id,
|
|
skillData.name,
|
|
skillData.description,
|
|
skillData.type || 'damage',
|
|
skillData.mpCost || skillData.mp_cost || 0,
|
|
skillData.basePower || skillData.base_power || 0,
|
|
skillData.accuracy || 100,
|
|
skillData.hitCount || skillData.hit_count || 1,
|
|
skillData.target || 'enemy',
|
|
statusEffect,
|
|
skillData.playerUsable !== false ? 1 : 0,
|
|
skillData.monsterUsable !== false ? 1 : 0,
|
|
skillData.enabled !== false ? 1 : 0
|
|
);
|
|
}
|
|
|
|
updateSkill(id, skillData) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE skills SET
|
|
name = ?, description = ?, type = ?, mp_cost = ?, base_power = ?,
|
|
accuracy = ?, hit_count = ?, target = ?, status_effect = ?,
|
|
player_usable = ?, monster_usable = ?, enabled = ?
|
|
WHERE id = ?
|
|
`);
|
|
const statusEffect = skillData.statusEffect
|
|
? (typeof skillData.statusEffect === 'string' ? skillData.statusEffect : JSON.stringify(skillData.statusEffect))
|
|
: null;
|
|
return stmt.run(
|
|
skillData.name,
|
|
skillData.description,
|
|
skillData.type || 'damage',
|
|
skillData.mpCost || skillData.mp_cost || 0,
|
|
skillData.basePower || skillData.base_power || 0,
|
|
skillData.accuracy || 100,
|
|
skillData.hitCount || skillData.hit_count || 1,
|
|
skillData.target || 'enemy',
|
|
statusEffect,
|
|
skillData.playerUsable !== false ? 1 : 0,
|
|
skillData.monsterUsable !== false ? 1 : 0,
|
|
skillData.enabled !== false ? 1 : 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
deleteSkill(id) {
|
|
// Also delete related class skill names and monster skills
|
|
this.db.prepare(`DELETE FROM class_skill_names WHERE skill_id = ?`).run(id);
|
|
this.db.prepare(`DELETE FROM monster_skills WHERE skill_id = ?`).run(id);
|
|
const stmt = this.db.prepare(`DELETE FROM skills WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
// =====================
|
|
// CLASS SKILL NAMES METHODS
|
|
// =====================
|
|
|
|
getAllClassSkillNames() {
|
|
const stmt = this.db.prepare(`SELECT * FROM class_skill_names`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getClassSkillNames(classId) {
|
|
const stmt = this.db.prepare(`SELECT * FROM class_skill_names WHERE class_id = ?`);
|
|
return stmt.all(classId);
|
|
}
|
|
|
|
getSkillNameForClass(skillId, classId) {
|
|
const stmt = this.db.prepare(`SELECT * FROM class_skill_names WHERE skill_id = ? AND class_id = ?`);
|
|
return stmt.get(skillId, classId);
|
|
}
|
|
|
|
createClassSkillName(data) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO class_skill_names (skill_id, class_id, custom_name, custom_description)
|
|
VALUES (?, ?, ?, ?)
|
|
`);
|
|
return stmt.run(
|
|
data.skillId || data.skill_id,
|
|
data.classId || data.class_id,
|
|
data.customName || data.custom_name,
|
|
data.customDescription || data.custom_description || null
|
|
);
|
|
}
|
|
|
|
updateClassSkillName(id, data) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE class_skill_names SET
|
|
skill_id = ?, class_id = ?, custom_name = ?, custom_description = ?
|
|
WHERE id = ?
|
|
`);
|
|
return stmt.run(
|
|
data.skillId || data.skill_id,
|
|
data.classId || data.class_id,
|
|
data.customName || data.custom_name,
|
|
data.customDescription || data.custom_description || null,
|
|
id
|
|
);
|
|
}
|
|
|
|
deleteClassSkillName(id) {
|
|
const stmt = this.db.prepare(`DELETE FROM class_skill_names WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
// =====================
|
|
// MONSTER SKILLS METHODS
|
|
// =====================
|
|
|
|
getAllMonsterSkills() {
|
|
const stmt = this.db.prepare(`SELECT * FROM monster_skills`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getMonsterTypeSkills(monsterTypeId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT ms.*, s.name, s.description, s.type, s.mp_cost, s.base_power,
|
|
s.accuracy, s.hit_count, s.target, s.status_effect
|
|
FROM monster_skills ms
|
|
JOIN skills s ON ms.skill_id = s.id
|
|
WHERE ms.monster_type_id = ? AND s.enabled = 1 AND s.monster_usable = 1
|
|
`);
|
|
return stmt.all(monsterTypeId);
|
|
}
|
|
|
|
createMonsterSkill(data) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO monster_skills (monster_type_id, skill_id, weight, min_level, custom_name)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
return stmt.run(
|
|
data.monsterTypeId || data.monster_type_id,
|
|
data.skillId || data.skill_id,
|
|
data.weight || 10,
|
|
data.minLevel || data.min_level || 1,
|
|
data.customName || data.custom_name || null
|
|
);
|
|
}
|
|
|
|
updateMonsterSkill(id, data) {
|
|
// Build dynamic update for partial updates
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (data.weight !== undefined) {
|
|
updates.push('weight = ?');
|
|
values.push(data.weight);
|
|
}
|
|
if (data.min_level !== undefined || data.minLevel !== undefined) {
|
|
updates.push('min_level = ?');
|
|
values.push(data.min_level || data.minLevel);
|
|
}
|
|
if (data.custom_name !== undefined || data.customName !== undefined) {
|
|
updates.push('custom_name = ?');
|
|
values.push(data.custom_name || data.customName || null);
|
|
}
|
|
|
|
if (updates.length === 0) return;
|
|
|
|
values.push(id);
|
|
const stmt = this.db.prepare(`UPDATE monster_skills SET ${updates.join(', ')} WHERE id = ?`);
|
|
return stmt.run(...values);
|
|
}
|
|
|
|
deleteMonsterSkill(id) {
|
|
const stmt = this.db.prepare(`DELETE FROM monster_skills WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
// =====================
|
|
// SKILL SEEDING
|
|
// =====================
|
|
|
|
seedDefaultSkills() {
|
|
console.log('Checking/seeding default skills...');
|
|
|
|
const defaultSkills = [
|
|
{
|
|
id: 'basic_attack',
|
|
name: 'Attack',
|
|
description: 'A basic physical attack',
|
|
type: 'damage',
|
|
mpCost: 0,
|
|
basePower: 100,
|
|
accuracy: 95,
|
|
hitCount: 1,
|
|
target: 'enemy',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
monsterUsable: true
|
|
},
|
|
{
|
|
id: 'double_attack',
|
|
name: 'Double Attack',
|
|
description: 'Strike twice in quick succession',
|
|
type: 'damage',
|
|
mpCost: 5,
|
|
basePower: 60,
|
|
accuracy: 85,
|
|
hitCount: 2,
|
|
target: 'enemy',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
monsterUsable: true
|
|
},
|
|
{
|
|
id: 'heal',
|
|
name: 'Heal',
|
|
description: 'Restore HP',
|
|
type: 'heal',
|
|
mpCost: 8,
|
|
basePower: 50,
|
|
accuracy: 100,
|
|
hitCount: 1,
|
|
target: 'self',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
monsterUsable: false
|
|
},
|
|
{
|
|
id: 'power_strike',
|
|
name: 'Power Strike',
|
|
description: 'A powerful blow with extra force',
|
|
type: 'damage',
|
|
mpCost: 10,
|
|
basePower: 180,
|
|
accuracy: 80,
|
|
hitCount: 1,
|
|
target: 'enemy',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
monsterUsable: true
|
|
},
|
|
{
|
|
id: 'defend',
|
|
name: 'Defend',
|
|
description: 'Raise defense temporarily',
|
|
type: 'buff',
|
|
mpCost: 3,
|
|
basePower: 50,
|
|
accuracy: 100,
|
|
hitCount: 1,
|
|
target: 'self',
|
|
statusEffect: { type: 'defense_up', percent: 50, duration: 2 },
|
|
playerUsable: true,
|
|
monsterUsable: true
|
|
},
|
|
{
|
|
id: 'poison',
|
|
name: 'Poison',
|
|
description: 'Inflict poison that deals damage over time',
|
|
type: 'status',
|
|
mpCost: 0,
|
|
basePower: 20,
|
|
accuracy: 75,
|
|
hitCount: 1,
|
|
target: 'enemy',
|
|
statusEffect: { type: 'poison', damage: 5, duration: 3 },
|
|
playerUsable: false,
|
|
monsterUsable: true
|
|
},
|
|
{
|
|
id: 'whirlwind',
|
|
name: 'Whirlwind',
|
|
description: 'A spinning attack that hits all enemies',
|
|
type: 'damage',
|
|
mpCost: 12,
|
|
basePower: 75,
|
|
accuracy: 85,
|
|
hitCount: 1,
|
|
target: 'all_enemies',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
monsterUsable: false
|
|
},
|
|
{
|
|
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: 'Catch your breath to restore MP',
|
|
type: 'restore',
|
|
mpCost: 0,
|
|
basePower: 30,
|
|
accuracy: 100,
|
|
hitCount: 1,
|
|
target: 'self',
|
|
statusEffect: null,
|
|
playerUsable: true,
|
|
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: 'double_attack', classId: 'trail_runner', customName: 'Brand New Hokas', customDescription: 'Break in those fresh kicks with two quick strikes!' },
|
|
{ skillId: 'power_strike', classId: 'trail_runner', customName: 'Downhill Sprint', customDescription: 'Use gravity to deliver a devastating blow!' },
|
|
{ skillId: 'heal', classId: 'trail_runner', customName: 'Gel Pack', customDescription: 'Quick energy gel restores your stamina' },
|
|
{ skillId: 'defend', classId: 'trail_runner', customName: 'Pace Yourself', customDescription: 'Slow down to conserve energy' }
|
|
];
|
|
|
|
for (const name of trailRunnerSkillNames) {
|
|
try {
|
|
this.createClassSkillName(name);
|
|
console.log(` Seeded class skill name: ${name.customName} for ${name.classId}`);
|
|
} catch (err) {
|
|
// Skip if already exists
|
|
}
|
|
}
|
|
|
|
// Assign poison skill to Moop monster
|
|
const moop = this.getMonsterType('moop');
|
|
if (moop) {
|
|
try {
|
|
this.createMonsterSkill({
|
|
monsterTypeId: 'moop',
|
|
skillId: 'poison',
|
|
weight: 30,
|
|
minLevel: 1
|
|
});
|
|
console.log(' Assigned poison skill to Moop');
|
|
} catch (err) {
|
|
// Skip if already exists
|
|
}
|
|
}
|
|
|
|
console.log('Default skills seeded successfully');
|
|
}
|
|
|
|
// =====================
|
|
// CLASSES METHODS
|
|
// =====================
|
|
|
|
getAllClasses(enabledOnly = false) {
|
|
const stmt = enabledOnly
|
|
? this.db.prepare(`SELECT * FROM classes WHERE enabled = 1`)
|
|
: this.db.prepare(`SELECT * FROM classes`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getClass(id) {
|
|
const stmt = this.db.prepare(`SELECT * FROM classes WHERE id = ?`);
|
|
return stmt.get(id);
|
|
}
|
|
|
|
createClass(classData) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO classes (id, name, description, base_hp, base_mp, base_atk, base_def,
|
|
base_accuracy, base_dodge, hp_per_level, mp_per_level, atk_per_level, def_per_level, enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
return stmt.run(
|
|
classData.id,
|
|
classData.name,
|
|
classData.description || '',
|
|
classData.base_hp || classData.baseHp || 100,
|
|
classData.base_mp || classData.baseMp || 50,
|
|
classData.base_atk || classData.baseAtk || 12,
|
|
classData.base_def || classData.baseDef || 8,
|
|
classData.base_accuracy || classData.baseAccuracy || 90,
|
|
classData.base_dodge || classData.baseDodge || 10,
|
|
classData.hp_per_level || classData.hpPerLevel || 10,
|
|
classData.mp_per_level || classData.mpPerLevel || 5,
|
|
classData.atk_per_level || classData.atkPerLevel || 2,
|
|
classData.def_per_level || classData.defPerLevel || 1,
|
|
classData.enabled ? 1 : 0
|
|
);
|
|
}
|
|
|
|
updateClass(id, classData) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE classes SET
|
|
name = ?, description = ?, base_hp = ?, base_mp = ?, base_atk = ?, base_def = ?,
|
|
base_accuracy = ?, base_dodge = ?, hp_per_level = ?, mp_per_level = ?,
|
|
atk_per_level = ?, def_per_level = ?, enabled = ?
|
|
WHERE id = ?
|
|
`);
|
|
return stmt.run(
|
|
classData.name,
|
|
classData.description || '',
|
|
classData.base_hp || classData.baseHp || 100,
|
|
classData.base_mp || classData.baseMp || 50,
|
|
classData.base_atk || classData.baseAtk || 12,
|
|
classData.base_def || classData.baseDef || 8,
|
|
classData.base_accuracy || classData.baseAccuracy || 90,
|
|
classData.base_dodge || classData.baseDodge || 10,
|
|
classData.hp_per_level || classData.hpPerLevel || 10,
|
|
classData.mp_per_level || classData.mpPerLevel || 5,
|
|
classData.atk_per_level || classData.atkPerLevel || 2,
|
|
classData.def_per_level || classData.defPerLevel || 1,
|
|
classData.enabled ? 1 : 0,
|
|
id
|
|
);
|
|
}
|
|
|
|
deleteClass(id) {
|
|
// Also delete related class_skills
|
|
this.db.prepare(`DELETE FROM class_skills WHERE class_id = ?`).run(id);
|
|
this.db.prepare(`DELETE FROM class_skill_names WHERE class_id = ?`).run(id);
|
|
const stmt = this.db.prepare(`DELETE FROM classes WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
toggleClassEnabled(id, enabled) {
|
|
const stmt = this.db.prepare(`UPDATE classes SET enabled = ? WHERE id = ?`);
|
|
return stmt.run(enabled ? 1 : 0, id);
|
|
}
|
|
|
|
// =====================
|
|
// CLASS SKILLS METHODS
|
|
// =====================
|
|
|
|
getClassSkills(classId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
|
|
s.mp_cost, s.base_power, s.accuracy, s.hit_count, s.target, s.status_effect
|
|
FROM class_skills cs
|
|
JOIN skills s ON cs.skill_id = s.id
|
|
WHERE cs.class_id = ? AND s.enabled = 1
|
|
ORDER BY cs.unlock_level ASC, cs.choice_group ASC
|
|
`);
|
|
return stmt.all(classId);
|
|
}
|
|
|
|
getAllClassSkills() {
|
|
const stmt = this.db.prepare(`
|
|
SELECT cs.*, s.name as base_name, s.description as base_description
|
|
FROM class_skills cs
|
|
LEFT JOIN skills s ON cs.skill_id = s.id
|
|
`);
|
|
return stmt.all();
|
|
}
|
|
|
|
getClassSkill(classId, skillId) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT cs.*, s.name as base_name, s.description as base_description
|
|
FROM class_skills cs
|
|
JOIN skills s ON cs.skill_id = s.id
|
|
WHERE cs.class_id = ? AND cs.skill_id = ?
|
|
`);
|
|
return stmt.get(classId, skillId);
|
|
}
|
|
|
|
createClassSkill(data) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO class_skills (class_id, skill_id, unlock_level, choice_group, custom_name, custom_description)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`);
|
|
return stmt.run(
|
|
data.class_id || data.classId,
|
|
data.skill_id || data.skillId,
|
|
data.unlock_level || data.unlockLevel || 1,
|
|
data.choice_group || data.choiceGroup || null,
|
|
data.custom_name || data.customName || null,
|
|
data.custom_description || data.customDescription || null
|
|
);
|
|
}
|
|
|
|
updateClassSkill(id, data) {
|
|
const stmt = this.db.prepare(`
|
|
UPDATE class_skills SET
|
|
unlock_level = ?, choice_group = ?, custom_name = ?, custom_description = ?
|
|
WHERE id = ?
|
|
`);
|
|
return stmt.run(
|
|
data.unlock_level || data.unlockLevel || 1,
|
|
data.choice_group || data.choiceGroup || null,
|
|
data.custom_name || data.customName || null,
|
|
data.custom_description || data.customDescription || null,
|
|
id
|
|
);
|
|
}
|
|
|
|
deleteClassSkill(id) {
|
|
const stmt = this.db.prepare(`DELETE FROM class_skills WHERE id = ?`);
|
|
return stmt.run(id);
|
|
}
|
|
|
|
// Get skills available for level-up choice at a specific level
|
|
getSkillChoicesForLevel(classId, level) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
|
|
s.mp_cost, s.base_power, s.accuracy, s.hit_count
|
|
FROM class_skills cs
|
|
JOIN skills s ON cs.skill_id = s.id
|
|
WHERE cs.class_id = ? AND cs.unlock_level = ? AND cs.choice_group IS NOT NULL
|
|
ORDER BY cs.choice_group ASC
|
|
`);
|
|
return stmt.all(classId, level);
|
|
}
|
|
|
|
// Get auto-learned skills up to a specific level (no choice_group)
|
|
getAutoLearnedSkills(classId, maxLevel) {
|
|
const stmt = this.db.prepare(`
|
|
SELECT cs.*, s.name as base_name, s.description as base_description
|
|
FROM class_skills cs
|
|
JOIN skills s ON cs.skill_id = s.id
|
|
WHERE cs.class_id = ? AND cs.unlock_level <= ? AND cs.choice_group IS NULL
|
|
ORDER BY cs.unlock_level ASC
|
|
`);
|
|
return stmt.all(classId, maxLevel);
|
|
}
|
|
|
|
// =====================
|
|
// CLASS SEEDING
|
|
// =====================
|
|
|
|
seedDefaultClasses() {
|
|
// Check if Trail Runner already exists
|
|
const existing = this.getClass('trail_runner');
|
|
if (existing) return;
|
|
|
|
console.log('Seeding default classes...');
|
|
|
|
// Trail Runner - the initial available class
|
|
this.createClass({
|
|
id: 'trail_runner',
|
|
name: 'Trail Runner',
|
|
description: 'A swift adventurer who conquers trails with endurance and agility. Uses running-themed skills.',
|
|
base_hp: 100,
|
|
base_mp: 50,
|
|
base_atk: 12,
|
|
base_def: 8,
|
|
base_accuracy: 90,
|
|
base_dodge: 15, // Trail runners are agile
|
|
hp_per_level: 10,
|
|
mp_per_level: 5,
|
|
atk_per_level: 2,
|
|
def_per_level: 1,
|
|
enabled: true
|
|
});
|
|
console.log(' Seeded class: Trail Runner');
|
|
|
|
// Gym Bro - coming soon
|
|
this.createClass({
|
|
id: 'gym_bro',
|
|
name: 'Gym Bro',
|
|
description: 'A powerhouse of strength who never skips leg day. High attack and defense.',
|
|
base_hp: 120,
|
|
base_mp: 30,
|
|
base_atk: 15,
|
|
base_def: 12,
|
|
base_accuracy: 85,
|
|
base_dodge: 5,
|
|
hp_per_level: 15,
|
|
mp_per_level: 3,
|
|
atk_per_level: 3,
|
|
def_per_level: 2,
|
|
enabled: false
|
|
});
|
|
console.log(' Seeded class: Gym Bro (disabled)');
|
|
|
|
// Yoga Master - coming soon
|
|
this.createClass({
|
|
id: 'yoga_master',
|
|
name: 'Yoga Master',
|
|
description: 'A balanced warrior with high dodge and healing abilities. Mind over matter.',
|
|
base_hp: 80,
|
|
base_mp: 80,
|
|
base_atk: 10,
|
|
base_def: 10,
|
|
base_accuracy: 95,
|
|
base_dodge: 20,
|
|
hp_per_level: 8,
|
|
mp_per_level: 8,
|
|
atk_per_level: 1,
|
|
def_per_level: 2,
|
|
enabled: false
|
|
});
|
|
console.log(' Seeded class: Yoga Master (disabled)');
|
|
|
|
// CrossFit Crusader - coming soon
|
|
this.createClass({
|
|
id: 'crossfit_crusader',
|
|
name: 'CrossFit Crusader',
|
|
description: 'An all-around athlete with balanced stats and versatile skills.',
|
|
base_hp: 100,
|
|
base_mp: 50,
|
|
base_atk: 13,
|
|
base_def: 10,
|
|
base_accuracy: 88,
|
|
base_dodge: 12,
|
|
hp_per_level: 12,
|
|
mp_per_level: 5,
|
|
atk_per_level: 2,
|
|
def_per_level: 2,
|
|
enabled: false
|
|
});
|
|
console.log(' Seeded class: CrossFit Crusader (disabled)');
|
|
|
|
// Seed Trail Runner skills
|
|
this.seedTrailRunnerSkills();
|
|
|
|
console.log('Default classes seeded successfully');
|
|
}
|
|
|
|
seedTrailRunnerSkills() {
|
|
// Check if already seeded
|
|
const existing = this.getClassSkills('trail_runner');
|
|
if (existing.length > 0) return;
|
|
|
|
console.log(' Seeding Trail Runner skills...');
|
|
|
|
// Level 1 - Basic Attack (auto-learned)
|
|
this.createClassSkill({
|
|
class_id: 'trail_runner',
|
|
skill_id: 'basic_attack',
|
|
unlock_level: 1,
|
|
choice_group: null,
|
|
custom_name: 'Trail Kick',
|
|
custom_description: 'A swift kick perfected on countless trails'
|
|
});
|
|
|
|
// Level 2 - Choice: Brand New Hokas (double_attack) OR Runner\'s High (heal)
|
|
this.createClassSkill({
|
|
class_id: 'trail_runner',
|
|
skill_id: 'double_attack',
|
|
unlock_level: 2,
|
|
choice_group: 1,
|
|
custom_name: 'Brand New Hokas',
|
|
custom_description: 'Break in those fresh kicks with two quick strikes!'
|
|
});
|
|
this.createClassSkill({
|
|
class_id: 'trail_runner',
|
|
skill_id: 'heal',
|
|
unlock_level: 2,
|
|
choice_group: 1,
|
|
custom_name: 'Gel Pack',
|
|
custom_description: 'Quick energy gel restores your stamina'
|
|
});
|
|
|
|
// Level 3 - Choice: Downhill Sprint (power_strike) OR Pace Yourself (defend)
|
|
this.createClassSkill({
|
|
class_id: 'trail_runner',
|
|
skill_id: 'power_strike',
|
|
unlock_level: 3,
|
|
choice_group: 2,
|
|
custom_name: 'Downhill Sprint',
|
|
custom_description: 'Use gravity to deliver a devastating blow!'
|
|
});
|
|
this.createClassSkill({
|
|
class_id: 'trail_runner',
|
|
skill_id: 'defend',
|
|
unlock_level: 3,
|
|
choice_group: 2,
|
|
custom_name: 'Pace Yourself',
|
|
custom_description: 'Slow down to conserve energy and reduce damage'
|
|
});
|
|
|
|
// 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"]',
|
|
updated_at = datetime('now')
|
|
WHERE user_id = ?
|
|
`);
|
|
const result = stmt.run(userId);
|
|
|
|
// Also clear their monster entourage
|
|
this.db.prepare(`DELETE FROM monster_entourage WHERE user_id = ?`).run(userId);
|
|
|
|
return result;
|
|
}
|
|
|
|
// Game settings methods
|
|
getSetting(key) {
|
|
const stmt = this.db.prepare(`SELECT value FROM game_settings WHERE key = ?`);
|
|
const row = stmt.get(key);
|
|
return row ? row.value : null;
|
|
}
|
|
|
|
setSetting(key, value) {
|
|
const stmt = this.db.prepare(`
|
|
INSERT INTO game_settings (key, value, updated_at)
|
|
VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
|
|
`);
|
|
return stmt.run(key, value);
|
|
}
|
|
|
|
getAllSettings() {
|
|
const stmt = this.db.prepare(`SELECT key, value FROM game_settings`);
|
|
const rows = stmt.all();
|
|
const settings = {};
|
|
rows.forEach(row => {
|
|
// Try to parse JSON values
|
|
try {
|
|
settings[row.key] = JSON.parse(row.value);
|
|
} catch {
|
|
settings[row.key] = row.value;
|
|
}
|
|
});
|
|
return settings;
|
|
}
|
|
|
|
seedDefaultSettings() {
|
|
const defaults = {
|
|
monsterSpawnInterval: 20000, // Timer interval in ms (20 seconds)
|
|
monsterSpawnChance: 50, // Percent chance per interval (50%)
|
|
monsterSpawnDistance: 10, // Meters player must move for new spawns (10m)
|
|
maxMonstersPerPlayer: 10,
|
|
xpMultiplier: 1.0,
|
|
combatEnabled: true
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
const existing = this.getSetting(key);
|
|
if (existing === null) {
|
|
this.setSetting(key, JSON.stringify(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
close() {
|
|
if (this.db) {
|
|
this.db.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = HikeMapDB;
|