diff --git a/database.js b/database.js
index b59172b..fa291df 100644
--- a/database.js
+++ b/database.js
@@ -144,6 +144,66 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`);
} catch (e) { /* Column already exists */ }
+ // Skills table - defines available skills/spells
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS skills (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ type TEXT NOT NULL,
+ mp_cost INTEGER DEFAULT 0,
+ base_power INTEGER DEFAULT 0,
+ accuracy INTEGER DEFAULT 100,
+ hit_count INTEGER DEFAULT 1,
+ target TEXT DEFAULT 'enemy',
+ status_effect TEXT,
+ player_usable BOOLEAN DEFAULT 1,
+ monster_usable BOOLEAN DEFAULT 1,
+ enabled BOOLEAN DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+
+ // Class skill names - class-specific naming for skills
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS class_skill_names (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ skill_id TEXT NOT NULL,
+ class_id TEXT NOT NULL,
+ custom_name TEXT NOT NULL,
+ custom_description TEXT,
+ UNIQUE(skill_id, class_id)
+ )
+ `);
+
+ // Monster skills - skills assigned to monster types
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS monster_skills (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ monster_type_id TEXT NOT NULL,
+ skill_id TEXT NOT NULL,
+ weight INTEGER DEFAULT 10,
+ min_level INTEGER DEFAULT 1,
+ UNIQUE(monster_type_id, skill_id)
+ )
+ `);
+
+ // Migration: Add accuracy/dodge to rpg_stats
+ try {
+ this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN accuracy INTEGER DEFAULT 90`);
+ } catch (e) { /* Column already exists */ }
+ try {
+ this.db.exec(`ALTER TABLE rpg_stats ADD COLUMN dodge INTEGER DEFAULT 10`);
+ } catch (e) { /* Column already exists */ }
+
+ // Migration: Add accuracy/dodge to monster_types
+ try {
+ this.db.exec(`ALTER TABLE monster_types ADD COLUMN accuracy INTEGER DEFAULT 85`);
+ } catch (e) { /* Column already exists */ }
+ try {
+ this.db.exec(`ALTER TABLE monster_types ADD COLUMN dodge INTEGER DEFAULT 5`);
+ } catch (e) { /* Column already exists */ }
+
// Game settings table - key/value store for game configuration
this.db.exec(`
CREATE TABLE IF NOT EXISTS game_settings (
@@ -161,6 +221,10 @@ class HikeMapDB {
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);
`);
}
@@ -399,7 +463,7 @@ class HikeMapDB {
// RPG Stats methods
getRpgStats(userId) {
const stmt = this.db.prepare(`
- SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, unlocked_skills
+ SELECT character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, accuracy, dodge, unlocked_skills
FROM rpg_stats WHERE user_id = ?
`);
return stmt.get(userId);
@@ -414,8 +478,8 @@ class HikeMapDB {
createCharacter(userId, characterData) {
const stmt = this.db.prepare(`
- INSERT INTO rpg_stats (user_id, character_name, race, class, level, xp, hp, max_hp, mp, max_mp, atk, def, unlocked_skills, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ 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,
@@ -428,6 +492,8 @@ class HikeMapDB {
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')
`);
@@ -446,14 +512,16 @@ class HikeMapDB {
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, unlocked_skills, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ 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 = COALESCE(excluded.character_name, rpg_stats.character_name),
race = COALESCE(excluded.race, rpg_stats.race),
@@ -466,6 +534,8 @@ class HikeMapDB {
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),
updated_at = datetime('now')
`);
@@ -484,6 +554,8 @@ class HikeMapDB {
stats.maxMp || 50,
stats.atk || 12,
stats.def || 8,
+ stats.accuracy || 90,
+ stats.dodge || 10,
unlockedSkillsJson
);
}
@@ -687,13 +759,326 @@ class HikeMapDB {
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)
+ VALUES (?, ?, ?, ?)
+ `);
+ return stmt.run(
+ data.monsterTypeId || data.monster_type_id,
+ data.skillId || data.skill_id,
+ data.weight || 10,
+ data.minLevel || data.min_level || 1
+ );
+ }
+
+ updateMonsterSkill(id, data) {
+ const stmt = this.db.prepare(`
+ UPDATE monster_skills SET
+ monster_type_id = ?, skill_id = ?, weight = ?, min_level = ?
+ WHERE id = ?
+ `);
+ return stmt.run(
+ data.monsterTypeId || data.monster_type_id,
+ data.skillId || data.skill_id,
+ data.weight || 10,
+ data.minLevel || data.min_level || 1,
+ id
+ );
+ }
+
+ deleteMonsterSkill(id) {
+ const stmt = this.db.prepare(`DELETE FROM monster_skills WHERE id = ?`);
+ return stmt.run(id);
+ }
+
+ // =====================
+ // SKILL SEEDING
+ // =====================
+
+ seedDefaultSkills() {
+ // Check if skills already exist
+ const existing = this.getSkill('basic_attack');
+ if (existing) return;
+
+ console.log('Seeding default skills...');
+
+ const defaultSkills = [
+ {
+ id: 'basic_attack',
+ name: 'Attack',
+ description: 'A basic physical attack',
+ type: 'damage',
+ mpCost: 0,
+ basePower: 100,
+ accuracy: 95,
+ hitCount: 1,
+ target: 'enemy',
+ statusEffect: null,
+ playerUsable: true,
+ monsterUsable: true
+ },
+ {
+ id: 'double_attack',
+ name: 'Double Attack',
+ description: 'Strike twice in quick succession',
+ type: 'damage',
+ mpCost: 5,
+ basePower: 60,
+ accuracy: 85,
+ hitCount: 2,
+ target: 'enemy',
+ statusEffect: null,
+ playerUsable: true,
+ monsterUsable: true
+ },
+ {
+ id: 'heal',
+ name: 'Heal',
+ description: 'Restore HP',
+ type: 'heal',
+ mpCost: 8,
+ basePower: 50,
+ accuracy: 100,
+ hitCount: 1,
+ target: 'self',
+ statusEffect: null,
+ playerUsable: true,
+ monsterUsable: false
+ },
+ {
+ id: 'power_strike',
+ name: 'Power Strike',
+ description: 'A powerful blow with extra force',
+ type: 'damage',
+ mpCost: 10,
+ basePower: 180,
+ accuracy: 80,
+ hitCount: 1,
+ target: 'enemy',
+ statusEffect: null,
+ playerUsable: true,
+ monsterUsable: true
+ },
+ {
+ id: 'defend',
+ name: 'Defend',
+ description: 'Raise defense temporarily',
+ type: 'buff',
+ mpCost: 3,
+ basePower: 50,
+ accuracy: 100,
+ hitCount: 1,
+ target: 'self',
+ statusEffect: { type: 'defense_up', percent: 50, duration: 2 },
+ playerUsable: true,
+ monsterUsable: true
+ },
+ {
+ id: 'poison',
+ name: 'Poison',
+ description: 'Inflict poison that deals damage over time',
+ type: 'status',
+ mpCost: 0,
+ basePower: 20,
+ accuracy: 75,
+ hitCount: 1,
+ target: 'enemy',
+ statusEffect: { type: 'poison', damage: 5, duration: 3 },
+ playerUsable: false,
+ monsterUsable: true
+ }
+ ];
+
+ for (const skill of defaultSkills) {
+ this.createSkill(skill);
+ console.log(` Seeded skill: ${skill.name}`);
+ }
+
+ // Seed Trail Runner class skill names
+ const trailRunnerSkillNames = [
+ { skillId: 'double_attack', classId: 'trail_runner', customName: 'Brand New Hokas', customDescription: 'Break in those fresh kicks with two quick strikes!' },
+ { skillId: 'power_strike', classId: 'trail_runner', customName: 'Downhill Sprint', customDescription: 'Use gravity to deliver a devastating blow!' },
+ { skillId: 'heal', classId: 'trail_runner', customName: 'Gel Pack', customDescription: 'Quick energy gel restores your stamina' },
+ { skillId: 'defend', classId: 'trail_runner', customName: 'Pace Yourself', customDescription: 'Slow down to conserve energy' }
+ ];
+
+ for (const name of trailRunnerSkillNames) {
+ this.createClassSkillName(name);
+ console.log(` Seeded class skill name: ${name.customName} for ${name.classId}`);
+ }
+
+ // Assign poison skill to Moop monster
+ const moop = this.getMonsterType('moop');
+ if (moop) {
+ this.createMonsterSkill({
+ monsterTypeId: 'moop',
+ skillId: 'poison',
+ weight: 30,
+ minLevel: 1
+ });
+ console.log(' Assigned poison skill to Moop');
+ }
+
+ console.log('Default skills seeded successfully');
+ }
+
// Admin: Get all users with their RPG stats
getAllUsers() {
const stmt = this.db.prepare(`
SELECT u.id, u.username, u.email, u.created_at, u.total_points, u.finds_count,
u.avatar_icon, u.avatar_color, u.is_admin,
r.character_name, r.race, r.class, r.level, r.xp, r.hp, r.max_hp,
- r.mp, r.max_mp, r.atk, r.def, r.unlocked_skills
+ 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
@@ -705,23 +1090,36 @@ class HikeMapDB {
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 = ?, unlocked_skills = ?, updated_at = datetime('now')
+ atk = ?, def = ?, accuracy = ?, dodge = ?, unlocked_skills = ?, updated_at = datetime('now')
WHERE user_id = ?
`);
- const unlockedSkillsJson = stats.unlockedSkills ? JSON.stringify(stats.unlockedSkills) : '["basic_attack"]';
- return stmt.run(
+ 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 || 100,
+ stats.maxHp || stats.max_hp || 100,
stats.mp || 50,
- stats.maxMp || 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
@@ -729,7 +1127,7 @@ class HikeMapDB {
const stmt = this.db.prepare(`
UPDATE rpg_stats SET
level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50,
- atk = 12, def = 8, unlocked_skills = '["basic_attack"]',
+ atk = 12, def = 8, accuracy = 90, dodge = 10, unlocked_skills = '["basic_attack"]',
updated_at = datetime('now')
WHERE user_id = ?
`);
diff --git a/index.html b/index.html
index 5fbd06e..9d7ea43 100644
--- a/index.html
+++ b/index.html
@@ -1595,6 +1595,43 @@
.char-sheet-skill.locked .skill-name {
color: #666;
}
+ .char-sheet-monster-count {
+ font-size: 16px;
+ color: #ffd93d;
+ margin-bottom: 10px;
+ }
+ .char-sheet-monster-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .char-sheet-monster {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px;
+ background: rgba(0,0,0,0.2);
+ border-radius: 8px;
+ }
+ .char-sheet-monster .monster-thumb {
+ width: 32px;
+ height: 32px;
+ object-fit: contain;
+ }
+ .char-sheet-monster .monster-info {
+ flex: 1;
+ font-size: 13px;
+ color: #fff;
+ }
+ .char-sheet-monster .monster-hp {
+ font-size: 11px;
+ color: #ff6b6b;
+ }
+ .char-sheet-no-monsters {
+ color: #666;
+ font-style: italic;
+ font-size: 13px;
+ }
/* Skill Choice Modal */
.skill-choice-modal {
@@ -1956,63 +1993,59 @@
top: 10px;
left: 50%;
transform: translateX(-50%);
- background: rgba(0, 0, 0, 0.85);
+ background: rgba(0, 0, 0, 0.9);
color: white;
- padding: 10px 20px;
- border-radius: 25px;
- font-size: 13px;
+ padding: 8px 12px;
+ border-radius: 12px;
+ font-size: 11px;
z-index: 1000;
display: flex;
- gap: 18px;
- align-items: center;
+ flex-direction: column;
+ gap: 4px;
border: 2px solid #e94560;
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3);
+ cursor: pointer;
+ min-width: 140px;
}
- .rpg-hud-class {
- font-weight: bold;
- color: #e94560;
- }
- .rpg-hud-stats {
- display: flex;
- gap: 12px;
- }
- .rpg-hud-stat {
+ .rpg-hud-bar {
display: flex;
align-items: center;
- gap: 4px;
+ gap: 6px;
}
- .rpg-hud-stat-label {
+ .rpg-hud-bar-label {
color: #888;
- font-size: 11px;
- }
- .rpg-hud-hp { color: #ff6b6b; }
- .rpg-hud-mp { color: #4ecdc4; }
- .rpg-hud-xp {
- display: flex;
- align-items: center;
- gap: 6px;
+ font-size: 10px;
+ font-weight: bold;
+ width: 20px;
+ text-align: right;
}
- .rpg-hud-xp-bar {
- width: 60px;
- height: 8px;
- background: #333;
- border-radius: 4px;
+ .rpg-hud-bar-track {
+ flex: 1;
+ height: 10px;
+ background: #222;
+ border-radius: 5px;
overflow: hidden;
- border: 1px solid #555;
+ border: 1px solid #444;
}
- .rpg-hud-xp-fill {
+ .rpg-hud-bar-fill {
height: 100%;
- background: linear-gradient(90deg, #ffd93d, #f0c419);
transition: width 0.3s ease;
- border-radius: 3px;
+ border-radius: 4px;
}
- .rpg-hud-xp-text {
- color: #ffd93d;
- font-size: 10px;
- min-width: 45px;
+ .rpg-hud-bar-fill.hp-fill {
+ background: linear-gradient(90deg, #ff6b6b, #ee5a5a);
}
- .rpg-hud-monsters {
- color: #ffd93d;
+ .rpg-hud-bar-fill.mp-fill {
+ background: linear-gradient(90deg, #4ecdc4, #3dbdb5);
+ }
+ .rpg-hud-bar-fill.xp-fill {
+ background: linear-gradient(90deg, #ffd93d, #f0c419);
+ }
+ .rpg-hud-bar-text {
+ font-size: 9px;
+ min-width: 42px;
+ text-align: right;
+ color: #aaa;
}
/* Combat Overlay Styles */
@@ -2142,6 +2175,13 @@
color: #ffd93d;
font-weight: bold;
}
+ .combat-log-miss {
+ color: #888;
+ font-style: italic;
+ }
+ .combat-log-buff {
+ color: #a8e6cf;
+ }
.combat-skills {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -2312,31 +2352,27 @@
N
-
-
🏃 Trail Runner
-
-
- Lv
- 1
-
-
-
-
HP
-
100/100
+
+
+
HP
+
-
- MP
- 50/50
+ 100/100
+
+
-
@@ -2699,6 +2735,12 @@
+
@@ -3312,6 +3354,15 @@
calculate: (atk) => Math.floor(atk * 2),
hits: 3,
description: 'Strike 3 times for 2x ATK each'
+ },
+ 'admin_banish': {
+ name: 'Banish All',
+ icon: '⚡',
+ mpCost: 0,
+ levelReq: 1,
+ type: 'admin_clear',
+ adminOnly: true,
+ description: 'Instantly banish all enemies (Admin only)'
}
};
@@ -3329,6 +3380,12 @@
let MONSTER_DIALOGUES = {};
let monsterTypesLoaded = false;
+ // Skills loaded from database API
+ let SKILLS_DB = {}; // Base skill definitions from API
+ let CLASS_SKILL_NAMES = []; // Class-specific skill names
+ let MONSTER_SKILLS = {}; // Skills assigned to each monster type
+ let skillsLoaded = false;
+
// Load monster types from the database
async function loadMonsterTypes() {
try {
@@ -3343,6 +3400,8 @@
baseAtk: t.baseAtk,
baseDef: t.baseDef,
xpReward: t.xpReward,
+ accuracy: t.accuracy || 85,
+ dodge: t.dodge || 5,
levelScale: t.levelScale
};
MONSTER_DIALOGUES[t.id] = t.dialogues;
@@ -3355,6 +3414,134 @@
}
}
+ // Load skills from database
+ async function loadSkillsFromDatabase() {
+ try {
+ // Load base skills
+ const skillsResponse = await fetch('/api/skills');
+ if (skillsResponse.ok) {
+ const skills = await skillsResponse.json();
+ skills.forEach(s => {
+ SKILLS_DB[s.id] = {
+ id: s.id,
+ name: s.name,
+ description: s.description,
+ type: s.type,
+ mpCost: s.mpCost,
+ basePower: s.basePower,
+ accuracy: s.accuracy,
+ hitCount: s.hitCount,
+ target: s.target,
+ statusEffect: s.statusEffect,
+ playerUsable: s.playerUsable,
+ monsterUsable: s.monsterUsable
+ };
+ });
+ console.log('Loaded skills from database:', Object.keys(SKILLS_DB));
+ }
+
+ // Load class skill names
+ const namesResponse = await fetch('/api/class-skill-names');
+ if (namesResponse.ok) {
+ CLASS_SKILL_NAMES = await namesResponse.json();
+ console.log('Loaded class skill names:', CLASS_SKILL_NAMES.length);
+ }
+
+ skillsLoaded = true;
+ } catch (err) {
+ console.error('Failed to load skills:', err);
+ }
+ }
+
+ // Load skills for a specific monster type
+ async function loadMonsterSkills(monsterTypeId) {
+ if (MONSTER_SKILLS[monsterTypeId]) return MONSTER_SKILLS[monsterTypeId];
+
+ try {
+ const response = await fetch(`/api/monster-types/${monsterTypeId}/skills`);
+ if (response.ok) {
+ MONSTER_SKILLS[monsterTypeId] = await response.json();
+ console.log(`Loaded skills for ${monsterTypeId}:`, MONSTER_SKILLS[monsterTypeId].length);
+ return MONSTER_SKILLS[monsterTypeId];
+ }
+ } catch (err) {
+ console.error(`Failed to load monster skills for ${monsterTypeId}:`, err);
+ }
+ return [];
+ }
+
+ // Get skill display name for a class (or base name if no custom)
+ function getSkillForClass(skillId, classId) {
+ const baseSkill = SKILLS_DB[skillId] || SKILLS[skillId];
+ if (!baseSkill) return null;
+
+ // Check for class-specific name
+ const customName = CLASS_SKILL_NAMES.find(
+ n => n.skillId === skillId && n.classId === classId
+ );
+
+ return {
+ ...baseSkill,
+ displayName: customName ? customName.customName : baseSkill.name,
+ displayDescription: customName?.customDescription || baseSkill.description
+ };
+ }
+
+ // Calculate hit chance: skill accuracy + (attacker accuracy - 90) - defender dodge
+ function calculateHitChance(attackerAccuracy, defenderDodge, skillAccuracy) {
+ const hitChance = skillAccuracy + (attackerAccuracy - 90) - defenderDodge;
+ return Math.max(5, Math.min(99, hitChance)); // Clamp 5-99%
+ }
+
+ // Roll for hit
+ function rollHit(hitChance) {
+ return Math.random() * 100 < hitChance;
+ }
+
+ // Select a monster skill using weighted random
+ function selectMonsterSkill(monsterTypeId, monsterLevel) {
+ const skills = MONSTER_SKILLS[monsterTypeId] || [];
+
+ // Filter by level requirement
+ const validSkills = skills.filter(s => monsterLevel >= s.minLevel);
+
+ if (validSkills.length === 0) {
+ // Fallback to basic attack
+ return SKILLS_DB['basic_attack'] || { id: 'basic_attack', name: 'Attack', basePower: 100, accuracy: 95 };
+ }
+
+ // Weighted random selection
+ const totalWeight = validSkills.reduce((sum, s) => sum + s.weight, 0);
+ let random = Math.random() * totalWeight;
+
+ for (const skill of validSkills) {
+ random -= skill.weight;
+ if (random <= 0) {
+ return {
+ id: skill.skillId,
+ name: skill.name,
+ basePower: skill.basePower,
+ accuracy: skill.accuracy,
+ hitCount: skill.hitCount || 1,
+ statusEffect: skill.statusEffect,
+ type: skill.type
+ };
+ }
+ }
+
+ // Fallback
+ const lastSkill = validSkills[validSkills.length - 1];
+ return {
+ id: lastSkill.skillId,
+ name: lastSkill.name,
+ basePower: lastSkill.basePower,
+ accuracy: lastSkill.accuracy,
+ hitCount: lastSkill.hitCount || 1,
+ statusEffect: lastSkill.statusEffect,
+ type: lastSkill.type
+ };
+ }
+
// Dialogue phase thresholds (in minutes)
const DIALOGUE_PHASES = [
{ maxMinutes: 5, phase: 'annoyed' },
@@ -5121,6 +5308,14 @@
console.log('Connected to multi-user tracking');
clearTimeout(wsReconnectTimer);
+ // Register authenticated user for real-time updates
+ if (currentUser && currentUser.id) {
+ ws.send(JSON.stringify({
+ type: 'auth',
+ authUserId: currentUser.id
+ }));
+ }
+
// Send our icon info if we have it
if (myIcon && myColor) {
setTimeout(() => {
@@ -5241,6 +5436,12 @@
});
}
break;
+
+ case 'statsUpdated':
+ // Admin updated our stats - refresh from server
+ console.log('Stats updated by admin, refreshing...');
+ refreshPlayerStats();
+ break;
}
};
@@ -8644,6 +8845,17 @@
}
}
+ // Register authenticated user with WebSocket for real-time updates
+ function registerWebSocketAuth() {
+ if (ws && ws.readyState === WebSocket.OPEN && currentUser && currentUser.id) {
+ ws.send(JSON.stringify({
+ type: 'auth',
+ authUserId: currentUser.id
+ }));
+ console.log('Registered auth user', currentUser.id, 'with WebSocket');
+ }
+ }
+
async function login(username, password) {
const response = await fetch('/api/login', {
method: 'POST',
@@ -8659,6 +8871,7 @@
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
+ registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
@@ -8683,6 +8896,7 @@
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
+ registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
@@ -8730,6 +8944,7 @@
if (response.ok) {
currentUser = await response.json();
updateAuthUI();
+ registerWebSocketAuth();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
} else {
@@ -9134,6 +9349,28 @@
`;
}).join('');
+ // Update monsters section
+ const maxMonsters = getMaxMonsters();
+ const monsterCount = monsterEntourage.length;
+ let monstersHtml = `${monsterCount}/${maxMonsters} nearby
`;
+ if (monsterCount > 0) {
+ monstersHtml += '';
+ monsterEntourage.forEach(m => {
+ const type = MONSTER_TYPES[m.type] || { name: 'Unknown', icon: '👹' };
+ monstersHtml += `
+
+

+
Lv${m.level} ${type.name}
+
${m.hp}/${m.maxHp} HP
+
+ `;
+ });
+ monstersHtml += '
';
+ } else {
+ monstersHtml += 'No monsters nearby
';
+ }
+ document.getElementById('charSheetMonsters').innerHTML = monstersHtml;
+
document.getElementById('charSheetModal').style.display = 'flex';
}
@@ -9411,6 +9648,29 @@
showCharCreatorModal();
}
+ // Refresh player stats from server (used when admin updates stats)
+ async function refreshPlayerStats() {
+ const token = localStorage.getItem('accessToken');
+ if (!token) return;
+
+ try {
+ const response = await fetch('/api/user/rpg-stats', {
+ headers: { 'Authorization': `Bearer ${token}` }
+ });
+
+ if (response.ok) {
+ const serverStats = await response.json();
+ if (serverStats && serverStats.name) {
+ playerStats = serverStats;
+ console.log('Refreshed RPG stats from server:', playerStats);
+ updateRpgHud();
+ }
+ }
+ } catch (e) {
+ console.error('Failed to refresh RPG stats:', e);
+ }
+ }
+
// Save player stats to server (and localStorage as backup)
function savePlayerStats() {
if (!playerStats) return;
@@ -9436,11 +9696,15 @@
function updateRpgHud() {
if (!playerStats) return;
- document.getElementById('hudLevel').textContent = playerStats.level;
+ // Update HP bar
+ const hpPercent = Math.min(100, (playerStats.hp / playerStats.maxHp) * 100);
+ document.getElementById('hudHpBar').style.width = hpPercent + '%';
document.getElementById('hudHp').textContent = `${playerStats.hp}/${playerStats.maxHp}`;
+
+ // Update MP bar
+ const mpPercent = Math.min(100, (playerStats.mp / playerStats.maxMp) * 100);
+ document.getElementById('hudMpBar').style.width = mpPercent + '%';
document.getElementById('hudMp').textContent = `${playerStats.mp}/${playerStats.maxMp}`;
- document.getElementById('hudMonsterCount').textContent = monsterEntourage.length;
- document.getElementById('hudMonsterMax').textContent = getMaxMonsters();
// Update XP bar
const xpNeeded = playerStats.level * 100;
@@ -9764,22 +10028,31 @@
// ==========================================
// Initiate combat with a monster
- function initiateCombat(clickedMonster) {
+ async function initiateCombat(clickedMonster) {
if (combatState) return; // Already in combat
if (!playerStats) return;
if (monsterEntourage.length === 0) return;
+ // Load skills for each unique monster type
+ const uniqueTypes = [...new Set(monsterEntourage.map(m => m.type))];
+ await Promise.all(uniqueTypes.map(type => loadMonsterSkills(type)));
+
// Gather ALL monsters from entourage for multi-monster combat
- const monstersInCombat = monsterEntourage.map(m => ({
- id: m.id,
- type: m.type,
- level: m.level,
- hp: m.hp,
- maxHp: m.maxHp,
- atk: m.atk,
- def: m.def,
- data: MONSTER_TYPES[m.type]
- }));
+ const monstersInCombat = monsterEntourage.map(m => {
+ const monsterType = MONSTER_TYPES[m.type];
+ return {
+ id: m.id,
+ type: m.type,
+ level: m.level,
+ hp: m.hp,
+ maxHp: m.maxHp,
+ atk: m.atk,
+ def: m.def,
+ accuracy: monsterType?.accuracy || 85,
+ dodge: monsterType?.dodge || 5,
+ data: monsterType
+ };
+ });
// Find the clicked monster's index to make it the initial target
const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id);
@@ -9791,13 +10064,17 @@
mp: playerStats.mp,
maxMp: playerStats.maxMp,
atk: playerStats.atk,
- def: playerStats.def
+ def: playerStats.def,
+ accuracy: playerStats.accuracy || 90,
+ dodge: playerStats.dodge || 10
},
monsters: monstersInCombat,
selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0,
turn: 'player',
currentMonsterTurn: 0,
- log: []
+ log: [],
+ playerStatusEffects: [], // [{type, damage, turnsLeft}]
+ defenseBuffTurns: 0 // Turns remaining for defense buff
};
showCombatUI();
@@ -9837,6 +10114,21 @@
skillsContainer.appendChild(btn);
});
+ // Add admin-only Banish All skill for admins
+ if (currentUser && currentUser.is_admin) {
+ const adminSkill = SKILLS['admin_banish'];
+ const btn = document.createElement('button');
+ btn.className = 'skill-btn';
+ btn.dataset.skillId = 'admin_banish';
+ btn.style.borderColor = '#ff6b35';
+ btn.innerHTML = `
+ ${adminSkill.icon} ${adminSkill.name}
+ Admin
+ `;
+ btn.onclick = () => executePlayerSkill('admin_banish');
+ skillsContainer.appendChild(btn);
+ }
+
// Set up flee button
document.getElementById('combatFleeBtn').onclick = fleeCombat;
@@ -9948,11 +10240,67 @@
log.scrollTop = log.scrollHeight;
}
+ // Process status effects at start of player turn
+ function processPlayerStatusEffects() {
+ if (!combatState || combatState.playerStatusEffects.length === 0) return;
+
+ let totalDamage = 0;
+ const effectsToRemove = [];
+
+ combatState.playerStatusEffects.forEach((effect, i) => {
+ if (effect.type === 'poison') {
+ totalDamage += effect.damage;
+ addCombatLog(`☠️ Poison deals ${effect.damage} damage!`, 'damage');
+ }
+
+ effect.turnsLeft--;
+ if (effect.turnsLeft <= 0) {
+ effectsToRemove.push(i);
+ addCombatLog(`💨 ${effect.type.charAt(0).toUpperCase() + effect.type.slice(1)} wore off!`);
+ }
+ });
+
+ // Remove expired effects (in reverse order to preserve indices)
+ effectsToRemove.reverse().forEach(i => {
+ combatState.playerStatusEffects.splice(i, 1);
+ });
+
+ if (totalDamage > 0) {
+ combatState.player.hp -= totalDamage;
+ updateCombatUI();
+
+ // Check for defeat from status damage
+ if (combatState.player.hp <= 0) {
+ handleCombatDefeat();
+ return false;
+ }
+ }
+
+ // Decrement defense buff
+ if (combatState.defenseBuffTurns > 0) {
+ combatState.defenseBuffTurns--;
+ if (combatState.defenseBuffTurns === 0) {
+ addCombatLog(`🛡️ Defense buff expired!`);
+ }
+ }
+
+ return true;
+ }
+
// Execute a player skill
function executePlayerSkill(skillId) {
if (!combatState || combatState.turn !== 'player') return;
- const skill = SKILLS[skillId];
+ // Get skill from DB first, then fall back to hardcoded SKILLS
+ const dbSkill = SKILLS_DB[skillId];
+ const hardcodedSkill = SKILLS[skillId];
+ const skill = dbSkill || hardcodedSkill;
+
+ if (!skill) {
+ addCombatLog(`Unknown skill: ${skillId}`);
+ return;
+ }
+
const levelReq = skill.levelReq || 1;
if (playerStats.level < levelReq) {
addCombatLog(`You need to be level ${levelReq} to use ${skill.name}!`);
@@ -9963,28 +10311,106 @@
return;
}
+ // Get class-specific display name
+ const skillDisplay = getSkillForClass(skillId, playerStats.class) || skill;
+ const displayName = skillDisplay.displayName || skill.name;
+
// Deduct MP
combatState.player.mp -= skill.mpCost;
// Get the targeted monster
const target = combatState.monsters[combatState.selectedTargetIndex];
- if (skill.type === 'damage') {
- const rawDamage = skill.calculate(combatState.player.atk);
- const damage = Math.max(1, rawDamage - target.def);
- target.hp -= damage;
- addCombatLog(`You used ${skill.name} on ${target.data.name}! Dealt ${damage} damage!`, 'damage');
+ if (skill.type === 'admin_clear') {
+ // Admin-only: instantly defeat all monsters (no XP)
+ if (!currentUser || !currentUser.is_admin) {
+ addCombatLog('This skill requires admin privileges!');
+ return;
+ }
+ const monsterCount = combatState.monsters.length;
+ combatState.monsters.forEach(m => m.hp = 0);
+ addCombatLog(`⚡ Admin Banish! All ${monsterCount} enemies vanished!`, 'victory');
+ updateCombatUI();
+
+ // End combat immediately (no XP awarded)
+ setTimeout(() => {
+ const monsterIds = combatState.monsters.map(m => m.id);
+ monsterIds.forEach(id => removeMonster(id));
+ playerStats.hp = combatState.player.hp;
+ playerStats.mp = combatState.player.mp;
+ savePlayerStats();
+ updateRpgHud();
+ closeCombatUI();
+ }, 1000);
+ return;
+ } else if (skill.type === 'damage') {
+ // Calculate hit chance
+ const skillAccuracy = dbSkill ? dbSkill.accuracy : 95;
+ const hitChance = calculateHitChance(
+ combatState.player.accuracy,
+ target.dodge,
+ skillAccuracy
+ );
+
+ // Roll for hit
+ if (!rollHit(hitChance)) {
+ addCombatLog(`❌ ${displayName} missed ${target.data.name}! (${hitChance}% chance)`, 'miss');
+ endPlayerTurn();
+ return;
+ }
+
+ // Calculate damage - support both old calculate() and new basePower
+ let rawDamage;
+ if (hardcodedSkill && hardcodedSkill.calculate) {
+ rawDamage = hardcodedSkill.calculate(combatState.player.atk);
+ } else if (dbSkill) {
+ rawDamage = Math.floor(combatState.player.atk * (dbSkill.basePower / 100));
+ } else {
+ rawDamage = combatState.player.atk;
+ }
+
+ // Handle multi-hit skills
+ const hitCount = skill.hitCount || skill.hits || 1;
+ let totalDamage = 0;
+
+ for (let hit = 0; hit < hitCount; hit++) {
+ const damage = Math.max(1, rawDamage - target.def);
+ totalDamage += damage;
+ target.hp -= damage;
+ }
+
+ if (hitCount > 1) {
+ addCombatLog(`✨ ${displayName} hits ${target.data.name} ${hitCount} times for ${totalDamage} total damage!`, 'damage');
+ } else {
+ addCombatLog(`⚔️ ${displayName} hits ${target.data.name} for ${totalDamage} damage!`, 'damage');
+ }
// Check if this monster died
if (target.hp <= 0) {
- addCombatLog(`${target.data.name} was defeated!`, 'victory');
- // Auto-retarget to next living monster if available
+ addCombatLog(`💀 ${target.data.name} was defeated!`, 'victory');
autoRetarget();
}
} else if (skill.type === 'heal') {
- const healAmount = skill.calculate(combatState.player.maxHp);
+ let healAmount;
+ if (hardcodedSkill && hardcodedSkill.calculate) {
+ healAmount = hardcodedSkill.calculate(combatState.player.maxHp);
+ } else if (dbSkill) {
+ healAmount = Math.floor(combatState.player.maxHp * (dbSkill.basePower / 100));
+ } else {
+ healAmount = 30;
+ }
combatState.player.hp = Math.min(combatState.player.maxHp, combatState.player.hp + healAmount);
- addCombatLog(`You used ${skill.name}! Healed ${healAmount} HP!`, 'heal');
+ addCombatLog(`💚 ${displayName}! Healed ${healAmount} HP!`, 'heal');
+ } else if (skill.type === 'buff') {
+ // Handle defense buff
+ if (dbSkill && dbSkill.statusEffect && dbSkill.statusEffect.type === 'defense_up') {
+ combatState.defenseBuffTurns = dbSkill.statusEffect.duration || 2;
+ const buffPercent = dbSkill.statusEffect.percent || 50;
+ addCombatLog(`🛡️ ${displayName}! DEF +${buffPercent}% for ${combatState.defenseBuffTurns} turns!`, 'buff');
+ } else if (hardcodedSkill && hardcodedSkill.effect === 'dodge') {
+ // Legacy dodge buff
+ addCombatLog(`⚡ ${displayName}! Next attack will be dodged!`);
+ }
}
updateCombatUI();
@@ -9996,7 +10422,11 @@
return;
}
- // Start monster turns sequence
+ endPlayerTurn();
+ }
+
+ // End player turn and start monster turns
+ function endPlayerTurn() {
combatState.turn = 'monsters';
combatState.currentMonsterTurn = 0;
updateCombatUI();
@@ -10038,6 +10468,14 @@
// All monsters have attacked, return to player turn
combatState.turn = 'player';
updateCombatUI();
+
+ // Process status effects at start of player turn
+ setTimeout(() => {
+ if (combatState && combatState.playerStatusEffects.length > 0) {
+ const survived = processPlayerStatusEffects();
+ if (!survived) return; // Player died from status effects
+ }
+ }, 200);
}
// Execute one monster's attack
@@ -10048,10 +10486,76 @@
combatState.currentMonsterTurn = monsterIndex;
updateCombatUI();
- const damage = Math.max(1, monster.atk - combatState.player.def);
- combatState.player.hp -= damage;
+ // Select a skill using weighted random (or basic attack if none)
+ const selectedSkill = selectMonsterSkill(monster.type, monster.level);
+
+ // Calculate hit chance
+ const skillAccuracy = selectedSkill.accuracy || 85;
+ const hitChance = calculateHitChance(
+ monster.accuracy,
+ combatState.player.dodge,
+ skillAccuracy
+ );
+
+ // Roll for hit
+ if (!rollHit(hitChance)) {
+ addCombatLog(`❌ ${monster.data.name}'s ${selectedSkill.name} missed! (${hitChance}% chance)`, 'miss');
+ combatState.currentMonsterTurn++;
+ setTimeout(executeMonsterTurns, 800);
+ return;
+ }
+
+ // Calculate effective defense (with buff if active)
+ let effectiveDef = combatState.player.def;
+ if (combatState.defenseBuffTurns > 0) {
+ effectiveDef = Math.floor(effectiveDef * 1.5);
+ }
+
+ // Handle different skill types
+ if (selectedSkill.type === 'status') {
+ // Status effect skill (like poison)
+ const baseDamage = selectedSkill.basePower || 20;
+ const damage = Math.max(1, Math.floor(monster.atk * (baseDamage / 100)) - effectiveDef);
+ combatState.player.hp -= damage;
+
+ // Apply status effect
+ if (selectedSkill.statusEffect) {
+ const effect = selectedSkill.statusEffect;
+ // Check if already poisoned
+ const existing = combatState.playerStatusEffects.find(e => e.type === effect.type);
+ if (!existing) {
+ combatState.playerStatusEffects.push({
+ type: effect.type,
+ damage: effect.damage || 5,
+ turnsLeft: effect.duration || 3
+ });
+ addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage + ${effect.type} applied!`, 'damage');
+ } else {
+ addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${damage} damage! (Already ${effect.type}ed)`, 'damage');
+ }
+ }
+ } else {
+ // Regular damage skill or basic attack
+ const basePower = selectedSkill.basePower || 100;
+ const rawDamage = Math.floor(monster.atk * (basePower / 100));
+ const hitCount = selectedSkill.hitCount || 1;
+ let totalDamage = 0;
+
+ for (let hit = 0; hit < hitCount; hit++) {
+ const damage = Math.max(1, rawDamage - effectiveDef);
+ totalDamage += damage;
+ combatState.player.hp -= damage;
+ }
+
+ if (selectedSkill.id === 'basic_attack' || selectedSkill.name === 'Attack') {
+ addCombatLog(`⚔️ ${monster.data.name} attacks! You take ${totalDamage} damage!`, 'damage');
+ } else if (hitCount > 1) {
+ addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! ${hitCount} hits for ${totalDamage} total damage!`, 'damage');
+ } else {
+ addCombatLog(`🔥 ${monster.data.name} uses ${selectedSkill.name}! You take ${totalDamage} damage!`, 'damage');
+ }
+ }
- addCombatLog(`${monster.data.name} attacks! You take ${damage} damage!`, 'damage');
updateCombatUI();
// Check for defeat
@@ -10163,8 +10667,8 @@
// END RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
- // Load monster types from database, then initialize auth
- loadMonsterTypes().then(() => {
+ // Load monster types and skills from database, then initialize auth
+ Promise.all([loadMonsterTypes(), loadSkillsFromDatabase()]).then(() => {
loadCurrentUser();
});
diff --git a/server.js b/server.js
index 51e002c..53d2d45 100644
--- a/server.js
+++ b/server.js
@@ -52,6 +52,14 @@ const wss = new WebSocket.Server({ server });
app.use(express.json());
app.use(express.text({ type: 'application/xml', limit: '10mb' }));
+// Disable caching for API routes
+app.use('/api', (req, res, next) => {
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
+ res.set('Pragma', 'no-cache');
+ res.set('Expires', '0');
+ next();
+});
+
// Serve static files - prioritize data directory for default.kml
app.get('/default.kml', async (req, res) => {
try {
@@ -742,6 +750,7 @@ app.get('/api/user/finds', authenticateToken, (req, res) => {
app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
try {
const stats = db.getRpgStats(req.user.userId);
+ console.log('GET /api/user/rpg-stats for user', req.user.userId, '- atk:', stats ? stats.atk : 'NO STATS');
if (stats) {
// Parse unlocked_skills from JSON string
let unlockedSkills = ['basic_attack'];
@@ -766,6 +775,8 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
maxMp: stats.max_mp,
atk: stats.atk,
def: stats.def,
+ accuracy: stats.accuracy || 90,
+ dodge: stats.dodge || 10,
unlockedSkills: unlockedSkills
});
} else {
@@ -851,6 +862,8 @@ app.get('/api/monster-types', (req, res) => {
baseAtk: t.base_atk,
baseDef: t.base_def,
xpReward: t.xp_reward,
+ accuracy: t.accuracy || 85,
+ dodge: t.dodge || 5,
levelScale: {
hp: t.level_scale_hp,
atk: t.level_scale_atk,
@@ -865,6 +878,84 @@ app.get('/api/monster-types', (req, res) => {
}
});
+// ============================================
+// Skills Endpoints (Public - needed for combat)
+// ============================================
+
+// Get all skills (public endpoint)
+app.get('/api/skills', (req, res) => {
+ try {
+ const skills = db.getAllSkills(true); // Only enabled skills
+ // Convert snake_case to camelCase and parse JSON
+ const formatted = skills.map(s => ({
+ id: s.id,
+ name: s.name,
+ description: s.description,
+ type: s.type,
+ mpCost: s.mp_cost,
+ basePower: s.base_power,
+ accuracy: s.accuracy,
+ hitCount: s.hit_count,
+ target: s.target,
+ statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null,
+ playerUsable: !!s.player_usable,
+ monsterUsable: !!s.monster_usable
+ }));
+ res.json(formatted);
+ } catch (err) {
+ console.error('Get skills error:', err);
+ res.status(500).json({ error: 'Failed to get skills' });
+ }
+});
+
+// Get all class skill names (public endpoint)
+app.get('/api/class-skill-names', (req, res) => {
+ try {
+ const names = db.getAllClassSkillNames();
+ // Convert snake_case to camelCase
+ const formatted = names.map(n => ({
+ id: n.id,
+ skillId: n.skill_id,
+ classId: n.class_id,
+ customName: n.custom_name,
+ customDescription: n.custom_description
+ }));
+ res.json(formatted);
+ } catch (err) {
+ console.error('Get class skill names error:', err);
+ res.status(500).json({ error: 'Failed to get class skill names' });
+ }
+});
+
+// Get skills for a specific monster type (public endpoint)
+app.get('/api/monster-types/:id/skills', (req, res) => {
+ try {
+ const skills = db.getMonsterTypeSkills(req.params.id);
+ // Convert snake_case to camelCase and parse JSON
+ const formatted = skills.map(s => ({
+ id: s.id,
+ skillId: s.skill_id,
+ monsterTypeId: s.monster_type_id,
+ weight: s.weight,
+ minLevel: s.min_level,
+ // Include skill details
+ name: s.name,
+ description: s.description,
+ type: s.type,
+ mpCost: s.mp_cost,
+ basePower: s.base_power,
+ accuracy: s.accuracy,
+ hitCount: s.hit_count,
+ target: s.target,
+ statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null
+ }));
+ res.json(formatted);
+ } catch (err) {
+ console.error('Get monster skills error:', err);
+ res.status(500).json({ error: 'Failed to get monster skills' });
+ }
+});
+
// Get monster entourage for current user
app.get('/api/user/monsters', authenticateToken, (req, res) => {
try {
@@ -1027,6 +1118,9 @@ app.get('/api/admin/users', adminOnly, (req, res) => {
def: u.def || 0,
unlocked_skills: u.unlocked_skills
}));
+ // Debug: log user 2's atk value
+ const user2 = formatted.find(u => u.id === 2);
+ if (user2) console.log('GET /api/admin/users - user 2 atk:', user2.atk);
res.json({ users: formatted });
} catch (err) {
console.error('Admin get users error:', err);
@@ -1038,7 +1132,15 @@ app.get('/api/admin/users', adminOnly, (req, res) => {
app.put('/api/admin/users/:id', adminOnly, (req, res) => {
try {
const stats = req.body;
- db.updateUserRpgStats(req.params.id, stats);
+ const targetUserId = parseInt(req.params.id);
+ console.log('Admin updating user', targetUserId, 'with stats:', JSON.stringify(stats));
+ const result = db.updateUserRpgStats(targetUserId, stats);
+ console.log('Update result:', result);
+
+ // Notify the user in real-time to refresh their stats
+ const notified = sendToAuthUser(targetUserId, { type: 'statsUpdated' });
+ console.log('User notified via WebSocket:', notified);
+
res.json({ success: true });
} catch (err) {
console.error('Admin update user error:', err);
@@ -1094,6 +1196,187 @@ app.put('/api/admin/settings', adminOnly, (req, res) => {
}
});
+// ============================================
+// Admin Skills Endpoints
+// ============================================
+
+// Get all skills (admin - includes disabled)
+app.get('/api/admin/skills', adminOnly, (req, res) => {
+ try {
+ const skills = db.getAllSkills(false); // Include disabled
+ const formatted = skills.map(s => ({
+ id: s.id,
+ name: s.name,
+ description: s.description,
+ type: s.type,
+ mp_cost: s.mp_cost,
+ base_power: s.base_power,
+ accuracy: s.accuracy,
+ hit_count: s.hit_count,
+ target: s.target,
+ status_effect: s.status_effect,
+ player_usable: !!s.player_usable,
+ monster_usable: !!s.monster_usable,
+ enabled: !!s.enabled,
+ created_at: s.created_at
+ }));
+ res.json({ skills: formatted });
+ } catch (err) {
+ console.error('Admin get skills error:', err);
+ res.status(500).json({ error: 'Failed to get skills' });
+ }
+});
+
+// Create skill
+app.post('/api/admin/skills', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ if (!data.id || !data.name) {
+ return res.status(400).json({ error: 'Missing required fields (id and name)' });
+ }
+ db.createSkill(data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin create skill error:', err);
+ res.status(500).json({ error: 'Failed to create skill' });
+ }
+});
+
+// Update skill
+app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ db.updateSkill(req.params.id, data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin update skill error:', err);
+ res.status(500).json({ error: 'Failed to update skill' });
+ }
+});
+
+// Delete skill
+app.delete('/api/admin/skills/:id', adminOnly, (req, res) => {
+ try {
+ db.deleteSkill(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin delete skill error:', err);
+ res.status(500).json({ error: 'Failed to delete skill' });
+ }
+});
+
+// Get all class skill names (admin)
+app.get('/api/admin/class-skill-names', adminOnly, (req, res) => {
+ try {
+ const names = db.getAllClassSkillNames();
+ const formatted = names.map(n => ({
+ id: n.id,
+ skill_id: n.skill_id,
+ class_id: n.class_id,
+ custom_name: n.custom_name,
+ custom_description: n.custom_description
+ }));
+ res.json({ classSkillNames: formatted });
+ } catch (err) {
+ console.error('Admin get class skill names error:', err);
+ res.status(500).json({ error: 'Failed to get class skill names' });
+ }
+});
+
+// Create class skill name
+app.post('/api/admin/class-skill-names', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ if (!data.skill_id || !data.class_id || !data.custom_name) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+ db.createClassSkillName(data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin create class skill name error:', err);
+ res.status(500).json({ error: 'Failed to create class skill name' });
+ }
+});
+
+// Update class skill name
+app.put('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ db.updateClassSkillName(req.params.id, data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin update class skill name error:', err);
+ res.status(500).json({ error: 'Failed to update class skill name' });
+ }
+});
+
+// Delete class skill name
+app.delete('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
+ try {
+ db.deleteClassSkillName(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin delete class skill name error:', err);
+ res.status(500).json({ error: 'Failed to delete class skill name' });
+ }
+});
+
+// Get all monster skills (admin)
+app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
+ try {
+ const skills = db.getAllMonsterSkills();
+ const formatted = skills.map(s => ({
+ id: s.id,
+ monster_type_id: s.monster_type_id,
+ skill_id: s.skill_id,
+ weight: s.weight,
+ min_level: s.min_level
+ }));
+ res.json({ monsterSkills: formatted });
+ } catch (err) {
+ console.error('Admin get monster skills error:', err);
+ res.status(500).json({ error: 'Failed to get monster skills' });
+ }
+});
+
+// Create monster skill assignment
+app.post('/api/admin/monster-skills', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ if (!data.monster_type_id || !data.skill_id) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+ db.createMonsterSkill(data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin create monster skill error:', err);
+ res.status(500).json({ error: 'Failed to create monster skill' });
+ }
+});
+
+// Update monster skill assignment
+app.put('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
+ try {
+ const data = req.body;
+ db.updateMonsterSkill(req.params.id, data);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin update monster skill error:', err);
+ res.status(500).json({ error: 'Failed to update monster skill' });
+ }
+});
+
+// Delete monster skill assignment
+app.delete('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
+ try {
+ db.deleteMonsterSkill(req.params.id);
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Admin delete monster skill error:', err);
+ res.status(500).json({ error: 'Failed to delete monster skill' });
+ }
+});
+
// Function to send push notification to all subscribers
async function sendPushNotification(title, body, data = {}) {
const notification = {
@@ -1154,6 +1437,19 @@ function broadcast(data, senderId) {
});
}
+// Map authenticated user IDs to WebSocket connections for targeted messages
+const authUserConnections = new Map(); // authUserId (number) -> ws connection
+
+// Send message to a specific authenticated user
+function sendToAuthUser(authUserId, data) {
+ const ws = authUserConnections.get(authUserId);
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify(data));
+ return true;
+ }
+ return false;
+}
+
// Clean up disconnected user
function removeUser(userId) {
if (users.has(userId)) {
@@ -1198,7 +1494,14 @@ wss.on('connection', (ws) => {
try {
const data = JSON.parse(message);
- if (data.type === 'location') {
+ if (data.type === 'auth') {
+ // Register authenticated user's WebSocket connection
+ if (data.authUserId) {
+ ws.authUserId = data.authUserId;
+ authUserConnections.set(data.authUserId, ws);
+ console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`);
+ }
+ } else if (data.type === 'location') {
// Store user location with icon info
users.set(userId, {
lat: data.lat,
@@ -1299,11 +1602,18 @@ wss.on('connection', (ws) => {
ws.on('close', () => {
removeUser(userId);
+ // Clean up auth user mapping
+ if (ws.authUserId) {
+ authUserConnections.delete(ws.authUserId);
+ }
});
ws.on('error', (err) => {
console.error(`WebSocket error for user ${userId}:`, err);
removeUser(userId);
+ if (ws.authUserId) {
+ authUserConnections.delete(ws.authUserId);
+ }
});
// Heartbeat to detect disconnected clients
@@ -1351,6 +1661,9 @@ server.listen(PORT, async () => {
// Seed default monsters if they don't exist
db.seedDefaultMonsters();
+ // Seed default skills if they don't exist
+ db.seedDefaultSkills();
+
// Seed default game settings if they don't exist
db.seedDefaultSettings();