';
$('#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 = '
';
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'); });
}