// 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 = '

Failed to load: ' + esc(e.message) + '

'; }); } function renderMobEditor(id, data) { var html = '

Mob: ' + esc(id) + '

'; html += '
'; html += '
'; html += '
'; html += '
'; MOB_SECTIONS.forEach(function(sec) { if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return; html += renderMobCard(sec, data); }); html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += ''; html += '
'; html += ''; html += ''; html += ''; html += '
'; $('#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 = '
'; h += '
'; h += '' + (collapsed ? '▶' : '▼') + ''; h += '' + sec.label + ''; if (!sec.always) { h += ''; } h += '
'; if (!collapsed) { h += '
'; if (sec.id === 'core') { h += renderMobCoreCard(data); } else if (sec.subtables) { h += '
'; sec.fields.forEach(function(f) { h += renderMobField(sec, f, data, -1); }); h += '
'; if (sec.inline) { sec.inline.forEach(function(il) { if (il.type === 'tags') { h += ced_renderTags(sec, il, data); } else { h += '
'; h += '' + esc(il.label) + ''; h += '
'; il.fields.forEach(function(f) { h += renderMobField(sec, f, data, -1, il.path); }); h += '
'; h += '
'; } }); } sec.subtables.forEach(function(st) { h += renderMobSubtable(sec, st, data); }); } else { h += '
'; sec.fields.forEach(function(f) { h += renderMobField(sec, f, data, -1); }); h += '
'; if (sec.inline) { sec.inline.forEach(function(il) { if (il.type === 'tags') { h += ced_renderTags(sec, il, data); } else { h += '
'; h += '' + esc(il.label) + ''; h += '
'; il.fields.forEach(function(f) { h += renderMobField(sec, f, data, -1, il.path); }); h += '
'; h += '
'; } }); } if (sec.id === 'talk') { h += '
'; } } h += '
'; } h += '
'; return h; } function renderMobCoreCard(data) { var sec = MOB_SECTIONS[0]; var h = '
'; h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1'); h += '
'; 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 ''; } if (f[2] === 'checkbox') { return ''; } return ''; } 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 = '
'; h += '
' + esc(st.label) + '
'; arr.forEach(function(item, idx) { h += '
'; st.fields.forEach(function(f) { var val = item[f[0]]; h += renderMobSubField(sec, st.key, idx, f, val); }); if (!st.single) { h += ''; } h += '
'; }); if (!st.single || arr.length === 0) { h += ''; } h += '
'; 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 = '

Deleted

'; 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'); }); }