Browse Source

Add spell/skill database system with miss mechanics

- Add skills, class_skill_names, monster_skills tables to SQLite
- Add accuracy/dodge stats to players and monster_types
- Implement hit/miss calculation (skill accuracy + attacker accuracy - defender dodge)
- Add status effect system (poison deals damage over turns)
- Monster AI uses weighted random skill selection
- Trail Runner class gets custom skill names (Brand New Hokas, etc.)
- Moop monster can now use Poison skill (30% weight)
- Add admin API endpoints for skill management

Default skills: basic_attack, double_attack, heal, power_strike, defend, poison

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
master
HikeMap User 1 month ago
parent
commit
4622670134
  1. 424
      database.js
  2. 668
      index.html
  3. 317
      server.js

424
database.js

@ -144,6 +144,66 @@ class HikeMapDB {
this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`); this.db.exec(`ALTER TABLE monster_types ADD COLUMN spawn_weight INTEGER DEFAULT 100`);
} catch (e) { /* Column already exists */ } } 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 // Game settings table - key/value store for game configuration
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS game_settings ( 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_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); 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_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 // RPG Stats methods
getRpgStats(userId) { getRpgStats(userId) {
const stmt = this.db.prepare(` 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 = ? FROM rpg_stats WHERE user_id = ?
`); `);
return stmt.get(userId); return stmt.get(userId);
@ -414,8 +478,8 @@ class HikeMapDB {
createCharacter(userId, characterData) { createCharacter(userId, characterData) {
const stmt = this.db.prepare(` 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 ON CONFLICT(user_id) DO UPDATE SET
character_name = excluded.character_name, character_name = excluded.character_name,
race = excluded.race, race = excluded.race,
@ -428,6 +492,8 @@ class HikeMapDB {
max_mp = excluded.max_mp, max_mp = excluded.max_mp,
atk = excluded.atk, atk = excluded.atk,
def = excluded.def, def = excluded.def,
accuracy = excluded.accuracy,
dodge = excluded.dodge,
unlocked_skills = excluded.unlocked_skills, unlocked_skills = excluded.unlocked_skills,
updated_at = datetime('now') updated_at = datetime('now')
`); `);
@ -446,14 +512,16 @@ class HikeMapDB {
characterData.maxMp || 50, characterData.maxMp || 50,
characterData.atk || 12, characterData.atk || 12,
characterData.def || 8, characterData.def || 8,
characterData.accuracy || 90,
characterData.dodge || 10,
unlockedSkillsJson unlockedSkillsJson
); );
} }
saveRpgStats(userId, stats) { saveRpgStats(userId, stats) {
const stmt = this.db.prepare(` 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 ON CONFLICT(user_id) DO UPDATE SET
character_name = COALESCE(excluded.character_name, rpg_stats.character_name), character_name = COALESCE(excluded.character_name, rpg_stats.character_name),
race = COALESCE(excluded.race, rpg_stats.race), race = COALESCE(excluded.race, rpg_stats.race),
@ -466,6 +534,8 @@ class HikeMapDB {
max_mp = excluded.max_mp, max_mp = excluded.max_mp,
atk = excluded.atk, atk = excluded.atk,
def = excluded.def, def = excluded.def,
accuracy = excluded.accuracy,
dodge = excluded.dodge,
unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills), unlocked_skills = COALESCE(excluded.unlocked_skills, rpg_stats.unlocked_skills),
updated_at = datetime('now') updated_at = datetime('now')
`); `);
@ -484,6 +554,8 @@ class HikeMapDB {
stats.maxMp || 50, stats.maxMp || 50,
stats.atk || 12, stats.atk || 12,
stats.def || 8, stats.def || 8,
stats.accuracy || 90,
stats.dodge || 10,
unlockedSkillsJson unlockedSkillsJson
); );
} }
@ -687,13 +759,326 @@ class HikeMapDB {
console.log('Seeded default monster: Moop'); 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 // Admin: Get all users with their RPG stats
getAllUsers() { getAllUsers() {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
SELECT u.id, u.username, u.email, u.created_at, u.total_points, u.finds_count, 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, 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.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 FROM users u
LEFT JOIN rpg_stats r ON u.id = r.user_id LEFT JOIN rpg_stats r ON u.id = r.user_id
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
@ -705,23 +1090,36 @@ class HikeMapDB {
updateUserRpgStats(userId, stats) { updateUserRpgStats(userId, stats) {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE rpg_stats SET UPDATE rpg_stats SET
character_name = COALESCE(?, character_name),
level = ?, xp = ?, hp = ?, max_hp = ?, mp = ?, max_mp = ?, 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 = ? 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.level || 1,
stats.xp || 0, stats.xp || 0,
stats.hp || 100, stats.hp || 100,
stats.maxHp || 100,
stats.maxHp || stats.max_hp || 100,
stats.mp || 50, stats.mp || 50,
stats.maxMp || 50,
stats.maxMp || stats.max_mp || 50,
stats.atk || 12, stats.atk || 12,
stats.def || 8, stats.def || 8,
stats.accuracy || 90,
stats.dodge || 10,
unlockedSkillsJson, unlockedSkillsJson,
userId 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 // Admin: Reset user RPG progress
@ -729,7 +1127,7 @@ class HikeMapDB {
const stmt = this.db.prepare(` const stmt = this.db.prepare(`
UPDATE rpg_stats SET UPDATE rpg_stats SET
level = 1, xp = 0, hp = 100, max_hp = 100, mp = 50, max_mp = 50, 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') updated_at = datetime('now')
WHERE user_id = ? WHERE user_id = ?
`); `);

668
index.html

@ -1595,6 +1595,43 @@
.char-sheet-skill.locked .skill-name { .char-sheet-skill.locked .skill-name {
color: #666; 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 */
.skill-choice-modal { .skill-choice-modal {
@ -1956,63 +1993,59 @@
top: 10px; top: 10px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.9);
color: white; color: white;
padding: 10px 20px;
border-radius: 25px;
font-size: 13px;
padding: 8px 12px;
border-radius: 12px;
font-size: 11px;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
gap: 18px;
align-items: center;
flex-direction: column;
gap: 4px;
border: 2px solid #e94560; border: 2px solid #e94560;
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3); 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; display: flex;
align-items: center; align-items: center;
gap: 4px;
gap: 6px;
} }
.rpg-hud-stat-label {
.rpg-hud-bar-label {
color: #888; 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; overflow: hidden;
border: 1px solid #555;
border: 1px solid #444;
} }
.rpg-hud-xp-fill {
.rpg-hud-bar-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #ffd93d, #f0c419);
transition: width 0.3s ease; 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 */ /* Combat Overlay Styles */
@ -2142,6 +2175,13 @@
color: #ffd93d; color: #ffd93d;
font-weight: bold; font-weight: bold;
} }
.combat-log-miss {
color: #888;
font-style: italic;
}
.combat-log-buff {
color: #a8e6cf;
}
.combat-skills { .combat-skills {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -2312,31 +2352,27 @@
<div id="compassIndicator" class="compass-indicator">N</div> <div id="compassIndicator" class="compass-indicator">N</div>
<!-- RPG HUD (shown when player has class) --> <!-- RPG HUD (shown when player has class) -->
<div id="rpgHud" class="rpg-hud" style="display: none;">
<div class="rpg-hud-class" style="cursor: pointer;" onclick="showCharacterSheet()" title="View Character Sheet">🏃 <span id="hudClassName">Trail Runner</span></div>
<div class="rpg-hud-stats">
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">Lv</span>
<span id="hudLevel" class="rpg-hud-hp">1</span>
</div>
<div class="rpg-hud-xp">
<span class="rpg-hud-stat-label">XP</span>
<div class="rpg-hud-xp-bar">
<div class="rpg-hud-xp-fill" id="hudXpBar" style="width: 0%;"></div>
<div id="rpgHud" class="rpg-hud" style="display: none;" onclick="showCharacterSheet()" title="Tap for Character Sheet">
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">HP</span>
<div class="rpg-hud-bar-track hp-track">
<div class="rpg-hud-bar-fill hp-fill" id="hudHpBar" style="width: 100%;"></div>
</div> </div>
<span class="rpg-hud-xp-text" id="hudXpText">0/100</span>
<span class="rpg-hud-bar-text" id="hudHp">100/100</span>
</div> </div>
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">HP</span>
<span id="hudHp" class="rpg-hud-hp">100/100</span>
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">MP</span>
<div class="rpg-hud-bar-track mp-track">
<div class="rpg-hud-bar-fill mp-fill" id="hudMpBar" style="width: 100%;"></div>
</div> </div>
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">MP</span>
<span id="hudMp" class="rpg-hud-mp">50/50</span>
<span class="rpg-hud-bar-text" id="hudMp">50/50</span>
</div> </div>
<div class="rpg-hud-bar">
<span class="rpg-hud-bar-label">XP</span>
<div class="rpg-hud-bar-track xp-track">
<div class="rpg-hud-bar-fill xp-fill" id="hudXpBar" style="width: 0%;"></div>
</div> </div>
<div class="rpg-hud-monsters">
👹 <span id="hudMonsterCount">0</span>/<span id="hudMonsterMax">2</span>
<span class="rpg-hud-bar-text" id="hudXpText">0/100</span>
</div> </div>
</div> </div>
@ -2699,6 +2735,12 @@
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
<div class="char-sheet-section">
<h3>👹 Monsters</h3>
<div class="char-sheet-monsters" id="charSheetMonsters">
<!-- Populated by JS -->
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -3312,6 +3354,15 @@
calculate: (atk) => Math.floor(atk * 2), calculate: (atk) => Math.floor(atk * 2),
hits: 3, hits: 3,
description: 'Strike 3 times for 2x ATK each' 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 MONSTER_DIALOGUES = {};
let monsterTypesLoaded = false; 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 // Load monster types from the database
async function loadMonsterTypes() { async function loadMonsterTypes() {
try { try {
@ -3343,6 +3400,8 @@
baseAtk: t.baseAtk, baseAtk: t.baseAtk,
baseDef: t.baseDef, baseDef: t.baseDef,
xpReward: t.xpReward, xpReward: t.xpReward,
accuracy: t.accuracy || 85,
dodge: t.dodge || 5,
levelScale: t.levelScale levelScale: t.levelScale
}; };
MONSTER_DIALOGUES[t.id] = t.dialogues; 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) // Dialogue phase thresholds (in minutes)
const DIALOGUE_PHASES = [ const DIALOGUE_PHASES = [
{ maxMinutes: 5, phase: 'annoyed' }, { maxMinutes: 5, phase: 'annoyed' },
@ -5121,6 +5308,14 @@
console.log('Connected to multi-user tracking'); console.log('Connected to multi-user tracking');
clearTimeout(wsReconnectTimer); 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 // Send our icon info if we have it
if (myIcon && myColor) { if (myIcon && myColor) {
setTimeout(() => { setTimeout(() => {
@ -5241,6 +5436,12 @@
}); });
} }
break; 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) { async function login(username, password) {
const response = await fetch('/api/login', { const response = await fetch('/api/login', {
method: 'POST', method: 'POST',
@ -8659,6 +8871,7 @@
localStorage.setItem('accessToken', accessToken); localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken); localStorage.setItem('refreshToken', refreshToken);
updateAuthUI(); updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users // Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username); await initializePlayerStats(currentUser.username);
return { success: true }; return { success: true };
@ -8683,6 +8896,7 @@
localStorage.setItem('accessToken', accessToken); localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken); localStorage.setItem('refreshToken', refreshToken);
updateAuthUI(); updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users // Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username); await initializePlayerStats(currentUser.username);
return { success: true }; return { success: true };
@ -8730,6 +8944,7 @@
if (response.ok) { if (response.ok) {
currentUser = await response.json(); currentUser = await response.json();
updateAuthUI(); updateAuthUI();
registerWebSocketAuth();
// Initialize RPG system for eligible users // Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username); await initializePlayerStats(currentUser.username);
} else { } else {
@ -9134,6 +9349,28 @@
`; `;
}).join(''); }).join('');
// Update monsters section
const maxMonsters = getMaxMonsters();
const monsterCount = monsterEntourage.length;
let monstersHtml = `<div class="char-sheet-monster-count">${monsterCount}/${maxMonsters} nearby</div>`;
if (monsterCount > 0) {
monstersHtml += '<div class="char-sheet-monster-list">';
monsterEntourage.forEach(m => {
const type = MONSTER_TYPES[m.type] || { name: 'Unknown', icon: '👹' };
monstersHtml += `
<div class="char-sheet-monster">
<img src="/mapgameimgs/${m.type}50.png" onerror="this.src='/mapgameimgs/default50.png'" alt="${type.name}" class="monster-thumb">
<span class="monster-info">Lv${m.level} ${type.name}</span>
<span class="monster-hp">${m.hp}/${m.maxHp} HP</span>
</div>
`;
});
monstersHtml += '</div>';
} else {
monstersHtml += '<div class="char-sheet-no-monsters">No monsters nearby</div>';
}
document.getElementById('charSheetMonsters').innerHTML = monstersHtml;
document.getElementById('charSheetModal').style.display = 'flex'; document.getElementById('charSheetModal').style.display = 'flex';
} }
@ -9411,6 +9648,29 @@
showCharCreatorModal(); 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) // Save player stats to server (and localStorage as backup)
function savePlayerStats() { function savePlayerStats() {
if (!playerStats) return; if (!playerStats) return;
@ -9436,11 +9696,15 @@
function updateRpgHud() { function updateRpgHud() {
if (!playerStats) return; 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}`; 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('hudMp').textContent = `${playerStats.mp}/${playerStats.maxMp}`;
document.getElementById('hudMonsterCount').textContent = monsterEntourage.length;
document.getElementById('hudMonsterMax').textContent = getMaxMonsters();
// Update XP bar // Update XP bar
const xpNeeded = playerStats.level * 100; const xpNeeded = playerStats.level * 100;
@ -9764,13 +10028,19 @@
// ========================================== // ==========================================
// Initiate combat with a monster // Initiate combat with a monster
function initiateCombat(clickedMonster) {
async function initiateCombat(clickedMonster) {
if (combatState) return; // Already in combat if (combatState) return; // Already in combat
if (!playerStats) return; if (!playerStats) return;
if (monsterEntourage.length === 0) 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 // Gather ALL monsters from entourage for multi-monster combat
const monstersInCombat = monsterEntourage.map(m => ({
const monstersInCombat = monsterEntourage.map(m => {
const monsterType = MONSTER_TYPES[m.type];
return {
id: m.id, id: m.id,
type: m.type, type: m.type,
level: m.level, level: m.level,
@ -9778,8 +10048,11 @@
maxHp: m.maxHp, maxHp: m.maxHp,
atk: m.atk, atk: m.atk,
def: m.def, def: m.def,
data: MONSTER_TYPES[m.type]
}));
accuracy: monsterType?.accuracy || 85,
dodge: monsterType?.dodge || 5,
data: monsterType
};
});
// Find the clicked monster's index to make it the initial target // Find the clicked monster's index to make it the initial target
const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id); const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id);
@ -9791,13 +10064,17 @@
mp: playerStats.mp, mp: playerStats.mp,
maxMp: playerStats.maxMp, maxMp: playerStats.maxMp,
atk: playerStats.atk, atk: playerStats.atk,
def: playerStats.def
def: playerStats.def,
accuracy: playerStats.accuracy || 90,
dodge: playerStats.dodge || 10
}, },
monsters: monstersInCombat, monsters: monstersInCombat,
selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0, selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0,
turn: 'player', turn: 'player',
currentMonsterTurn: 0, currentMonsterTurn: 0,
log: []
log: [],
playerStatusEffects: [], // [{type, damage, turnsLeft}]
defenseBuffTurns: 0 // Turns remaining for defense buff
}; };
showCombatUI(); showCombatUI();
@ -9837,6 +10114,21 @@
skillsContainer.appendChild(btn); 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 = `
<span class="skill-name">${adminSkill.icon} ${adminSkill.name}</span>
<span class="skill-cost free">Admin</span>
`;
btn.onclick = () => executePlayerSkill('admin_banish');
skillsContainer.appendChild(btn);
}
// Set up flee button // Set up flee button
document.getElementById('combatFleeBtn').onclick = fleeCombat; document.getElementById('combatFleeBtn').onclick = fleeCombat;
@ -9948,11 +10240,67 @@
log.scrollTop = log.scrollHeight; 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 // Execute a player skill
function executePlayerSkill(skillId) { function executePlayerSkill(skillId) {
if (!combatState || combatState.turn !== 'player') return; 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; const levelReq = skill.levelReq || 1;
if (playerStats.level < levelReq) { if (playerStats.level < levelReq) {
addCombatLog(`You need to be level ${levelReq} to use ${skill.name}!`); addCombatLog(`You need to be level ${levelReq} to use ${skill.name}!`);
@ -9963,28 +10311,106 @@
return; return;
} }
// Get class-specific display name
const skillDisplay = getSkillForClass(skillId, playerStats.class) || skill;
const displayName = skillDisplay.displayName || skill.name;
// Deduct MP // Deduct MP
combatState.player.mp -= skill.mpCost; combatState.player.mp -= skill.mpCost;
// Get the targeted monster // Get the targeted monster
const target = combatState.monsters[combatState.selectedTargetIndex]; const target = combatState.monsters[combatState.selectedTargetIndex];
if (skill.type === 'damage') {
const rawDamage = skill.calculate(combatState.player.atk);
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); const damage = Math.max(1, rawDamage - target.def);
totalDamage += damage;
target.hp -= damage; target.hp -= damage;
addCombatLog(`You used ${skill.name} on ${target.data.name}! Dealt ${damage} damage!`, '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 // Check if this monster died
if (target.hp <= 0) { 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(); autoRetarget();
} }
} else if (skill.type === 'heal') { } 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); 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(); updateCombatUI();
@ -9996,7 +10422,11 @@
return; return;
} }
// Start monster turns sequence
endPlayerTurn();
}
// End player turn and start monster turns
function endPlayerTurn() {
combatState.turn = 'monsters'; combatState.turn = 'monsters';
combatState.currentMonsterTurn = 0; combatState.currentMonsterTurn = 0;
updateCombatUI(); updateCombatUI();
@ -10038,6 +10468,14 @@
// All monsters have attacked, return to player turn // All monsters have attacked, return to player turn
combatState.turn = 'player'; combatState.turn = 'player';
updateCombatUI(); 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 // Execute one monster's attack
@ -10048,10 +10486,76 @@
combatState.currentMonsterTurn = monsterIndex; combatState.currentMonsterTurn = monsterIndex;
updateCombatUI(); updateCombatUI();
const damage = Math.max(1, monster.atk - combatState.player.def);
// 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; 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(); updateCombatUI();
// Check for defeat // Check for defeat
@ -10163,8 +10667,8 @@
// END RPG COMBAT SYSTEM FUNCTIONS // 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(); loadCurrentUser();
}); });

317
server.js

@ -52,6 +52,14 @@ const wss = new WebSocket.Server({ server });
app.use(express.json()); app.use(express.json());
app.use(express.text({ type: 'application/xml', limit: '10mb' })); 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 // Serve static files - prioritize data directory for default.kml
app.get('/default.kml', async (req, res) => { app.get('/default.kml', async (req, res) => {
try { try {
@ -742,6 +750,7 @@ app.get('/api/user/finds', authenticateToken, (req, res) => {
app.get('/api/user/rpg-stats', authenticateToken, (req, res) => { app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
try { try {
const stats = db.getRpgStats(req.user.userId); 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) { if (stats) {
// Parse unlocked_skills from JSON string // Parse unlocked_skills from JSON string
let unlockedSkills = ['basic_attack']; let unlockedSkills = ['basic_attack'];
@ -766,6 +775,8 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
maxMp: stats.max_mp, maxMp: stats.max_mp,
atk: stats.atk, atk: stats.atk,
def: stats.def, def: stats.def,
accuracy: stats.accuracy || 90,
dodge: stats.dodge || 10,
unlockedSkills: unlockedSkills unlockedSkills: unlockedSkills
}); });
} else { } else {
@ -851,6 +862,8 @@ app.get('/api/monster-types', (req, res) => {
baseAtk: t.base_atk, baseAtk: t.base_atk,
baseDef: t.base_def, baseDef: t.base_def,
xpReward: t.xp_reward, xpReward: t.xp_reward,
accuracy: t.accuracy || 85,
dodge: t.dodge || 5,
levelScale: { levelScale: {
hp: t.level_scale_hp, hp: t.level_scale_hp,
atk: t.level_scale_atk, 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 // Get monster entourage for current user
app.get('/api/user/monsters', authenticateToken, (req, res) => { app.get('/api/user/monsters', authenticateToken, (req, res) => {
try { try {
@ -1027,6 +1118,9 @@ app.get('/api/admin/users', adminOnly, (req, res) => {
def: u.def || 0, def: u.def || 0,
unlocked_skills: u.unlocked_skills 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 }); res.json({ users: formatted });
} catch (err) { } catch (err) {
console.error('Admin get users error:', 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) => { app.put('/api/admin/users/:id', adminOnly, (req, res) => {
try { try {
const stats = req.body; 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 }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Admin update user error:', 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 // Function to send push notification to all subscribers
async function sendPushNotification(title, body, data = {}) { async function sendPushNotification(title, body, data = {}) {
const notification = { 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 // Clean up disconnected user
function removeUser(userId) { function removeUser(userId) {
if (users.has(userId)) { if (users.has(userId)) {
@ -1198,7 +1494,14 @@ wss.on('connection', (ws) => {
try { try {
const data = JSON.parse(message); 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 // Store user location with icon info
users.set(userId, { users.set(userId, {
lat: data.lat, lat: data.lat,
@ -1299,11 +1602,18 @@ wss.on('connection', (ws) => {
ws.on('close', () => { ws.on('close', () => {
removeUser(userId); removeUser(userId);
// Clean up auth user mapping
if (ws.authUserId) {
authUserConnections.delete(ws.authUserId);
}
}); });
ws.on('error', (err) => { ws.on('error', (err) => {
console.error(`WebSocket error for user ${userId}:`, err); console.error(`WebSocket error for user ${userId}:`, err);
removeUser(userId); removeUser(userId);
if (ws.authUserId) {
authUserConnections.delete(ws.authUserId);
}
}); });
// Heartbeat to detect disconnected clients // Heartbeat to detect disconnected clients
@ -1351,6 +1661,9 @@ server.listen(PORT, async () => {
// Seed default monsters if they don't exist // Seed default monsters if they don't exist
db.seedDefaultMonsters(); db.seedDefaultMonsters();
// Seed default skills if they don't exist
db.seedDefaultSkills();
// Seed default game settings if they don't exist // Seed default game settings if they don't exist
db.seedDefaultSettings(); db.seedDefaultSettings();

Loading…
Cancel
Save