From 74153c7814fc4cef988066ef04e38731a943bc12 Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Thu, 9 Jul 2026 04:03:00 -0400
Subject: feat(admin): nested condition/action buttons to manage complex YAML
---
internal/admin/static/admin.css | 52 ++
internal/admin/static/cardeditor.js | 18 +-
internal/admin/static/intereditor.js | 1050 +++++++++++++++++++++++++++++++++
internal/admin/static/mobeditor.js | 150 ++---
internal/admin/static/objecteditor.js | 190 +++---
internal/admin/templates/mobs.html | 1 +
internal/admin/templates/objects.html | 1 +
7 files changed, 1244 insertions(+), 218 deletions(-)
create mode 100644 internal/admin/static/intereditor.js
(limited to 'internal')
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index b710491..aa80488 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -379,3 +379,55 @@ g.ud-hover:hover text{font-weight:bold}
.course-phase .phase-msg{font-family:monospace}
.course-phase .mini-input{width:auto!important;flex:1}
.course-details-card{margin-top:10px}
+
+/* --- Structured condition & action editors (intereditor.js) --- */
+
+.ie-cond{background:rgba(100,160,255,.04);border:1px solid rgba(100,160,255,.12);border-radius:5px;padding:8px;margin-bottom:4px}
+.ie-cond-root{padding:0}
+.ie-cond-modebar{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap}
+.ie-cond-modesel{font-size:11px;padding:2px 4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace}
+.ie-cond-sm-label{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.5px}
+.ie-cond-notlbl{font-size:10px;color:var(--text);display:inline-flex;align-items:center;gap:2px;white-space:nowrap;cursor:pointer}
+.ie-cond-not{accent-color:var(--danger);margin:0}
+.ie-cond-children{display:flex;flex-direction:column;gap:4px}
+.ie-cond-leaf{display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg);border:1px solid var(--border);border-radius:3px}
+.ie-cond-leaf-label{font-size:10px;color:#6af;text-transform:uppercase;letter-spacing:.3px;font-weight:bold;white-space:nowrap;min-width:70px}
+.ie-cond-leaf input[type="text"],
+.ie-cond-leaf input[type="number"],
+.ie-cond-val{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;flex:1;min-width:80px}
+.ie-cond-subval{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:100px}
+.ie-cond-rm{flex-shrink:0}
+.ie-cond-grp-rm{margin-left:auto}
+.ie-cond-addbar{display:flex;gap:3px;flex-wrap:wrap;margin-top:4px}
+.ie-add-btn{font-size:10px!important;padding:2px 6px!important;background:var(--accent)!important;color:var(--text)!important;border:1px solid #1a5a8e!important;font-family:monospace!important;border-radius:3px!important;cursor:pointer;white-space:nowrap}
+.ie-add-btn:hover{background:#1a5a8e!important;color:#fff!important}
+
+/* Action editor */
+.ie-act-root{background:rgba(100,160,255,.04);border:1px solid rgba(100,160,255,.12);border-radius:5px;padding:8px;display:flex;flex-direction:column;gap:6px}
+.ie-act-row{display:flex;align-items:center;gap:6px}
+.ie-act-label{font-size:10px;color:#6af;text-transform:uppercase;letter-spacing:.3px;font-weight:bold;white-space:nowrap;min-width:90px}
+.ie-act-input{font-size:11px;padding:3px 6px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace}
+.ie-act-rm{flex-shrink:0}
+.ie-act-addbar{display:flex;gap:3px;flex-wrap:wrap;margin-top:2px}
+.ie-act-kv{background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:6px}
+.ie-act-kv-header{display:flex;align-items:center;gap:8px;margin-bottom:4px}
+.ie-kv-list{display:flex;flex-direction:column;gap:3px}
+.ie-kv-row{display:flex;gap:4px;align-items:center}
+.ie-kv-key,.ie-kv-val{font-size:11px;padding:2px 4px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;font-family:monospace;width:120px}
+.ie-kv-val{width:80px}
+
+/* Interaction row layout: column. The single flex header row holds the
+ +Add Required Item / +Add Condition / +Add Action buttons alongside the
+ Remove Interaction button (right-aligned via a flex spacer). The expanded
+ item_id field flexes inside the same row. Condition and action panels
+ stack beneath. Replaces the old separate .ie-inter-toolbar row. */
+.obj-sub-row.ie-inter-row{flex-direction:column;align-items:stretch;gap:6px}
+.ie-inter-header{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
+.ie-inter-header-spacer{flex:1}
+.ie-inter-header .obj-field{margin-bottom:0;flex:1;min-width:0}
+.obj-sub-row.ie-inter-row .ie-inter-remove{white-space:nowrap;flex-shrink:0}
+.ie-inter-field-x{flex-shrink:0;margin-bottom:3px}
+.ie-inter-panel{background:rgba(0,0,0,.15);border-radius:4px;padding:6px}
+.ie-inter-panel-row{display:flex;gap:6px;align-items:flex-start}
+.ie-inter-panel-body{flex:1;min-width:0}
+.ie-inter-panel-x{margin-top:4px;flex-shrink:0}
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
index a11388b..e430e1d 100644
--- a/internal/admin/static/cardeditor.js
+++ b/internal/admin/static/cardeditor.js
@@ -2,6 +2,13 @@
// Used by mobeditor, objecteditor, itemeditor, and future editors.
// Prefix all exports with ced_ to avoid collisions with editor.js globals.
+// Hide any open search-results dropdown when clicking outside a search input.
+document.addEventListener('click', function(e) {
+ if (!e.target.closest('.search-results') && !e.target.closest('.search-input')) {
+ document.querySelectorAll('.search-results').forEach(function(r) { r.style.display = 'none'; });
+ }
+});
+
// ---- Path helpers ----
function ced_cardPath(sec) {
@@ -153,12 +160,6 @@ function ced_setupSearchFields() {
}
});
});
-
- document.addEventListener('click', function(e) {
- if (!e.target.closest('.search-results') && !e.target.closest('.search-input')) {
- document.querySelectorAll('.search-results').forEach(function(r) { r.style.display = 'none'; });
- }
- });
}
function ced_selectSearchResult(el) {
@@ -235,6 +236,11 @@ function ced_syncDOMToData(ed) {
}
ced_setPathInData(ed, dataPath, val);
}
+ // Condition & action DOM→data sync lives in renderCurrent()
+ // (the _ieRenderCurrent path) to avoid stale-DOM overwrites when
+ // mutation handlers call _ieRenderOnly after modifying in-memory data.
+ // Do NOT call _ieSyncAll here — it would collect stale DOM and undo
+ // the mutation before the next render.
}
function ced_resolveDataPath(obj, path) {
diff --git a/internal/admin/static/intereditor.js b/internal/admin/static/intereditor.js
new file mode 100644
index 0000000..4e48df1
--- /dev/null
+++ b/internal/admin/static/intereditor.js
@@ -0,0 +1,1050 @@
+// intereditor.js — Structured condition & action editors for on_use/on_look/on_kill.
+// Replaces free-form JSON textareas with button-driven pickers.
+// Shared by objecteditor.js and mobeditor.js.
+
+// ── Condition leaf types ──
+
+var IE_COND_TYPES = [
+ {type: 'global_flag', label: 'Global Flag', hint: 'world flag name'},
+ {type: 'player_flag', label: 'Player Flag', hint: 'player flag name'},
+ {type: 'has_item', label: 'Has Item', hint: 'item ID'},
+ {type: 'min_credits', label: 'Min Credits', hint: 'amount'},
+];
+
+var IE_ACT_TYPES = [
+ {key: 'set_global_flags', label: 'Set Global Flags', kind: 'kv'},
+ {key: 'set_player_flags', label: 'Set Player Flags', kind: 'kv'},
+ {key: 'give_item', label: 'Give Item', kind: 'search', entity: 'items'},
+ {key: 'take_item', label: 'Take Item', kind: 'search', entity: 'items'},
+ {key: 'teleport', label: 'Teleport', kind: 'number', hint: 'room ID'},
+ {key: 'heal', label: 'Heal', kind: 'number', hint: 'HP to restore'},
+ {key: 'credits', label: 'Credits', kind: 'number', hint: 'amount (negative = cost)'},
+ {key: 'aps_node', label: 'APS Node', kind: 'checkbox'},
+];
+
+// Sequence-only effects (only shown for scope === 'seq').
+var IE_ACT_TYPES_SEQ = [
+ {key: 'broadcast', label: 'Broadcast', kind: 'text', hint: '%p = player'},
+ {key: 'broadcast_global', label: 'Broadcast Global', kind: 'text', hint: '%p = player'},
+ {key: 'spawn_mob', label: 'Spawn Mob', kind: 'search', entity: 'mobs'},
+ {key: 'despawn_mob', label: 'Despawn Mob', kind: 'search', entity: 'mobs'},
+];
+
+// Sentinel child-index for a root-level single leaf (no parent group).
+var IE_ROOT_LEAF = -1;
+
+// ── Navigation in condition tree ──
+
+function ie_condAt(cond, path) {
+ if (!path) return cond;
+ var parts = path.split('-').map(Number);
+ var cur = cond;
+ for (var i = 0; i < parts.length; i++) {
+ if (!cur || (!cur.all_of && !cur.any_of)) return null;
+ var children = cur.all_of || cur.any_of || [];
+ if (parts[i] >= children.length) return null;
+ cur = children[parts[i]];
+ }
+ return cur;
+}
+
+// Replaces the condition at path within the overall tree. Returns new root.
+function ie_condReplace(cond, path, newVal) {
+ if (!path) return newVal;
+ if (!cond) return cond;
+ var parts = path.split('-').map(Number);
+
+ function replace(cur, depth) {
+ if (depth === parts.length) return newVal;
+ if (!cur || (!cur.all_of && !cur.any_of)) return cur;
+ var mode = cur.all_of ? 'all_of' : 'any_of';
+ var children = (cur.all_of || cur.any_of).slice();
+ children[parts[depth]] = replace(children[parts[depth]], depth + 1);
+ children = children.filter(function(c) { return c !== null; });
+ if (children.length === 0) return null;
+ var r = {};
+ if (cur.not) r.not = true;
+ r[mode] = children;
+ return r;
+ }
+
+ return replace(cond, 0);
+}
+
+// ── Clean condition tree for save ──
+//
+// The live editor tree intentionally holds empty leaves/groups while the user
+// is building up a condition (so clicking a second leaf button adds a sibling
+// rather than replacing the first). This walker strips those in-progress
+// artifacts before save: drops leaves whose value is missing/empty, drops
+// 0-child groups, and collapses 1-child non-NOT groups to their child. min_credits
+// keeps the value 0 (falsy in JS but a valid threshold).
+function ie_cleanCondition(cond) {
+ if (!cond || typeof cond !== 'object') return null;
+ if (cond.all_of || cond.any_of) {
+ var mode = cond.all_of ? 'all_of' : 'any_of';
+ var cleaned = cond[mode].map(ie_cleanCondition).filter(function(c) { return c !== null; });
+ if (cleaned.length === 0) return null;
+ if (cleaned.length === 1 && !cond.not) return cleaned[0];
+ var r = {};
+ if (cond.not) r.not = true;
+ r[mode] = cleaned;
+ return r;
+ }
+ // Leaf
+ var not = !!cond.not;
+ var leaf = null;
+ if (cond.global_flag !== undefined) {
+ if (cond.global_flag) {
+ leaf = { global_flag: cond.global_flag };
+ if (cond.value !== undefined) leaf.value = cond.value;
+ }
+ } else if (cond.player_flag !== undefined) {
+ if (cond.player_flag) {
+ leaf = { player_flag: cond.player_flag };
+ if (cond.value !== undefined) leaf.value = cond.value;
+ }
+ } else if (cond.has_item !== undefined) {
+ if (cond.has_item) leaf = { has_item: cond.has_item };
+ } else if (cond.min_credits !== undefined) {
+ var mc = parseInt(cond.min_credits, 10);
+ if (!isNaN(mc)) leaf = { min_credits: mc };
+ }
+ if (!leaf) return null;
+ if (not) leaf.not = true;
+ return leaf;
+}
+
+// ── Collect condition from DOM ──
+
+function ie_collectCond(rootEl) {
+ if (!rootEl) return null;
+
+ // A .ie-cond-root wraps either a single group (.ie-cond) or a single bare
+ // leaf (.ie-cond-leaf). Unwrap it so sync is the pure inverse of render:
+ // cond-root > .ie-cond(group) -> ie_collectCond(.ie-cond)
+ // cond-root > .ie-cond-leaf -> ie_collectLeaf(leafEl)
+ // The old version recursed into the .ie-cond child and then wrapped the
+ // result, producing {all_of:[{all_of:[...]}]} for any group condition.
+ if (rootEl.classList.contains('ie-cond-root')) {
+ var groupEl = rootEl.querySelector(':scope > .ie-cond');
+ if (groupEl) return ie_collectCond(groupEl);
+ var leafEl = rootEl.querySelector(':scope > .ie-cond-leaf');
+ if (leafEl) return ie_collectLeaf(leafEl);
+ return null;
+ }
+
+ // rootEl is a .ie-cond group box.
+ var mode = 'all_of';
+ var modeSel = rootEl.querySelector(':scope > .ie-cond-modebar .ie-cond-modesel');
+ if (modeSel) mode = modeSel.value;
+ var notCb = rootEl.querySelector(':scope > .ie-cond-modebar .ie-cond-not');
+ var not = notCb ? notCb.checked : false;
+
+ var container = rootEl.querySelector(':scope > .ie-cond-children');
+ var items = [];
+ if (container) {
+ Array.prototype.forEach.call(container.children, function(child) {
+ if (child.classList.contains('ie-cond-leaf')) {
+ var leaf = ie_collectLeaf(child);
+ if (leaf) items.push(leaf);
+ } else if (child.classList.contains('ie-cond')) {
+ var sub = ie_collectCond(child);
+ if (sub) items.push(sub);
+ }
+ });
+ }
+
+ // Preserve empty groups (no modebar rendered for <2 children, so empty groups
+ // always serialize as {all_of:[]}). Without this, "+ Group" would vanish on
+ // the next sync before the user could add their first leaf, and the
+ // subsequent "+ leaf" would turn it into a bare leaf — silently dropping
+ // the user's group intent. Empty {all_of:[]} is harmless game-side
+ // (checkCondition returns true, equivalent to no condition) and
+ // ced_cleanOutput strips it from saved YAML.
+ var result = {};
+ if (not) result.not = true;
+ result[mode] = items;
+ return result;
+}
+
+function ie_collectLeaf(el) {
+ var leafType = el.getAttribute('data-ie-leaftype');
+ if (!leafType) return null;
+ var result = {};
+ var notCb = el.querySelector('.ie-cond-not');
+ if (notCb && notCb.checked) result.not = true;
+
+ var valInput = el.querySelector('.ie-cond-val');
+ var val = valInput ? valInput.value.trim() : '';
+
+ // Always return the leaf (even when val is empty) so the in-memory
+ // condition tree keeps "in-progress" leaves the user just added. Without
+ // this, clicking a second leaf button would sync, see null, and wipe the
+ // first leaf — breaking multi-add and nested-group building. ie_cleanCondition
+ // strips empty leaves at save time.
+ switch (leafType) {
+ case 'global_flag':
+ result.global_flag = val;
+ var gfSubInput = el.querySelector('.ie-cond-subval');
+ if (gfSubInput && gfSubInput.value.trim()) {
+ var gfsv = gfSubInput.value.trim();
+ try { result.value = JSON.parse(gfsv); } catch(e) { result.value = gfsv; }
+ }
+ break;
+ case 'player_flag':
+ result.player_flag = val;
+ var subInput = el.querySelector('.ie-cond-subval');
+ if (subInput && subInput.value.trim()) {
+ var sv = subInput.value.trim();
+ try { result.value = JSON.parse(sv); } catch(e) { result.value = sv; }
+ }
+ break;
+ case 'has_item':
+ result.has_item = val;
+ break;
+ case 'min_credits':
+ result.min_credits = parseInt(val) || 0;
+ break;
+ default:
+ return null;
+ }
+ return result;
+}
+
+// ── Collect action from DOM ──
+
+function ie_collectAct(rootEl) {
+ if (!rootEl) return null;
+ var r = {};
+
+ // Message (rolled into action)
+ var msgEl = rootEl.querySelector('.ie-act-msg');
+ if (msgEl) {
+ var msg = msgEl.value.trim();
+ r.message = msg;
+ }
+
+ // KV-type effects — include the map even when empty (as long as the
+ // .ie-act-kv element exists in the DOM). This lets a just-added section
+ // (e.g. "+ Set Player Flags" → no rows yet) survive the next sync instead
+ // of being dropped on the next add-effect call. ced_cleanOutput strips
+ // empty maps at save time.
+ rootEl.querySelectorAll('.ie-act-kv').forEach(function(kvEl) {
+ var key = kvEl.getAttribute('data-ie-act-key');
+ if (!key) return;
+ var map = {};
+ kvEl.querySelectorAll('.ie-kv-row').forEach(function(row) {
+ var kEl = row.querySelector('.ie-kv-key');
+ var vEl = row.querySelector('.ie-kv-val');
+ if (kEl && kEl.value.trim() && vEl) {
+ var v = vEl.value.trim();
+ var num = parseFloat(v);
+ map[kEl.value.trim()] = v === 'true' ? true : (v === 'false' ? false : (!isNaN(num) ? num : v));
+ }
+ });
+ r[key] = map;
+ });
+
+ // Scalar effects — preserve empty strings so a present-but-unfilled input
+ // round-trips on every sync.
+ rootEl.querySelectorAll('.ie-act-input').forEach(function(el) {
+ var key = el.getAttribute('data-ie-act-key');
+ if (!key) return;
+ var kind = el.getAttribute('data-ie-act-kind');
+ if (kind === 'checkbox') {
+ r[key] = el.checked;
+ } else if (kind === 'number') {
+ var n = parseFloat(el.value);
+ if (isNaN(n)) return;
+ r[key] = n;
+ } else {
+ r[key] = el.value.trim();
+ }
+ });
+
+ if (Object.keys(r).length === 0) return null;
+ return r;
+}
+
+// ── Sync all interaction DOM back to data ──
+
+function ie_syncAllInteractions(data, secs) {
+ if (!data || !secs) return;
+ secs.forEach(function(sec) {
+ if (!sec.interactionStyle || !sec.isArray) return;
+ var p = sec.path;
+ if (!data[p]) return;
+ var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ rows.forEach(function(row, idx) {
+ if (!data[p][idx]) data[p][idx] = {};
+ var condRoot = row.querySelector('.ie-cond-root');
+ if (condRoot) {
+ var cond = ie_collectCond(condRoot);
+ if (cond) data[p][idx].condition = cond;
+ else delete data[p][idx].condition;
+ }
+ var actRoot = row.querySelector('.ie-act-root');
+ if (actRoot) {
+ var act = ie_collectAct(actRoot);
+ if (act) data[p][idx].action = act;
+ else delete data[p][idx].action;
+ // Clear any legacy top-level message on sync.
+ delete data[p][idx].message;
+ }
+ });
+ });
+}
+
+// ── Render condition ──
+
+function ie_renderCond(condObj, secId, idx, secPath) {
+ var h = '';
+ var isGroup = condObj && (condObj.all_of || condObj.any_of);
+ if (condObj && typeof condObj === 'object') {
+ h += '
';
+ h += ie_renderGroup(condObj, secId, secPath, idx, '');
+ h += '
';
+ }
+ // For a group root, the group's own addbar (rendered inside _ie_renderGroupNode)
+ // already covers appending — don't emit a duplicate top-level addbar.
+ var showAddbar = !isGroup;
+ if (showAddbar) {
+ h += '
';
+ IE_COND_TYPES.forEach(function(ct) {
+ h += '';
+ });
+ h += '';
+ h += '
';
+ }
+ return h;
+}
+
+function ie_renderGroup(cond, secId, secPath, idx, gpath) {
+ var mode = null, children = null;
+ if (cond.all_of) { mode = 'all_of'; children = cond.all_of; }
+ else if (cond.any_of) { mode = 'any_of'; children = cond.any_of; }
+
+ var not = !!cond.not;
+
+ if (mode && children) {
+ return _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath);
+ }
+
+ // Leaf
+ if (cond.global_flag !== undefined) return _ie_renderLeaf('global_flag', cond.global_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF);
+ if (cond.player_flag !== undefined) return _ie_renderLeaf('player_flag', cond.player_flag, cond.value, not, secId, secPath, idx, gpath, IE_ROOT_LEAF);
+ if (cond.has_item !== undefined) return _ie_renderLeaf('has_item', cond.has_item, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF);
+ if (cond.min_credits !== undefined) return _ie_renderLeaf('min_credits', cond.min_credits, '', not, secId, secPath, idx, gpath, IE_ROOT_LEAF);
+
+ return '';
+}
+
+function _ie_renderGroupNode(mode, children, not, secId, secPath, idx, gpath) {
+ var h = '';
+
+ if (children.length === 0) {
+ // Empty group: single line — addbar buttons left, X right.
+ h += '
';
+ h += '
';
+ IE_COND_TYPES.forEach(function(ct) {
+ h += '';
+ });
+ h += '';
+ h += '';
+ h += '
';
+ h += '
';
+ return h;
+ }
+
+ // Non-empty group: modebar (no X) + children + addbar below.
+ h += '
';
+ h += '
';
+ if (children.length >= 2) {
+ h += '';
+ }
+ if (children.length >= 2 || not) {
+ h += '';
+ }
+ h += '
';
+ }
+
+ // Standard effects
+ IE_ACT_TYPES.forEach(function(at) {
+ if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return;
+ var val = actionObj[at.key];
+ if (at.kind === 'checkbox' && val !== true) return;
+ hasAny = true;
+
+ if (at.kind === 'kv') {
+ var kvMap = (val && typeof val === 'object') ? val : {};
+ var kvKeys = Object.keys(kvMap);
+ var kvRmAttr = 'ie_removeKVRow(this,\'' + escAttr(secId) + '\',\'' + escAttr(secPath) + '\',' + idx + ',\'' + escAttr(at.key) + '\')';
+ rows += '
';
+ rows += '
' + esc(at.label) + '
';
+ rows += '
';
+ if (kvKeys.length === 0) {
+ // Start with a blank row so there are input boxes immediately. The
+ // row's X acts as the section's only remove path (no header X).
+ rows += '
';
+
+ // Only wrap the content + addbar in .ie-act-root when there's actual content.
+ // An empty action just emits the addbar (mirroring ie_renderCond, which
+ // emits an unwrapped addbar for an empty condition). This keeps the empty
+ // condition and empty action panels visually symmetric — one box, not two.
+ if (hasAny) {
+ return '
' + rows + addbar + '
';
+ }
+ return addbar;
+}
+
+// ── Add/Remove/Toggle: Condition ──
+
+function ie_addLeaf(secId, secPath, idx, groupPath, leafType) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+ var group = ie_condAt(cond, groupPath);
+
+ var leaf = {};
+ leaf[leafType] = leafType === 'min_credits' ? 0 : '';
+
+ var newGroup;
+ if (!group) {
+ newGroup = leaf;
+ } else if (group.all_of || group.any_of) {
+ var mode = group.all_of ? 'all_of' : 'any_of';
+ var children = (group.all_of || group.any_of).concat([leaf]);
+ newGroup = {};
+ if (group.not) newGroup.not = true;
+ newGroup[mode] = children;
+ } else {
+ newGroup = {all_of: [group, leaf]};
+ }
+
+ var newCond = ie_condReplace(cond, groupPath, newGroup);
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (newCond) ed[secPath][idx].condition = newCond;
+ else delete ed[secPath][idx].condition;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_removeCond(secId, secPath, idx, groupPath, childIdx) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+
+ // Removing the single root leaf. Note: children of a *root-level group*
+ // are also rendered with groupPath='' (and a real child index), so the
+ // test must be childIdx, not groupPath, otherwise removing any leaf from
+ // a root group wipes the entire condition.
+ if (childIdx === IE_ROOT_LEAF) {
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ delete ed[secPath][idx].condition;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+ return;
+ }
+
+ var group = ie_condAt(cond, groupPath);
+ if (!group) return;
+
+ if (group.all_of || group.any_of) {
+ var mode = group.all_of ? 'all_of' : 'any_of';
+ var children = (group.all_of || group.any_of).slice();
+ children.splice(childIdx, 1);
+ var newGroup;
+ if (children.length === 0) {
+ newGroup = null;
+ } else if (children.length === 1 && !group.not) {
+ // Collapse a single-child non-NOT group to the child itself so removal
+ // doesn't leave a one-child all_of/any_of wrapper in the live tree.
+ newGroup = children[0];
+ } else {
+ newGroup = {};
+ if (group.not) newGroup.not = true;
+ newGroup[mode] = children;
+ }
+ var newCond = ie_condReplace(cond, groupPath, newGroup);
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (newCond) ed[secPath][idx].condition = newCond;
+ else delete ed[secPath][idx].condition;
+ } else {
+ var nc = ie_condReplace(cond, groupPath, null);
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (nc) ed[secPath][idx].condition = nc;
+ else delete ed[secPath][idx].condition;
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_removeGroup(secId, secPath, idx, groupPath) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ if (!ed[secPath] || !ed[secPath][idx]) return;
+ var cond = ed[secPath][idx].condition;
+
+ // Root group: removing the entire condition.
+ if (!groupPath) {
+ delete ed[secPath][idx].condition;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+ return;
+ }
+
+ // Non-root group: gpath is the group's own path. Parent path = gpath minus
+ // last segment; childIdx = last segment.
+ var parts = groupPath.split('-').map(Number);
+ var childIdx = parts[parts.length - 1];
+ var parentPath = parts.slice(0, -1).join('-');
+ var parent = ie_condAt(cond, parentPath);
+ if (!parent || !(parent.all_of || parent.any_of)) return;
+
+ var mode = parent.all_of ? 'all_of' : 'any_of';
+ var children = parent[mode].slice();
+ children.splice(childIdx, 1);
+
+ var newParent;
+ if (children.length === 0) {
+ newParent = null;
+ } else if (children.length === 1 && !parent.not) {
+ newParent = children[0];
+ } else {
+ newParent = {};
+ if (parent.not) newParent.not = true;
+ newParent[mode] = children;
+ }
+
+ var newCond = ie_condReplace(cond, parentPath, newParent);
+ if (newCond) ed[secPath][idx].condition = newCond;
+ else delete ed[secPath][idx].condition;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_addRootConditionGroup(secPath, idx) {
+ var ed = window._editorData;
+ if (!ed) return;
+ if (!ed[secPath]) ed[secPath] = [];
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (!ed[secPath][idx].condition || typeof ed[secPath][idx].condition !== 'object') {
+ ed[secPath][idx].condition = {all_of: []};
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_addEffectToInteraction(secPath, idx) {
+ var ed = window._editorData;
+ if (!ed) return;
+ if (!ed[secPath]) ed[secPath] = [];
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (!ed[secPath][idx].action || typeof ed[secPath][idx].action !== 'object') {
+ ed[secPath][idx].action = {};
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_addGroup(secId, secPath, idx, groupPath) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+ var group = ie_condAt(cond, groupPath);
+
+ var newSub = {all_of: []};
+
+ var newGroup;
+ if (!group) {
+ newGroup = newSub;
+ } else if (group.all_of || group.any_of) {
+ var mode = group.all_of ? 'all_of' : 'any_of';
+ var children = (group.all_of || group.any_of).concat([newSub]);
+ newGroup = {};
+ if (group.not) newGroup.not = true;
+ newGroup[mode] = children;
+ } else {
+ newGroup = {all_of: [group, newSub]};
+ }
+
+ var newCond = ie_condReplace(cond, groupPath, newGroup);
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ ed[secPath][idx].condition = newCond;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_toggleCondMode(secId, secPath, idx, groupPath) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+ var group = ie_condAt(cond, groupPath);
+ if (!group || !group.all_of && !group.any_of) return;
+
+ var oldMode = group.all_of ? 'all_of' : 'any_of';
+ var children = group.all_of || group.any_of;
+ var newGroup = {};
+ if (group.not) newGroup.not = true;
+ newGroup[oldMode === 'all_of' ? 'any_of' : 'all_of'] = children;
+
+ var newCond = ie_condReplace(cond, groupPath, newGroup);
+ ed[secPath][idx].condition = newCond;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_toggleCondNot(secId, secPath, idx, groupPath) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+ var group = ie_condAt(cond, groupPath);
+ if (!group) return;
+
+ if (group.all_of || group.any_of) {
+ group.not = !group.not;
+ if (!group.not) delete group.not;
+ var nc = ie_condReplace(cond, groupPath, group);
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (nc) ed[secPath][idx].condition = nc;
+ else delete ed[secPath][idx].condition;
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_toggleNotLeaf(secId, secPath, idx, groupPath, ci) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncCondEntry(ed, secId, secPath, idx);
+ var cond = (ed[secPath] && ed[secPath][idx]) ? ed[secPath][idx].condition : null;
+ var parent = ie_condAt(cond, groupPath);
+ if (!parent) return;
+
+ var leaf;
+ if (ci === IE_ROOT_LEAF) {
+ leaf = parent;
+ } else {
+ if (!parent.all_of && !parent.any_of) {
+ leaf = parent;
+ } else {
+ var children = parent.all_of || parent.any_of;
+ if (typeof ci !== 'number' || ci < 0 || ci >= children.length) return;
+ leaf = children[ci];
+ }
+ }
+
+ leaf.not = !leaf.not;
+ if (!leaf.not) delete leaf.not;
+ ed[secPath][idx].condition = cond;
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+// ── Add/Remove: Action ──
+
+function ie_addActEffect(secId, secPath, idx, effectKey, scope) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncActEntry(ed, secId, secPath, idx);
+ if (!ed[secPath]) ed[secPath] = [];
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (!ed[secPath][idx].action) ed[secPath][idx].action = {};
+
+ var act = ed[secPath][idx].action;
+ if (effectKey === 'message') {
+ act.message = '';
+ } else {
+ var at = IE_ACT_TYPES.find(function(a) { return a.key === effectKey; });
+ var at2 = IE_ACT_TYPES_SEQ.find(function(a) { return a.key === effectKey; });
+ var info = at || at2;
+ if (!info) return;
+ if (info.kind === 'kv') {
+ act[effectKey] = {};
+ } else if (info.kind === 'checkbox') {
+ act[effectKey] = true;
+ } else if (info.kind === 'number') {
+ act[effectKey] = 0;
+ } else {
+ act[effectKey] = '';
+ }
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+function ie_removeActEffect(secId, secPath, idx, effectKey) {
+ var ed = window._editorData;
+ if (!ed) return;
+ ie_syncActEntry(ed, secId, secPath, idx);
+ if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return;
+ delete ed[secPath][idx].action[effectKey];
+ if (Object.keys(ed[secPath][idx].action).length === 0) {
+ delete ed[secPath][idx].action;
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+// ── Add/Remove: KV action row ──
+//
+// KV rows are added/removed through these handlers rather than inline DOM
+// manipulation so removal can drop the whole effect when the last row goes.
+// "+ Add" appends a blank row to the DOM (no data change yet — it joins the
+// map on the next sync, the same way typing into an existing row does).
+// Removal deletes the row's key from the data map; if the map empties out the
+// whole action key is dropped so the section "disappears when all flags are
+// removed" (per the spec) and re-renders with a single starter row only when
+// the user clicks the addbar "+ Set ... Flags" again.
+function ie_addKVRow(secId, secPath, idx, effectKey) {
+ var ed = window._editorData;
+ if (!ed) return;
+ // Sync first so any in-progress typing in the existing rows is captured.
+ ie_syncActEntry(ed, secId, secPath, idx);
+ // Append a blank row directly to the DOM (no data change yet).
+ var card = document.querySelector('.obj-card[data-card="' + secId + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ var row = rows[idx];
+ if (!row) return;
+ var list = row.querySelector('.ie-act-kv[data-ie-act-key="' + effectKey + '"] .ie-kv-list');
+ if (!list) return;
+ var rowEl = document.createElement('div');
+ rowEl.className = 'ie-kv-row';
+ rowEl.innerHTML = '';
+ list.appendChild(rowEl);
+ rowEl.querySelector('.ie-kv-key').focus();
+}
+
+function ie_removeKVRow(rowEl, secId, secPath, idx, effectKey) {
+ var ed = window._editorData;
+ if (!ed) return;
+ // rowEl may be the X button itself (callers use `this` in inline onclick) —
+ // walk up to the actual .ie-kv-row so the key input below is found.
+ if (rowEl && rowEl.closest) {
+ var kvRow = rowEl.closest('.ie-kv-row');
+ if (kvRow) rowEl = kvRow;
+ }
+ ie_syncActEntry(ed, secId, secPath, idx);
+ if (!ed[secPath] || !ed[secPath][idx] || !ed[secPath][idx].action) return;
+ var act = ed[secPath][idx].action;
+ if (!act.hasOwnProperty(effectKey)) return;
+ var keyEl = rowEl.querySelector('.ie-kv-key');
+ var k = keyEl ? keyEl.value.trim() : '';
+ if (k && act[effectKey].hasOwnProperty(k)) delete act[effectKey][k];
+ // Empty-key rows are not in the data map at all; nothing to delete there.
+ if (Object.keys(act[effectKey]).length === 0) {
+ delete act[effectKey];
+ if (Object.keys(act).length === 0) delete ed[secPath][idx].action;
+ }
+ ced_syncDOMToData(ed);
+ window._ieRenderOnly();
+}
+
+// ── Pre-render sync helpers ──
+
+function ie_syncCondEntry(ed, secId, secPath, idx) {
+ var card = document.querySelector('.obj-card[data-card="' + secId + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ var row = rows[idx];
+ if (!row) return;
+ var condRoot = row.querySelector('.ie-cond-root');
+ if (!condRoot) return;
+ var cond = ie_collectCond(condRoot);
+ if (!ed[secPath]) ed[secPath] = [];
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (cond) ed[secPath][idx].condition = cond;
+ else delete ed[secPath][idx].condition;
+}
+
+function ie_syncActEntry(ed, secId, secPath, idx) {
+ var card = document.querySelector('.obj-card[data-card="' + secId + '"]');
+ if (!card) return;
+ var rows = card.querySelectorAll('.obj-sub-row');
+ var row = rows[idx];
+ if (!row) return;
+ var actRoot = row.querySelector('.ie-act-root');
+ if (!actRoot) return;
+ var act = ie_collectAct(actRoot);
+ if (!ed[secPath]) ed[secPath] = [];
+ if (!ed[secPath][idx]) ed[secPath][idx] = {};
+ if (act) {
+ // Preserve any action keys that aren't rendered in the DOM (e.g. legacy
+ // seq-only effects on an inline section, or unknown future keys). Without
+ // this, the first sync after any edit would silently drop them.
+ var existing = (ed[secPath][idx].action && typeof ed[secPath][idx].action === 'object') ? ed[secPath][idx].action : null;
+ if (existing) {
+ Object.keys(existing).forEach(function(k) {
+ if (!Object.prototype.hasOwnProperty.call(act, k)) act[k] = existing[k];
+ });
+ }
+ ed[secPath][idx].action = act;
+ } else {
+ delete ed[secPath][idx].action;
+ }
+ // Clear any legacy top-level message on sync.
+ delete ed[secPath][idx].message;
+}
+
+// ── Upgrade: migrate legacy message/condition/action on load ──
+
+function ie_upgradeItem(item) {
+ if (!item) return;
+ if (item.message && typeof item.message === 'string' && item.message.trim()) {
+ if (!item.action || typeof item.action !== 'object') item.action = {};
+ if (!item.action.message) item.action.message = item.message;
+ delete item.message;
+ }
+ if (item.condition && typeof item.condition === 'string') {
+ try { item.condition = JSON.parse(item.condition); } catch(e) { delete item.condition; }
+ }
+ if (item.action && typeof item.action === 'string') {
+ try { item.action = JSON.parse(item.action); } catch(e) { delete item.action; }
+ }
+}
+
+// ── Shared interaction-style array-section renderer ──
+//
+// Replaces the duplicated `renderArraySection` (objecteditor.js) and
+// `renderMobArraySection` (mobeditor.js).
+//
+// ctx: {
+// sec: section def (.id, .path, .isArray, .interactionStyle, .interScope, .fields, .label),
+// data: editor's data root,
+// visMap: per-editor vis object (e.g. window._interVis[sec.id]),
+// showRemove(idx) => onclick string for "Remove Interaction",
+// showField(secId, idx, key) => onclick string for "+ Add Required Item/...",
+// hideField(secId, idx, key) => onclick string for the panel "X",
+// renderField(sec, f, data, idx, optStyle) => HTML for the item_id field,
+// onAdd() => onclick string for the bottom "+ Add ...",
+// }
+function ie_renderArraySection(ctx) {
+ var sec = ctx.sec;
+ var data = ctx.data;
+ var visMap = ctx.visMap;
+ var arr = data[sec.path] || [];
+ var h = '';
+
+ arr.forEach(function(item, idx) {
+ ie_upgradeItem(item);
+
+ visMap[idx] = visMap[idx] || {};
+ var vis = visMap[idx];
+ var showItem = vis.item_id || (item.item_id && item.item_id !== '');
+ var condExists = !!(item.condition && typeof item.condition === 'object');
+ var actExists = !!(item.action && typeof item.action === 'object');
+
+ h += '
';
+ h += '
';
+ if (!showItem) {
+ h += '';
+ } else {
+ h += ctx.renderField(sec, sec.fields[0], data, idx, 'flex:1;min-width:0');
+ h += '';
+ }
+ if (!condExists) {
+ h += '';
+ }
+ if (!actExists) {
+ h += '';
+ }
+ h += '';
+ h += '';
+ h += '
';
+ });
+
+ h += '';
+ return h;
+}
+
+// ── Show/Hide field on a row ──
+
+function ie_showField(visMap, secId, idx, key) {
+ if (!visMap[secId]) visMap[secId] = {};
+ if (!visMap[secId][idx]) visMap[secId][idx] = {};
+ visMap[secId][idx][key] = true;
+}
+
+function ie_hideField(visMap, secId, idx, key) {
+ if (!visMap[secId]) visMap[secId] = {};
+ if (!visMap[secId][idx]) visMap[secId][idx] = {};
+ visMap[secId][idx][key] = false;
+}
+
+// ── Bindings (set by each editor) ──
+
+// _ieRenderCurrent: full sync + render. Used by chrome toggles
+// (show/hide panels, add row, remove row, add section, etc.).
+// _ieRenderOnly: render only, no sync. Used by ie_* mutation handlers
+// that have already synced + mutated the data and must not re-read the
+// stale DOM before the next render.
+window._ieRenderCurrent = function() {};
+window._ieRenderOnly = function() {};
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
index 7f8c0e3..c2364c8 100644
--- a/internal/admin/static/mobeditor.js
+++ b/internal/admin/static/mobeditor.js
@@ -121,13 +121,12 @@ var MOB_SECTIONS = [
]
},
{
- id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true,
+ id: 'on_kill', label: 'On Kill', labelHint: '(fired when this mob is defeated or its task completes — additive over drops)', path: 'on_kill', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline',
detect: function(d) { return d.on_kill && Array.isArray(d.on_kill); },
fields: [
['item_id', 'Item ID', 'search', '(optional — only fires if you wield this weapon when the mob is defeated)', '', null, 'items'],
- ['condition', 'Condition', 'json', 'e.g. {"global_flag":"quest_active"}'],
- ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"boss_killed":true},"broadcast":"%p has slain the boss!","spawn_mob":"mini_boss"}'],
- ['message', 'Message', 'text', 'Shown to the killer when this fires...'],
+ ['condition', 'Condition', 'inter_cond'],
+ ['action', 'Action', 'inter_act'],
]
},
];
@@ -138,6 +137,9 @@ 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() {
+ window._ieRenderCurrent = renderCurrent;
+ window._ieRenderOnly = function() { if (mobID && mobData) renderMobEditor(mobID, mobData); };
+ window._ieSyncAll = function(ed) { if (ed) ie_syncAllInteractions(ed, MOB_SECTIONS); };
editorType = 'mobs';
editorFields = [];
loadList();
@@ -156,6 +158,7 @@ function loadMob(id) {
API.get('/api/mobs/' + encodeURIComponent(id)).then(function(data) {
mobData = data;
window._editorData = mobData;
+ window._mobInterVis = {};
if (mobData.shop && mobData.shop.buys_anything === undefined) {
mobData.shop.buys_anything = true;
}
@@ -220,6 +223,7 @@ function renderMobEditor(id, data) {
function renderCurrent() {
ced_syncDOMToData(mobData);
+ ie_syncAllInteractions(mobData, MOB_SECTIONS);
if (!mobData || !mobID) return;
renderMobEditor(mobID, mobData);
}
@@ -438,78 +442,23 @@ function renderMobField(sec, f, data, idx, subPath, optStyle) {
// ---- Interaction-style array rendering (On Kill) ----
function renderMobArraySection(sec, data) {
- var arr = data[mobCardPath(sec)] || [];
- var h = '';
- var isInter = !!sec.interactionStyle;
- if (isInter) {
+ if (sec.interactionStyle) {
window._mobInterVis = window._mobInterVis || {};
if (!window._mobInterVis[sec.id]) window._mobInterVis[sec.id] = {};
+ return ie_renderArraySection({
+ sec: sec,
+ data: data,
+ visMap: window._mobInterVis[sec.id],
+ showRemove: function(idx) { return 'mobRemoveArrayItem(\'' + sec.id + '\',' + idx + ')'; },
+ showField: function(secId, idx, key) { return 'mobShowInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; },
+ hideField: function(secId, idx, key) { return 'mobHideInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; },
+ addCondition: function(secId, idx) { return 'mobAddInterRootCondition(\'' + secId + '\',' + idx + ')'; },
+ addEffect: function(secId, idx) { return 'mobAddInterAction(\'' + secId + '\',' + idx + ')'; },
+ renderField: function(sec, f, data, idx, optStyle) { return renderMobField(sec, f, data, idx, null, optStyle); },
+ onAdd: function() { return 'mobAddArrayItem(\'' + sec.id + '\')'; },
+ });
}
- arr.forEach(function(item, idx) {
- var visMap = window._mobInterVis[sec.id];
- var vis = visMap[idx] || {};
- visMap[idx] = vis;
- var showItem = vis.item_id || (item.item_id && item.item_id !== '');
- var showCond = vis.condition || (item.condition && item.condition !== '');
- var showAct = vis.action || (item.action && item.action !== '');
- var showMsg = vis.message || (item.message && item.message !== '');
-
- h += '
';
- h += '';
- h += '
';
- if (!showItem) {
- h += '';
- h += '';
- }
- if (!showCond) {
- h += '';
- }
- if (!showAct) {
- h += '';
- }
- if (!showMsg) {
- h += '';
- }
- h += '
';
- h += '
';
- h += renderMobField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
- h += '
';
- h += renderMobField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
- h += '
';
- h += renderMobField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
- h += '
';
- h += renderMobField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
- h += '
';
- });
- h += '';
- if (isInter) {
- var helpKey = 'mob_' + sec.id + '_helpCollapsed';
- var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
- h += '
';
- h += '
';
- h += '' + (helpCollapsed ? '▶' : '▼') + '';
- h += 'How Conditions & Actions Work';
- h += '
';
- if (!helpCollapsed) {
- h += '
';
- h += 'Entries checked top-to-bottom, first match wins (one fires per kill; additive to standard loot).
';
- h += 'Item ID: only fires if you wield this weapon (in main_hand or off_hand) when the mob is defeated. Leave empty to fire unconditionally. ';
- h += 'Condition: JSON gate. {"global_flag":"boss_quest"}, {"player_flag":"step","value":2}, {"has_item":"trophy"}, {"all_of":[...]}, {"any_of":[...]}. ';
- h += 'Action: JSON effects. {"set_global_flags":{...}}, {"set_player_flags":{...}}, {"give_item":"gem"}, {"take_item":"key"}, {"teleport":5}, {"heal":10}, {"credits":100}, {"aps_node":true}, {"spawn_mob":"mini_boss"}, {"despawn_mob":"boss"}, {"broadcast":"%p vanquished the boss!"}, {"broadcast_global":"..."}. ';
- h += 'Message: shown to the killer when this interaction fires.';
- h += '
';
- if (!showItem) {
- h += '';
- h += '';
- }
- if (!showCond) {
- h += '';
- }
- if (!showAct) {
- h += '';
- }
- if (!showMsg) {
- h += '';
- }
- h += '
';
-
- h += '
';
- h += renderField(sec, sec.fields[0], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
-
- h += '
';
- h += renderField(sec, sec.fields[1], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
-
- h += '
';
- h += renderField(sec, sec.fields[2], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
-
- h += '
';
- h += renderField(sec, sec.fields[3], data, idx, null, 'flex:1;min-width:0');
- h += '';
- h += '
';
-
- h += '
';
- } else {
- h += '
';
- h += '
';
- sec.fields.forEach(function(f) {
- h += renderField(sec, f, data, idx);
- });
- h += '
';
- h += '';
- h += '
';
- }
- });
- h += '';
- if (isInter) {
- var helpKey = sec.id + '_helpCollapsed';
- var helpCollapsed = window[helpKey] === undefined ? true : window[helpKey];
- h += '
';
- h += '
';
- h += '' + (helpCollapsed ? '▶' : '▼') + '';
- h += 'How Conditions & Actions Work';
+ h += '
';
+ h += '
';
+ sec.fields.forEach(function(f) {
+ h += renderField(sec, f, data, idx);
+ });
h += '
';
- if (!helpCollapsed) {
- h += '
';
- h += 'Entries checked top-to-bottom, first match wins (one fires per use/look/kill).
';
- h += 'Item ID: on_use requires use <item> on <object>; empty = bare use <object>. on_look = only fires if you carry this item. on_kill = only fires if you wield this weapon when the mob is defeated. ';
- h += 'Condition: JSON. Available gates: ';
- h += ' {"global_flag":"gate_open","not":true} — world flag is NOT set ';
- h += ' {"player_flag":"has_key","value":1} — player flag matches value ';
- h += ' {"has_item":"silver_bar"} — player carries item ';
- h += ' {"min_credits":50} — minimum credits ';
- h += ' {"all_of":[{"global_flag":"a"},{"has_item":"b"}]} — ALL ';
- h += ' {"any_of":[...]} — ANY ';
- h += ' Omit for an unconditional interaction. ';
- h += 'Action: JSON. Effects that fire when the interaction runs: ';
- h += ' {"set_global_flags":{"gate_open":true}} — set world flags ';
- h += ' {"set_player_flags":{"spoke":true}} — set player flags ';
- h += ' {"give_item":"keycard","take_item":"bomb"} — give/take item ';
- h += ' {"teleport":5,"heal":10,"credits":100} — move, heal, give credits ';
- h += ' {"aps_node":true} — mark current room as APS node ';
- h += ' {"spawn_mob":"rat","despawn_mob":"rat"} — spawn/despawn transient mob ';
- h += ' {"broadcast":"%p activates it.","broadcast_global":"%p did it!"} — announce (%p = player, %v = flag value) ';
- h += ' {"message":"You feel energized.","delay":3} — only meaningful inside on_enter steps / triggers (delay in ticks) ';
- h += 'Message: Sent to the player when the interaction fires (one-tick action).';
- h += '
';
- }
+ h += '';
h += '
';
- }
+ });
+ h += '';
return h;
}
@@ -973,6 +900,7 @@ function validateDropRow(idx) {
function renderCurrent() {
ced_syncDOMToData(objectData);
+ ie_syncAllInteractions(objectData, SECTIONS);
syncGatherToolsFromDOM();
if (!objectData || !objectID) return;
renderObjectEditor(objectID, objectData);
@@ -980,20 +908,35 @@ function renderCurrent() {
function showInterField(secId, idx, key) {
window._interVis = window._interVis || {};
- if (!window._interVis[secId]) window._interVis[secId] = {};
- if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
- window._interVis[secId][idx][key] = true;
+ ie_showField(window._interVis, secId, idx, key);
renderCurrent();
}
+function addInterRootCondition(secId, idx) {
+ var sec = SECTIONS.find(function(s) { return s.id === secId; });
+ if (!sec) return;
+ ie_addRootConditionGroup(sec.path, idx);
+}
+
+function addInterAction(secId, idx) {
+ var sec = SECTIONS.find(function(s) { return s.id === secId; });
+ if (!sec) return;
+ ie_addEffectToInteraction(sec.path, idx);
+}
+
function hideInterField(secId, idx, key) {
window._interVis = window._interVis || {};
- if (!window._interVis[secId]) window._interVis[secId] = {};
- if (!window._interVis[secId][idx]) window._interVis[secId][idx] = {};
- window._interVis[secId][idx][key] = false;
+ ie_hideField(window._interVis, secId, idx, key);
+ var sec = SECTIONS.find(function(s) { return s.id === secId; });
+ var secPath = sec ? sec.path : secId;
ced_syncDOMToData(objectData);
- if (objectData[secId] && objectData[secId][idx]) {
- objectData[secId][idx][key] = '';
+ ie_syncAllInteractions(objectData, SECTIONS);
+ if (objectData[secPath] && objectData[secPath][idx]) {
+ if (key === 'condition' || key === 'action') {
+ delete objectData[secPath][idx][key];
+ } else {
+ objectData[secPath][idx][key] = '';
+ }
}
renderObjectEditor(objectID, objectData);
}
@@ -1032,16 +975,6 @@ function hideGuardMob() {
renderObjectEditor(objectID, objectData);
}
-function toggleInterHelp(secId) {
- var key = secId + '_helpCollapsed';
- if (window[key] === undefined) {
- window[key] = false;
- } else {
- window[key] = !window[key];
- }
- renderCurrent();
-}
-
function toggleGatherConditionals() {
var skillEl = document.getElementById('f_gather_skill');
var skill = skillEl ? skillEl.value : '';
@@ -1072,6 +1005,8 @@ function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
function saveObject() {
if (!validateFields()) return;
+ ie_syncAllInteractions(objectData, SECTIONS);
+
var out = {};
SECTIONS.forEach(function(sec) {
@@ -1083,10 +1018,22 @@ function saveObject() {
rows.forEach(function(row, idx) {
var item = {};
sec.fields.forEach(function(f) {
+ if (f[2] === 'inter_cond' || f[2] === 'inter_act') return;
var fid = fieldID(sec, f[0], idx);
var el = document.getElementById(fid);
if (el) item[f[0]] = collectFieldValue(el, f[2]);
});
+ if (sec.interactionStyle) {
+ var dataArr = objectData[sec.path] || [];
+ var dataItem = dataArr[idx] || {};
+ if (dataItem.condition && typeof dataItem.condition === 'object') {
+ var cleanedCond = ie_cleanCondition(dataItem.condition);
+ if (cleanedCond) item.condition = cleanedCond;
+ }
+ if (dataItem.action && typeof dataItem.action === 'object') {
+ item.action = dataItem.action;
+ }
+ }
if (Object.keys(item).length > 0) arr.push(item);
});
if (arr.length > 0) out[sec.path] = arr;
@@ -1182,6 +1129,7 @@ function saveObject() {
}
});
+ ced_cleanOutput(out);
out.id = objectID;
API.put('/api/objects/' + encodeURIComponent(objectID), out).then(function(resp) {
diff --git a/internal/admin/templates/mobs.html b/internal/admin/templates/mobs.html
index ea47ac1..f53b3e3 100644
--- a/internal/admin/templates/mobs.html
+++ b/internal/admin/templates/mobs.html
@@ -11,6 +11,7 @@