@ -344,6 +344,65 @@
gap: 15px;
}
.form-row-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
}
.modal-wide {
max-width: 700px;
}
.status-effect-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
.status-effect-section h4 {
margin-bottom: 5px;
color: #aaa;
font-size: 0.9rem;
}
/* Skill Type Badges */
.skill-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.skill-type-damage {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.skill-type-heal {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
.skill-type-buff {
background: rgba(33, 150, 243, 0.2);
color: #2196F3;
}
.skill-type-debuff {
background: rgba(156, 39, 176, 0.2);
color: #9C27B0;
}
.skill-type-status {
background: rgba(255, 152, 0, 0.2);
color: #FF9800;
}
.form-actions {
display: flex;
gap: 10px;
@ -444,6 +503,89 @@
padding: 8px 12px;
}
/* Skills Editor */
.skills-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
}
.skills-section h4 {
margin-bottom: 15px;
color: #aaa;
font-size: 0.9rem;
}
.add-skill-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.add-skill-row select {
flex: 1;
}
.monster-skill-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,0.05);
border-radius: 6px;
margin-bottom: 8px;
}
.monster-skill-item .skill-name {
flex: 1;
font-weight: 500;
}
.monster-skill-item .skill-weight,
.monster-skill-item .skill-min-level {
width: 60px;
text-align: center;
}
.monster-skill-item label {
font-size: 11px;
color: #888;
}
.skill-name-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.skill-custom-name {
width: 100%;
padding: 4px 8px;
font-size: 13px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
color: #fff;
}
.skill-custom-name:focus {
outline: none;
border-color: #4CAF50;
}
.skill-custom-name::placeholder {
color: #888;
font-style: italic;
}
.skill-base-name {
font-size: 10px;
color: #666;
}
/* Stats Display */
.stats-grid {
display: grid;
@ -517,6 +659,9 @@
< a class = "nav-item active" data-section = "monsters" >
< span class = "icon" > 👾 < / span > Monsters
< / a >
< a class = "nav-item" data-section = "skills" >
< span class = "icon" > ⚡ < / span > Skills
< / a >
< a class = "nav-item" data-section = "users" >
< span class = "icon" > 👤 < / span > Users
< / a >
@ -557,6 +702,34 @@
< / table >
< / section >
<!-- Skills Section -->
< section id = "skills-section" class = "section" >
< div class = "section-header" >
< h2 > Skills Database< / h2 >
< button class = "btn btn-primary" id = "addSkillBtn" > + Add Skill< / button >
< / div >
< table class = "data-table" id = "skillTable" >
< thead >
< tr >
< th > Name< / th >
< th > ID< / th >
< th > Type< / th >
< th > Power< / th >
< th > Accuracy< / th >
< th > MP< / th >
< th > Target< / th >
< th > Player< / th >
< th > Monster< / th >
< th > Enabled< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody id = "skillTableBody" >
< tr > < td colspan = "11" class = "loading" > Loading...< / td > < / tr >
< / tbody >
< / table >
< / section >
<!-- Users Section -->
< section id = "users-section" class = "section" >
< div class = "section-header" >
@ -678,6 +851,21 @@
< / label >
< / div >
< div class = "skills-section" >
< h4 > Monster Skills< / h4 >
< div id = "monsterSkillsList" > < / div >
< div class = "add-skill-row" >
< select id = "addSkillSelect" onchange = "onSkillSelectChange()" >
< option value = "" > -- Select a skill --< / option >
< / select >
< input type = "text" id = "addSkillCustomName" placeholder = "Custom name (optional)" style = "width: 150px;" >
< input type = "number" id = "addSkillWeight" placeholder = "Weight" value = "10" min = "1" style = "width: 70px;" >
< input type = "number" id = "addSkillMinLevel" placeholder = "Min Lvl" value = "1" min = "1" style = "width: 70px;" >
< button type = "button" class = "btn btn-secondary btn-small" onclick = "addMonsterSkill()" > Add< / button >
< / div >
< p style = "font-size: 11px; color: #666; margin-top: 5px;" > Custom name overrides skill name for this monster. Weight = selection probability.< / p >
< / div >
< div class = "dialogue-section" >
< h4 > Dialogues (one per line)< / h4 >
< div class = "form-group" >
@ -771,12 +959,128 @@
< / div >
< / div >
<!-- Skill Edit Modal -->
< div class = "modal-overlay" id = "skillModal" >
< div class = "modal modal-wide" >
< div class = "modal-header" >
< h3 id = "skillModalTitle" > Edit Skill< / h3 >
< button class = "modal-close" onclick = "closeSkillModal()" > × < / button >
< / div >
< form id = "skillForm" >
< input type = "hidden" id = "skillEditId" >
< div class = "form-row" >
< div class = "form-group" >
< label > Skill Name< / label >
< input type = "text" id = "skillName" required placeholder = "e.g., Double Attack" >
< / div >
< div class = "form-group" >
< label > ID (unique identifier)< / label >
< input type = "text" id = "skillId" required placeholder = "e.g., double_attack" >
< / div >
< / div >
< div class = "form-group" >
< label > Description< / label >
< input type = "text" id = "skillDescription" placeholder = "e.g., Attack twice with reduced power" >
< / div >
< div class = "form-row" >
< div class = "form-group" >
< label > Type< / label >
< select id = "skillType" required >
< option value = "damage" > Damage< / option >
< option value = "heal" > Heal< / option >
< option value = "buff" > Buff< / option >
< option value = "debuff" > Debuff< / option >
< option value = "status" > Status Effect< / option >
< / select >
< / div >
< div class = "form-group" >
< label > Target< / label >
< select id = "skillTarget" >
< option value = "enemy" > Enemy< / option >
< option value = "self" > Self< / option >
< option value = "all_enemies" > All Enemies< / option >
< / select >
< / div >
< / div >
< div class = "form-row-4" >
< div class = "form-group" >
< label > MP Cost< / label >
< input type = "number" id = "skillMpCost" value = "0" min = "0" >
< / div >
< div class = "form-group" >
< label > Base Power< / label >
< input type = "number" id = "skillBasePower" value = "100" min = "0" >
< / div >
< div class = "form-group" >
< label > Accuracy (%)< / label >
< input type = "number" id = "skillAccuracy" value = "95" min = "0" max = "100" >
< / div >
< div class = "form-group" >
< label > Hit Count< / label >
< input type = "number" id = "skillHitCount" value = "1" min = "1" >
< / div >
< / div >
< div class = "status-effect-section" >
< h4 > Status Effect (optional)< / h4 >
< p style = "font-size: 11px; color: #666; margin-bottom: 10px;" > For skills that apply effects over time (poison, burn, etc.)< / p >
< div class = "form-row-3" >
< div class = "form-group" >
< label > Effect Type< / label >
< select id = "skillStatusType" >
< option value = "" > None< / option >
< option value = "poison" > Poison< / option >
< option value = "burn" > Burn< / option >
< option value = "bleed" > Bleed< / option >
< option value = "regen" > Regeneration< / option >
< option value = "stun" > Stun< / option >
< / select >
< / div >
< div class = "form-group" >
< label > Damage/Turn< / label >
< input type = "number" id = "skillStatusDamage" value = "5" min = "0" >
< / div >
< div class = "form-group" >
< label > Duration (turns)< / label >
< input type = "number" id = "skillStatusDuration" value = "3" min = "1" >
< / div >
< / div >
< / div >
< div class = "form-row-3" style = "margin-top: 15px;" >
< div class = "form-group" >
< label >
< input type = "checkbox" id = "skillPlayerUsable" checked > Player Can Use
< / label >
< / div >
< div class = "form-group" >
< label >
< input type = "checkbox" id = "skillMonsterUsable" checked > Monster Can Use
< / label >
< / div >
< div class = "form-group" >
< label >
< input type = "checkbox" id = "skillEnabled" checked > Enabled
< / label >
< / div >
< / div >
< div class = "form-actions" >
< button type = "button" class = "btn btn-secondary" onclick = "closeSkillModal()" > Cancel< / button >
< button type = "submit" class = "btn btn-primary" > Save Skill< / button >
< / div >
< / form >
< / div >
< / div >
< script >
// State
let accessToken = localStorage.getItem('accessToken');
let monsters = [];
let users = [];
let settings = {};
let allSkills = [];
let currentMonsterSkills = []; // Skills for the monster being edited
// API Helper
async function api(endpoint, options = {}) {
@ -864,6 +1168,7 @@
loadMonsters();
loadUsers();
loadSettings();
loadSkillsAdmin(); // Load skills for admin (includes disabled)
}
// ============= MONSTERS =============
@ -877,6 +1182,164 @@
}
}
async function loadSkills() {
try {
const data = await api('/api/skills');
allSkills = data.skills || [];
populateSkillSelect();
} catch (e) {
console.error('Failed to load skills:', e);
}
}
function populateSkillSelect() {
const select = document.getElementById('addSkillSelect');
select.innerHTML = '< option value = "" > -- Select a skill --< / option > ';
// Support both snake_case (admin API) and camelCase (public API)
allSkills.filter(s => s.monster_usable || s.monsterUsable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadMonsterSkills(monsterTypeId) {
if (!monsterTypeId) {
currentMonsterSkills = [];
renderMonsterSkills();
return;
}
try {
const data = await api('/api/admin/monster-skills');
currentMonsterSkills = (data.monsterSkills || []).filter(ms => ms.monster_type_id === monsterTypeId);
renderMonsterSkills();
} catch (e) {
console.error('Failed to load monster skills:', e);
currentMonsterSkills = [];
renderMonsterSkills();
}
}
function renderMonsterSkills() {
const container = document.getElementById('monsterSkillsList');
if (currentMonsterSkills.length === 0) {
container.innerHTML = '< p style = "color: #666; font-size: 12px;" > No skills assigned. Monster will only use basic attack.< / p > ';
return;
}
container.innerHTML = currentMonsterSkills.map(ms => {
const skill = allSkills.find(s => s.id === ms.skill_id);
const baseName = skill ? skill.name : ms.skill_id;
const displayName = ms.custom_name || baseName;
const hasCustomName = !!ms.custom_name;
return `
< div class = "monster-skill-item" data-id = "${ms.id}" >
< div class = "skill-name-section" >
< input type = "text" class = "skill-custom-name" value = "${escapeHtml(ms.custom_name || '')}"
placeholder="${escapeHtml(baseName)}"
onchange="updateMonsterSkillName(${ms.id}, this.value)"
title="Custom name (leave empty for default: ${escapeHtml(baseName)})">
< span class = "skill-base-name" > ${escapeHtml(baseName)}< / span >
< / div >
< div >
< label > Wt< / label >
< input type = "number" class = "skill-weight" value = "${ms.weight}" min = "1"
onchange="updateMonsterSkill(${ms.id}, 'weight', this.value)">
< / div >
< div >
< label > Lvl< / label >
< input type = "number" class = "skill-min-level" value = "${ms.min_level}" min = "1"
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
< / div >
< button type = "button" class = "btn btn-danger btn-small" onclick = "removeMonsterSkill(${ms.id})" > ✕< / button >
< / div >
`;
}).join('');
}
function onSkillSelectChange() {
// Clear custom name when selecting a new skill
document.getElementById('addSkillCustomName').value = '';
}
async function addMonsterSkill() {
const monsterId = document.getElementById('monsterId').value;
const skillId = document.getElementById('addSkillSelect').value;
const customName = document.getElementById('addSkillCustomName').value.trim();
const weight = parseInt(document.getElementById('addSkillWeight').value) || 10;
const minLevel = parseInt(document.getElementById('addSkillMinLevel').value) || 1;
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!monsterId) {
showToast('Please save the monster first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentMonsterSkills.some(ms => ms.skill_id === skillId)) {
showToast('This skill is already assigned to this monster', 'error');
return;
}
try {
await api('/api/admin/monster-skills', {
method: 'POST',
body: JSON.stringify({
monster_type_id: monsterId,
skill_id: skillId,
custom_name: customName || null,
weight: weight,
min_level: minLevel
})
});
showToast('Skill added');
await loadMonsterSkills(monsterId);
document.getElementById('addSkillSelect').value = '';
document.getElementById('addSkillCustomName').value = '';
} catch (e) {
showToast('Failed to add skill: ' + e.message, 'error');
}
}
async function updateMonsterSkillName(id, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ custom_name: value.trim() || null })
});
// Update local state
const ms = currentMonsterSkills.find(s => s.id === id);
if (ms) ms.custom_name = value.trim() || null;
} catch (e) {
showToast('Failed to update skill name: ' + e.message, 'error');
}
}
async function updateMonsterSkill(id, field, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ [field]: parseInt(value) })
});
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeMonsterSkill(id) {
try {
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
showToast('Skill removed');
const monsterId = document.getElementById('monsterId').value;
await loadMonsterSkills(monsterId);
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
function renderMonsterTable() {
const tbody = document.getElementById('monsterTableBody');
if (monsters.length === 0) {
@ -911,8 +1374,8 @@
async function toggleMonster(id, enabled) {
try {
await api(`/api/admin/monster-types/${id}`, {
method: 'PUT ',
await api(`/api/admin/monster-types/${id}/enabled `, {
method: 'PATCH ',
body: JSON.stringify({ enabled })
});
showToast(enabled ? 'Monster enabled' : 'Monster disabled');
@ -935,7 +1398,7 @@
}
}
function editMonster(id) {
async function editMonster(id) {
const monster = monsters.find(m => m.id === id);
if (!monster) return;
@ -960,6 +1423,9 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Load monster skills
await loadMonsterSkills(monster.id);
document.getElementById('monsterModal').classList.add('active');
}
@ -997,6 +1463,10 @@
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Clear skills (cloned monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '< p style = "color: #666; font-size: 12px;" > Save monster first, then edit to add skills.< / p > ';
document.getElementById('monsterModal').classList.add('active');
}
@ -1005,6 +1475,9 @@
document.getElementById('monsterForm').reset();
document.getElementById('monsterId').value = '';
document.getElementById('monsterEnabled').checked = true;
// Clear skills (new monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '< p style = "color: #666; font-size: 12px;" > Save monster first, then edit to add skills.< / p > ';
document.getElementById('monsterModal').classList.add('active');
});
@ -1059,6 +1532,188 @@
}
});
// ============= SKILLS DATABASE =============
async function loadSkillsAdmin() {
try {
const data = await api('/api/admin/skills');
allSkills = data.skills || [];
renderSkillTable();
populateSkillSelect(); // Also update the monster skill dropdown
} catch (e) {
showToast('Failed to load skills: ' + e.message, 'error');
}
}
function renderSkillTable() {
const tbody = document.getElementById('skillTableBody');
if (allSkills.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "11" > No skills found< / td > < / tr > ';
return;
}
tbody.innerHTML = allSkills.map(s => {
const statusEffect = s.status_effect ? JSON.parse(s.status_effect) : null;
return `
< tr >
< td > < strong > ${escapeHtml(s.name)}< / strong > < / td >
< td > < code > ${escapeHtml(s.id)}< / code > < / td >
< td > < span class = "skill-type-badge skill-type-${s.type}" > ${s.type}< / span > < / td >
< td > ${s.base_power}${s.hit_count > 1 ? ' ×' + s.hit_count : ''}< / td >
< td > ${s.accuracy}%< / td >
< td > ${s.mp_cost}< / td >
< td > ${s.target}< / td >
< td > ${s.player_usable ? '✓' : '✗'}< / td >
< td > ${s.monster_usable ? '✓' : '✗'}< / td >
< td >
< label class = "toggle" >
< input type = "checkbox" $ { s . enabled ? ' checked ' : ' ' }
onchange="toggleSkill('${s.id}', this.checked)">
< span class = "toggle-slider" > < / span >
< / label >
< / td >
< td class = "actions" >
< button class = "btn btn-secondary btn-small" onclick = "editSkill('${s.id}')" > Edit< / button >
< button class = "btn btn-danger btn-small" onclick = "deleteSkill('${s.id}')" > Delete< / button >
< / td >
< / tr >
`}).join('');
}
async function toggleSkill(id, enabled) {
try {
await api(`/api/admin/skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ enabled })
});
showToast(enabled ? 'Skill enabled' : 'Skill disabled');
loadSkillsAdmin();
} catch (e) {
showToast('Failed to toggle skill: ' + e.message, 'error');
loadSkillsAdmin();
}
}
async function deleteSkill(id) {
if (!confirm('Are you sure you want to delete this skill? This will also remove it from all monsters.')) return;
try {
await api(`/api/admin/skills/${id}`, { method: 'DELETE' });
showToast('Skill deleted');
loadSkillsAdmin();
} catch (e) {
showToast('Failed to delete skill: ' + e.message, 'error');
}
}
function editSkill(id) {
const skill = allSkills.find(s => s.id === id);
if (!skill) return;
document.getElementById('skillModalTitle').textContent = 'Edit Skill';
document.getElementById('skillEditId').value = skill.id;
document.getElementById('skillId').value = skill.id;
document.getElementById('skillId').disabled = true; // Can't change ID on edit
document.getElementById('skillName').value = skill.name;
document.getElementById('skillDescription').value = skill.description || '';
document.getElementById('skillType').value = skill.type;
document.getElementById('skillTarget').value = skill.target;
document.getElementById('skillMpCost').value = skill.mp_cost;
document.getElementById('skillBasePower').value = skill.base_power;
document.getElementById('skillAccuracy').value = skill.accuracy;
document.getElementById('skillHitCount').value = skill.hit_count;
document.getElementById('skillPlayerUsable').checked = skill.player_usable;
document.getElementById('skillMonsterUsable').checked = skill.monster_usable;
document.getElementById('skillEnabled').checked = skill.enabled;
// Parse status effect
if (skill.status_effect) {
try {
const effect = JSON.parse(skill.status_effect);
document.getElementById('skillStatusType').value = effect.type || '';
document.getElementById('skillStatusDamage').value = effect.damage || 5;
document.getElementById('skillStatusDuration').value = effect.duration || 3;
} catch {
document.getElementById('skillStatusType').value = '';
}
} else {
document.getElementById('skillStatusType').value = '';
document.getElementById('skillStatusDamage').value = 5;
document.getElementById('skillStatusDuration').value = 3;
}
document.getElementById('skillModal').classList.add('active');
}
document.getElementById('addSkillBtn').addEventListener('click', () => {
document.getElementById('skillModalTitle').textContent = 'Add Skill';
document.getElementById('skillForm').reset();
document.getElementById('skillEditId').value = '';
document.getElementById('skillId').disabled = false;
document.getElementById('skillPlayerUsable').checked = true;
document.getElementById('skillMonsterUsable').checked = true;
document.getElementById('skillEnabled').checked = true;
document.getElementById('skillModal').classList.add('active');
});
function closeSkillModal() {
document.getElementById('skillModal').classList.remove('active');
document.getElementById('skillId').disabled = false;
}
document.getElementById('skillForm').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('skillEditId').value;
const skillId = document.getElementById('skillId').value;
// Build status effect JSON if type is selected
let statusEffect = null;
const statusType = document.getElementById('skillStatusType').value;
if (statusType) {
statusEffect = JSON.stringify({
type: statusType,
damage: parseInt(document.getElementById('skillStatusDamage').value) || 5,
duration: parseInt(document.getElementById('skillStatusDuration').value) || 3
});
}
const data = {
id: skillId,
name: document.getElementById('skillName').value,
description: document.getElementById('skillDescription').value,
type: document.getElementById('skillType').value,
target: document.getElementById('skillTarget').value,
mp_cost: parseInt(document.getElementById('skillMpCost').value) || 0,
base_power: parseInt(document.getElementById('skillBasePower').value) || 100,
accuracy: parseInt(document.getElementById('skillAccuracy').value) || 95,
hit_count: parseInt(document.getElementById('skillHitCount').value) || 1,
status_effect: statusEffect,
player_usable: document.getElementById('skillPlayerUsable').checked,
monster_usable: document.getElementById('skillMonsterUsable').checked,
enabled: document.getElementById('skillEnabled').checked
};
try {
if (editId) {
await api(`/api/admin/skills/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showToast('Skill updated');
} else {
await api('/api/admin/skills', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('Skill created');
}
closeSkillModal();
loadSkillsAdmin();
} catch (e) {
showToast('Failed to save skill: ' + e.message, 'error');
}
});
// ============= USERS =============
async function loadUsers() {
try {