// 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: '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'],
['restock_ticks', 'Restock Ticks', 'number', '', 'narrow'],
['buys_anything', 'Buys Anything', 'checkbox'],
],
subtables: [
{label:'Items', key:'items', addLabel:'+ Add Item', fields:[
['item_id', 'Item ID', 'search', '', '', null, 'items'],
['stock', 'Stock', 'number', '', 'narrow'],
['restock_ticks', 'Restock Ticks', 'number', '', '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', '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 += '
' + esc(data._raw || JSON.stringify(data, null, 2)) + '
';
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 += '';
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 += '';
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:{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();
}
// ---- 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;
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'); });
}