aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/static')
-rw-r--r--internal/admin/static/admin.css129
-rw-r--r--internal/admin/static/cardeditor.js452
-rw-r--r--internal/admin/static/itemeditor.js108
-rw-r--r--internal/admin/static/mobeditor.js532
-rw-r--r--internal/admin/static/objecteditor.js206
-rw-r--r--internal/admin/static/talktree.js1084
6 files changed, 2113 insertions, 398 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index 93c1924..43354f9 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -50,15 +50,126 @@ body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100
.notification.error{border-color:var(--danger)}
.notification.success{border-color:var(--success)}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
-.talk-tree .node{margin-left:16px;margin-bottom:6px;border-left:2px solid var(--accent);padding-left:10px}
-.talk-tree .node-header{display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:12px}
-.talk-tree .node-header .key{color:#6cf;font-weight:bold}
-.talk-tree .node-header .preview{color:#aaa;font-size:11px}
-.talk-tree .node-children{display:none;padding:4px 0}
-.talk-tree .node.open>.node-children,.talk-tree .node.root>.node-children{display:block}
-.talk-tree .option{margin-left:10px;padding:3px 6px;font-size:11px;color:#8cf;border-left:1px solid #444}
-.talk-tree .add-btn{font-size:10px;padding:2px 6px;background:var(--accent);color:var(--text);border:none;border-radius:2px;cursor:pointer;margin:4px 0}
-.talk-tree .add-btn:hover{opacity:.8}
+/* talk-tree */
+.talk-tree{margin-top:6px}
+.talk-node{margin-left:16px;margin-bottom:8px;border-left:2px solid var(--accent);padding-left:10px}
+.talk-node.ref{margin-bottom:4px;font-size:11px;color:#888;border-left-color:#444}
+.talk-node.ref .talk-node-key{color:#6cf}
+.talk-node-header{display:flex;align-items:center;gap:6px;padding:4px 0;cursor:pointer;font-size:12px;user-select:none}
+.talk-node-header .talk-node-arrow{font-size:8px;width:10px;color:#666;flex-shrink:0;text-align:center}
+.talk-node.open>.talk-node-header .talk-node-arrow::before{content:'\25BC'}
+.talk-node .talk-node-header .talk-node-arrow::before{content:'\25B6'}
+.talk-node.open>.talk-node-header .talk-node-arrow::before{content:'\25BC'}
+.talk-node-key{color:#6cf;font-weight:bold;white-space:nowrap}
+.talk-node-preview{color:#888;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
+.talk-node-spacer{flex:1}
+.talk-node-btn{padding:2px 6px;font-size:10px;border-radius:2px;border:none;cursor:pointer;font-family:monospace}
+.talk-node-del{background:var(--danger);color:#fff}
+.talk-node-body{display:none;padding:6px 0 2px 0}
+.talk-node.open>.talk-node-body{display:block}
+.talk-props{padding:0 0 4px 0}
+.talk-prop{margin:2px 0;font-size:11px;line-height:1.4}
+.talk-prop-label{color:#aaa;font-size:10px;text-transform:uppercase;letter-spacing:.3px;margin-right:4px}
+.talk-prop-val{color:var(--text)}
+.talk-prop-goto{color:#8cf;font-weight:bold}
+.talk-prop-json{color:#888;font-size:10px;background:var(--input-bg);padding:1px 4px;border-radius:2px}
+.talk-seq-line{display:flex;gap:6px;margin:2px 0 2px 10px;font-size:11px;color:#bbb;align-items:center}
+.talk-seq-num{color:#666;min-width:14px;text-align:right;flex-shrink:0}
+.talk-action-summary{color:#e8c47c;font-size:10px}
+.talk-options{margin-top:6px;padding-top:4px;border-top:1px solid rgba(255,255,255,.05)}
+.talk-options-label{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.3px}
+.talk-option{display:flex;align-items:center;gap:5px;margin:3px 0;padding:3px 6px;font-size:11px;border-left:1px solid #444;border-radius:0 3px 3px 0}
+.talk-option-empty{color:#666;font-style:italic;border-left-color:transparent}
+.talk-opt-arrow{color:#555;flex-shrink:0}
+.talk-opt-text{color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.talk-opt-goto{color:#8cf;font-weight:bold;white-space:nowrap;min-width:30px}
+.talk-opt-tag{display:inline-block;padding:0 3px;font-size:9px;border-radius:2px;line-height:1.4;flex-shrink:0}
+.talk-opt-tag-cond{background:rgba(100,160,255,.2);color:#6af}
+.talk-opt-tag-act{background:rgba(232,196,124,.2);color:#e8c47c}
+.talk-opt-del{padding:0 4px;font-size:10px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;flex-shrink:0}
+.talk-add-btn{display:inline-block;margin-top:4px;padding:3px 10px;font-size:10px;background:var(--accent);color:var(--text);border:1px solid #1a5a8e;border-radius:3px;cursor:pointer;font-family:monospace}
+.talk-children{margin-top:0}
+
+.talk-section{margin-bottom:6px}
+.talk-section-header{display:flex;align-items:center;gap:6px;margin-bottom:3px}
+.talk-section-header .talk-prop-label{font-size:10px;color:#aaa;text-transform:uppercase;letter-spacing:.3px}
+.talk-section-remove{padding:1px 5px;font-size:9px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace}
+.talk-input{width:100%;padding:4px 6px;font-size:11px;font-family:monospace;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px}
+.talk-input:focus{border-color:#4a7aaa;outline:none}
+.talk-textarea{width:100%;padding:4px 6px;font-size:11px;font-family:monospace;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;resize:vertical;min-height:32px}
+.talk-textarea:focus{border-color:#4a7aaa;outline:none}
+.talk-input-sm{padding:2px 4px;font-size:10px}
+.talk-input-num{width:60px;padding:2px 4px;font-size:10px;font-family:monospace;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px}
+.talk-input-num:focus{border-color:#4a7aaa;outline:none}
+.talk-select{font-size:10px;font-family:monospace;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;padding:2px 4px}
+.talk-select:focus{border-color:#4a7aaa;outline:none}
+.talk-check-label{font-size:10px;color:#bbb;display:inline-flex;align-items:center;gap:3px;cursor:pointer;margin-right:10px}
+.talk-check-label input[type=checkbox]{width:auto;margin:0;cursor:pointer}
+.talk-inline-row{display:flex;gap:4px;align-items:center;margin-bottom:3px}
+.talk-inline-row .talk-input{flex:1;min-width:0}
+.talk-form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:3px 8px}
+.talk-form-grid .talk-field-row{display:flex;flex-direction:column;gap:1px}
+.talk-form-grid .talk-field-row .talk-prop-label{font-size:9px}
+.talk-kv-section{margin-top:4px;padding:4px 6px;background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:4px}
+.talk-kv-header{display:flex;align-items:center;gap:6px;margin-bottom:3px}
+.talk-kv-header .talk-prop-label{font-size:9px;color:#aaa;text-transform:uppercase;letter-spacing:.3px}
+.talk-kv-row{display:flex;gap:3px;align-items:center;margin-bottom:2px}
+.talk-kv-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-kv-add{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid #1a5a8e;border-radius:2px;background:var(--accent);color:var(--text);cursor:pointer;font-family:monospace}
+.talk-kv-del{font-size:9px;padding:0 4px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
+.talk-action-block{margin-top:3px;padding:6px;background:rgba(255,255,255,.02);border:1px solid var(--border);border-radius:4px}
+.talk-action-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:3px 8px}
+.talk-shop-block{margin-top:4px;padding:6px;background:rgba(232,196,124,.04);border:1px solid rgba(232,196,124,.15);border-radius:4px}
+.talk-shop-header{font-size:10px;color:#e8c47c;text-transform:uppercase;letter-spacing:.3px;margin-bottom:4px}
+.talk-shop-item{display:flex;gap:3px;align-items:center;margin-bottom:2px;padding:2px 4px;background:rgba(255,255,255,.02);border-radius:2px}
+.talk-shop-item .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-shop-item .talk-input-num{width:55px}
+.talk-add-cond-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(100,160,255,.25);border-radius:2px;background:transparent;color:#6af;cursor:pointer;font-family:monospace;margin-bottom:6px}
+.talk-add-act-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(232,196,124,.25);border-radius:2px;background:transparent;color:#e8c47c;cursor:pointer;font-family:monospace;margin-bottom:6px}
+
+.talk-cond-card{border:1px solid rgba(100,160,255,.25);border-radius:5px;margin-bottom:6px;overflow:hidden}
+.talk-cond-card-header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;background:rgba(100,160,255,.08);border-bottom:1px solid rgba(100,160,255,.15);font-size:10px;color:#aaa;text-transform:uppercase;letter-spacing:.3px}
+.talk-cond-card-body{padding:6px 8px}
+.talk-cond-card-sep{border-top:1px solid rgba(100,160,255,.12);margin:1px 0}
+
+.talk-cond-mode-row{display:flex;align-items:center;gap:6px;margin-bottom:4px}
+.talk-cond-mode-row .talk-prop-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.3px}
+
+.talk-clause-row{display:flex;align-items:center;gap:4px;margin-bottom:2px;font-size:10px}
+.talk-clause-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-clause-type{color:#6af;font-size:9px;text-transform:uppercase;letter-spacing:.3px;min-width:24px;white-space:nowrap}
+.talk-clause-label{color:#888;font-size:9px;white-space:nowrap}
+.talk-clause-del{padding:0 4px;font-size:9px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
+
+.talk-clause-add-bar{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
+.talk-clause-add-btn{font-size:9px;padding:1px 6px;line-height:1.4;border:1px solid rgba(100,160,255,.2);border-radius:2px;background:transparent;color:#6af;cursor:pointer;font-family:monospace}
+.talk-clause-add-btn:hover{background:rgba(100,160,255,.1)}
+
+.talk-goto-fallback{display:flex;align-items:center;gap:6px;margin-top:6px;padding:4px 6px;background:rgba(255,255,255,.015);border:1px dashed var(--border);border-radius:3px}
+.talk-goto-fallback .talk-prop-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap;flex-shrink:0}
+.talk-goto-fallback .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+
+.talk-input-num::-webkit-inner-spin-button,
+.talk-input-num::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
+.talk-input-num{-moz-appearance:textfield}
+
+.talk-add-node-row{display:flex;gap:4px;align-items:center;margin-top:8px}
+.talk-add-node-row .talk-input{flex:1;max-width:200px}
+.talk-add-node-row .talk-add-btn{flex-shrink:0}
+.talk-opt-edit-row{display:flex;gap:4px;align-items:center;margin:3px 0;padding:3px 6px;font-size:11px}
+.talk-opt-edit-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-opt-edit-row .talk-opt-arrow{flex-shrink:0}
+.talk-opt-edit-actions{display:flex;gap:2px;align-items:center;flex-shrink:0}
+.talk-add-opt-row{display:flex;gap:4px;align-items:center;margin-top:4px;padding:0 6px}
+.talk-add-opt-row .talk-input{flex:1;min-width:0;padding:2px 4px;font-size:10px}
+.talk-add-opt-row .talk-add-btn{flex-shrink:0;margin-top:0}
+.talk-seq-add-row{display:flex;gap:6px;align-items:center;margin:2px 0 2px 10px}
+.talk-seq-add-row .talk-input{flex:1;padding:2px 4px;font-size:10px}
+.talk-seq-add-row .talk-add-btn{flex-shrink:0;margin-top:0}
+.talk-seq-del{font-size:9px;padding:0 4px;line-height:1.4;border:none;border-radius:2px;background:var(--danger);color:#fff;cursor:pointer;font-family:monospace;flex-shrink:0}
+.talk-orphans{margin-top:12px;padding-top:8px;border-top:1px dashed var(--border)}
+.talk-orphans-label{font-size:10px;color:#f39c12;text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px}
+.talk-orphans-hint{font-size:9px;color:#888;margin-bottom:6px;font-style:italic}
.tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
.tab{padding:6px 14px;font-size:12px;background:transparent;color:#aaa;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace}
.tab:hover{color:var(--text)}
diff --git a/internal/admin/static/cardeditor.js b/internal/admin/static/cardeditor.js
new file mode 100644
index 0000000..0235894
--- /dev/null
+++ b/internal/admin/static/cardeditor.js
@@ -0,0 +1,452 @@
+// cardeditor.js — Shared utilities for card-based entity editors.
+// Used by mobeditor, objecteditor, itemeditor, and future editors.
+// Prefix all exports with ced_ to avoid collisions with editor.js globals.
+
+// ---- Path helpers ----
+
+function ced_cardPath(sec) {
+ return sec.path || '';
+}
+
+function ced_getVal(data, sec, key) {
+ var p = ced_cardPath(sec);
+ if (sec.isArray || p === 'on_look') {
+ return data[p] && data[p][key] !== undefined ? data[p][key] : '';
+ }
+ var fp = p ? p + '.' + key : key;
+ var v = getNested(data, fp);
+ return v === undefined || v === null ? '' : v;
+}
+
+// ---- Field ID helpers ----
+
+function ced_fieldID(sec, key, idx) {
+ if (idx !== undefined && idx >= 0) return 'f_' + sec.id + '_' + idx + '_' + key.replace(/\./g, '_');
+ return 'f_' + sec.id + '_' + key.replace(/\./g, '_');
+}
+
+function ced_fieldIDSub(sec, subKey, idx, fieldKey) {
+ return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey;
+}
+
+// ---- Value collector ----
+
+function ced_collectFieldValue(el, type) {
+ if (type === 'checkbox') return el.checked;
+ if (type === 'select') return el.value;
+ if (type === 'search') return el.value;
+ if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; }
+ if (type === 'json') {
+ var raw = el.value.trim();
+ if (!raw) return null;
+ try { return JSON.parse(raw); } catch(e) { return raw; }
+ }
+ if (type === 'textarea') return el.value;
+ return el.value;
+}
+
+// ---- Tags rendering (comma-separated string arrays) ----
+
+function ced_renderTags(sec, il, data, getter) {
+ var sectionRoot = data[ced_cardPath(sec)] || data;
+ var arr = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || []);
+ var str = Array.isArray(arr) ? arr.join(', ') : String(arr || '');
+ var fid = ced_fieldID(sec, il.key);
+ return '<label class="obj-field obj-wide"><span>' + esc(il.label) + '</span><input id="' + fid + '" value="' + escAttr(str) + '" placeholder="comma separated"></label>';
+}
+
+// ---- KV rendering (key=value map) ----
+
+function ced_renderKV(sec, il, data, getter) {
+ var sectionRoot = data[ced_cardPath(sec)] || data;
+ var map = getter ? getter(sectionRoot, il) : (sectionRoot[il.key] || {});
+ var h = '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">' + esc(il.label) + '</span>';
+ h += '<div class="obj-kv-list" data-sec="' + sec.id + '" data-key="' + il.key + '">';
+ Object.keys(map).forEach(function(k, idx) {
+ h += '<div class="obj-kv-row"><input value="' + escAttr(k) + '" placeholder="key" class="obj-kv-key">';
+ h += '<input value="' + escAttr(String(map[k])) + '" placeholder="val" class="obj-kv-val">';
+ h += '<button class="btn btn-sm btn-danger" onclick="this.parentElement.remove()">X</button></div>';
+ });
+ h += '</div>';
+ h += '<button class="btn btn-sm obj-sub-add" onclick="ced_addKVRow(\'' + sec.id + '\',\'' + il.key + '\')">+ Add</button>';
+ h += '</div>';
+ return h;
+}
+
+function ced_addKVRow(cardID, subKey) {
+ var el = document.querySelector('.obj-kv-list[data-sec="' + cardID + '"][data-key="' + subKey + '"]');
+ if (!el) return;
+ var row = document.createElement('div');
+ row.className = 'obj-kv-row';
+ row.innerHTML = '<input value="" placeholder="key" class="obj-kv-key"><input value="0" placeholder="val" class="obj-kv-val"><button class="btn btn-sm btn-danger" onclick="this.parentElement.remove()">X</button>';
+ el.appendChild(row);
+}
+
+// ---- Search autocomplete ----
+
+function ced_setupSearchFields() {
+ document.querySelectorAll('.search-input').forEach(function(input) {
+ if (input._searchSetup) return;
+ input._searchSetup = true;
+ var entityType = input.getAttribute('data-search-type') || 'items';
+ var results = input.parentElement.querySelector('.search-results');
+ if (!results) return;
+
+ results._targetInput = input;
+
+ input.addEventListener('input', function() {
+ var val = input.value.trim();
+ if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; }
+ API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
+ var items = [];
+ if (entityType === 'items' && data.items) items = data.items;
+ if (entityType === 'objects' && data.objects) items = data.objects;
+ if (entityType === 'mobs' && data.mobs) items = data.mobs;
+ if (entityType === 'drops' && data.drops) items = data.drops;
+ if (items.length === 0) {
+ results.style.display = 'block';
+ results.innerHTML = '<div class="sr-item" style="color:#888">No results</div>';
+ return;
+ }
+ results.style.display = 'block';
+ results.innerHTML = items.map(function(r) {
+ return '<div class="sr-item" onclick="event.stopPropagation();ced_selectSearchResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</span></div>';
+ }).join('');
+ results._items = items;
+ results._selectedIndex = -1;
+ }).catch(function() {
+ results.style.display = 'none';
+ });
+ });
+
+ input.addEventListener('focus', function() {
+ if (input.value.trim()) {
+ input.dispatchEvent(new Event('input'));
+ }
+ });
+
+ input.addEventListener('blur', function() {
+ setTimeout(function() { results.style.display = 'none'; }, 200);
+ });
+
+ input.addEventListener('keydown', function(e) {
+ if (results.style.display === 'none' || !results.children.length) return;
+ if (results._selectedIndex === undefined) results._selectedIndex = -1;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1);
+ ced_updateSearchHighlight(results);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ results._selectedIndex = Math.max(results._selectedIndex - 1, 0);
+ ced_updateSearchHighlight(results);
+ } else if (e.key === 'Enter') {
+ if (results._selectedIndex >= 0) {
+ e.preventDefault();
+ ced_selectSearchResult(results.children[results._selectedIndex]);
+ }
+ } else if (e.key === 'Escape') {
+ results.style.display = 'none';
+ results._selectedIndex = -1;
+ }
+ });
+ });
+
+ 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) {
+ var results = el.parentElement;
+ var idx = Array.prototype.indexOf.call(results.children, el);
+ if (results._items && results._items[idx] && results._targetInput) {
+ results._targetInput.value = results._items[idx].id;
+ results._targetInput.dispatchEvent(new Event('input'));
+ results.style.display = 'none';
+ results.innerHTML = '';
+ }
+}
+
+function ced_updateSearchHighlight(results) {
+ for (var i = 0; i < results.children.length; i++) {
+ results.children[i].classList.toggle('active', i === results._selectedIndex);
+ if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'});
+ }
+}
+
+// ---- Generic field renderer (used by all card-based editors) ----
+
+function ced_renderField(sec, f, data, idx, subPath, optStyle, cardPathFn, fieldIDFn, getValFn) {
+ var key = f[0], label = f[1], type = f[2], hint = f[3], wide = f[4], showIf = f[5], searchEntity = f[6];
+ var fp = subPath ? subPath + '.' + key : key;
+ var fid = fieldIDFn(sec, fp, idx);
+ var val;
+
+ if (sec.isArray && idx >= 0) {
+ var arr = data[cardPathFn(sec)] || [];
+ var item = arr[idx] || {};
+ val = item[key];
+ } else if (subPath) {
+ var sectionRoot = data[cardPathFn(sec)] || {};
+ var subObj = sectionRoot[subPath] || {};
+ val = subObj[key];
+ } else {
+ val = getValFn(data, sec, key);
+ }
+ val = val === undefined || val === null ? '' : val;
+
+ var hiddenStyle = '';
+ if (showIf) {
+ var sectionData = data[cardPathFn(sec)] || {};
+ if (!showIf(sectionData)) hiddenStyle = ' style="display:none"';
+ }
+
+ function wrap(cls, inner) {
+ var w = (wide === 'wide') ? ' obj-wide' : '';
+ if (wide === 'narrow') w = ' obj-narrow';
+ var s = optStyle ? ' style="' + optStyle + '"' : '';
+ return '<label class="' + cls + w + '"' + hiddenStyle + s + '>' + inner + '</label>';
+ }
+
+ if (type === 'checkbox') {
+ return wrap('obj-field obj-check', '<input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(label) + '</span>');
+ }
+ if (type === 'json') {
+ var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val);
+ return wrap('obj-field', '<span>' + esc(label) + '</span><textarea id="' + fid + '" rows="2" placeholder="{}">' + esc(jstr) + '</textarea>');
+ }
+ if (type === 'textarea') {
+ var dstr = typeof val === 'object' ? (Array.isArray(val) ? val.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(val)) : String(val);
+ return wrap('obj-field', '<span>' + esc(label) + '</span><textarea id="' + fid + '" rows="3">' + esc(dstr) + '</textarea>');
+ }
+ if (type === 'select') {
+ var opts = hint ? hint.split('|') : [];
+ var inner = '<span>' + esc(label) + '</span><select id="' + fid + '">';
+ opts.forEach(function(o) {
+ inner += '<option value="' + escAttr(o) + '"' + (String(val) === o ? ' selected' : '') + '>' + esc(o) + '</option>';
+ });
+ inner += '</select>';
+ return wrap('obj-field', inner);
+ }
+ if (type === 'color') {
+ var swatch = '<span class="color-swatch" style="background:#' + escAttr(String(val) || '808080') + '"></span>';
+ return wrap('obj-field color-field', '<span>' + esc(label) + '</span><div class="obj-color-row">' + swatch + '<input id="' + fid + '" value="' + escAttr(String(val)) + '" class="color-input"></div>');
+ }
+ if (type === 'search') {
+ var entity = searchEntity || 'items';
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."';
+ return wrap('obj-field obj-search', '<span>' + esc(label) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div>');
+ }
+ var ph = hint ? ' placeholder="' + esc(hint) + '"' : '';
+ return wrap('obj-field', '<span>' + esc(label) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + ph + '>');
+}
+
+// ---- Subtable row add/remove ----
+
+function ced_addSubRow(sec, subKey, dataRef, cardPathFn) {
+ var p = cardPathFn(sec);
+ var sectionRoot = p ? (dataRef[p] || {}) : dataRef;
+ var raw = sectionRoot[subKey];
+ var arr;
+ if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ var empty = {};
+ var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
+ if (st) {
+ st.fields.forEach(function(f) {
+ empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : '');
+ });
+ }
+ arr.push(empty);
+ sectionRoot[subKey] = arr;
+}
+
+function ced_removeSubRow(sec, subKey, idx, dataRef, cardPathFn) {
+ var p = cardPathFn(sec);
+ var sectionRoot = p ? (dataRef[p] || {}) : dataRef;
+ var raw = sectionRoot[subKey];
+ var arr;
+ if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ arr.splice(idx, 1);
+}
+
+// ---- Field save helpers ----
+
+function ced_saveSectionFields(sec, sectionObj, cardPathFn, fieldIDFn) {
+ var hasValues = sec.always;
+ sec.fields.forEach(function(f) {
+ var fid = fieldIDFn(sec, f[0]);
+ var el = document.getElementById(fid);
+ if (!el) return;
+ var val = ced_collectFieldValue(el, f[2]);
+ if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true;
+ sectionObj[f[0]] = val;
+ });
+ return hasValues;
+}
+
+function ced_saveSubtable(sec, st, sectionObj, cardPathFn, fieldIDSubFn) {
+ var items = [];
+ for (var i = 0; ; i++) {
+ var item = {};
+ var found = false;
+ st.fields.forEach(function(f) {
+ var fid = fieldIDSubFn(sec, st.key, i, f[0]);
+ var el = document.getElementById(fid);
+ if (el) found = true;
+ if (el) item[f[0]] = ced_collectFieldValue(el, f[2]);
+ });
+ if (!found) break;
+ if (Object.keys(item).length > 0) items.push(item);
+ }
+ if (st.single) {
+ if (items.length > 0) { sectionObj[st.key] = items[0]; return true; }
+ } else {
+ if (items.length > 0) { sectionObj[st.key] = items; return true; }
+ }
+ return false;
+}
+
+function ced_saveInlineFields(sec, sectionObj, cardPathFn, fieldIDFn) {
+ var hasValues = false;
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ var fid = fieldIDFn(sec, il.key);
+ var el = document.getElementById(fid);
+ if (el && el.value.trim()) {
+ sectionObj[il.key] = el.value.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; });
+ hasValues = true;
+ }
+ } else if (il.type === 'kv') {
+ var kvEl = document.querySelector('.obj-kv-list[data-sec="' + sec.id + '"][data-key="' + il.key + '"]');
+ if (kvEl) {
+ var map = {};
+ kvEl.querySelectorAll('.obj-kv-row').forEach(function(row) {
+ var keyEl = row.querySelector('.obj-kv-key');
+ var valEl = row.querySelector('.obj-kv-val');
+ if (keyEl && keyEl.value.trim() && valEl) {
+ map[keyEl.value.trim()] = parseFloat(valEl.value) || 0;
+ }
+ });
+ if (Object.keys(map).length > 0) { sectionObj[il.key] = map; hasValues = true; }
+ }
+ } else {
+ var ssec = {};
+ var sHas = false;
+ il.fields.forEach(function(f) {
+ var fp = il.path + '.' + f[0];
+ var fid = fieldIDFn(sec, fp);
+ var el = document.getElementById(fid);
+ if (el) sHas = true;
+ if (el) ssec[f[0]] = ced_collectFieldValue(el, f[2]);
+ });
+ if (sHas) { sectionObj[il.path] = ssec; hasValues = true; }
+ }
+ });
+ return hasValues;
+}
+
+// ---- Validation ----
+
+function ced_validateFields(SECTIONS, fieldIDFn, fieldIDSubFn) {
+ var errors = [];
+ document.querySelectorAll('.obj-field-error').forEach(function(field) {
+ field.classList.remove('obj-field-error');
+ });
+
+ document.querySelectorAll('input[id][type="text"], input[id]:not([type])').forEach(function(input) {
+ var id = input.id;
+ if (!id) return;
+ var labelEl = input.closest('.obj-field');
+ if (!labelEl) return;
+
+ if (input.classList.contains('search-input') || input.classList.contains('color-input')) return;
+
+ var isNumber = false;
+ var labelText = '';
+
+ var fieldDiv = labelEl.closest('[data-card]');
+ var cardID = fieldDiv ? fieldDiv.getAttribute('data-card') : '';
+ var sec = SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+
+ var baseMatch = id.match(/^f_(\w+)_(\w+)$/);
+ var subMatch = id.match(/^f_(\w+)_(\w+)_(\d+)_(\w.+)$/);
+
+ if (subMatch) {
+ var subKey = subMatch[2], fieldKey = subMatch[4];
+ var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
+ if (st) {
+ var nf = st.fields.find(function(f) { return f[0] === fieldKey && f[2] === 'number'; });
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+ } else if (baseMatch) {
+ var fieldKey = baseMatch[2];
+ var nf = sec.fields ? sec.fields.find(function(f) {
+ var fid = fieldIDFn(sec, f[0], -1);
+ return fid === id && f[2] === 'number';
+ }) : null;
+ if (!nf && sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.fields) {
+ var inf = il.fields.find(function(f) {
+ var fid = fieldIDFn(sec, il.path + '.' + f[0], -1);
+ return fid === id && f[2] === 'number';
+ });
+ if (inf) nf = inf;
+ }
+ });
+ }
+ if (nf) { isNumber = true; labelText = nf[1]; }
+ }
+
+ if (isNumber) {
+ var rawVal = input.value.trim();
+ if (rawVal !== '' && !/^-?\d*\.?\d*$/.test(rawVal)) {
+ labelEl.classList.add('obj-field-error');
+ input.focus();
+ input.select();
+ labelText = labelText || (labelEl.querySelector('span') || {}).textContent || '';
+ errors.push('Invalid value in "' + esc(labelText) + '": "' + esc(rawVal) + '"');
+ }
+ }
+ });
+
+ if (errors.length > 0) {
+ notify(errors.join('; '), 'error');
+ return false;
+ }
+ return true;
+}
+
+// ---- Output cleanup ----
+
+function ced_cleanOutput(obj) {
+ for (var key in obj) {
+ var val = obj[key];
+ if (val === '' || val === null || val === undefined) {
+ delete obj[key];
+ } else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
+ ced_cleanOutput(val);
+ if (Object.keys(val).length === 0) delete obj[key];
+ } else if (Array.isArray(val) && val.length === 0) {
+ delete obj[key];
+ }
+ }
+}
diff --git a/internal/admin/static/itemeditor.js b/internal/admin/static/itemeditor.js
index 7c7c676..215baf0 100644
--- a/internal/admin/static/itemeditor.js
+++ b/internal/admin/static/itemeditor.js
@@ -1128,99 +1128,11 @@ function updateOutputQtyVisibility() {
});
}
-function setupSearchFields() {
- document.querySelectorAll('.search-input').forEach(function(input) {
- if (input._searchSetup) return;
- input._searchSetup = true;
- var entityType = input.getAttribute('data-search-type') || 'items';
- var results = input.parentElement.querySelector('.search-results');
- if (!results) return;
-
- results._targetInput = input;
-
- input.addEventListener('input', function() {
- var val = input.value.trim();
- if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; }
- API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
- var items = [];
- if (entityType === 'items' && data.items) items = data.items;
- if (entityType === 'objects' && data.objects) items = data.objects;
- if (entityType === 'mobs' && data.mobs) items = data.mobs;
- if (entityType === 'drops' && data.drops) items = data.drops;
- if (items.length === 0) {
- results.style.display = 'block';
- results.innerHTML = '<div class="sr-item" style="color:#888">No results</div>';
- return;
- }
- results.style.display = 'block';
- results.innerHTML = items.map(function(r) {
- return '<div class="sr-item" onclick="event.stopPropagation();selectSearchResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</span></div>';
- }).join('');
- results._items = items;
- results._selectedIndex = -1;
- }).catch(function() {
- results.style.display = 'none';
- });
- });
-
- input.addEventListener('focus', function() {
- if (input.value.trim()) {
- input.dispatchEvent(new Event('input'));
- }
- });
-
- input.addEventListener('blur', function() {
- setTimeout(function() { results.style.display = 'none'; }, 200);
- });
-
- input.addEventListener('keydown', function(e) {
- if (results.style.display === 'none' || !results.children.length) return;
- if (results._selectedIndex === undefined) results._selectedIndex = -1;
-
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1);
- updateSearchHighlight(results);
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- results._selectedIndex = Math.max(results._selectedIndex - 1, 0);
- updateSearchHighlight(results);
- } else if (e.key === 'Enter') {
- if (results._selectedIndex >= 0) {
- e.preventDefault();
- selectSearchResult(results.children[results._selectedIndex]);
- }
- } else if (e.key === 'Escape') {
- results.style.display = 'none';
- results._selectedIndex = -1;
- }
- });
- });
+function setupSearchFields() { 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 selectSearchResult(el) { ced_selectSearchResult(el); }
-function selectSearchResult(el) {
- var results = el.parentElement;
- var idx = Array.prototype.indexOf.call(results.children, el);
- if (results._items && results._items[idx] && results._targetInput) {
- results._targetInput.value = results._items[idx].id;
- results._targetInput.dispatchEvent(new Event('input'));
- results.style.display = 'none';
- results.innerHTML = '';
- }
-}
-
-function updateSearchHighlight(results) {
- for (var i = 0; i < results.children.length; i++) {
- results.children[i].classList.toggle('active', i === results._selectedIndex);
- if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'});
- }
-}
+function updateSearchHighlight(results) { ced_updateSearchHighlight(results); }
// =========================================================================
// Validation
@@ -1321,19 +1233,7 @@ function validateFields() {
// Field value collector
// =========================================================================
-function collectFieldValue(el, type) {
- if (type === 'checkbox') return el.checked;
- if (type === 'select') return el.value;
- if (type === 'search') return el.value;
- if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; }
- if (type === 'json') {
- var raw = el.value.trim();
- if (!raw) return null;
- try { return JSON.parse(raw); } catch(e) { return raw; }
- }
- if (type === 'textarea') return el.value;
- return el.value;
-}
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
// =========================================================================
// Save
diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js
new file mode 100644
index 0000000..a65e578
--- /dev/null
+++ b/internal/admin/static/mobeditor.js
@@ -0,0 +1,532 @@
+// mobeditor.js — Card-based mob editor following the object/item pattern.
+var mobData = null;
+var mobID = null;
+var expandedCards = {};
+var addedSections = {};
+
+var MOB_SECTIONS = [
+ {
+ id: 'core', label: 'Core', always: true,
+ fields: [
+ ['name', 'Name', 'text', 'spaceport attendant'],
+ ['description', 'Description', 'textarea', 'A lanky man in a wrinkled grey uniform.'],
+ ],
+ inline: [
+ {label:'Idle Descriptions', key:'idle_descriptions', type:'tags'},
+ {label:'Combat Descriptions', key:'combat_descriptions', type:'tags'},
+ ]
+ },
+ {
+ id: 'combat', label: 'Combat Stats',
+ detect: function(d) { return d.attack || d.strength || d.defense || d.hp; },
+ fields: [
+ ['attack', 'Attack', 'number', '', 'narrow'],
+ ['strength', 'Strength', 'number', '', 'narrow'],
+ ['defense', 'Defense', 'number', '', 'narrow'],
+ ['hp', 'HP', 'number', '', 'narrow'],
+ ['ranged', 'Ranged', 'number', '', 'narrow'],
+ ['science', 'Science', 'number', '', 'narrow'],
+ ['speed', 'Speed', 'number', '', 'narrow'],
+ ],
+ inline: [
+ {label:'Bonuses', path:'bonuses', fields:[
+ ['attack_bonus', 'Atk Bonus', 'number', '', 'narrow'],
+ ['strength_bonus', 'Str Bonus', 'number', '', 'narrow'],
+ ['attack_type', 'Atk Type', 'select', 'stab|slash|crush|ranged|science'],
+ ]},
+ {label:'Defenses', path:'defenses', fields:[
+ ['stab_defense', 'Stab', 'number', '', 'narrow'],
+ ['slash_defense', 'Slash', 'number', '', 'narrow'],
+ ['crush_defense', 'Crush', 'number', '', 'narrow'],
+ ['science_defense', 'Science', 'number', '', 'narrow'],
+ ['ranged_defense', 'Ranged', 'number', '', 'narrow'],
+ ['weakness', 'Weakness', 'text'],
+ ]},
+ ]
+ },
+ {
+ id: 'behavior', label: 'Behavior',
+ detect: function(d) { return d.aggressive || d.protected || d.unique || d.respawn_ticks || d.kind; },
+ fields: [
+ ['aggressive', 'Aggressive', 'checkbox'],
+ ['protected', 'Protected', 'checkbox'],
+ ['unique', 'Unique', 'checkbox'],
+ ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'],
+ ['kind', 'Kind', 'select', 'combat|task'],
+ ]
+ },
+ {
+ id: 'steal', label: 'Steal',
+ detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp; },
+ fields: [
+ ['steal_table', 'Table ID', 'search', '', '', null, 'drops'],
+ ['steal_level', 'Level', 'number', '', 'narrow'],
+ ['steal_xp', 'XP', 'number', '', 'narrow'],
+ ['steal_speed', 'Speed', 'number', '', 'narrow'],
+ ]
+ },
+ {
+ id: 'assassin', label: 'Assassin',
+ detect: function(d) { return d.assassin_level || d.finishing_blow || d.damage_without || d.size; },
+ fields: [
+ ['assassin_level', 'Level', 'number', '', 'narrow'],
+ ['finishing_blow', 'Finishing Blow', 'text'],
+ ['damage_without', 'Damage Without', 'text'],
+ ['size', 'Size', 'text'],
+ ]
+ },
+ {
+ id: 'task', label: 'Task',
+ detect: function(d) { return d.kind === 'task'; },
+ fields: [
+ ['verb', 'Verb', 'text', 'work'],
+ ['progress_noun', 'Progress Noun', 'text', 'construction'],
+ ['complete_message', 'Complete Message', 'text'],
+ ]
+ },
+ {
+ id: 'talk', label: 'Talk', path: 'talk',
+ detect: function(d) { return d.talk && d.talk.nodes; },
+ fields: []
+ },
+ {
+ id: 'drops', label: 'Drops', path: 'drops',
+ detect: function(d) { return d.drops && (d.drops.remains || (d.drops.loot && d.drops.loot.length > 0)); },
+ fields: [
+ ['remains', 'Remains', 'text', 'bones'],
+ ],
+ subtables: [
+ {label:'Loot', key:'loot', fields:[
+ ['item_id', 'Item ID', 'search', '', '', null, 'items'],
+ ['table', 'Table', 'search', '', '', null, 'drops'],
+ ['weight', 'Weight', 'number', '', 'narrow'],
+ ['level', 'Level', 'number', '', 'narrow'],
+ ['xp', 'XP', 'number', '', 'narrow'],
+ ['quantity', 'Quantity', 'number', '', 'narrow'],
+ ]}
+ ]
+ },
+];
+
+function mobCardPath(sec) { return ced_cardPath(sec); }
+function mobGetVal(data, sec, key) { return ced_getVal(data, sec, key); }
+function mobFieldID(sec, key, idx) { return ced_fieldID(sec, key, idx); }
+function mobFieldIDSub(sec, subKey, idx, fieldKey) { return ced_fieldIDSub(sec, subKey, idx, fieldKey); }
+
+function initMobEditor() {
+ editorType = 'mobs';
+ editorFields = [];
+ loadList();
+ if (window.location.hash) loadMob(window.location.hash.substring(1));
+}
+
+window.onTalkTreeChange = function(data) {
+ mobData.talk = data.talk;
+};
+
+function loadMob(id) {
+ mobID = id;
+ currentID = id;
+ window.location.hash = id;
+ renderList('');
+ API.get('/api/mobs/' + encodeURIComponent(id)).then(function(data) {
+ mobData = data;
+ renderMobEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderMobEditor(id, data) {
+ var html = '<h2>Mob: ' + esc(id) + '</h2>';
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="obj-fields-wrap">';
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return;
+ html += renderMobCard(sec, data);
+ });
+
+ html += '</div>';
+
+ html += '<div class="add-section-bar">';
+ html += '<select id="addSectionSelect">';
+ html += '<option value="">+ Add Section...</option>';
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.always) return;
+ if (sec.detect && sec.detect(data)) return;
+ if (addedSections[sec.id]) return;
+ html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>';
+ });
+ html += '</select>';
+ html += '<button class="btn btn-primary btn-sm" onclick="mobAddSection()">Add</button>';
+ html += '</div>';
+
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(data._raw || JSON.stringify(data, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveMob()">Save</button>';
+ html += '<button class="btn" onclick="duplicateMob()">Duplicate</button>';
+ html += '<button class="btn btn-danger" onclick="deleteMob()">Delete Mob</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ ced_setupSearchFields();
+ setupMobKindChange();
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.id === 'talk' && data.talk && data.talk.nodes) {
+ renderTalkTree(data);
+ }
+ });
+}
+
+function renderCurrent() {
+ if (!mobData || !mobID) return;
+ renderMobEditor(mobID, mobData);
+}
+
+// ---- Card rendering ----
+
+function renderMobCard(sec, data) {
+ var collapsed = expandedCards[sec.id] === false;
+ var h = '<div class="obj-card" data-card="' + sec.id + '">';
+ h += '<div class="obj-card-header" onclick="toggleMobCard(\'' + sec.id + '\')">';
+ h += '<span class="obj-card-arrow">' + (collapsed ? '&#9654;' : '&#9660;') + '</span>';
+ h += '<span class="obj-card-label">' + sec.label + '</span>';
+ if (!sec.always) {
+ h += '<button class="btn btn-sm btn-danger obj-card-remove" onclick="event.stopPropagation();removeMobSection(\'' + sec.id + '\')">X</button>';
+ }
+ h += '</div>';
+ if (!collapsed) {
+ h += '<div class="obj-card-body">';
+
+ if (sec.id === 'core') {
+ h += renderMobCoreCard(data);
+ } else if (sec.subtables) {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1);
+ });
+ h += '</div>';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ h += ced_renderTags(sec, il, data);
+ } else {
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">' + esc(il.label) + '</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ il.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1, il.path);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ });
+ }
+
+ sec.subtables.forEach(function(st) {
+ h += renderMobSubtable(sec, st, data);
+ });
+ } else {
+ h += '<div class="obj-card-grid">';
+ sec.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1);
+ });
+ h += '</div>';
+
+ if (sec.inline) {
+ sec.inline.forEach(function(il) {
+ if (il.type === 'tags') {
+ h += ced_renderTags(sec, il, data);
+ } else {
+ h += '<div class="obj-inline-group">';
+ h += '<span class="obj-inline-label">' + esc(il.label) + '</span>';
+ h += '<div class="obj-card-grid" style="margin-top:4px">';
+ il.fields.forEach(function(f) {
+ h += renderMobField(sec, f, data, -1, il.path);
+ });
+ h += '</div>';
+ h += '</div>';
+ }
+ });
+ }
+
+ if (sec.id === 'talk') {
+ h += '<div id="talktree"></div>';
+ }
+ }
+ h += '</div>';
+ }
+ h += '</div>';
+ return h;
+}
+
+function renderMobCoreCard(data) {
+ var sec = MOB_SECTIONS[0];
+ var h = '<div class="obj-core-row">';
+ h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1');
+ h += '</div>';
+ h += renderMobField(sec, sec.fields[1], data, -1);
+ sec.inline.forEach(function(il) {
+ h += ced_renderTags(sec, il, data);
+ });
+ return h;
+}
+
+function renderMobField(sec, f, data, idx, subPath, optStyle) {
+ return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal);
+}
+
+// ---- Subtable rendering ----
+
+function renderMobSubField(sec, stKey, idx, f, val) {
+ var fid = mobFieldIDSub(sec, stKey, idx, f[0]);
+ val = val === undefined || val === null ? '' : val;
+ var extraCls = '';
+ if (f[4] === 'narrow') extraCls = ' obj-narrow';
+ else if (f[4] === 'grow') extraCls = ' obj-grow';
+ var defaultPh = '';
+ if (f[3]) defaultPh = ' placeholder="' + escAttr(f[3]) + '"';
+ if (f[2] === 'search') {
+ var entity = f[6] || 'items';
+ var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."';
+ return '<label class="obj-field obj-search' + extraCls + '"><span>' + esc(f[1]) + '</span><div style="position:relative;width:100%"><input id="' + fid + '" value="' + escAttr(String(val)) + '" class="search-input" data-search-type="' + entity + '"' + ph + '><div class="search-results" style="display:none"></div></div></label>';
+ }
+ if (f[2] === 'checkbox') {
+ return '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>';
+ }
+ return '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"' + defaultPh + '></label>';
+}
+
+function renderMobSubtable(sec, st, data) {
+ var p = mobCardPath(sec);
+ var sectionRoot = p ? (mobData[p] || {}) : mobData;
+ var raw = sectionRoot[st.key];
+ var arr;
+ if (st.single) {
+ arr = raw ? [raw] : [];
+ } else if (Array.isArray(raw)) {
+ arr = raw;
+ } else if (raw && typeof raw === 'object') {
+ arr = [raw];
+ } else {
+ arr = [];
+ }
+ var h = '<div class="obj-subtable">';
+ h += '<div class="obj-subtable-header">' + esc(st.label) + '</div>';
+ arr.forEach(function(item, idx) {
+ h += '<div class="obj-sub-row">';
+ st.fields.forEach(function(f) {
+ var val = item[f[0]];
+ h += renderMobSubField(sec, st.key, idx, f, val);
+ });
+ if (!st.single) {
+ h += '<button class="btn btn-sm btn-danger obj-sub-remove" style="margin-bottom:3px" onclick="removeMobSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>';
+ }
+ h += '</div>';
+ });
+ if (!st.single || arr.length === 0) {
+ h += '<button class="btn btn-sm obj-sub-add" onclick="addMobSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add Loot</button>';
+ }
+ h += '</div>';
+ return h;
+}
+
+// ---- Card toggle / section add-remove ----
+
+function toggleMobCard(id) {
+ expandedCards[id] = expandedCards[id] === false ? true : false;
+ renderCurrent();
+}
+
+function removeMobSection(id) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+ if (sec.path) {
+ delete mobData[sec.path];
+ } else {
+ if (sec.fields) sec.fields.forEach(function(f) { delete mobData[f[0]]; });
+ if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete mobData[il.key]; });
+ if (sec.subtables) sec.subtables.forEach(function(st) { delete mobData[st.key]; });
+ }
+ delete addedSections[id];
+ renderCurrent();
+}
+
+function mobAddSection() {
+ var sel = $('#addSectionSelect');
+ var id = sel.value;
+ if (!id) return;
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === id; });
+ if (!sec) return;
+
+ if (sec.path) {
+ if (sec.id === 'drops') {
+ mobData[sec.path] = {remains:'', loot:[]};
+ } else if (sec.id === 'talk') {
+ mobData[sec.path] = {nodes:{start:{message:'',options:[]}}};
+ } else {
+ mobData[sec.path] = {};
+ }
+ }
+ addedSections[id] = true;
+ expandedCards[id] = true;
+ renderCurrent();
+}
+
+// ---- Subtable row add/remove ----
+
+function addMobSubRow(cardID, subKey) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ ced_addSubRow(sec, subKey, mobData, mobCardPath);
+ renderCurrent();
+}
+
+function removeMobSubRow(cardID, subKey, idx) {
+ var sec = MOB_SECTIONS.find(function(s) { return s.id === cardID; });
+ if (!sec) return;
+ ced_removeSubRow(sec, subKey, idx, mobData, mobCardPath);
+ renderCurrent();
+}
+
+// ---- Kind change handler (show/hide task fields) ----
+
+function setupMobKindChange() {
+ var kindSel = document.getElementById(mobFieldID(MOB_SECTIONS.find(function(s){return s.id==='behavior';}), 'kind', -1));
+ if (!kindSel) return;
+ kindSel.addEventListener('change', function() {
+ if (kindSel.value === 'task') {
+ mobData.kind = 'task';
+ if (!mobData.verb) mobData.verb = '';
+ if (!mobData.progress_noun) mobData.progress_noun = '';
+ if (!mobData.complete_message) mobData.complete_message = '';
+ addedSections['task'] = true;
+ expandedCards['task'] = true;
+ renderCurrent();
+ } else {
+ mobData.kind = '';
+ delete addedSections['task'];
+ renderCurrent();
+ }
+ });
+}
+
+// ---- Save ----
+
+function saveMob() {
+ if (!ced_validateFields(MOB_SECTIONS, mobFieldID, mobFieldIDSub)) return;
+
+ var out = {};
+
+ MOB_SECTIONS.forEach(function(sec) {
+ if (sec.id === 'talk') {
+ if (mobData.talk) out.talk = mobData.talk;
+ return;
+ }
+
+ var sectionObj = {};
+ var hasValues = sec.always;
+
+ if (sec.fields) {
+ hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues;
+ }
+
+ if (sec.subtables) {
+ sec.subtables.forEach(function(st) {
+ if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true;
+ });
+ }
+
+ if (sec.inline) {
+ if (ced_saveInlineFields(sec, sectionObj, mobCardPath, mobFieldID)) hasValues = true;
+ }
+
+ if (sec.always) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ } else if (hasValues && sec.path) {
+ out[sec.path] = sectionObj;
+ } else if (hasValues && !sec.path) {
+ Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; });
+ }
+ });
+
+ ced_cleanOutput(out);
+ out.id = mobID;
+
+ API.put('/api/mobs/' + encodeURIComponent(mobID), out).then(function(resp) {
+ notify('Mob ' + mobID + ' saved', 'success');
+ updateUndoBar();
+ if (resp && resp._raw) mobData._raw = resp._raw;
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+// ---- Duplicate ----
+
+function duplicateMob() {
+ var baseID = mobID + '_copy';
+ var newID = baseID;
+ var counter = 2;
+ while (true) {
+ var exists = false;
+ for (var i = 0; i < allIDs.length; i++) {
+ if (allIDs[i] === newID) { exists = true; break; }
+ }
+ if (!exists) break;
+ newID = baseID + '_' + counter;
+ counter++;
+ }
+
+ var copy = JSON.parse(JSON.stringify(mobData));
+ delete copy._raw;
+ delete copy._path;
+ copy.id = newID;
+
+ API.post('/api/mobs', {id: newID}).then(function() {
+ return API.put('/api/mobs/' + encodeURIComponent(newID), copy);
+ }).then(function() {
+ notify('Duplicated as ' + newID, 'success');
+ updateUndoBar();
+ loadList();
+ loadMob(newID);
+ }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); });
+}
+
+// ---- Delete ----
+
+function deleteMob() {
+ if (!confirm('Delete mob "' + mobID + '"?')) return;
+ API.del('/api/mobs/' + encodeURIComponent(mobID)).then(function() {
+ notify('Mob ' + mobID + ' deleted', 'success');
+ updateUndoBar();
+ mobID = null;
+ currentID = null;
+ mobData = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+// ---- editor.js compatibility ----
+
+function loadItem(id) { loadMob(id); }
+function createNew() {
+ var name = prompt('Mob ID:');
+ if (!name) return;
+ API.post('/api/mobs', {id: name}).then(function() {
+ notify('Mob ' + name + ' created', 'success');
+ updateUndoBar();
+ loadList();
+ loadMob(name);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+}
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js
index 56894d2..c2374a7 100644
--- a/internal/admin/static/objecteditor.js
+++ b/internal/admin/static/objecteditor.js
@@ -112,11 +112,6 @@ var SECTIONS = [
]
},
{
- id: 'talk', label: 'Talk', path: 'talk',
- detect: function(d) { return d.talk && d.talk.nodes; },
- fields: []
- },
- {
id: 'on_look', label: 'On Look', path: 'on_look',
detect: function(d) { return d.on_look; },
fields: [
@@ -144,10 +139,6 @@ function initObjectEditor() {
if (window.location.hash) loadObject(window.location.hash.substring(1));
}
-window.onTalkTreeChange = function(data) {
- objectData.talk = data.talk;
-};
-
function loadObject(id) {
objectID = id;
currentID = id;
@@ -206,12 +197,6 @@ function renderObjectEditor(id, data) {
setTimeout(function() {
document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); });
}, 50);
-
- SECTIONS.forEach(function(sec) {
- if (sec.id === 'talk' && data.talk && data.talk.nodes) {
- renderTalkTree(data);
- }
- });
}
function cardPath(sec) {
@@ -319,10 +304,6 @@ function renderCard(sec, data) {
h += renderSubtable(sec, st, data);
});
}
-
- if (sec.id === 'talk') {
- h += '<div id="talktree"></div>';
- }
}
h += '</div>';
}
@@ -573,8 +554,6 @@ function addSection() {
objectData[sec.path] = {message:'', wait:1, consume:{}, reward:{}, fail_message:'', success:{base:0, per_level:0, cap:1}, skill:'', level:1, xp:0};
} else if (sec.id === 'safespot') {
objectData[sec.path] = {tier:1, max_block_size:'', max_occupants:1, unsafe_chance:0, decay_ticks:0, decay_chance:0, respawn_on_hide:false, respawn_ticks:0, levels:[]};
- } else if (sec.id === 'talk') {
- objectData[sec.path] = {nodes:{start:{message:'',options:[]}}};
} else {
objectData[sec.path] = {};
}
@@ -678,185 +657,15 @@ function toggleGatherConditionals() {
});
}
-function setupSearchFields() {
- document.querySelectorAll('.search-input').forEach(function(input) {
- if (input._searchSetup) return;
- input._searchSetup = true;
- var entityType = input.getAttribute('data-search-type') || 'items';
- var results = input.parentElement.querySelector('.search-results');
- if (!results) return;
-
- results._targetInput = input;
-
- input.addEventListener('input', function() {
- var val = input.value.trim();
- if (!val) { results.style.display = 'none'; results.innerHTML = ''; results._selectedIndex = -1; return; }
- API.get('/api/search?q=' + encodeURIComponent(val)).then(function(data) {
- var items = [];
- if (entityType === 'items' && data.items) items = data.items;
- if (entityType === 'objects' && data.objects) items = data.objects;
- if (entityType === 'mobs' && data.mobs) items = data.mobs;
- if (entityType === 'drops' && data.drops) items = data.drops;
- if (items.length === 0) {
- results.style.display = 'block';
- results.innerHTML = '<div class="sr-item" style="color:#888">No results</div>';
- return;
- }
- results.style.display = 'block';
- results.innerHTML = items.map(function(r) {
- return '<div class="sr-item" onclick="event.stopPropagation();selectObjSearchResult(this)">' + esc(r.id) + ' <span style="color:#aaa">' + esc(r.name || '') + '</span></div>';
- }).join('');
- results._items = items;
- results._selectedIndex = -1;
- }).catch(function() {
- results.style.display = 'none';
- });
- });
-
- input.addEventListener('focus', function() {
- if (input.value.trim()) {
- input.dispatchEvent(new Event('input'));
- }
- });
-
- input.addEventListener('blur', function() {
- setTimeout(function() { results.style.display = 'none'; }, 200);
- });
+function setupSearchFields() { ced_setupSearchFields(); }
- input.addEventListener('keydown', function(e) {
- if (results.style.display === 'none' || !results.children.length) return;
- if (results._selectedIndex === undefined) results._selectedIndex = -1;
-
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- results._selectedIndex = Math.min(results._selectedIndex + 1, results.children.length - 1);
- updateSearchHighlight(results);
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- results._selectedIndex = Math.max(results._selectedIndex - 1, 0);
- updateSearchHighlight(results);
- } else if (e.key === 'Enter') {
- if (results._selectedIndex >= 0) {
- e.preventDefault();
- selectObjSearchResult(results.children[results._selectedIndex]);
- }
- } else if (e.key === 'Escape') {
- results.style.display = 'none';
- results._selectedIndex = -1;
- }
- });
- });
+function selectObjSearchResult(el) { ced_selectSearchResult(el); }
- 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 updateSearchHighlight(results) { ced_updateSearchHighlight(results); }
-function selectObjSearchResult(el) {
- var results = el.parentElement;
- var idx = Array.prototype.indexOf.call(results.children, el);
- if (results._items && results._items[idx] && results._targetInput) {
- results._targetInput.value = results._items[idx].id;
- results._targetInput.dispatchEvent(new Event('input'));
- results.style.display = 'none';
- results.innerHTML = '';
- }
-}
+function validateFields() { return ced_validateFields(SECTIONS, fieldID, fieldIDSub); }
-function updateSearchHighlight(results) {
- for (var i = 0; i < results.children.length; i++) {
- results.children[i].classList.toggle('active', i === results._selectedIndex);
- if (i === results._selectedIndex) results.children[i].scrollIntoView({block: 'nearest'});
- }
-}
-
-function validateFields() {
- var errors = [];
- document.querySelectorAll('.obj-field-error').forEach(function(field) {
- field.classList.remove('obj-field-error');
- });
-
- document.querySelectorAll('input[id][type="text"], input[id]:not([type])').forEach(function(input) {
- var id = input.id;
- if (!id) return;
- var labelEl = input.closest('.obj-field');
- if (!labelEl) return;
-
- var fieldDiv = labelEl.closest('[data-card]');
- var cardID = fieldDiv ? fieldDiv.getAttribute('data-card') : '';
- var sec = SECTIONS.find(function(s) { return s.id === cardID; });
- if (!sec) return;
-
- var isSearch = input.classList.contains('search-input');
- var subKeyMatch = id.match(/^f_(\w+)_(\w+)_(\d+)_(\w+)$/);
- var numberField = null;
-
- if (isSearch) {
- return;
- }
-
- if (subKeyMatch) {
- var subKey = subKeyMatch[2];
- var st = (sec.subtables || []).find(function(s) { return s.key === subKey; });
- if (st) {
- var fieldKey = subKeyMatch[4];
- numberField = st.fields.find(function(f) { return f[0] === fieldKey && f[2] === 'number'; });
- }
- } else {
- numberField = sec.fields.find(function(f) {
- var fp = f[0];
- if (sec.path && sec.path !== 'on_look') fp = fp;
- var fid = fieldID(sec, fp, -1);
- return fid === id && f[2] === 'number';
- });
- if (!numberField && sec.inline) {
- sec.inline.forEach(function(il) {
- if (il.fields) {
- var nf = il.fields.find(function(f) {
- var fid = fieldID(sec, il.path + '.' + f[0], -1);
- return fid === id && f[2] === 'number';
- });
- if (nf) numberField = nf;
- }
- });
- }
- }
-
- if (numberField) {
- var rawVal = input.value.trim();
- if (rawVal !== '' && !/^-?\d*\.?\d*$/.test(rawVal)) {
- labelEl.classList.add('obj-field-error');
- input.focus();
- input.select();
- var labelText = (labelEl.querySelector('span') || {}).textContent || numberField[1] || '';
- errors.push('Invalid value in "' + esc(labelText) + '": "' + esc(rawVal) + '"');
- return;
- }
- }
- });
-
- if (errors.length > 0) {
- notify(errors.join('; '), 'error');
- return false;
- }
- return true;
-}
-
-function collectFieldValue(el, type) {
- if (type === 'checkbox') return el.checked;
- if (type === 'select') return el.value;
- if (type === 'search') return el.value;
- if (type === 'number') { var n = parseFloat(el.value); return isNaN(n) ? 0 : n; }
- if (type === 'json') {
- var raw = el.value.trim();
- if (!raw) return null;
- try { return JSON.parse(raw); } catch(e) { return raw; }
- }
- if (type === 'textarea') return el.value;
- return el.value;
-}
+function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); }
function saveObject() {
if (!validateFields()) return;
@@ -882,11 +691,6 @@ function saveObject() {
return;
}
- if (sec.id === 'talk') {
- if (objectData.talk) out.talk = objectData.talk;
- return;
- }
-
var sectionObj = {};
var hasValues = sec.always;
diff --git a/internal/admin/static/talktree.js b/internal/admin/static/talktree.js
index f254c21..e7079f5 100644
--- a/internal/admin/static/talktree.js
+++ b/internal/admin/static/talktree.js
@@ -1,92 +1,1008 @@
-function renderTalkTree(data) {
- var container = document.getElementById('talktree');
- if (!container) return;
- if (!data || !data.talk || !data.talk.nodes) {
- container.innerHTML = '<p style="color:#888">No conversation tree defined.</p>';
- return;
- }
- window._talkData = data;
- var nodes = data.talk.nodes;
- var html = '<div class="talk-tree">';
- html += renderTreeNodes(nodes, 'start', {});
- html += '</div>';
- container.innerHTML = html;
-}
-
-function renderTreeNodes(nodes, rootKey, seen) {
- var node = nodes[rootKey];
- if (!node) return '';
- if (seen[rootKey]) {
- return '<div class="node ref"><span class="key">' + esc(rootKey) + '</span> <span class="preview">(already shown)</span></div>';
- }
- seen[rootKey] = true;
-
- var html = '';
- html += '<div class="node root' + (rootKey === 'start' ? ' open' : '') + '">';
- html += '<div class="node-header" onclick="this.parentElement.classList.toggle(\'open\')">';
- html += '<span class="arrow"></span>';
- html += '<span class="key">' + esc(rootKey) + '</span>';
- html += '<span class="preview">' + esc((node.message || '').substring(0, 60)) + '</span>';
- html += '</div>';
- html += '<div class="node-children">';
-
- html += '<div style="margin-left:8px;font-size:11px">';
- if (node.message) html += '<div><b>Message:</b> ' + esc(node.message) + '</div>';
- if (node.condition) html += '<div><b>Condition:</b> ' + esc(JSON.stringify(node.condition)) + '</div>';
- if (node.goto) html += '<div><b>Goto:</b> ' + esc(node.goto) + '</div>';
- html += '</div>';
-
- html += '<div style="margin-left:10px"><b style="font-size:11px;color:#aaa">Options:</b>';
- if (node.options && node.options.length > 0) {
- node.options.forEach(function(opt, idx) {
- html += '<div class="option">';
- html += '<span>' + esc(opt.text || '') + '</span> \u2192 <span style="color:#8cf">' + esc(opt.goto || '') + '</span>';
- if (opt.condition) html += ' <span style="color:#888;font-size:10px">[cond]</span>';
- if (opt.action) html += ' <span style="color:#888;font-size:10px">[action]</span>';
- html += ' <button class="btn btn-danger btn-sm" onclick="deleteOption(event,\'' + escAttr(rootKey) + '\',' + idx + ')">Delete Option</button>';
- html += '</div>';
+// talktree.js — Full-featured conversation tree editor.
+// Inline editing for all TalkNode and TalkOption fields.
+// No prompt() dialogs. Clause-based condition editor, action forms.
+
+(function() {
+ 'use strict';
+
+ // ---- Path helpers ----
+
+ function talkRoot() { return window._talkData && window._talkData.talk; }
+ function talkNodes() { var r = talkRoot(); return r && r.nodes; }
+ function talkSync() { if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(window._talkData); }
+
+ function talkGet(path) {
+ var parts = path.split('.'), obj = talkRoot();
+ for (var i = 0; i < parts.length; i++) {
+ if (obj == null) return undefined;
+ var k = parts[i];
+ obj = /^\d+$/.test(k) ? obj[parseInt(k, 10)] : obj[k];
+ }
+ return obj;
+ }
+
+ function talkSet(path, value) {
+ var parts = path.split('.'), root = talkRoot();
+ for (var i = 0; i < parts.length - 1; i++) {
+ var k = parts[i];
+ if (/^\d+$/.test(k)) {
+ var idx = parseInt(k, 10);
+ if (root[idx] == null) root[idx] = /^\d+$/.test(parts[i + 1]) ? [] : {};
+ root = root[idx];
+ } else {
+ if (root[k] == null) root[k] = /^\d+$/.test(parts[i + 1]) ? [] : {};
+ root = root[k];
+ }
+ }
+ var lk = parts[parts.length - 1];
+ if (/^\d+$/.test(lk)) { root[parseInt(lk, 10)] = value; }
+ else { root[lk] = value; }
+ }
+
+ function talkDel(path) {
+ var parts = path.split('.'), root = talkRoot();
+ for (var i = 0; i < parts.length - 1; i++) {
+ if (root == null) return;
+ var k = parts[i];
+ root = /^\d+$/.test(k) ? root[parseInt(k, 10)] : root[k];
+ }
+ if (!root) return;
+ var lk = parts[parts.length - 1];
+ if (/^\d+$/.test(lk)) { root.splice(parseInt(lk, 10), 1); }
+ else { delete root[lk]; }
+ }
+
+ // ---- Clause types ----
+
+ var CLAUSE_TYPES = [
+ { key: 'flag', label: 'Flag', hasValue: true },
+ { key: 'player_flag', label: 'Player Flag', hasValue: true },
+ { key: 'has_item', label: 'Has Item', hasValue: false },
+ { key: 'min_credits', label: 'Min Credits', hasValue: false }
+ ];
+
+ function clauseTypeLabel(key) {
+ for (var i = 0; i < CLAUSE_TYPES.length; i++) {
+ if (CLAUSE_TYPES[i].key === key) return CLAUSE_TYPES[i].label;
+ }
+ return key;
+ }
+
+ function clauseTypeHasValue(key) {
+ for (var i = 0; i < CLAUSE_TYPES.length; i++) {
+ if (CLAUSE_TYPES[i].key === key) return CLAUSE_TYPES[i].hasValue;
+ }
+ return false;
+ }
+
+ // Extract clauses from a condition object
+ function extractClauses(cond) {
+ if (!cond || typeof cond !== 'object' || Array.isArray(cond)) return { mode: 'none', clauses: [], groupKey: null };
+ if (cond.all_of && Array.isArray(cond.all_of) && cond.all_of.length > 0) {
+ return { mode: 'multi', clauses: cond.all_of.slice(), groupKey: 'all_of' };
+ }
+ if (cond.any_of && Array.isArray(cond.any_of) && cond.any_of.length > 0) {
+ return { mode: 'multi', clauses: cond.any_of.slice(), groupKey: 'any_of' };
+ }
+ // Single clause from flat fields
+ for (var i = 0; i < CLAUSE_TYPES.length; i++) {
+ if (cond[CLAUSE_TYPES[i].key] !== undefined && cond[CLAUSE_TYPES[i].key] !== '') {
+ var c = {};
+ c[CLAUSE_TYPES[i].key] = cond[CLAUSE_TYPES[i].key];
+ if (cond.not) c.not = cond.not;
+ if (cond.value !== undefined && cond.value !== '') c.value = cond.value;
+ if (cond.has_item && CLAUSE_TYPES[i].key !== 'has_item') c.has_item = cond.has_item;
+ if (cond.player_flag && CLAUSE_TYPES[i].key !== 'player_flag') c.player_flag = cond.player_flag;
+ if (cond.flag && CLAUSE_TYPES[i].key !== 'flag') c.flag = cond.flag;
+ return { mode: 'single', clauses: [c], groupKey: null };
+ }
+ }
+ // Check for has_item or min_credits as the only key
+ for (var j = 0; j < CLAUSE_TYPES.length; j++) {
+ var ck = CLAUSE_TYPES[j].key;
+ if (cond[ck] !== undefined && cond[ck] !== '') {
+ var cc = {};
+ cc[ck] = cond[ck];
+ if (cond.not) cc.not = cond.not;
+ if (cond.value !== undefined && cond.value !== '') cc.value = cond.value;
+ return { mode: 'single', clauses: [cc], groupKey: null };
+ }
+ }
+ return { mode: 'none', clauses: [], groupKey: null };
+ }
+
+ // Determine the type key for a clause object
+ function clauseObjType(clause) {
+ for (var i = 0; i < CLAUSE_TYPES.length; i++) {
+ var k = CLAUSE_TYPES[i].key;
+ if (clause[k] !== undefined && clause[k] !== '') return k;
+ }
+ return null;
+ }
+
+ // Build a full condition object from clauses
+ function buildCondition(clauses, groupKey) {
+ if (!clauses || clauses.length === 0) return null;
+ if (clauses.length === 1) {
+ var flat = {};
+ var c = clauses[0];
+ for (var k in c) { if (c[k] !== undefined && c[k] !== '') flat[k] = c[k]; }
+ return flat;
+ }
+ var cond = {};
+ cond[groupKey || 'all_of'] = clauses.map(function(c) {
+ var sub = {};
+ for (var k in c) { if (c[k] !== undefined && c[k] !== '') sub[k] = c[k]; }
+ return sub;
});
- } else {
- html += '<div class="option" style="color:#888">No options</div>';
+ return cond;
+ }
+
+ // ---- Action field definitions ----
+
+ var ACTION_FIELDS = [
+ { key: 'give_item', label: 'Give Item', type: 'text' },
+ { key: 'take_item', label: 'Take Item', type: 'text' },
+ { key: 'teleport', label: 'Teleport Room', type: 'number' },
+ { key: 'heal', label: 'Heal HP', type: 'number' },
+ { key: 'cost', label: 'Cost (Credits)', type: 'number' },
+ { key: 'reputation_cost', label: 'Rep Cost', type: 'number' },
+ ];
+
+ var ACTION_BOOLS = [
+ { key: 'assign_task', label: 'Assign Task' },
+ { key: 'skip_task', label: 'Skip Task' },
+ { key: 'extend_task', label: 'Extend Task' },
+ { key: 'sawmill', label: 'Sawmill' },
+ { key: 'aps_node', label: 'APS Node' },
+ ];
+
+ // ---- Event delegation ----
+
+ function talkEvent(e) {
+ var el = e.target;
+ var path = el.getAttribute('data-tp');
+ if (!path) return;
+
+ var tag = el.tagName;
+ var type = el.type;
+
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
+ var val;
+ if (type === 'checkbox') { val = el.checked; }
+ else if (type === 'number') { var n = parseFloat(el.value); val = isNaN(n) ? 0 : n; }
+ else { val = el.value; }
+ talkSyncPath(path, val);
+ } else if (tag === 'SELECT') {
+ talkSyncPath(path, el.value);
+ }
+ }
+
+ function talkSyncPath(path, val) {
+ talkSet(path, val);
+ talkSync();
}
- html += '<div style="margin-top:4px"><button class="btn btn-primary btn-sm" onclick="addOption(event,\'' + escAttr(rootKey) + '\')">+ Add Option</button></div>';
- html += '</div>';
- html += '</div></div>';
+ function talkHandleClick(e) {
+ var el = e.target;
+ var action = el.getAttribute('data-action');
+ if (!action) return;
- if (node.options) {
- node.options.forEach(function(opt) {
- if (opt.goto && nodes[opt.goto]) {
- html += renderTreeNodes(nodes, opt.goto, seen);
+ var nodeKey = el.getAttribute('data-nk');
+ var optIdx = el.getAttribute('data-oi');
+ var subIdx = el.getAttribute('data-si');
+ var subKey = el.getAttribute('data-sk');
+ var condGroupKey = el.getAttribute('data-cgk');
+
+ switch (action) {
+ case 'delete-node':
+ deleteNode(nodeKey);
+ break;
+ case 'add-option':
+ addOption(nodeKey);
+ break;
+ case 'delete-option':
+ deleteOption(nodeKey, parseInt(optIdx, 10));
+ break;
+ case 'add-node':
+ addNode();
+ break;
+ case 'add-sequence':
+ addSequenceLine(nodeKey);
+ break;
+ case 'delete-sequence':
+ deleteSequenceLine(nodeKey, parseInt(optIdx, 10));
+ break;
+ case 'add-condition':
+ toggleCondition(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
+ break;
+ case 'remove-condition':
+ removeCondition(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
+ break;
+ case 'add-action':
+ addAction(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
+ break;
+ case 'remove-action':
+ removeAction(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
+ break;
+ case 'add-clause':
+ addClause(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-ct'));
+ break;
+ case 'remove-clause':
+ removeClause(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, parseInt(subIdx, 10));
+ break;
+ case 'set-cond-group':
+ setCondGroup(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, condGroupKey);
+ break;
+ case 'add-kv':
+ addKVPair(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-kvk'));
+ break;
+ case 'remove-kv':
+ removeKVPair(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, el.getAttribute('data-kvk'), subKey);
+ break;
+ case 'add-shop-item':
+ addShopItem(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null);
+ break;
+ case 'remove-shop-item':
+ removeShopItem(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, parseInt(subIdx, 10));
+ break;
+ }
+ }
+
+ // ---- Render entry point ----
+
+ window.renderTalkTree = function(data) {
+ var container = document.getElementById('talktree');
+ if (!container) return;
+ window._talkData = data;
+ if (!data || !data.talk || !data.talk.nodes) {
+ container.innerHTML = '<p style="color:#888">No conversation tree defined.</p>';
+ return;
+ }
+ var nodes = data.talk.nodes;
+ var visited = {};
+ var html = '<div class="talk-tree" id="talkTreeRoot">';
+ html += '<datalist id="talkNodeKeys">';
+ Object.keys(nodes).forEach(function(k) { html += '<option value="' + escAttr(k) + '">'; });
+ html += '</datalist>';
+ html += renderTreeNodes(nodes, 'start', visited);
+
+ var allKeys = Object.keys(nodes);
+ var orphanKeys = [];
+ for (var i = 0; i < allKeys.length; i++) {
+ if (!visited[allKeys[i]]) orphanKeys.push(allKeys[i]);
+ }
+ if (orphanKeys.length > 0) {
+ html += '<div class="talk-orphans">';
+ html += '<div class="talk-orphans-label">Unreferenced Nodes</div>';
+ html += '<div class="talk-orphans-hint">These nodes are not reachable from start. Add a goto to them from another node\'s option.</div>';
+ for (var j = 0; j < orphanKeys.length; j++) {
+ html += renderOrphanNode(orphanKeys[j], nodes[orphanKeys[j]], nodes);
}
+ html += '</div>';
+ }
+
+ html += renderAddNodeRow();
+ html += '</div>';
+ container.innerHTML = html;
+ };
+
+ // ---- Add/Delete Node ----
+
+ function renderAddNodeRow() {
+ return '<div class="talk-add-node-row">' +
+ '<input class="talk-input talk-input-sm" id="talkNewNodeKey" placeholder="New node key (unique name)">' +
+ '<button class="talk-add-btn" data-action="add-node">Add Node</button>' +
+ '</div>';
+ }
+
+ function addNode() {
+ var input = document.getElementById('talkNewNodeKey');
+ if (!input) return;
+ var key = input.value.trim();
+ if (!key) return;
+ if (talkNodes()[key]) { notify('Node "' + key + '" already exists.', 'error'); return; }
+ talkNodes()[key] = { message: '', options: [] };
+ input.value = '';
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function deleteNode(nodeKey) {
+ if (nodeKey === 'start') { notify('Cannot delete the start node.', 'error'); return; }
+ var nodes = talkNodes();
+ if (!nodes || !nodes[nodeKey]) return;
+ delete nodes[nodeKey];
+ Object.keys(nodes).forEach(function(k) {
+ var n = nodes[k];
+ if (n.goto === nodeKey) delete n.goto;
+ if (n.options) {
+ n.options.forEach(function(opt) { if (opt.goto === nodeKey) opt.goto = ''; });
+ }
+ });
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Orphan node ----
+
+ function renderOrphanNode(key, node, nodes) {
+ var h = '<div class="talk-node">';
+ h += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))this.parentElement.parentElement.classList.toggle(\'open\')">';
+ h += '<span class="talk-node-arrow"></span>';
+ h += '<span class="talk-node-key">' + esc(key) + '</span>';
+ h += '<span class="talk-node-preview">' + esc((getNodePreview(node) || '').substring(0, 60)) + '</span>';
+ h += '<span class="talk-node-spacer"></span>';
+ h += '<button class="talk-node-btn talk-node-del" data-action="delete-node" data-nk="' + escAttr(key) + '">Delete</button>';
+ h += '</div>';
+ h += '<div class="talk-node-body">';
+ h += renderNodeBody(key, node, nodes);
+ h += '</div>';
+ h += '</div>';
+ return h;
+ }
+
+ // ---- Recursive tree render ----
+
+ function renderTreeNodes(nodes, rootKey, seen, indent) {
+ indent = indent || 0;
+ var node = nodes[rootKey];
+ if (!node) return '';
+ if (seen[rootKey]) {
+ return '<div class="talk-node ref"><span class="talk-node-key">' + esc(rootKey) + '</span> <span class="talk-node-preview">(already shown)</span></div>';
+ }
+ seen[rootKey] = true;
+
+ var previewText = getNodePreview(node);
+
+ var html = '<div class="talk-node' + (rootKey === 'start' ? ' open' : '') + '">';
+
+ // Header — fix: check for data-action before toggling
+ html += '<div class="talk-node-header" onclick="if(!event.target.closest(\'[data-action]\'))this.parentElement.classList.toggle(\'open\')">';
+ html += '<span class="talk-node-arrow"></span>';
+ html += '<span class="talk-node-key">' + esc(rootKey) + '</span>';
+ html += '<span class="talk-node-preview">' + esc((previewText || '').substring(0, 60)) + '</span>';
+ html += '<span class="talk-node-spacer"></span>';
+ html += '<button class="talk-node-btn talk-node-del" data-action="delete-node" data-nk="' + escAttr(rootKey) + '">Delete</button>';
+ html += '</div>';
+
+ // Body
+ html += '<div class="talk-node-body">';
+ html += renderNodeBody(rootKey, node, nodes);
+ html += '</div>'; // .talk-node-body
+
+ // Children
+ var childKeys = [];
+ if (node.goto && nodes[node.goto]) childKeys.push(node.goto);
+ if (node.options) {
+ node.options.forEach(function(opt) {
+ if (opt.goto && nodes[opt.goto] && childKeys.indexOf(opt.goto) < 0) childKeys.push(opt.goto);
+ });
+ }
+ if (childKeys.length > 0) {
+ html += '<div class="talk-children">';
+ childKeys.forEach(function(k) { html += renderTreeNodes(nodes, k, seen, indent + 1); });
+ html += '</div>';
+ }
+
+ html += '</div>'; // .talk-node
+ return html;
+ }
+
+ function getNodePreview(node) {
+ if (node.message) return typeof node.message === 'string' ? node.message : (Array.isArray(node.message) ? node.message[0] : '');
+ if (node.sequence && node.sequence.length) return node.sequence[0];
+ return '';
+ }
+
+ // ---- Node body: condition-gated content ----
+
+ function renderNodeBody(nodeKey, node, nodes) {
+ var ec = extractClauses(node.condition);
+ var hasCond = ec.mode !== 'none';
+
+ var html = '';
+
+ if (hasCond) {
+ html += '<div class="talk-cond-card">';
+ html += '<div class="talk-cond-card-header">' +
+ 'Condition' +
+ '<button class="talk-section-remove" data-action="remove-condition" data-nk="' + escAttr(nodeKey) + '">Remove</button>' +
+ '</div>';
+ html += '<div class="talk-cond-card-body">';
+ html += renderConditionClauses(ec, nodeKey, null);
+ html += '<div class="talk-cond-card-sep"></div>';
+ html += renderMessageSection(nodeKey, node);
+ html += renderSequenceSection(nodeKey, node);
+ html += renderActionSection(nodeKey, null, node.action);
+ html += renderOptionsSection(nodeKey, node);
+ html += '</div>'; // .talk-cond-card-body
+ html += '</div>'; // .talk-cond-card
+ } else {
+ html += '<button class="talk-add-cond-btn" data-action="add-condition" data-nk="' + escAttr(nodeKey) + '">+ Add Condition</button>';
+ html += renderMessageSection(nodeKey, node);
+ html += renderSequenceSection(nodeKey, node);
+ html += renderActionSection(nodeKey, null, node.action);
+ html += renderOptionsSection(nodeKey, node);
+ }
+
+ // Goto fallback — only when no options
+ if (!node.options || node.options.length === 0) {
+ html += renderGotoFallback(nodeKey, node);
+ }
+
+ return html;
+ }
+
+ // ---- Goto fallback ----
+
+ function renderGotoFallback(nodeKey, node) {
+ return '<div class="talk-goto-fallback">' +
+ '<span class="talk-prop-label">Auto-advance to</span>' +
+ '<input class="talk-input talk-input-sm" data-tp="nodes.' + escAttr(nodeKey) + '.goto" list="talkNodeKeys" value="' + escAttr(node.goto || '') + '" placeholder="Fallback node when no options are available...">' +
+ '</div>';
+ }
+
+ // ---- Message / Sequence ----
+
+ function renderMessageSection(nodeKey, node) {
+ var msg = node.message || '';
+ if (Array.isArray(msg)) msg = msg.join('\n');
+ return '<div class="talk-section">' +
+ '<div class="talk-section-header"><span class="talk-prop-label">Message</span></div>' +
+ '<textarea class="talk-textarea" data-tp="nodes.' + escAttr(nodeKey) + '.message" rows="2" placeholder="NPC dialogue text...">' + esc(msg) + '</textarea>' +
+ '</div>';
+ }
+
+ function renderSequenceSection(nodeKey, node) {
+ var seq = node.sequence || [];
+ var html = '<div class="talk-section">';
+ html += '<div class="talk-section-header"><span class="talk-prop-label">Sequence</span></div>';
+ seq.forEach(function(line, i) {
+ html += '<div class="talk-seq-line">' +
+ '<span class="talk-seq-num">' + (i + 1) + '</span>' +
+ '<input class="talk-input talk-input-sm" data-tp="nodes.' + escAttr(nodeKey) + '.sequence.' + i + '" value="' + escAttr(line) + '" placeholder="Sequence line...">' +
+ '<button class="talk-seq-del" data-action="delete-sequence" data-nk="' + escAttr(nodeKey) + '" data-oi="' + i + '">x</button>' +
+ '</div>';
+ });
+ html += '<div class="talk-seq-add-row">' +
+ '<span class="talk-seq-num">' + (seq.length + 1) + '</span>' +
+ '<input class="talk-input talk-input-sm" id="talkSeqAdd_' + escAttr(nodeKey) + '" placeholder="Add sequence line...">' +
+ '<button class="talk-add-btn" data-action="add-sequence" data-nk="' + escAttr(nodeKey) + '">Add</button>' +
+ '</div>';
+ html += '</div>';
+ return html;
+ }
+
+ // ---- Condition clauses ----
+
+ function renderConditionClauses(ec, nodeKey, optIdx) {
+ var clauses = ec.clauses;
+ var groupKey = ec.groupKey || 'all_of';
+ var html = '';
+
+ // AND/OR mode selector (only when 2+ clauses)
+ if (clauses.length >= 2) {
+ html += '<div class="talk-cond-mode-row">' +
+ '<span class="talk-prop-label">Combine with</span>' +
+ '<select class="talk-select" data-action="set-cond-group" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + ' data-cgk="' + escAttr(groupKey) + '" onchange="event.stopPropagation(); talkCondGroupChange(this)">' +
+ '<option value="all_of"' + (groupKey === 'all_of' ? ' selected' : '') + '>All of (AND)</option>' +
+ '<option value="any_of"' + (groupKey === 'any_of' ? ' selected' : '') + '>Any of (OR)</option>' +
+ '</select>' +
+ '</div>';
+ }
+
+ // Clause rows
+ for (var i = 0; i < clauses.length; i++) {
+ var typeKey = clauseObjType(clauses[i]) || 'flag';
+ var pathPrefix = clauses.length === 1 ?
+ 'nodes.' + escAttr(nodeKey) + (optIdx !== null ? '.options.' + optIdx : '') + '.condition' :
+ 'nodes.' + escAttr(nodeKey) + (optIdx !== null ? '.options.' + optIdx : '') + '.condition.' + groupKey + '.' + i;
+ html += renderClauseRow(clauses[i], pathPrefix, typeKey, nodeKey, optIdx, i);
+ }
+
+ // Add clause buttons
+ html += '<div class="talk-clause-add-bar">';
+ CLAUSE_TYPES.forEach(function(ct) {
+ html += '<button class="talk-clause-add-btn" data-action="add-clause" data-nk="' + escAttr(nodeKey) + '"' +
+ (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + ' data-ct="' + escAttr(ct.key) + '">+ ' + esc(ct.label) + '</button>';
+ });
+ html += '</div>';
+
+ return html;
+ }
+
+ function renderClauseRow(clause, pathPrefix, typeKey, nodeKey, optIdx, clauseIdx) {
+ var hasValue = clauseTypeHasValue(typeKey);
+ var html = '<div class="talk-clause-row">';
+ html += '<span class="talk-clause-type">' + esc(clauseTypeLabel(typeKey)) + '</span>';
+
+ if (typeKey === 'flag' || typeKey === 'player_flag') {
+ html += '<span class="talk-clause-label">key:</span>';
+ html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.' + typeKey) + '" value="' + escAttr(clause[typeKey] || '') + '" placeholder="Flag name...">';
+ html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not</label>';
+ html += '<span class="talk-clause-label">val:</span>';
+ html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.value') + '" value="' + escAttr(clause.value || '') + '" placeholder="Value match..." style="max-width:80px">';
+ } else if (typeKey === 'has_item') {
+ html += '<span class="talk-clause-label">item:</span>';
+ html += '<input class="talk-input talk-input-sm" data-tp="' + escAttr(pathPrefix + '.has_item') + '" value="' + escAttr(clause.has_item || '') + '" placeholder="Item ID...">';
+ html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not</label>';
+ } else if (typeKey === 'min_credits') {
+ html += '<span class="talk-clause-label">cr:</span>';
+ html += '<input class="talk-input-num" data-tp="' + escAttr(pathPrefix + '.min_credits') + '" type="number" value="' + (clause.min_credits || 0) + '" placeholder="0">';
+ html += '<label class="talk-check-label"><input type="checkbox" data-tp="' + escAttr(pathPrefix + '.not') + '"' + (clause.not ? ' checked' : '') + '> Not</label>';
+ }
+
+ html += '<button class="talk-clause-del" data-action="remove-clause" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + ' data-si="' + clauseIdx + '" title="Remove clause">x</button>';
+ html += '</div>';
+ return html;
+ }
+
+ // ---- Clause add/remove/migrate ----
+
+ function getConditionFor(nodeKey, optIdx) {
+ var node = talkNodes()[nodeKey];
+ if (!node) return null;
+ if (optIdx !== null) {
+ if (!node.options || !node.options[optIdx]) return null;
+ return node.options[optIdx];
+ }
+ return node;
+ }
+
+ function condAddClause(target, typeKey, optIdx, nodeKey) {
+ var ec = extractClauses(target.condition);
+ var newClause = {};
+ newClause[typeKey] = '';
+ ec.clauses.push(newClause);
+
+ if (ec.clauses.length === 1) {
+ // First clause: use flat fields
+ target.condition = buildCondition(ec.clauses, null);
+ } else {
+ ec.groupKey = ec.groupKey || 'all_of';
+ target.condition = buildCondition(ec.clauses, ec.groupKey);
+ }
+ }
+
+ function addClause(nodeKey, optIdx, typeKey) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ if (!target.condition || typeof target.condition !== 'object' || Array.isArray(target.condition)) {
+ target.condition = {};
+ }
+ condAddClause(target, typeKey, optIdx, nodeKey);
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function removeClause(nodeKey, optIdx, clauseIdx) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ var ec = extractClauses(target.condition);
+ if (clauseIdx < 0 || clauseIdx >= ec.clauses.length) return;
+
+ ec.clauses.splice(clauseIdx, 1);
+
+ if (ec.clauses.length === 0) {
+ delete target.condition;
+ } else {
+ target.condition = buildCondition(ec.clauses, ec.groupKey);
+ }
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function setCondGroup(nodeKey, optIdx, newGroupKey) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ var ec = extractClauses(target.condition);
+ if (ec.clauses.length < 2) return;
+ ec.groupKey = newGroupKey;
+ target.condition = buildCondition(ec.clauses, ec.groupKey);
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // Called from onchange on the AND/OR select
+ window.talkCondGroupChange = function(select) {
+ var nodeKey = select.getAttribute('data-nk');
+ var optIdx = select.getAttribute('data-oi');
+ setCondGroup(nodeKey, optIdx !== null ? parseInt(optIdx, 10) : null, select.value);
+ };
+
+ // ---- Add/Remove whole Condition ----
+
+ function toggleCondition(nodeKey, optIdx) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ if (target.condition && typeof target.condition === 'object' && !Array.isArray(target.condition)) {
+ delete target.condition;
+ } else {
+ target.condition = {};
+ }
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function removeCondition(nodeKey, optIdx) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ delete target.condition;
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Action editor ----
+
+ function renderActionSection(nodeKey, optIdx, action) {
+ var prefix = optIdx !== null ? 'nodes.' + escAttr(nodeKey) + '.options.' + optIdx + '.action' : 'nodes.' + escAttr(nodeKey) + '.action';
+ var hasAct = action && typeof action === 'object' && !Array.isArray(action);
+
+ if (!hasAct) {
+ return '<div class="talk-section">' +
+ '<button class="talk-add-act-btn" data-action="add-action" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>+ Add Action</button>' +
+ '</div>';
+ }
+
+ return '<div class="talk-section">' +
+ '<div class="talk-cond-card-header">' +
+ '<span class="talk-prop-label">Action</span>' +
+ '<button class="talk-section-remove" data-action="remove-action" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>Remove</button>' +
+ '</div>' +
+ renderActionForm(action, prefix, nodeKey, optIdx) +
+ '</div>';
+ }
+
+ function renderActionForm(action, prefix, nodeKey, optIdx) {
+ var html = '<div class="talk-action-block">';
+
+ html += '<div class="talk-action-grid">';
+ ACTION_FIELDS.forEach(function(f) {
+ var fpath = prefix + '.' + f.key;
+ var val = (action && action[f.key] !== undefined) ? action[f.key] : '';
+ html += '<div class="talk-field-row">' +
+ '<span class="talk-prop-label">' + esc(f.label) + '</span>' +
+ '<input class="talk-input talk-input-sm" data-tp="' + escAttr(fpath) + '" type="' + f.type + '" value="' + escAttr(String(val)) + '">' +
+ '</div>';
+ });
+ html += '</div>';
+
+ html += '<div style="margin-top:4px">';
+ ACTION_BOOLS.forEach(function(b) {
+ html += '<label class="talk-check-label">' +
+ '<input type="checkbox" data-tp="' + escAttr(prefix + '.' + b.key) + '"' + (action && action[b.key] ? ' checked' : '') + '> ' + esc(b.label) +
+ '</label>';
+ });
+ html += '</div>';
+
+ html += renderKVEditor(action, prefix, 'set_flags', 'Set Flags', nodeKey, optIdx);
+ html += renderKVEditor(action, prefix, 'set_player_flags', 'Set Player Flags', nodeKey, optIdx);
+ html += renderShopEditor(action, prefix, nodeKey, optIdx);
+
+ html += '</div>'; // .talk-action-block
+ return html;
+ }
+
+ function renderKVEditor(action, prefix, key, label, nodeKey, optIdx) {
+ var kv = (action && action[key]) || {};
+ var keys = Object.keys(kv);
+ var fcID = prefix.replace(/\./g, '_') + '_' + key;
+ var addKeyID = 'kvAddKey_' + fcID;
+ var nkAttr = escAttr(nodeKey);
+ var oiAttr = optIdx !== null ? ' data-oi="' + optIdx + '"' : '';
+ var html = '<div class="talk-kv-section">';
+ html += '<div class="talk-kv-header">';
+ html += '<span class="talk-prop-label">' + esc(label) + '</span>';
+ html += '</div>';
+ keys.forEach(function(k) {
+ html += '<div class="talk-kv-row">' +
+ '<input class="talk-input" data-tp="' + escAttr(prefix + '.' + key + '.' + k) + '" value="' + escAttr(String(kv[k])) + '" placeholder="value">' +
+ '<span style="font-size:9px;color:#888;white-space:nowrap;min-width:60px">key: ' + esc(k) + '</span>' +
+ '<button class="talk-kv-del" data-action="remove-kv" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '" data-sk="' + escAttr(k) + '">x</button>' +
+ '</div>';
});
+ html += '<div class="talk-kv-row">' +
+ '<input class="talk-input" id="' + escAttr(addKeyID) + '" placeholder="New key name" style="max-width:100px" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '">' +
+ '<input class="talk-input" placeholder="Value" style="display:none">' +
+ '<span style="font-size:9px;color:#888;white-space:nowrap;min-width:60px"></span>' +
+ '<button class="talk-kv-add" data-action="add-kv" data-nk="' + nkAttr + '"' + oiAttr + ' data-kvk="' + escAttr(key) + '">Add</button>' +
+ '</div>';
+ html += '</div>';
+ return html;
}
- return html;
-}
-
-function addOption(e, nodeKey) {
- if (e) e.stopPropagation();
- var data = window._talkData;
- if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
- var text = prompt('Option text:');
- if (text === null) return;
- var dest = prompt('Goto node key:') || '';
- var node = data.talk.nodes[nodeKey];
- if (!node.options) node.options = [];
- node.options.push({ text: text, goto: dest });
- renderTalkTree(data);
- if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
-}
-
-function deleteOption(e, nodeKey, idx) {
- if (e) e.stopPropagation();
- var data = window._talkData;
- if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
- var node = data.talk.nodes[nodeKey];
- if (!node.options || idx < 0 || idx >= node.options.length) return;
- if (!confirm('Delete this option?')) return;
- node.options.splice(idx, 1);
- renderTalkTree(data);
- if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
-}
+ function renderShopEditor(action, prefix, nodeKey, optIdx) {
+ var shop = action && action.shop;
+ var hasShop = shop && typeof shop === 'object' && !Array.isArray(shop);
+ var html = '<div class="talk-shop-block">';
+ html += '<div class="talk-shop-header">Shop</div>';
+
+ if (hasShop) {
+ html += '<div class="talk-field-row" style="margin-bottom:4px">' +
+ '<span class="talk-prop-label">Shop Message</span>' +
+ '<textarea class="talk-textarea" data-tp="' + escAttr(prefix + '.shop.message') + '" rows="2" style="min-height:24px">' + esc(shop.message || '') + '</textarea>' +
+ '</div>';
+
+ var items = shop.items || [];
+ html += '<div class="talk-prop-label" style="margin-bottom:2px">Items</div>';
+ items.forEach(function(item, si) {
+ var ip = prefix + '.shop.items.' + si;
+ html += '<div class="talk-shop-item">' +
+ '<input class="talk-input" data-tp="' + escAttr(ip + '.item_id') + '" value="' + escAttr(item.item_id || '') + '" placeholder="Item ID">' +
+ '<input class="talk-input-num" data-tp="' + escAttr(ip + '.buy_price') + '" type="number" value="' + (item.buy_price || 0) + '" placeholder="Buy" title="Buy price">' +
+ '<input class="talk-input-num" data-tp="' + escAttr(ip + '.sell_price') + '" type="number" value="' + (item.sell_price || 0) + '" placeholder="Sell" title="Sell price">' +
+ '<button class="talk-kv-del" data-action="remove-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + ' data-si="' + si + '">x</button>' +
+ '</div>';
+ });
+ html += '<button class="talk-kv-add" data-action="add-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>+ Add Item</button>';
+ } else {
+ html += '<button class="talk-kv-add" data-action="add-shop-item" data-nk="' + escAttr(nodeKey) + '"' + (optIdx !== null ? ' data-oi="' + optIdx + '"' : '') + '>+ Add Shop</button>';
+ }
+
+ html += '</div>';
+ return html;
+ }
+
+ // ---- Options section ----
+
+ function renderOptionsSection(nodeKey, node) {
+ var options = node.options || [];
+ var html = '<div class="talk-options">';
+ html += '<b class="talk-options-label">Options</b>';
+
+ if (options.length > 0) {
+ options.forEach(function(opt, idx) {
+ html += '<div class="talk-opt-edit-row">';
+ html += '<span class="talk-opt-arrow">&#10132;</span>';
+ html += '<input class="talk-input" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.text" value="' + escAttr(opt.text || '') + '" placeholder="Option text...">';
+ html += '<input class="talk-input" data-tp="nodes.' + escAttr(nodeKey) + '.options.' + idx + '.goto" list="talkNodeKeys" value="' + escAttr(opt.goto || '') + '" placeholder="Goto..." style="max-width:120px">';
+
+ html += '<div class="talk-opt-edit-actions">';
+ if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) {
+ html += '<span class="talk-opt-tag talk-opt-tag-cond" title="Has condition" onclick="toggleOptionDetail(event,\'cond\',' + idx + ',\'' + escAttr(nodeKey) + '\')">cond</span>';
+ } else {
+ html += '<button class="talk-add-cond-btn" style="margin:0;margin-bottom:0" data-action="add-condition" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add condition">c</button>';
+ }
+ if (opt.action) {
+ html += '<span class="talk-opt-tag talk-opt-tag-act" title="Has action" onclick="toggleOptionDetail(event,\'act\',' + idx + ',\'' + escAttr(nodeKey) + '\')">act</span>';
+ } else {
+ html += '<button class="talk-add-act-btn" style="margin:0;margin-bottom:0" data-action="add-action" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Add action">a</button>';
+ }
+ html += '</div>';
+
+ html += '<button class="talk-opt-del" data-action="delete-option" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '" title="Delete option">x</button>';
+ html += '</div>';
+
+ if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) {
+ html += '<div class="talk-opt-detail" id="optDetail_cond_' + idx + '_' + escAttr(nodeKey) + '" style="display:none;margin-left:18px">';
+ html += '<div class="talk-cond-card" style="margin-top:2px">';
+ html += '<div class="talk-cond-card-header">Condition' +
+ '<button class="talk-section-remove" data-action="remove-condition" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '">Remove</button></div>';
+ html += '<div class="talk-cond-card-body">';
+ html += renderConditionClauses(extractClauses(opt.condition), nodeKey, idx);
+ html += '</div>';
+ html += '</div>';
+ html += '</div>';
+ }
+ if (opt.action) {
+ html += '<div class="talk-opt-detail" id="optDetail_act_' + idx + '_' + escAttr(nodeKey) + '" style="display:none;margin-left:18px">';
+ html += '<div class="talk-cond-card-header" style="border:1px solid var(--border);border-radius:5px 5px 0 0">' +
+ '<span class="talk-prop-label">Action</span>' +
+ '<button class="talk-section-remove" data-action="remove-action" data-nk="' + escAttr(nodeKey) + '" data-oi="' + idx + '">Remove</button></div>';
+ html += renderActionForm(opt.action, 'nodes.' + escAttr(nodeKey) + '.options.' + idx + '.action', nodeKey, idx);
+ html += '</div>';
+ }
+ });
+ } else {
+ html += '<div class="talk-option-empty">none</div>';
+ }
+
+ html += '<div class="talk-add-opt-row">' +
+ '<span class="talk-opt-arrow" style="color:#555">&#10132;</span>' +
+ '<input class="talk-input" id="talkOptText_' + escAttr(nodeKey) + '" placeholder="Option text...">' +
+ '<input class="talk-input" id="talkOptGoto_' + escAttr(nodeKey) + '" list="talkNodeKeys" placeholder="Goto..." style="max-width:120px">' +
+ '<span style="width:62px;flex-shrink:0"></span>' +
+ '<button class="talk-add-btn" data-action="add-option" data-nk="' + escAttr(nodeKey) + '">Add</button>' +
+ '</div>';
+
+ html += '</div>'; // .talk-options
+ return html;
+ }
+
+ window.toggleOptionDetail = function(e, type, idx, nodeKey) {
+ e.stopPropagation();
+ var el = document.getElementById('optDetail_' + type + '_' + idx + '_' + nodeKey);
+ if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
+ };
+
+ // ---- Add/Delete Option ----
+
+ function addOption(nodeKey) {
+ var textInput = document.getElementById('talkOptText_' + nodeKey);
+ var gotoInput = document.getElementById('talkOptGoto_' + nodeKey);
+ var text = textInput ? textInput.value.trim() : '';
+ var dest = gotoInput ? gotoInput.value.trim() : '';
+ var node = talkNodes()[nodeKey];
+ if (!node) return;
+ if (!node.options) node.options = [];
+ node.options.push({ text: text, goto: dest });
+ if (textInput) textInput.value = '';
+ if (gotoInput) gotoInput.value = '';
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function deleteOption(nodeKey, idx) {
+ var node = talkNodes()[nodeKey];
+ if (!node || !node.options || idx < 0 || idx >= node.options.length) return;
+ node.options.splice(idx, 1);
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Add/Delete Sequence Line ----
+
+ function addSequenceLine(nodeKey) {
+ var input = document.getElementById('talkSeqAdd_' + nodeKey);
+ var text = input ? input.value.trim() : '';
+ var node = talkNodes()[nodeKey];
+ if (!node) return;
+ if (!node.sequence) node.sequence = [];
+ node.sequence.push(text);
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function deleteSequenceLine(nodeKey, idx) {
+ var node = talkNodes()[nodeKey];
+ if (!node || !node.sequence) return;
+ node.sequence.splice(idx, 1);
+ if (node.sequence.length === 0) delete node.sequence;
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Add/Remove Action ----
+
+ function addAction(nodeKey, optIdx) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ target.action = {};
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function removeAction(nodeKey, optIdx) {
+ var target = getConditionFor(nodeKey, optIdx);
+ if (!target) return;
+ delete target.action;
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Add/Remove KV Pairs (set_flags/set_player_flags) ----
+
+ function kvPath(nodeKey, optIdx, kvKey, flagKey) {
+ var base = optIdx !== null ?
+ 'nodes.' + nodeKey + '.options.' + optIdx + '.action.' + kvKey :
+ 'nodes.' + nodeKey + '.action.' + kvKey;
+ return flagKey ? base + '.' + flagKey : base;
+ }
+
+ function addKVPair(nodeKey, optIdx, kvKey) {
+ var prefixBase = optIdx !== null ?
+ 'nodes.' + nodeKey + '.options.' + optIdx + '.action' :
+ 'nodes.' + nodeKey + '.action';
+ var fcID = prefixBase.replace(/\./g, '_') + '_' + kvKey;
+ var addKeyID = 'kvAddKey_' + fcID;
+ var input = document.getElementById(addKeyID);
+ var key = input ? input.value.trim() : '';
+ if (!key) return;
+ var basePath = kvPath(nodeKey, optIdx, kvKey);
+ var obj = talkGet(basePath);
+ if (!obj || typeof obj !== 'object') {
+ talkSet(basePath, {});
+ obj = talkGet(basePath);
+ }
+ if (obj[key] !== undefined) { notify('Flag "' + key + '" already exists.', 'error'); return; }
+ obj[key] = true;
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function removeKVPair(nodeKey, optIdx, kvKey, flagKey) {
+ var fullPath = kvPath(nodeKey, optIdx, kvKey, flagKey);
+ talkDel(fullPath);
+ var basePath = kvPath(nodeKey, optIdx, kvKey);
+ var obj = talkGet(basePath);
+ if (obj && typeof obj === 'object' && Object.keys(obj).length === 0) {
+ talkDel(basePath);
+ }
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Add/Remove Shop Items ----
+
+ function addShopItem(nodeKey, optIdx) {
+ var actionPath = optIdx !== null ? 'nodes.' + nodeKey + '.options.' + optIdx + '.action' : 'nodes.' + nodeKey + '.action';
+ var action = talkGet(actionPath);
+ if (!action || typeof action !== 'object') {
+ action = {};
+ talkSet(actionPath, action);
+ }
+ if (!action.shop) { action.shop = { items: [] }; }
+ if (!action.shop.items) action.shop.items = [];
+ action.shop.items.push({ item_id: '', buy_price: 0, sell_price: 0 });
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ function removeShopItem(nodeKey, optIdx, idx) {
+ var actionPath = optIdx !== null ? 'nodes.' + nodeKey + '.options.' + optIdx + '.action' : 'nodes.' + nodeKey + '.action';
+ var action = talkGet(actionPath);
+ if (!action || !action.shop || !action.shop.items) return;
+ action.shop.items.splice(idx, 1);
+ if (action.shop.items.length === 0) delete action.shop;
+ renderTalkTree(window._talkData);
+ talkSync();
+ }
+
+ // ---- Global event listeners ----
+
+ document.addEventListener('input', function(e) {
+ var el = e.target;
+ if (!el.closest || !el.closest('#talktree')) return;
+ if (!el.getAttribute('data-tp')) return;
+ talkEvent(e);
+ });
+
+ document.addEventListener('change', function(e) {
+ var el = e.target;
+ if (!el.closest || !el.closest('#talktree')) return;
+ talkEvent(e);
+ });
+
+ document.addEventListener('click', function(e) {
+ var el = e.target;
+ if (!el.closest || !el.closest('#talktree')) return;
+ talkHandleClick(e);
+ });
+
+ document.addEventListener('keydown', function(e) {
+ if (e.key !== 'Enter') return;
+ var el = e.target;
+ if (!el.closest || !el.closest('#talktree')) return;
+
+ if (el.id === 'talkNewNodeKey') {
+ e.preventDefault();
+ addNode();
+ return;
+ }
+
+ if (el.id && (el.id.startsWith('talkOptText_') || el.id.startsWith('talkOptGoto_'))) {
+ e.preventDefault();
+ var nodeKey = el.id.replace('talkOptText_', '').replace('talkOptGoto_', '');
+ addOption(nodeKey);
+ return;
+ }
+
+ if (el.id && el.id.startsWith('talkSeqAdd_')) {
+ e.preventDefault();
+ var nk = el.id.replace('talkSeqAdd_', '');
+ addSequenceLine(nk);
+ return;
+ }
+
+ if (el.id && el.id.startsWith('kvAddKey_')) {
+ e.preventDefault();
+ var nk = el.getAttribute('data-nk');
+ var oi = el.getAttribute('data-oi');
+ var kvk = el.getAttribute('data-kvk');
+ addKVPair(nk, oi !== null ? parseInt(oi, 10) : null, kvk);
+ return;
+ }
+ });
+
+})();