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 += '
'; + h += '
'; + children.forEach(function(child, ci) { + var cpath = gpath ? gpath + '-' + ci : String(ci); + if (child.all_of || child.any_of) { + h += ie_renderGroup(child, secId, secPath, idx, cpath); + } else { + var cval = '', csub = '', ctype = '', cnot = !!child.not; + if (child.global_flag !== undefined) { ctype = 'global_flag'; cval = child.global_flag; csub = child.value; } + else if (child.player_flag !== undefined) { ctype = 'player_flag'; cval = child.player_flag; csub = child.value; } + else if (child.has_item !== undefined) { ctype = 'has_item'; cval = child.has_item; } + else if (child.min_credits !== undefined) { ctype = 'min_credits'; cval = child.min_credits; } + if (ctype) h += _ie_renderLeaf(ctype, cval, csub, cnot, secId, secPath, idx, gpath, ci); + } + }); + h += '
'; + h += '
'; + IE_COND_TYPES.forEach(function(ct) { + h += ''; + }); + h += ''; + h += '
'; + h += '
'; + return h; +} + +function _ie_renderLeaf(leafType, leafVal, leafSub, not, secId, secPath, idx, gpath, ci) { + var label = ''; + IE_COND_TYPES.forEach(function(ct) { if (ct.type === leafType) label = ct.label; }); + var hint = ''; + IE_COND_TYPES.forEach(function(ct) { if (ct.type === leafType) hint = ct.hint; }); + var isSearch = leafType === 'has_item'; + var sv = leafVal === undefined || leafVal === null ? '' : String(leafVal); + var ssv = leafSub === undefined || leafSub === null ? '' : String(leafSub); + var ciToken = (ci === undefined || ci === null) ? IE_ROOT_LEAF : ci; + + var h = '
'; + h += '' + esc(label) + ''; + if (isSearch) { + h += '
'; + h += ''; + h += ''; + h += '
'; + } else if (leafType === 'min_credits') { + h += ''; + } else { + h += ''; + } + if (leafType === 'player_flag' || leafType === 'global_flag') { + h += ''; + } + h += ''; + h += ''; + h += '
'; + return h; +} + +// ── Render action ── + +function ie_renderAct(actionObj, scope, secId, idx, secPath) { + var hasAny = false; + var rows = ''; + + // Message + var msg = (actionObj && actionObj.message !== undefined && actionObj.message !== null) ? String(actionObj.message) : ''; + var msgPresent = actionObj && Object.prototype.hasOwnProperty.call(actionObj, 'message'); + if (msgPresent) { + hasAny = true; + rows += '
Message'; + rows += ''; + rows += ''; + rows += '
'; + } + + // 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 += '
'; + rows += ''; + rows += '
'; + } else { + kvKeys.forEach(function(k) { + rows += '
'; + rows += ''; + rows += '
'; + }); + } + rows += '
'; + rows += ''; + rows += '
'; + } else if (at.kind === 'checkbox') { + rows += '
' + esc(at.label) + ''; + rows += ''; + rows += ''; + rows += '
'; + } else if (at.kind === 'search') { + rows += '
' + esc(at.label) + ''; + rows += '
'; + rows += ''; + rows += '
'; + } else { + rows += '
' + esc(at.label) + ''; + rows += ''; + rows += ''; + rows += '
'; + } + }); + + // Seq-only effects + if (scope === 'seq') { + IE_ACT_TYPES_SEQ.forEach(function(at) { + if (!actionObj || !Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; + var val = actionObj[at.key]; + if (val === undefined || val === null) return; + hasAny = true; + + if (at.kind === 'search') { + rows += '
' + esc(at.label) + ''; + rows += '
'; + rows += ''; + rows += '
'; + } else { + rows += '
' + esc(at.label) + ''; + rows += ''; + rows += ''; + rows += '
'; + } + }); + } + + // Add effect buttons + var addbar = '
'; + if (!msgPresent) { + addbar += ''; + } + IE_ACT_TYPES.forEach(function(at) { + if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) { + if (at.kind === 'checkbox') { + if (actionObj[at.key] === true) return; + } else { + return; + } + } + addbar += ''; + }); + if (scope === 'seq') { + IE_ACT_TYPES_SEQ.forEach(function(at) { + if (actionObj && Object.prototype.hasOwnProperty.call(actionObj, at.key)) return; + addbar += ''; + }); + } + addbar += '
'; + + // 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 += '
'; + h += '
' + ie_renderCond(item.condition || null, sec.id, idx, sec.path) + '
'; + h += '
'; + + h += '
'; + h += '
' + ie_renderAct(item.action || null, sec.interScope || 'inline', sec.id, idx, sec.path) + '
'; + 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 += '
'; - } - h += '
'; - } - return h; + return ''; } function mobAddArrayItem(cardID) { @@ -536,34 +485,39 @@ function mobRemoveArrayItem(cardID, idx) { function mobShowInterField(secId, idx, key) { window._mobInterVis = window._mobInterVis || {}; - if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {}; - if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {}; - window._mobInterVis[secId][idx][key] = true; + ie_showField(window._mobInterVis, secId, idx, key); renderCurrent(); } +function mobAddInterRootCondition(secId, idx) { + var sec = MOB_SECTIONS.find(function(s) { return s.id === secId; }); + if (!sec) return; + ie_addRootConditionGroup(sec.path, idx); +} + +function mobAddInterAction(secId, idx) { + var sec = MOB_SECTIONS.find(function(s) { return s.id === secId; }); + if (!sec) return; + ie_addEffectToInteraction(sec.path, idx); +} + function mobHideInterField(secId, idx, key) { window._mobInterVis = window._mobInterVis || {}; - if (!window._mobInterVis[secId]) window._mobInterVis[secId] = {}; - if (!window._mobInterVis[secId][idx]) window._mobInterVis[secId][idx] = {}; - window._mobInterVis[secId][idx][key] = false; + ie_hideField(window._mobInterVis, secId, idx, key); + var sec = MOB_SECTIONS.find(function(s) { return s.id === secId; }); + var secPath = sec ? sec.path : secId; ced_syncDOMToData(mobData); - if (mobData[secId] && mobData[secId][idx]) { - mobData[secId][idx][key] = ''; + ie_syncAllInteractions(mobData, MOB_SECTIONS); + if (mobData[secPath] && mobData[secPath][idx]) { + if (key === 'condition' || key === 'action') { + delete mobData[secPath][idx][key]; + } else { + mobData[secPath][idx][key] = ''; + } } renderMobEditor(mobID, mobData); } -function mobToggleInterHelp(secId) { - var key = 'mob_' + secId + '_helpCollapsed'; - if (window[key] === undefined) { - window[key] = false; - } else { - window[key] = !window[key]; - } - renderCurrent(); -} - // ---- Subtable rendering ---- function renderMobSubField(sec, stKey, idx, f, val) { @@ -829,6 +783,8 @@ function unflattenObj(obj) { function saveMob() { if (!ced_validateFields(MOB_SECTIONS, mobFieldID, mobFieldIDSub)) return; + ie_syncAllInteractions(mobData, MOB_SECTIONS); + var out = {}; MOB_SECTIONS.forEach(function(sec) { @@ -845,10 +801,22 @@ function saveMob() { rows.forEach(function(row, idx) { var item = {}; sec.fields.forEach(function(f) { + if (f[2] === 'inter_cond' || f[2] === 'inter_act') return; var fid = mobFieldID(sec, f[0], idx); var el = document.getElementById(fid); if (el) item[f[0]] = ced_collectFieldValue(el, f[2]); }); + if (sec.interactionStyle) { + var dataArr = mobData[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; diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js index ecb3ced..9c5a279 100644 --- a/internal/admin/static/objecteditor.js +++ b/internal/admin/static/objecteditor.js @@ -86,28 +86,29 @@ var SECTIONS = [ ] }, { - id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, + id: 'on_use', label: 'On Use', labelHint: '(what happens when you use items on this)', path: 'on_use', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', detect: function(d) { return d.on_use && Array.isArray(d.on_use); }, fields: [ ['item_id', 'Item ID', 'search', 'e.g. keycard (empty = bare use)', '', null, 'items'], - ['condition', 'Condition', 'json', 'e.g. {"flag":"my_flag"} or {"player_flag":"x","value":1} or {"all_of":[{"flag":"a"},{"has_item":"key"}]}'], - ['action', 'Action', 'json', 'e.g. {"set_global_flags":{"my_flag":true},"message":"You activate it.","give_item":"gem","teleport":5,"heal":10,"credits":100,"aps_node":true,"spawn_mob":"rat","despawn_mob":"rat","broadcast":"%p activates it.","broadcast_global":"%p did it!","delay":3}'], - ['message', 'Message', 'text', 'You use the object...'], + ['condition', 'Condition', 'inter_cond'], + ['action', 'Action', 'inter_act'], ] }, { - id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look ")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, + id: 'on_look', label: 'On Look', labelHint: '(what happens when you "look ")', path: 'on_look', isArray: true, fullWidth: true, interactionStyle: true, interScope: 'inline', detect: function(d) { return d.on_look && Array.isArray(d.on_look); }, fields: [ ['item_id', 'Item ID', 'search', '(optional — only fires if you carry this item)', '', null, 'items'], - ['condition', 'Condition', 'json', 'e.g. {"player_flag":"read_sign","value":true}'], - ['action', 'Action', 'json', 'e.g. {"set_player_flags":{"read_sign":true},"give_item":"gem"}'], - ['message', 'Message', 'text', 'Shown to the player after the description...'], + ['condition', 'Condition', 'inter_cond'], + ['action', 'Action', 'inter_act'], ] }, ]; function initObjectEditor() { + window._ieRenderCurrent = renderCurrent; + window._ieRenderOnly = function() { if (objectID && objectData) renderObjectEditor(objectID, objectData); }; + window._ieSyncAll = function(ed) { if (ed) ie_syncAllInteractions(ed, SECTIONS); }; editorType = 'objects'; editorFields = []; loadList(); @@ -123,7 +124,7 @@ function loadObject(id) { API.get('/api/objects/' + encodeURIComponent(id)).then(function(data) { objectData = data; window._editorData = objectData; - window._useInterVis = {}; + window._interVis = {}; window._useInterHelpCollapsed = undefined; window._coreVis = {}; window._stealVis = {}; @@ -509,104 +510,30 @@ function renderArraySection(sec, data) { if (isInter) { window._interVis = window._interVis || {}; if (!window._interVis[sec.id]) window._interVis[sec.id] = {}; + return ie_renderArraySection({ + sec: sec, + data: data, + visMap: window._interVis[sec.id], + showRemove: function(idx) { return 'removeArrayItem(\'' + sec.id + '\',' + idx + ')'; }, + showField: function(secId, idx, key) { return 'showInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, + hideField: function(secId, idx, key) { return 'hideInterField(\'' + secId + '\',' + idx + ',\'' + key + '\')'; }, + addCondition: function(secId, idx) { return 'addInterRootCondition(\'' + secId + '\',' + idx + ')'; }, + addEffect: function(secId, idx) { return 'addInterAction(\'' + secId + '\',' + idx + ')'; }, + renderField: function(sec, f, data, idx, optStyle) { return renderField(sec, f, data, idx, null, optStyle); }, + onAdd: function() { return 'addArrayItem(\'' + sec.id + '\')'; }, + }); } arr.forEach(function(item, idx) { - if (isInter) { - var visMap = window._interVis[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 += 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 @@
+ +