// 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.'], ['unique', 'Unique', 'checkbox'], ['protected', 'Protected', 'checkbox'], ['aggressive', 'Aggressive', 'checkbox'], ['kind', 'Kind', 'select', 'combat|task'], ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], ], 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: '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: 'shop', label: 'Shop', path: 'shop', detect: function(d) { return !!d.shop; }, fields: [ ['message', 'Greeting', 'text', 'What would you like to buy or sell?'], ['buy_percentage', 'Buy %', 'number', '40', 'narrow'], ['change_percentage', 'Change %', 'number', '3', 'narrow'], ['buys_anything', 'Buys Anything', 'checkbox'], ], subtables: [ {label:'Items', key:'items', addLabel:'+ Add Item', table:true, fields:[ ['item_id', 'Item', 'search', '', '', null, 'items'], ['stock', 'Stock', 'number', '', 'narrow'], ['restock_ticks', 'Restock Ticks', 'number', '1000', 'narrow'], ]} ] }, { 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', 'search', '', '', null, 'items'], ], subtables: [ {label:'Loot', key:'loot', fields:[ ['item_id', 'Item', 'search', '', '', null, 'items'], ['table', 'Table', 'search', '', '', null, 'drops'], ['weight', 'Weight', '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; // buys_anything defaults to true (Go treats absent as true); reflect that in // the checkbox unless the shop explicitly opted out with false. if (mobData.shop && mobData.shop.buys_anything === undefined) { mobData.shop.buys_anything = true; } 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(); setupMobProtectedToggle(); 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 byKey = {}; sec.fields.forEach(function(f) { byKey[f[0]] = f; }); var h = '
'; h += renderMobField(sec, byKey['name'], data, -1, null, 'flex:1'); h += '
'; h += renderMobField(sec, byKey['description'], data, -1); sec.inline.forEach(function(il) { h += ced_renderTags(sec, il, data); }); // Behavior fields (merged into Core). h += '
'; h += renderMobField(sec, byKey['unique'], data, -1); h += renderMobField(sec, byKey['protected'], data, -1); h += renderMobField(sec, byKey['aggressive'], data, -1); h += '
'; h += '
'; h += renderMobField(sec, byKey['kind'], data, -1); h += renderMobField(sec, byKey['respawn_ticks'], data, -1); h += '
'; return h; } // setupMobProtectedToggle hides Aggressive, Respawn Ticks and Kind while // Protected is checked (protected mobs never attack, respawn, or work). Toggles // CSS only — no re-render — so other unsaved field edits are preserved. function setupMobProtectedToggle() { var sec = MOB_SECTIONS.find(function(s) { return s.id === 'core'; }); if (!sec) return; var protEl = document.getElementById(mobFieldID(sec, 'protected', -1)); if (!protEl) return; function apply() { var hide = protEl.checked; ['aggressive', 'respawn_ticks', 'kind'].forEach(function(key) { var el = document.getElementById(mobFieldID(sec, key, -1)); if (!el) return; var field = el.closest('.obj-field'); if (field) field.style.display = hide ? 'none' : ''; }); } if (!protEl._hasProtectedToggle) { protEl._hasProtectedToggle = true; protEl.addEventListener('change', apply); } apply(); } 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 = []; } if (st.table) { return renderMobTableSubtable(sec, st, arr); } if (sec.id === 'drops' && st.key === 'loot') { return renderMobLootSubtable(sec, st, 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; } // renderMobLootSubtable renders the Drops loot list where each row is either an // item drop (Item + Weight + Quantity) or a table drop (Table + Weight + Rolls). // A table entry's "Rolls" is stored in the `quantity` field and means the number // of independent draws from that table (see behavior.ResolveDropList). function renderMobLootSubtable(sec, st, arr) { var byKey = {}; st.fields.forEach(function(f) { byKey[f[0]] = f; }); var rollsField = ['quantity', 'Rolls', 'number', '', 'narrow']; var h = '
'; h += '
' + esc(st.label) + '
'; arr.forEach(function(item, idx) { var kind = item.table ? 'table' : (item.item_id ? 'item' : (item._kind || 'item')); h += '
'; if (kind === 'table') { h += renderMobSubField(sec, st.key, idx, byKey['table'], item.table); h += renderMobSubField(sec, st.key, idx, byKey['weight'], item.weight); h += renderMobSubField(sec, st.key, idx, rollsField, item.quantity); } else { h += renderMobSubField(sec, st.key, idx, byKey['item_id'], item.item_id); h += renderMobSubField(sec, st.key, idx, byKey['weight'], item.weight); h += renderMobSubField(sec, st.key, idx, byKey['quantity'], item.quantity); } h += ''; h += '
'; }); h += '
'; h += ''; h += ''; h += '
'; h += '
'; return h; } // Column width (px) for a non-search field, sized to fit its header label. function mobTableColWidth(f) { return Math.max(60, esc(f[1]).length * 8 + 20); } // renderMobTableSubtable renders a subtable in the ingredients-style table // layout: a single column-header row (no card header) and one row per entry. function renderMobTableSubtable(sec, st, arr) { var h = '
'; h += '
'; st.fields.forEach(function(f) { if (f[2] === 'search') { h += '
' + esc(f[1]) + '
'; } else { h += '
' + esc(f[1]) + '
'; } }); h += '
'; h += '
'; arr.forEach(function(item, idx) { h += '
'; st.fields.forEach(function(f) { var fid = mobFieldIDSub(sec, st.key, idx, f[0]); var val = item[f[0]]; if (f[2] === 'search') { var entity = f[6] || 'items'; var sval = (val === undefined || val === null) ? '' : String(val); h += '
'; } else { // Numeric column. Blank a 0/unset value only when the field has a // placeholder, so the placeholder (e.g. "1000") shows through. var hasPh = !!f[3]; var nval; if (val === undefined || val === null || val === '') nval = ''; else if (val === 0 && hasPh) nval = ''; else nval = String(val); var ph = hasPh ? ' placeholder="' + escAttr(f[3]) + '"' : ''; h += '
' + '' + '
'; } }); h += '
'; h += '
'; }); 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:'bones', loot:[]}; } else if (sec.id === 'talk') { mobData[sec.path] = {nodes:{start:{messages:[],options:[]}}}; } else if (sec.id === 'shop') { mobData[sec.path] = {buy_percentage:40, change_percentage:3, buys_anything:true, items:[]}; } 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(); } // addMobLootRow appends an item- or table-kind loot entry. The ephemeral _kind // marker is only used for rendering fresh empty rows; it's never saved (save // rebuilds rows from the visible inputs) and is re-inferred from item_id/table // on reload. function addMobLootRow(kind) { if (!mobData.drops) mobData.drops = {}; if (!Array.isArray(mobData.drops.loot)) mobData.drops.loot = []; if (kind === 'table') { mobData.drops.loot.push({table:'', weight:0, quantity:1, _kind:'table'}); } else { mobData.drops.loot.push({item_id:'', weight:0, quantity:1, _kind:'item'}); } renderCurrent(); } // ---- Kind change handler (show/hide task fields) ---- function setupMobKindChange() { var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='core';}), '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; var pre = document.querySelector('#tabYaml pre'); if (pre) pre.textContent = mobData._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'); }); }