// mobeditor.js — Card-based mob editor following the object/item pattern. var mobData = null; var mobID = null; var expandedCards = {}; var addedSections = {}; var cardWidths = {}; 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'], ], inline: [ {label:'Idle Descriptions', key:'idle_descriptions', type:'tags'}, ] }, { id: 'combat', label: 'Combat Stats', path: 'combat', detect: function(d) { return d.combat; }, fields: [ ['kind', 'Kind', 'select', 'combat|task'], ['aggressive', 'Aggressive', 'checkbox'], ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], ['stats.hp', 'HP', 'number', '', 'narrow'], ['stats.attack', 'Attack', 'number', '', 'narrow'], ['stats.strength', 'Strength', 'number', '', 'narrow'], ['stats.defense', 'Defense', 'number', '', 'narrow'], ['stats.ranged', 'Ranged', 'number', '', 'narrow'], ['stats.science', 'Science', 'number', '', 'narrow'], ['stats.speed', 'Speed', 'number', '', 'narrow'], ['stats.max_melee_hit', 'Max Melee Hit', 'number', '', 'narrow'], ['stats.max_ranged_hit', 'Max Ranged Hit', 'number', '', 'narrow'], ['stats.max_science_hit', 'Max Science Hit', 'number', '', 'narrow'], ['stats.bonuses.attack_bonus', 'Atk Bonus', 'number', '', 'narrow'], ['stats.bonuses.strength_bonus', 'Str Bonus', 'number', '', 'narrow'], ['stats.bonuses.science_bonus', 'Sci Bonus', 'number', '', 'narrow'], ['stats.bonuses.science_percent_bonus', 'Sci % Bonus', 'number', '', 'narrow'], ['stats.bonuses.ranged_bonus', 'Rng Bonus', 'number', '', 'narrow'], ['stats.bonuses.ranged_strength_bonus', 'Rng Str Bonus', 'number', '', 'narrow'], ['stats.defenses.stab_defense', 'Stab Def', 'number', '', 'narrow'], ['stats.defenses.slash_defense', 'Slash Def', 'number', '', 'narrow'], ['stats.defenses.crush_defense', 'Crush Def', 'number', '', 'narrow'], ['stats.defenses.science_defense', 'Sci Def', 'number', '', 'narrow'], ['stats.defenses.ranged_defense', 'Rng Def', 'number', '', 'narrow'], ['stats.defenses.weakness', 'Weakness', 'text'], ['stats.defenses.weakness_percent', 'Weakness %', 'number', '', 'narrow'], ['assassin_level', 'Assassin Lvl', 'number', '', 'narrow'], ['finishing_blow', 'Finishing Blow', 'search', '', '', null, 'items'], ['damage_without', 'Damage Without', 'search', '', '', null, 'items'], ['size', 'Size', 'text'], ], inline: [ {label:'Combat Descriptions', key:'combat_descriptions', type:'tags'}, ] }, { id: 'steal', label: 'Steal', path: 'steal', detect: function(d) { return d.steal; }, fields: [ ['table', 'Table ID', 'search', '', '', null, 'drops'], ['level', 'Level', 'number', '', 'narrow'], ['xp', 'XP', 'number', '', 'narrow'], ['speed', 'Speed', 'number', '', 'narrow'], ] }, { id: 'task', label: 'Task', path: 'task', detect: function(d) { return d.task; }, fields: [ ['verb', 'Verb', 'text', 'work'], ['progress_noun', 'Progress Noun', 'text', 'construction'], ['complete_message', 'Complete Message', 'text'], ] }, { id: 'talk', label: 'Talk', path: 'talk', fullWidth: true, 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; window._editorData = mobData; 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 = renderRenameableHeader('Mob', 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 += renderMoveBar(); html += '
'; html += '
'; html += ''; html += '
'; html += ''; html += ''; html += ''; html += ''; html += '
'; $('#editorMain').innerHTML = html; ced_setupSearchFields(); setupMobProtectedToggle(); MOB_SECTIONS.forEach(function(sec) { if (sec.id === 'talk' && data.talk && data.talk.nodes) { renderTalkTree(data); } }); } function renderCurrent() { ced_syncDOMToData(mobData); if (!mobData || !mobID) return; renderMobEditor(mobID, mobData); } // ---- Card rendering ---- function renderMobCard(sec, data) { var collapsed = expandedCards[sec.id] === false; var isFull = cardWidths[sec.id] !== undefined ? cardWidths[sec.id] : (sec.fullWidth || false); var fullClass = isFull ? ' obj-card-full' : ''; var h = '
'; h += '
'; h += '' + (collapsed ? '▶' : '▼') + ''; h += '' + sec.label + ''; h += ''; if (!sec.always) { h += ''; } h += '
'; if (!collapsed) { h += '
'; if (sec.id === 'core') { h += renderMobCoreCard(data); } else if (sec.id === 'combat') { h += renderMobCombatCard(data); } else if (sec.subtables) { h += '
'; sec.fields.forEach(function(f) { h += renderMobField(sec, f, data, -1); }); 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); } }); } 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); }); h += '
'; h += renderMobField(sec, byKey['unique'], data, -1); h += renderMobField(sec, byKey['protected'], data, -1); h += '
'; return h; } // normalizeAttackTypes coerces a mob attack_types value (array) into an array of // strings, tolerating a legacy scalar string for display only. function normalizeAttackTypes(v) { if (Array.isArray(v)) return v.slice(); if (typeof v === 'string' && v) return [v]; return []; } // renderMobAttackTypeWidget renders the Atk Type selector: one required melee // type (radio: stab/slash/crush) plus optional ranged/science (checkboxes). function renderMobAttackTypeWidget(data) { var combat = data.combat || {}; var types = normalizeAttackTypes(combat.attack_types); var melee = types.filter(function(t) { return t === 'stab' || t === 'slash' || t === 'crush'; })[0] || 'crush'; var hasRanged = types.indexOf('ranged') >= 0; var hasScience = types.indexOf('science') >= 0; var h = '
'; h += 'Atk Type'; h += '
'; ['stab','slash','crush'].forEach(function(m) { h += ''; }); h += '
'; h += '
'; h += ''; h += ''; h += '
'; h += '
'; return h; } // collectMobAttackType reads the Atk Type widget into an array: the selected // melee type first, then any optional ranged/science types. function collectMobAttackType() { var melee = 'crush'; var r = document.querySelector('input[name="mob_atktype_melee"]:checked'); if (r) melee = r.value; var out = [melee]; var rng = document.getElementById('mob_atktype_ranged'); var sci = document.getElementById('mob_atktype_science'); if (rng && rng.checked) out.push('ranged'); if (sci && sci.checked) out.push('science'); return out; } function renderMobCombatCard(data) { var sec = MOB_SECTIONS.find(function(s) { return s.id === 'combat'; }); if (!sec) return ''; var byKey = {}; sec.fields.forEach(function(f) { byKey[f[0]] = f; }); var h = '
'; h += renderMobField(sec, byKey['kind'], data, -1); h += renderMobField(sec, byKey['aggressive'], data, -1); h += renderMobField(sec, byKey['respawn_ticks'], data, -1); h += '
'; h += renderMobAttackTypeWidget(data); h += '
'; h += 'Stats'; h += '
'; var statKeys = ['stats.hp','stats.attack','stats.strength','stats.defense','stats.ranged','stats.science']; statKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; h += renderMobField(sec, byKey['stats.speed'], data, -1); h += '
'; h += '
'; var statKeys3 = ['stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit']; statKeys3.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; h += '
'; h += 'Bonuses'; h += '
'; var bonusKeys = ['stats.bonuses.attack_bonus','stats.bonuses.strength_bonus','stats.bonuses.science_bonus', 'stats.bonuses.science_percent_bonus','stats.bonuses.ranged_bonus','stats.bonuses.ranged_strength_bonus']; bonusKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; h += '
'; h += 'Defenses'; h += '
'; var defKeys = ['stats.defenses.stab_defense','stats.defenses.slash_defense','stats.defenses.crush_defense', 'stats.defenses.science_defense','stats.defenses.ranged_defense']; defKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; h += renderMobField(sec, byKey['stats.defenses.weakness'], data, -1); h += renderMobField(sec, byKey['stats.defenses.weakness_percent'], data, -1); h += '
'; h += '
'; h += '
'; h += renderMobField(sec, byKey['assassin_level'], data, -1); h += renderMobField(sec, byKey['size'], data, -1); h += '
'; h += '
'; h += renderMobField(sec, byKey['finishing_blow'], data, -1); h += renderMobField(sec, byKey['damage_without'], data, -1); h += '
'; sec.inline.forEach(function(il) { h += ced_renderTags(sec, il, data); }); return h; } // setupMobProtectedToggle hides the entire Combat card when Protected is checked. 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 combatCard = document.querySelector('.obj-card[data-card="combat"]'); if (combatCard) { combatCard.style.display = protEl.checked ? '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]) + '"'; var dp = mobCardPath(sec) + '.' + stKey + '[' + idx + '].' + f[0]; var dpAttr = ' data-field-path="' + escAttr(dp) + '"'; 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; } 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; } function mobTableColWidth(f) { return Math.max(60, esc(f[1]).length * 8 + 20); } 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]]; var dp = mobCardPath(sec) + '.' + st.key + '[' + idx + '].' + f[0]; if (f[2] === 'search') { var entity = f[6] || 'items'; var sval = (val === undefined || val === null) ? '' : String(val); h += '
'; } else { 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 toggleMobCardWidth(id) { cardWidths[id] = !cardWidths[id]; renderCurrent(); } function removeMobSection(id) { var sec = MOB_SECTIONS.find(function(s) { return s.id === id; }); if (!sec) return; ced_syncDOMToData(mobData); 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]; renderMobEditor(mobID, mobData); } 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 (id === 'combat') { mobData[sec.path] = { kind: 'combat', aggressive: false, attack_types: ['crush'], respawn_ticks: 30, stats: { hp: 1, attack: 1, strength: 1, defense: 1, speed: 5, max_melee_hit: 0, max_ranged_hit: 0, max_science_hit: 0, bonuses: {attack_bonus:0, strength_bonus:0, science_bonus:0, science_percent_bonus:0, ranged_bonus:0, ranged_strength_bonus:0}, defenses: {stab_defense:0, slash_defense:0, crush_defense:0, science_defense:0, ranged_defense:0, weakness_percent:0} }, size: 'small' }; } else if (id === 'drops') { mobData[sec.path] = {remains:'bones', loot:[]}; } else if (id === 'talk') { mobData[sec.path] = {nodes:{start:{messages:[],options:[]}}}; } else if (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_syncDOMToData(mobData); ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath); renderMobEditor(mobID, mobData); } 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(); } // ---- Nested object helpers ---- function unflattenObj(obj) { var result = {}; Object.keys(obj).forEach(function(key) { var parts = key.split('.'); var current = result; for (var i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) current[parts[i]] = {}; current = current[parts[i]]; } current[parts[parts.length - 1]] = obj[key]; }); return result; } // ---- 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.id === 'combat' && document.querySelector('input[name="mob_atktype_melee"]')) { sectionObj['attack_types'] = collectMobAttackType(); hasValues = true; } if (sec.subtables) { sec.subtables.forEach(function(st) { if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true; }); } if (sec.inline) { sec.inline.forEach(function(il) { if (il.type === 'tags') { var fid = mobFieldID(sec, il.key); var el = document.getElementById(fid); if (el && el.value.trim()) { sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); hasValues = true; } } }); } if (sec.always) { Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); } else if (hasValues && sec.path) { out[sec.path] = unflattenObj(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'); }); }