aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static/mobeditor.js
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-01 06:32:15 -0400
committerhistoria <[not public]>2026-07-01 06:32:15 -0400
commit649ef4b7416ce7053145878841fc9b0bff2f5fec (patch)
tree1dae6678dac94d5f5948973059b7ddf8a70c3659 /internal/admin/static/mobeditor.js
parentbf7c27c2cbf3eb56a0a4c5141e39b152c51614b5 (diff)
downloadthehouseoficarus-649ef4b7416ce7053145878841fc9b0bff2f5fec.tar.gz
feat: admin gui card code unified, unhinged conversation tree GUI sort of implemented
Diffstat (limited to 'internal/admin/static/mobeditor.js')
-rw-r--r--internal/admin/static/mobeditor.js532
1 files changed, 532 insertions, 0 deletions
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
new file mode 100644
index 0000000..a65e578
--- /dev/null
+++ b/internal/admin/static/mobeditor.js
@@ -0,0 +1,532 @@
+// mobeditor.js — Card-based mob editor following the object/item pattern.
+var mobData = null;
+var mobID = null;
+var expandedCards = {};
+var addedSections = {};
+
+var MOB_SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text', 'spaceport attendant'],
+ ['description', 'Description', 'textarea', 'A lanky man in a wrinkled grey uniform.'],
+ ],
+ inline: [
+ {label:'Idle Descriptions', key:'idle_descriptions', type:'tags'},
+ {label:'Combat Descriptions', key:'combat_descriptions', type:'tags'},
+ ]
+ },
+ {
+ id: 'combat', label: 'Combat Stats',
+ detect: function(d) { return d.attack || d.strength || d.defense || d.hp; },
+ fields: [
+ ['attack', 'Attack', 'number', '', 'narrow'],
+ ['strength', 'Strength', 'number', '', 'narrow'],
+ ['defense', 'Defense', 'number', '', 'narrow'],
+ ['hp', 'HP', 'number', '', 'narrow'],
+ ['ranged', 'Ranged', 'number', '', 'narrow'],
+ ['science', 'Science', 'number', '', 'narrow'],
+ ['speed', 'Speed', 'number', '', 'narrow'],
+ ],
+ inline: [
+ {label:'Bonuses', path:'bonuses', fields:[
+ ['attack_bonus', 'Atk Bonus', 'number', '', 'narrow'],
+ ['strength_bonus', 'Str Bonus', 'number', '', 'narrow'],
+ ['attack_type', 'Atk Type', 'select', 'stab|slash|crush|ranged|science'],
+ ]},
+ {label:'Defenses', path:'defenses', fields:[
+ ['stab_defense', 'Stab', 'number', '', 'narrow'],
+ ['slash_defense', 'Slash', 'number', '', 'narrow'],
+ ['crush_defense', 'Crush', 'number', '', 'narrow'],
+ ['science_defense', 'Science', 'number', '', 'narrow'],
+ ['ranged_defense', 'Ranged', 'number', '', 'narrow'],
+ ['weakness', 'Weakness', 'text'],
+ ]},
+ ]
+ },
+ {
+ id: 'behavior', label: 'Behavior',
+ detect: function(d) { return d.aggressive || d.protected || d.unique || d.respawn_ticks || d.kind; },
+ fields: [
+ ['aggressive', 'Aggressive', 'checkbox'],
+ ['protected', 'Protected', 'checkbox'],
+ ['unique', 'Unique', 'checkbox'],
+ ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'],
+ ['kind', 'Kind', 'select', 'combat|task'],
+ ]
+ },
+ {
+ id: 'steal', label: 'Steal',
+ detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp; },
+ fields: [
+ ['steal_table', 'Table ID', 'search', '', '', null, 'drops'],
+ ['steal_level', 'Level', 'number', '', 'narrow'],
+ ['steal_xp', 'XP', 'number', '', 'narrow'],
+ ['steal_speed', 'Speed', 'number', '', 'narrow'],
+ ]
+ },
+ {
+ id: 'assassin', label: 'Assassin',
+ detect: function(d) { return d.assassin_level || d.finishing_blow || d.damage_without || d.size; },
+ fields: [
+ ['assassin_level', 'Level', 'number', '', 'narrow'],
+ ['finishing_blow', 'Finishing Blow', 'text'],
+ ['damage_without', 'Damage Without', 'text'],
+ ['size', 'Size', 'text'],
+ ]
+ },
+ {
+ id: 'task', label: 'Task',
+ detect: function(d) { return d.kind === 'task'; },
+ fields: [
+ ['verb', 'Verb', 'text', 'work'],
+ ['progress_noun', 'Progress Noun', 'text', 'construction'],
+ ['complete_message', 'Complete Message', 'text'],
+ ]
+ },
+ {
+ id: 'talk', label: 'Talk', path: 'talk',
+ detect: function(d) { return d.talk && d.talk.nodes; },
+ fields: []
+ },
+ {
+ id: 'drops', label: 'Drops', path: 'drops',
+ detect: function(d) { return d.drops && (d.drops.remains || (d.drops.loot && d.drops.loot.length > 0)); },
+ fields: [
+ ['remains', 'Remains', 'text', 'bones'],
+ ],
+ subtables: [
+ {label:'Loot', key:'loot', fields:[
+ ['item_id', 'Item ID', 'search', '', '', null, 'items'],
+ ['table', 'Table', 'search', '', '', null, 'drops'],
+ ['weight', 'Weight', 'number', '', 'narrow'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['xp', 'XP', 'number', '', 'narrow'],
+ ['quantity', 'Quantity', 'number', '', 'narrow'],
+ ]}
+ ]
+ },
+];
+
+function mobCardPath(sec) { return ced_cardPath(sec); }
+function mobGetVal(data, sec, key) { return ced_getVal(data, sec, key); }
+function mobFieldID(sec, key, idx) { return ced_fieldID(sec, key, idx); }
+function mobFieldIDSub(sec, subKey, idx, fieldKey) { return ced_fieldIDSub(sec, subKey, idx, fieldKey); }
+
+function initMobEditor() {
+ editorType = 'mobs';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadMob(window.location.hash.substring(1));
+}
+
+window.onTalkTreeChange = function(data) {
+ mobData.talk = data.talk;
+};
+
+function loadMob(id) {
+ mobID = id;
+ currentID = id;
+ window.location.hash = id;
+ renderList('');
+ API.get('/api/mobs/' + encodeURIComponent(id)).then(function(data) {
+ mobData = data;
+ renderMobEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderMobEditor(id, data) {
+ var html = '<h2>Mob: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return;
+ html += renderMobCard(sec, data);
+ });
+
+ html += '</div>';
+
+ html += '<div class="add-section-bar">';
+ html += '<select id="addSectionSelect">';
+ html += '<option value="">+ Add Section...</option>';
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.always) return;
+ if (sec.detect && sec.detect(data)) return;
+ if (addedSections[sec.id]) return;
+ html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>';
+ });
+ html += '</select>';
+ html += '<button class="btn btn-primary btn-sm" onclick="mobAddSection()">Add</button>';
+ html += '</div>';
+
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveMob()">Save</button>';
+ html += '<button class="btn" onclick="duplicateMob()">Duplicate</button>';
+ html += '<button class="btn btn-danger" onclick="deleteMob()">Delete Mob</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ ced_setupSearchFields();
+ setupMobKindChange();
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.id === 'talk' && data.talk && data.talk.nodes) {
+ renderTalkTree(data);
+ }
+ });
+}
+
+function renderCurrent() {
+ if (!mobData || !mobID) return;
+ renderMobEditor(mobID, mobData);
+}
+
+// ---- Card rendering ----
+
+function renderMobCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var h = '<div class="obj-card" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleMobCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ if (!sec.always) {
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeMobSection(\'' + sec.id + '\')">X</button>';
+ }
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+
+ if (sec.id === 'core') {
+ h += renderMobCoreCard(data);
+ } else if (sec.subtables) {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1);
+ });
+ h += '</div>';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ h += ced_renderTags(sec, il, data);
+ } else {
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">' + esc(il.label) + '</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ il.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1, il.path);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ });
+ }
+
+ sec.subtables.forEach(function(st) {
+ h += renderMobSubtable(sec, st, data);
+ });
+ } else {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1);
+ });
+ h += '</div>';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ h += ced_renderTags(sec, il, data);
+ } else {
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">' + esc(il.label) + '</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ il.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1, il.path);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ });
+ }
+
+ if (sec.id === 'talk') {
+ h += '<div id="talktree"></div>';
+ }
+ }
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderMobCoreCard(data) {
+ var sec = MOB_SECTIONS[0];
+ var h = '<div class="obj-core-row">';
+ h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1');
+ h += '</div>';
+ h += renderMobField(sec, sec.fields[1], data, -1);
+ sec.inline.forEach(function(il) {
+ h += ced_renderTags(sec, il, data);
+ });
+ return h;
+}
+
+function renderMobField(sec, f, data, idx, subPath, optStyle) {
+ return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal);
+}
+
+// ---- Subtable rendering ----
+
+function renderMobSubField(sec, stKey, idx, f, val) {
+ var fid = mobFieldIDSub(sec, stKey, idx, f[0]);
+ val = val === undefined || val === null ? '' : val;
+ var extraCls = '';
+ if (f[4] === 'narrow') extraCls = ' obj-narrow';
+ else if (f[4] === 'grow') extraCls = ' obj-grow';
+ var defaultPh = '';
+ if (f[3]) defaultPh = ' placeholder="' + escAttr(f[3]) + '"';
+ if (f[2] === 'search') {
+ var entity = f[6] || 'items';
+ var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."';
+ return '<label class="obj-field obj-search' + extraCls + '"><span>' + esc(f[1]) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div></label>';
+ }
+ if (f[2] === 'checkbox') {
+ return '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>';
+ }
+ return '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + defaultPh + '></label>';
+}
+
+function renderMobSubtable(sec, st, data) {
+ var p = mobCardPath(sec);
+ var sectionRoot = p ? (mobData[p] || {}) : mobData;
+ var raw = sectionRoot[st.key];
+ var arr;
+ if (st.single) {
+ arr = raw ? [raw] : [];
+ } else if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ var h = '<div class="obj-subtable">';
+ h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>';
+ arr.forEach(function(item, idx) {
+ h += '<div class="obj-sub-row">';
+ st.fields.forEach(function(f) {
+ var val = item[f[0]];
+ h += renderMobSubField(sec, st.key, idx, f, val);
+ });
+ if (!st.single) {
+ h += '<button class="btn btn-sm btn-danger obj-sub-remove" style="margin-bottom:3px" onclick="removeMobSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>';
+ }
+ h += '</div>';
+ });
+ if (!st.single || arr.length === 0) {
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add Loot</button>';
+ }
+ h += '</div>';
+ return h;
+}
+
+// ---- Card toggle / section add-remove ----
+
+function toggleMobCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function removeMobSection(id) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+ if (sec.path) {
+ delete mobData[sec.path];
+ } else {
+ if (sec.fields) sec.fields.forEach(function(f) { delete mobData[f[0]]; });
+ if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete mobData[il.key]; });
+ if (sec.subtables) sec.subtables.forEach(function(st) { delete mobData[st.key]; });
+ }
+ delete addedSections[id];
+ renderCurrent();
+}
+
+function mobAddSection() {
+ var sel = $('#addSectionSelect');
+ var id = sel.value;
+ if (!id) return;
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+
+ if (sec.path) {
+ if (sec.id === 'drops') {
+ mobData[sec.path] = {remains:'', loot:[]};
+ } else if (sec.id === 'talk') {
+ mobData[sec.path] = {nodes:{start:{message:'',options:[]}}};
+ } else {
+ mobData[sec.path] = {};
+ }
+ }
+ addedSections[id] = true;
+ expandedCards[id] = true;
+ renderCurrent();
+}
+
+// ---- Subtable row add/remove ----
+
+function addMobSubRow(cardID, subKey) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ ced_addSubRow(sec, subKey, mobData, mobCardPath);
+ renderCurrent();
+}
+
+function removeMobSubRow(cardID, subKey, idx) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath);
+ renderCurrent();
+}
+
+// ---- Kind change handler (show/hide task fields) ----
+
+function setupMobKindChange() {
+ var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='behavior';}), 'kind', -1));
+ if (!kindSel) return;
+ kindSel.addEventListener('change', function() {
+ if (kindSel.value === 'task') {
+ mobData.kind = 'task';
+ if (!mobData.verb) mobData.verb = '';
+ if (!mobData.progress_noun) mobData.progress_noun = '';
+ if (!mobData.complete_message) mobData.complete_message = '';
+ addedSections['task'] = true;
+ expandedCards['task'] = true;
+ renderCurrent();
+ } else {
+ mobData.kind = '';
+ delete addedSections['task'];
+ renderCurrent();
+ }
+ });
+}
+
+// ---- Save ----
+
+function saveMob() {
+ if (!ced_validateFields(MOB_SECTIONS, mobFieldID, mobFieldIDSub)) return;
+
+ var out = {};
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.id === 'talk') {
+ if (mobData.talk) out.talk = mobData.talk;
+ return;
+ }
+
+ var sectionObj = {};
+ var hasValues = sec.always;
+
+ if (sec.fields) {
+ hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues;
+ }
+
+ if (sec.subtables) {
+ sec.subtables.forEach(function(st) {
+ if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true;
+ });
+ }
+
+ if (sec.inline) {
+ if (ced_saveInlineFields(sec, sectionObj, mobCardPath, mobFieldID)) hasValues = true;
+ }
+
+ if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (hasValues && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (hasValues && !sec.path) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = mobID;
+
+ API.put('/api/mobs/' + encodeURIComponent(mobID), out).then(function(resp) {
+ notify('Mob ' + mobID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) mobData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+// ---- Duplicate ----
+
+function duplicateMob() {
+ var baseID = mobID + '_copy';
+ var newID = baseID;
+ var counter = 2;
+ while (true) {
+ var exists = false;
+ for (var i = 0; i < allIDs.length; i++) {
+ if (allIDs[i] === newID) { exists = true; break; }
+ }
+ if (!exists) break;
+ newID = baseID + '_' + counter;
+ counter++;
+ }
+
+ var copy = JSON.parse(JSON.stringify(mobData));
+ delete copy._raw;
+ delete copy._path;
+ copy.id = newID;
+
+ API.post('/api/mobs', {id: newID}).then(function() {
+ return API.put('/api/mobs/' + encodeURIComponent(newID), copy);
+ }).then(function() {
+ notify('Duplicated as ' + newID, 'success');
+ updateUndoBar();
+ loadList();
+ loadMob(newID);
+ }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); });
+}
+
+// ---- Delete ----
+
+function deleteMob() {
+ if (!confirm('Delete mob "' + mobID + '"?')) return;
+ API.del('/api/mobs/' + encodeURIComponent(mobID)).then(function() {
+ notify('Mob ' + mobID + ' deleted', 'success');
+ updateUndoBar();
+ mobID = null;
+ currentID = null;
+ mobData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+// ---- editor.js compatibility ----
+
+function loadItem(id) { loadMob(id); }
+function createNew() {
+ var name = prompt('Mob ID:');
+ if (!name) return;
+ API.post('/api/mobs', {id: name}).then(function() {
+ notify('Mob ' + name + ' created', 'success');
+ updateUndoBar();
+ loadList();
+ loadMob(name);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+}