Browse Source

Add class editor system with database-driven skills

- Add classes table (base stats, stat growth per level)
- Add class_skills table (skill assignments with unlock levels, choice groups)
- Seed 4 classes: Trail Runner (enabled), Gym Bro, Yoga Master, CrossFit Crusader
- Seed Trail Runner skills with level-up choices at levels 2 and 3
- Add API endpoints for class and class skill CRUD
- Add Classes section to admin panel with full editor UI
- Skills grouped by unlock level with choice group indicators

🤖 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
f504d001c7
  1. 464
      admin.html
  2. 362
      database.js
  3. 174
      server.js

464
admin.html

@ -662,6 +662,9 @@
<a class="nav-item" data-section="skills"> <a class="nav-item" data-section="skills">
<span class="icon">&#9889;</span> Skills <span class="icon">&#9889;</span> Skills
</a> </a>
<a class="nav-item" data-section="classes">
<span class="icon">&#127942;</span> Classes
</a>
<a class="nav-item" data-section="users"> <a class="nav-item" data-section="users">
<span class="icon">&#128100;</span> Users <span class="icon">&#128100;</span> Users
</a> </a>
@ -730,6 +733,30 @@
</table> </table>
</section> </section>
<!-- Classes Section -->
<section id="classes-section" class="section">
<div class="section-header">
<h2>Character Classes</h2>
<button class="btn btn-primary" id="addClassBtn">+ Add Class</button>
</div>
<table class="data-table" id="classTable">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Base Stats</th>
<th>Growth/Level</th>
<th>Skills</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="classTableBody">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
<!-- Users Section --> <!-- Users Section -->
<section id="users-section" class="section"> <section id="users-section" class="section">
<div class="section-header"> <div class="section-header">
@ -1087,6 +1114,111 @@
</div> </div>
</div> </div>
<!-- Class Edit Modal -->
<div class="modal-overlay" id="classModal">
<div class="modal modal-wide">
<div class="modal-header">
<h3 id="classModalTitle">Edit Class</h3>
<button class="modal-close" onclick="closeClassModal()">&times;</button>
</div>
<form id="classForm">
<input type="hidden" id="classEditId">
<div class="form-row">
<div class="form-group">
<label>Class Name</label>
<input type="text" id="className" required placeholder="e.g., Trail Runner">
</div>
<div class="form-group">
<label>ID (unique identifier)</label>
<input type="text" id="classId" required placeholder="e.g., trail_runner">
</div>
</div>
<div class="form-group">
<label>Description</label>
<textarea id="classDescription" placeholder="Class description..."></textarea>
</div>
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Base Stats (Level 1)</h4>
<div class="form-row-3">
<div class="form-group">
<label>Base HP</label>
<input type="number" id="classBaseHp" value="100" min="1">
</div>
<div class="form-group">
<label>Base MP</label>
<input type="number" id="classBaseMp" value="50" min="0">
</div>
<div class="form-group">
<label>Base ATK</label>
<input type="number" id="classBaseAtk" value="12" min="1">
</div>
</div>
<div class="form-row-4">
<div class="form-group">
<label>Base DEF</label>
<input type="number" id="classBaseDef" value="8" min="0">
</div>
<div class="form-group">
<label>Base Accuracy</label>
<input type="number" id="classBaseAccuracy" value="90" min="1" max="100">
</div>
<div class="form-group">
<label>Base Dodge</label>
<input type="number" id="classBaseDodge" value="10" min="0" max="100">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="classEnabled"> Enabled
</label>
</div>
</div>
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Stat Growth (Per Level)</h4>
<div class="form-row-4">
<div class="form-group">
<label>HP/Level</label>
<input type="number" id="classHpPerLevel" value="10" min="0">
</div>
<div class="form-group">
<label>MP/Level</label>
<input type="number" id="classMpPerLevel" value="5" min="0">
</div>
<div class="form-group">
<label>ATK/Level</label>
<input type="number" id="classAtkPerLevel" value="2" min="0">
</div>
<div class="form-group">
<label>DEF/Level</label>
<input type="number" id="classDefPerLevel" value="1" min="0">
</div>
</div>
<div class="skills-section">
<h4>Class Skills</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
Assign skills to this class. Set unlock level and choice group for skill selection at level-up.
<br>Skills with the same choice group at the same level = player picks one.
</p>
<div id="classSkillsList"></div>
<div class="add-skill-row">
<select id="addClassSkillSelect">
<option value="">-- Select a skill --</option>
</select>
<input type="number" id="addClassSkillLevel" placeholder="Lvl" value="1" min="1" style="width: 60px;">
<input type="number" id="addClassSkillChoiceGroup" placeholder="Group" style="width: 70px;" title="Choice group (blank for auto-learn)">
<input type="text" id="addClassSkillName" placeholder="Custom name" style="width: 120px;">
<button type="button" class="btn btn-secondary btn-small" onclick="addClassSkill()">Add</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeClassModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Class</button>
</div>
</form>
</div>
</div>
<script> <script>
// State // State
let accessToken = localStorage.getItem('accessToken'); let accessToken = localStorage.getItem('accessToken');
@ -1095,6 +1227,8 @@
let settings = {}; let settings = {};
let allSkills = []; let allSkills = [];
let currentMonsterSkills = []; // Skills for the monster being edited let currentMonsterSkills = []; // Skills for the monster being edited
let allClasses = [];
let currentClassSkills = []; // Skills for the class being edited
// API Helper // API Helper
async function api(endpoint, options = {}) { async function api(endpoint, options = {}) {
@ -1183,6 +1317,7 @@
loadUsers(); loadUsers();
loadSettings(); loadSettings();
loadSkillsAdmin(); // Load skills for admin (includes disabled) loadSkillsAdmin(); // Load skills for admin (includes disabled)
loadClasses();
} }
// ============= MONSTERS ============= // ============= MONSTERS =============
@ -1728,6 +1863,335 @@
} }
}); });
// ============= CLASSES =============
async function loadClasses() {
try {
const data = await api('/api/admin/classes');
allClasses = data || [];
renderClassTable();
} catch (e) {
showToast('Failed to load classes: ' + e.message, 'error');
}
}
function renderClassTable() {
const tbody = document.getElementById('classTableBody');
if (allClasses.length === 0) {
tbody.innerHTML = '<tr><td colspan="7">No classes found</td></tr>';
return;
}
tbody.innerHTML = allClasses.map(c => `
<tr>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td><code>${escapeHtml(c.id)}</code></td>
<td>
<small>HP:${c.base_hp} MP:${c.base_mp} ATK:${c.base_atk} DEF:${c.base_def}</small>
</td>
<td>
<small>+${c.hp_per_level}HP +${c.mp_per_level}MP +${c.atk_per_level}ATK +${c.def_per_level}DEF</small>
</td>
<td><span class="badge badge-level" id="class-skill-count-${c.id}">...</span></td>
<td>
<label class="toggle">
<input type="checkbox" ${c.enabled ? 'checked' : ''}
onchange="toggleClass('${c.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editClass('${c.id}')">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteClass('${c.id}')">Delete</button>
</td>
</tr>
`).join('');
// Load skill counts for each class
allClasses.forEach(async c => {
try {
const skills = await api(`/api/admin/class-skills/${c.id}`);
document.getElementById(`class-skill-count-${c.id}`).textContent = `${skills.length} skills`;
} catch (e) {
document.getElementById(`class-skill-count-${c.id}`).textContent = '?';
}
});
}
async function toggleClass(id, enabled) {
try {
await api(`/api/admin/classes/${id}/toggle`, { method: 'PUT' });
showToast(enabled ? 'Class enabled' : 'Class disabled');
loadClasses();
} catch (e) {
showToast('Failed to toggle class: ' + e.message, 'error');
loadClasses();
}
}
async function deleteClass(id) {
if (!confirm('Are you sure you want to delete this class? This will also remove all skill assignments.')) return;
try {
await api(`/api/admin/classes/${id}`, { method: 'DELETE' });
showToast('Class deleted');
loadClasses();
} catch (e) {
showToast('Failed to delete class: ' + e.message, 'error');
}
}
async function editClass(id) {
const classData = allClasses.find(c => c.id === id);
if (!classData) return;
document.getElementById('classModalTitle').textContent = 'Edit Class';
document.getElementById('classEditId').value = classData.id;
document.getElementById('classId').value = classData.id;
document.getElementById('classId').disabled = true;
document.getElementById('className').value = classData.name;
document.getElementById('classDescription').value = classData.description || '';
document.getElementById('classBaseHp').value = classData.base_hp;
document.getElementById('classBaseMp').value = classData.base_mp;
document.getElementById('classBaseAtk').value = classData.base_atk;
document.getElementById('classBaseDef').value = classData.base_def;
document.getElementById('classBaseAccuracy').value = classData.base_accuracy;
document.getElementById('classBaseDodge').value = classData.base_dodge;
document.getElementById('classHpPerLevel').value = classData.hp_per_level;
document.getElementById('classMpPerLevel').value = classData.mp_per_level;
document.getElementById('classAtkPerLevel').value = classData.atk_per_level;
document.getElementById('classDefPerLevel').value = classData.def_per_level;
document.getElementById('classEnabled').checked = classData.enabled;
// Populate skill dropdown for adding
populateClassSkillSelect();
// Load class skills
await loadClassSkills(classData.id);
document.getElementById('classModal').classList.add('active');
}
document.getElementById('addClassBtn').addEventListener('click', () => {
document.getElementById('classModalTitle').textContent = 'Add Class';
document.getElementById('classForm').reset();
document.getElementById('classEditId').value = '';
document.getElementById('classId').disabled = false;
document.getElementById('classEnabled').checked = false;
currentClassSkills = [];
document.getElementById('classSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save class first, then edit to add skills.</p>';
populateClassSkillSelect();
document.getElementById('classModal').classList.add('active');
});
function closeClassModal() {
document.getElementById('classModal').classList.remove('active');
document.getElementById('classId').disabled = false;
}
document.getElementById('classForm').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('classEditId').value;
const classId = document.getElementById('classId').value;
const data = {
id: classId,
name: document.getElementById('className').value,
description: document.getElementById('classDescription').value,
base_hp: parseInt(document.getElementById('classBaseHp').value) || 100,
base_mp: parseInt(document.getElementById('classBaseMp').value) || 50,
base_atk: parseInt(document.getElementById('classBaseAtk').value) || 12,
base_def: parseInt(document.getElementById('classBaseDef').value) || 8,
base_accuracy: parseInt(document.getElementById('classBaseAccuracy').value) || 90,
base_dodge: parseInt(document.getElementById('classBaseDodge').value) || 10,
hp_per_level: parseInt(document.getElementById('classHpPerLevel').value) || 10,
mp_per_level: parseInt(document.getElementById('classMpPerLevel').value) || 5,
atk_per_level: parseInt(document.getElementById('classAtkPerLevel').value) || 2,
def_per_level: parseInt(document.getElementById('classDefPerLevel').value) || 1,
enabled: document.getElementById('classEnabled').checked
};
try {
if (editId) {
await api(`/api/admin/classes/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showToast('Class updated');
} else {
await api('/api/admin/classes', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('Class created');
}
closeClassModal();
loadClasses();
} catch (e) {
showToast('Failed to save class: ' + e.message, 'error');
}
});
// ============= CLASS SKILLS =============
function populateClassSkillSelect() {
const select = document.getElementById('addClassSkillSelect');
select.innerHTML = '<option value="">-- Select a skill --</option>';
// Filter to player-usable skills
allSkills.filter(s => s.player_usable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadClassSkills(classId) {
if (!classId) {
currentClassSkills = [];
renderClassSkills();
return;
}
try {
currentClassSkills = await api(`/api/admin/class-skills/${classId}`);
renderClassSkills();
} catch (e) {
console.error('Failed to load class skills:', e);
currentClassSkills = [];
renderClassSkills();
}
}
function renderClassSkills() {
const container = document.getElementById('classSkillsList');
if (currentClassSkills.length === 0) {
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Add skills below.</p>';
return;
}
// Group by unlock level
const byLevel = {};
currentClassSkills.forEach(cs => {
const lvl = cs.unlock_level || 1;
if (!byLevel[lvl]) byLevel[lvl] = [];
byLevel[lvl].push(cs);
});
let html = '';
Object.keys(byLevel).sort((a, b) => a - b).forEach(level => {
const skills = byLevel[level];
html += `<div style="margin-bottom: 10px; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px;">`;
html += `<div style="font-size: 11px; color: #4CAF50; margin-bottom: 5px;">Level ${level}</div>`;
skills.forEach(cs => {
const displayName = cs.custom_name || cs.base_name || cs.skill_id;
const choiceLabel = cs.choice_group ? `<span style="color: #FF9800; font-size: 10px;">Choice ${cs.choice_group}</span>` : '<span style="color: #4CAF50; font-size: 10px;">Auto</span>';
html += `
<div class="monster-skill-item" data-id="${cs.id}">
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(cs.custom_name || '')}"
placeholder="${escapeHtml(cs.base_name || cs.skill_id)}"
onchange="updateClassSkill(${cs.id}, 'custom_name', this.value)"
title="Custom name for this class">
<span class="skill-base-name">${escapeHtml(cs.base_name || cs.skill_id)} ${choiceLabel}</span>
</div>
<div>
<label>Lvl</label>
<input type="number" class="skill-min-level" value="${cs.unlock_level}" min="1"
onchange="updateClassSkill(${cs.id}, 'unlock_level', this.value)">
</div>
<div>
<label>Grp</label>
<input type="number" class="skill-weight" value="${cs.choice_group || ''}" min="1"
onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)"
placeholder="-" title="Choice group (empty = auto-learn)">
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeClassSkill(${cs.id})"></button>
</div>
`;
});
html += '</div>';
});
container.innerHTML = html;
}
async function addClassSkill() {
const classId = document.getElementById('classEditId').value;
const skillId = document.getElementById('addClassSkillSelect').value;
const unlockLevel = parseInt(document.getElementById('addClassSkillLevel').value) || 1;
const choiceGroupVal = document.getElementById('addClassSkillChoiceGroup').value;
const choiceGroup = choiceGroupVal ? parseInt(choiceGroupVal) : null;
const customName = document.getElementById('addClassSkillName').value.trim();
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!classId) {
showToast('Please save the class first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentClassSkills.some(cs => cs.skill_id === skillId)) {
showToast('This skill is already assigned to this class', 'error');
return;
}
try {
await api('/api/admin/class-skills', {
method: 'POST',
body: JSON.stringify({
class_id: classId,
skill_id: skillId,
unlock_level: unlockLevel,
choice_group: choiceGroup,
custom_name: customName || null
})
});
showToast('Skill added');
await loadClassSkills(classId);
document.getElementById('addClassSkillSelect').value = '';
document.getElementById('addClassSkillName').value = '';
document.getElementById('addClassSkillLevel').value = '1';
document.getElementById('addClassSkillChoiceGroup').value = '';
} catch (e) {
showToast('Failed to add skill: ' + e.message, 'error');
}
}
async function updateClassSkill(id, field, value) {
try {
const data = {};
if (field === 'choice_group') {
data[field] = value === '' || value === null ? null : parseInt(value);
} else if (field === 'unlock_level') {
data[field] = parseInt(value) || 1;
} else {
data[field] = value || null;
}
await api(`/api/admin/class-skills/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Reload to update display
const classId = document.getElementById('classEditId').value;
await loadClassSkills(classId);
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeClassSkill(id) {
try {
await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' });
showToast('Skill removed');
const classId = document.getElementById('classEditId').value;
await loadClassSkills(classId);
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
// ============= USERS ============= // ============= USERS =============
async function loadUsers() { async function loadUsers() {
try { try {

362
database.js

@ -164,7 +164,42 @@ class HikeMapDB {
) )
`); `);
// Class skill names - class-specific naming for skills
// Classes table - defines playable classes
this.db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
base_hp INTEGER DEFAULT 100,
base_mp INTEGER DEFAULT 50,
base_atk INTEGER DEFAULT 12,
base_def INTEGER DEFAULT 8,
base_accuracy INTEGER DEFAULT 90,
base_dodge INTEGER DEFAULT 10,
hp_per_level INTEGER DEFAULT 10,
mp_per_level INTEGER DEFAULT 5,
atk_per_level INTEGER DEFAULT 2,
def_per_level INTEGER DEFAULT 1,
enabled BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Class skills - assigns skills to classes with unlock levels and choice groups
this.db.exec(`
CREATE TABLE IF NOT EXISTS class_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
unlock_level INTEGER DEFAULT 1,
choice_group INTEGER,
custom_name TEXT,
custom_description TEXT,
UNIQUE(class_id, skill_id)
)
`);
// Class skill names - class-specific naming for skills (legacy, replaced by class_skills)
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS class_skill_names ( CREATE TABLE IF NOT EXISTS class_skill_names (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -247,6 +282,8 @@ class HikeMapDB {
CREATE INDEX IF NOT EXISTS idx_class_skill_names_class ON class_skill_names(class_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_monster ON monster_skills(monster_type_id);
CREATE INDEX IF NOT EXISTS idx_monster_skills_skill ON monster_skills(skill_id); CREATE INDEX IF NOT EXISTS idx_monster_skills_skill ON monster_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_class_skills_class ON class_skills(class_id);
CREATE INDEX IF NOT EXISTS idx_class_skills_skill ON class_skills(skill_id);
`); `);
} }
@ -1204,6 +1241,329 @@ class HikeMapDB {
console.log('Default skills seeded successfully'); console.log('Default skills seeded successfully');
} }
// =====================
// CLASSES METHODS
// =====================
getAllClasses(enabledOnly = false) {
const stmt = enabledOnly
? this.db.prepare(`SELECT * FROM classes WHERE enabled = 1`)
: this.db.prepare(`SELECT * FROM classes`);
return stmt.all();
}
getClass(id) {
const stmt = this.db.prepare(`SELECT * FROM classes WHERE id = ?`);
return stmt.get(id);
}
createClass(classData) {
const stmt = this.db.prepare(`
INSERT INTO classes (id, name, description, base_hp, base_mp, base_atk, base_def,
base_accuracy, base_dodge, hp_per_level, mp_per_level, atk_per_level, def_per_level, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(
classData.id,
classData.name,
classData.description || '',
classData.base_hp || classData.baseHp || 100,
classData.base_mp || classData.baseMp || 50,
classData.base_atk || classData.baseAtk || 12,
classData.base_def || classData.baseDef || 8,
classData.base_accuracy || classData.baseAccuracy || 90,
classData.base_dodge || classData.baseDodge || 10,
classData.hp_per_level || classData.hpPerLevel || 10,
classData.mp_per_level || classData.mpPerLevel || 5,
classData.atk_per_level || classData.atkPerLevel || 2,
classData.def_per_level || classData.defPerLevel || 1,
classData.enabled ? 1 : 0
);
}
updateClass(id, classData) {
const stmt = this.db.prepare(`
UPDATE classes SET
name = ?, description = ?, base_hp = ?, base_mp = ?, base_atk = ?, base_def = ?,
base_accuracy = ?, base_dodge = ?, hp_per_level = ?, mp_per_level = ?,
atk_per_level = ?, def_per_level = ?, enabled = ?
WHERE id = ?
`);
return stmt.run(
classData.name,
classData.description || '',
classData.base_hp || classData.baseHp || 100,
classData.base_mp || classData.baseMp || 50,
classData.base_atk || classData.baseAtk || 12,
classData.base_def || classData.baseDef || 8,
classData.base_accuracy || classData.baseAccuracy || 90,
classData.base_dodge || classData.baseDodge || 10,
classData.hp_per_level || classData.hpPerLevel || 10,
classData.mp_per_level || classData.mpPerLevel || 5,
classData.atk_per_level || classData.atkPerLevel || 2,
classData.def_per_level || classData.defPerLevel || 1,
classData.enabled ? 1 : 0,
id
);
}
deleteClass(id) {
// Also delete related class_skills
this.db.prepare(`DELETE FROM class_skills WHERE class_id = ?`).run(id);
this.db.prepare(`DELETE FROM class_skill_names WHERE class_id = ?`).run(id);
const stmt = this.db.prepare(`DELETE FROM classes WHERE id = ?`);
return stmt.run(id);
}
toggleClassEnabled(id, enabled) {
const stmt = this.db.prepare(`UPDATE classes SET enabled = ? WHERE id = ?`);
return stmt.run(enabled ? 1 : 0, id);
}
// =====================
// CLASS SKILLS METHODS
// =====================
getClassSkills(classId) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
s.mp_cost, s.base_power, s.accuracy, s.hit_count, s.target, s.status_effect
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND s.enabled = 1
ORDER BY cs.unlock_level ASC, cs.choice_group ASC
`);
return stmt.all(classId);
}
getAllClassSkills() {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
LEFT JOIN skills s ON cs.skill_id = s.id
`);
return stmt.all();
}
getClassSkill(classId, skillId) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.skill_id = ?
`);
return stmt.get(classId, skillId);
}
createClassSkill(data) {
const stmt = this.db.prepare(`
INSERT INTO class_skills (class_id, skill_id, unlock_level, choice_group, custom_name, custom_description)
VALUES (?, ?, ?, ?, ?, ?)
`);
return stmt.run(
data.class_id || data.classId,
data.skill_id || data.skillId,
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null
);
}
updateClassSkill(id, data) {
const stmt = this.db.prepare(`
UPDATE class_skills SET
unlock_level = ?, choice_group = ?, custom_name = ?, custom_description = ?
WHERE id = ?
`);
return stmt.run(
data.unlock_level || data.unlockLevel || 1,
data.choice_group || data.choiceGroup || null,
data.custom_name || data.customName || null,
data.custom_description || data.customDescription || null,
id
);
}
deleteClassSkill(id) {
const stmt = this.db.prepare(`DELETE FROM class_skills WHERE id = ?`);
return stmt.run(id);
}
// Get skills available for level-up choice at a specific level
getSkillChoicesForLevel(classId, level) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description, s.type,
s.mp_cost, s.base_power, s.accuracy, s.hit_count
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.unlock_level = ? AND cs.choice_group IS NOT NULL
ORDER BY cs.choice_group ASC
`);
return stmt.all(classId, level);
}
// Get auto-learned skills up to a specific level (no choice_group)
getAutoLearnedSkills(classId, maxLevel) {
const stmt = this.db.prepare(`
SELECT cs.*, s.name as base_name, s.description as base_description
FROM class_skills cs
JOIN skills s ON cs.skill_id = s.id
WHERE cs.class_id = ? AND cs.unlock_level <= ? AND cs.choice_group IS NULL
ORDER BY cs.unlock_level ASC
`);
return stmt.all(classId, maxLevel);
}
// =====================
// CLASS SEEDING
// =====================
seedDefaultClasses() {
// Check if Trail Runner already exists
const existing = this.getClass('trail_runner');
if (existing) return;
console.log('Seeding default classes...');
// Trail Runner - the initial available class
this.createClass({
id: 'trail_runner',
name: 'Trail Runner',
description: 'A swift adventurer who conquers trails with endurance and agility. Uses running-themed skills.',
base_hp: 100,
base_mp: 50,
base_atk: 12,
base_def: 8,
base_accuracy: 90,
base_dodge: 15, // Trail runners are agile
hp_per_level: 10,
mp_per_level: 5,
atk_per_level: 2,
def_per_level: 1,
enabled: true
});
console.log(' Seeded class: Trail Runner');
// Gym Bro - coming soon
this.createClass({
id: 'gym_bro',
name: 'Gym Bro',
description: 'A powerhouse of strength who never skips leg day. High attack and defense.',
base_hp: 120,
base_mp: 30,
base_atk: 15,
base_def: 12,
base_accuracy: 85,
base_dodge: 5,
hp_per_level: 15,
mp_per_level: 3,
atk_per_level: 3,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: Gym Bro (disabled)');
// Yoga Master - coming soon
this.createClass({
id: 'yoga_master',
name: 'Yoga Master',
description: 'A balanced warrior with high dodge and healing abilities. Mind over matter.',
base_hp: 80,
base_mp: 80,
base_atk: 10,
base_def: 10,
base_accuracy: 95,
base_dodge: 20,
hp_per_level: 8,
mp_per_level: 8,
atk_per_level: 1,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: Yoga Master (disabled)');
// CrossFit Crusader - coming soon
this.createClass({
id: 'crossfit_crusader',
name: 'CrossFit Crusader',
description: 'An all-around athlete with balanced stats and versatile skills.',
base_hp: 100,
base_mp: 50,
base_atk: 13,
base_def: 10,
base_accuracy: 88,
base_dodge: 12,
hp_per_level: 12,
mp_per_level: 5,
atk_per_level: 2,
def_per_level: 2,
enabled: false
});
console.log(' Seeded class: CrossFit Crusader (disabled)');
// Seed Trail Runner skills
this.seedTrailRunnerSkills();
console.log('Default classes seeded successfully');
}
seedTrailRunnerSkills() {
// Check if already seeded
const existing = this.getClassSkills('trail_runner');
if (existing.length > 0) return;
console.log(' Seeding Trail Runner skills...');
// Level 1 - Basic Attack (auto-learned)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'basic_attack',
unlock_level: 1,
choice_group: null,
custom_name: 'Trail Kick',
custom_description: 'A swift kick perfected on countless trails'
});
// Level 2 - Choice: Brand New Hokas (double_attack) OR Runner\'s High (heal)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'double_attack',
unlock_level: 2,
choice_group: 1,
custom_name: 'Brand New Hokas',
custom_description: 'Break in those fresh kicks with two quick strikes!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'heal',
unlock_level: 2,
choice_group: 1,
custom_name: 'Gel Pack',
custom_description: 'Quick energy gel restores your stamina'
});
// Level 3 - Choice: Downhill Sprint (power_strike) OR Pace Yourself (defend)
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'power_strike',
unlock_level: 3,
choice_group: 2,
custom_name: 'Downhill Sprint',
custom_description: 'Use gravity to deliver a devastating blow!'
});
this.createClassSkill({
class_id: 'trail_runner',
skill_id: 'defend',
unlock_level: 3,
choice_group: 2,
custom_name: 'Pace Yourself',
custom_description: 'Slow down to conserve energy and reduce damage'
});
console.log(' Trail Runner skills seeded');
}
// 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(`

174
server.js

@ -1565,6 +1565,177 @@ app.delete('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
} }
}); });
// =====================
// CLASS API ENDPOINTS
// =====================
// Get all classes (public - enabled only, for character creation)
app.get('/api/classes', (req, res) => {
try {
const classes = db.getAllClasses(true); // Enabled only
res.json(classes);
} catch (err) {
console.error('Get classes error:', err);
res.status(500).json({ error: 'Failed to get classes' });
}
});
// Get class skills (public - for character sheet display)
app.get('/api/classes/:id/skills', (req, res) => {
try {
const skills = db.getClassSkills(req.params.id);
res.json(skills);
} catch (err) {
console.error('Get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Get skill choices for a specific level (for level-up modal)
app.get('/api/classes/:id/skill-choices/:level', (req, res) => {
try {
const choices = db.getSkillChoicesForLevel(req.params.id, parseInt(req.params.level));
res.json(choices);
} catch (err) {
console.error('Get skill choices error:', err);
res.status(500).json({ error: 'Failed to get skill choices' });
}
});
// Admin: Get all classes (including disabled)
app.get('/api/admin/classes', adminOnly, (req, res) => {
try {
const classes = db.getAllClasses(false); // All classes
res.json(classes);
} catch (err) {
console.error('Admin get classes error:', err);
res.status(500).json({ error: 'Failed to get classes' });
}
});
// Admin: Get single class
app.get('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
const classData = db.getClass(req.params.id);
if (!classData) {
return res.status(404).json({ error: 'Class not found' });
}
res.json(classData);
} catch (err) {
console.error('Admin get class error:', err);
res.status(500).json({ error: 'Failed to get class' });
}
});
// Admin: Create class
app.post('/api/admin/classes', adminOnly, (req, res) => {
try {
db.createClass(req.body);
const newClass = db.getClass(req.body.id);
res.json(newClass);
} catch (err) {
console.error('Admin create class error:', err);
res.status(500).json({ error: 'Failed to create class' });
}
});
// Admin: Update class
app.put('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
db.updateClass(req.params.id, req.body);
const updated = db.getClass(req.params.id);
res.json(updated);
} catch (err) {
console.error('Admin update class error:', err);
res.status(500).json({ error: 'Failed to update class' });
}
});
// Admin: Delete class
app.delete('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
db.deleteClass(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete class error:', err);
res.status(500).json({ error: 'Failed to delete class' });
}
});
// Admin: Toggle class enabled
app.put('/api/admin/classes/:id/toggle', adminOnly, (req, res) => {
try {
const classData = db.getClass(req.params.id);
if (!classData) {
return res.status(404).json({ error: 'Class not found' });
}
db.toggleClassEnabled(req.params.id, !classData.enabled);
res.json({ success: true, enabled: !classData.enabled });
} catch (err) {
console.error('Admin toggle class error:', err);
res.status(500).json({ error: 'Failed to toggle class' });
}
});
// =====================
// CLASS SKILLS API ENDPOINTS
// =====================
// Admin: Get all class skills
app.get('/api/admin/class-skills', adminOnly, (req, res) => {
try {
const classSkills = db.getAllClassSkills();
res.json(classSkills);
} catch (err) {
console.error('Admin get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Admin: Get skills for a specific class
app.get('/api/admin/class-skills/:classId', adminOnly, (req, res) => {
try {
const skills = db.getClassSkills(req.params.classId);
res.json(skills);
} catch (err) {
console.error('Admin get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Admin: Create class skill
app.post('/api/admin/class-skills', adminOnly, (req, res) => {
try {
db.createClassSkill(req.body);
res.json({ success: true });
} catch (err) {
console.error('Admin create class skill error:', err);
res.status(500).json({ error: 'Failed to create class skill' });
}
});
// Admin: Update class skill
app.put('/api/admin/class-skills/:id', adminOnly, (req, res) => {
try {
db.updateClassSkill(req.params.id, req.body);
res.json({ success: true });
} catch (err) {
console.error('Admin update class skill error:', err);
res.status(500).json({ error: 'Failed to update class skill' });
}
});
// Admin: Delete class skill
app.delete('/api/admin/class-skills/:id', adminOnly, (req, res) => {
try {
db.deleteClassSkill(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete class skill error:', err);
res.status(500).json({ error: 'Failed to delete class 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 = {
@ -1852,6 +2023,9 @@ server.listen(PORT, async () => {
// Seed default skills if they don't exist // Seed default skills if they don't exist
db.seedDefaultSkills(); db.seedDefaultSkills();
// Seed default classes if they don't exist
db.seedDefaultClasses();
// 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