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
- @@ -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 += ` +
+ ${type.name} + 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();