From 649ef4b7416ce7053145878841fc9b0bff2f5fec Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 1 Jul 2026 06:32:15 -0400 Subject: feat: admin gui card code unified, unhinged conversation tree GUI sort of implemented --- internal/admin/static/admin.css | 129 +++- internal/admin/static/cardeditor.js | 452 ++++++++++++++ internal/admin/static/itemeditor.js | 108 +--- internal/admin/static/mobeditor.js | 532 ++++++++++++++++ internal/admin/static/objecteditor.js | 206 +------ internal/admin/static/talktree.js | 1084 ++++++++++++++++++++++++++++++--- internal/admin/templates/items.html | 1 + internal/admin/templates/mobs.html | 39 +- internal/admin/templates/objects.html | 2 +- 9 files changed, 2118 insertions(+), 435 deletions(-) create mode 100644 internal/admin/static/cardeditor.js create mode 100644 internal/admin/static/mobeditor.js (limited to 'internal') 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 ''; +} + +// ---- 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 = '
'; + h += '' + esc(il.label) + ''; + h += '
'; + Object.keys(map).forEach(function(k, idx) { + h += '
'; + h += ''; + h += '
'; + }); + h += '
'; + h += ''; + h += '
'; + 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 = ''; + 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 = '
No results
'; + return; + } + results.style.display = 'block'; + results.innerHTML = items.map(function(r) { + return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; + }).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 ''; + } + + if (type === 'checkbox') { + return wrap('obj-field obj-check', ' ' + esc(label) + ''); + } + if (type === 'json') { + var jstr = typeof val === 'object' ? JSON.stringify(val) : String(val); + return wrap('obj-field', '' + esc(label) + ''); + } + 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', '' + esc(label) + ''); + } + if (type === 'select') { + var opts = hint ? hint.split('|') : []; + var inner = '' + esc(label) + ''; + return wrap('obj-field', inner); + } + if (type === 'color') { + var swatch = ''; + return wrap('obj-field color-field', '' + esc(label) + '
' + swatch + '
'); + } + if (type === 'search') { + var entity = searchEntity || 'items'; + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ' placeholder="Search ' + entity + '..."'; + return wrap('obj-field obj-search', '' + esc(label) + '
'); + } + var ph = hint ? ' placeholder="' + esc(hint) + '"' : ''; + return wrap('obj-field', '' + esc(label) + ''); +} + +// ---- 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 = '
No results
'; - return; - } - results.style.display = 'block'; - results.innerHTML = items.map(function(r) { - return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; - }).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 = '

Failed to load: ' + esc(e.message) + '

'; + }); +} + +function renderMobEditor(id, data) { + var html = '

Mob: ' + esc(id) + '

'; + html += '
'; + html += '
'; + + html += '
'; + html += '
'; + + MOB_SECTIONS.forEach(function(sec) { + if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return; + html += renderMobCard(sec, data); + }); + + html += '
'; + + html += '
'; + html += ''; + html += ''; + html += '
'; + + html += '
'; + + html += ''; + + html += '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + + $('#editorMain').innerHTML = html; + ced_setupSearchFields(); + setupMobKindChange(); + + MOB_SECTIONS.forEach(function(sec) { + if (sec.id === 'talk' && data.talk && data.talk.nodes) { + renderTalkTree(data); + } + }); +} + +function renderCurrent() { + if (!mobData || !mobID) return; + renderMobEditor(mobID, mobData); +} + +// ---- Card rendering ---- + +function renderMobCard(sec, data) { + var collapsed = expandedCards[sec.id] === false; + var h = '
'; + h += '
'; + h += '' + (collapsed ? '▶' : '▼') + ''; + h += '' + sec.label + ''; + if (!sec.always) { + h += ''; + } + h += '
'; + if (!collapsed) { + h += '
'; + + if (sec.id === 'core') { + h += renderMobCoreCard(data); + } else if (sec.subtables) { + h += '
'; + sec.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1); + }); + h += '
'; + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + h += ced_renderTags(sec, il, data); + } else { + h += '
'; + h += '' + esc(il.label) + ''; + h += '
'; + il.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1, il.path); + }); + h += '
'; + h += '
'; + } + }); + } + + sec.subtables.forEach(function(st) { + h += renderMobSubtable(sec, st, data); + }); + } else { + h += '
'; + sec.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1); + }); + h += '
'; + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + h += ced_renderTags(sec, il, data); + } else { + h += '
'; + h += '' + esc(il.label) + ''; + h += '
'; + il.fields.forEach(function(f) { + h += renderMobField(sec, f, data, -1, il.path); + }); + h += '
'; + h += '
'; + } + }); + } + + if (sec.id === 'talk') { + h += '
'; + } + } + h += '
'; + } + h += '
'; + return h; +} + +function renderMobCoreCard(data) { + var sec = MOB_SECTIONS[0]; + var h = '
'; + h += renderMobField(sec, sec.fields[0], data, -1, null, 'flex:1'); + h += '
'; + h += renderMobField(sec, sec.fields[1], data, -1); + sec.inline.forEach(function(il) { + h += ced_renderTags(sec, il, data); + }); + return h; +} + +function renderMobField(sec, f, data, idx, subPath, optStyle) { + return ced_renderField(sec, f, data, idx, subPath, optStyle, mobCardPath, mobFieldID, mobGetVal); +} + +// ---- Subtable rendering ---- + +function renderMobSubField(sec, stKey, idx, f, val) { + var fid = mobFieldIDSub(sec, stKey, idx, f[0]); + val = val === undefined || val === null ? '' : val; + var extraCls = ''; + if (f[4] === 'narrow') extraCls = ' obj-narrow'; + else if (f[4] === 'grow') extraCls = ' obj-grow'; + var defaultPh = ''; + if (f[3]) defaultPh = ' placeholder="' + escAttr(f[3]) + '"'; + if (f[2] === 'search') { + var entity = f[6] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + return ''; + } + if (f[2] === 'checkbox') { + return ''; + } + return ''; +} + +function renderMobSubtable(sec, st, data) { + var p = mobCardPath(sec); + var sectionRoot = p ? (mobData[p] || {}) : mobData; + var raw = sectionRoot[st.key]; + var arr; + if (st.single) { + arr = raw ? [raw] : []; + } else if (Array.isArray(raw)) { + arr = raw; + } else if (raw && typeof raw === 'object') { + arr = [raw]; + } else { + arr = []; + } + var h = '
'; + h += '
' + esc(st.label) + '
'; + arr.forEach(function(item, idx) { + h += '
'; + st.fields.forEach(function(f) { + var val = item[f[0]]; + h += renderMobSubField(sec, st.key, idx, f, val); + }); + if (!st.single) { + h += ''; + } + h += '
'; + }); + if (!st.single || arr.length === 0) { + h += ''; + } + h += '
'; + return h; +} + +// ---- 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 = '

Deleted

'; + loadList(); + }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); +} + +// ---- editor.js compatibility ---- + +function loadItem(id) { loadMob(id); } +function createNew() { + var name = prompt('Mob ID:'); + if (!name) return; + API.post('/api/mobs', {id: name}).then(function() { + notify('Mob ' + name + ' created', 'success'); + updateUndoBar(); + loadList(); + loadMob(name); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} 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 @@ -111,11 +111,6 @@ var SECTIONS = [ ['action', 'Action', 'json'], ] }, - { - 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; }, @@ -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 += '
'; - } } h += ''; } @@ -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 = '
No results
'; - return; - } - results.style.display = 'block'; - results.innerHTML = items.map(function(r) { - return '
' + esc(r.id) + ' ' + esc(r.name || '') + '
'; - }).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 = '

No conversation tree defined.

'; - return; - } - window._talkData = data; - var nodes = data.talk.nodes; - var html = '
'; - html += renderTreeNodes(nodes, 'start', {}); - html += '
'; - container.innerHTML = html; -} - -function renderTreeNodes(nodes, rootKey, seen) { - var node = nodes[rootKey]; - if (!node) return ''; - if (seen[rootKey]) { - return '
' + esc(rootKey) + ' (already shown)
'; - } - seen[rootKey] = true; - - var html = ''; - html += '
'; - html += '
'; - html += ''; - html += '' + esc(rootKey) + ''; - html += '' + esc((node.message || '').substring(0, 60)) + ''; - html += '
'; - html += '
'; - - html += '
'; - if (node.message) html += '
Message: ' + esc(node.message) + '
'; - if (node.condition) html += '
Condition: ' + esc(JSON.stringify(node.condition)) + '
'; - if (node.goto) html += '
Goto: ' + esc(node.goto) + '
'; - html += '
'; - - html += '
Options:'; - if (node.options && node.options.length > 0) { - node.options.forEach(function(opt, idx) { - html += '
'; - html += '' + esc(opt.text || '') + ' \u2192 ' + esc(opt.goto || '') + ''; - if (opt.condition) html += ' [cond]'; - if (opt.action) html += ' [action]'; - html += ' '; - html += '
'; +// 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 += '
No options
'; + 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 += '
'; - html += '
'; - html += '
'; + 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 = '

No conversation tree defined.

'; + return; + } + var nodes = data.talk.nodes; + var visited = {}; + var html = '
'; + html += ''; + Object.keys(nodes).forEach(function(k) { html += ''; + 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 += '
'; + html += '
Unreferenced Nodes
'; + html += '
These nodes are not reachable from start. Add a goto to them from another node\'s option.
'; + for (var j = 0; j < orphanKeys.length; j++) { + html += renderOrphanNode(orphanKeys[j], nodes[orphanKeys[j]], nodes); } + html += '
'; + } + + html += renderAddNodeRow(); + html += '
'; + container.innerHTML = html; + }; + + // ---- Add/Delete Node ---- + + function renderAddNodeRow() { + return '
' + + '' + + '' + + '
'; + } + + 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 = '
'; + h += '
'; + h += ''; + h += '' + esc(key) + ''; + h += '' + esc((getNodePreview(node) || '').substring(0, 60)) + ''; + h += ''; + h += ''; + h += '
'; + h += '
'; + h += renderNodeBody(key, node, nodes); + h += '
'; + h += '
'; + 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 '
' + esc(rootKey) + ' (already shown)
'; + } + seen[rootKey] = true; + + var previewText = getNodePreview(node); + + var html = '
'; + + // Header — fix: check for data-action before toggling + html += '
'; + html += ''; + html += '' + esc(rootKey) + ''; + html += '' + esc((previewText || '').substring(0, 60)) + ''; + html += ''; + html += ''; + html += '
'; + + // Body + html += '
'; + html += renderNodeBody(rootKey, node, nodes); + html += '
'; // .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 += '
'; + childKeys.forEach(function(k) { html += renderTreeNodes(nodes, k, seen, indent + 1); }); + html += '
'; + } + + html += '
'; // .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 += '
'; + html += '
' + + 'Condition' + + '' + + '
'; + html += '
'; + html += renderConditionClauses(ec, nodeKey, null); + html += '
'; + html += renderMessageSection(nodeKey, node); + html += renderSequenceSection(nodeKey, node); + html += renderActionSection(nodeKey, null, node.action); + html += renderOptionsSection(nodeKey, node); + html += '
'; // .talk-cond-card-body + html += '
'; // .talk-cond-card + } else { + html += ''; + 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 '
' + + 'Auto-advance to' + + '' + + '
'; + } + + // ---- Message / Sequence ---- + + function renderMessageSection(nodeKey, node) { + var msg = node.message || ''; + if (Array.isArray(msg)) msg = msg.join('\n'); + return '
' + + '
Message
' + + '' + + '
'; + } + + function renderSequenceSection(nodeKey, node) { + var seq = node.sequence || []; + var html = '
'; + html += '
Sequence
'; + seq.forEach(function(line, i) { + html += '
' + + '' + (i + 1) + '' + + '' + + '' + + '
'; + }); + html += '
' + + '' + (seq.length + 1) + '' + + '' + + '' + + '
'; + html += '
'; + 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 += '
' + + 'Combine with' + + '' + + '
'; + } + + // 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 += '
'; + CLAUSE_TYPES.forEach(function(ct) { + html += ''; + }); + html += '
'; + + return html; + } + + function renderClauseRow(clause, pathPrefix, typeKey, nodeKey, optIdx, clauseIdx) { + var hasValue = clauseTypeHasValue(typeKey); + var html = '
'; + html += '' + esc(clauseTypeLabel(typeKey)) + ''; + + if (typeKey === 'flag' || typeKey === 'player_flag') { + html += 'key:'; + html += ''; + html += ''; + html += 'val:'; + html += ''; + } else if (typeKey === 'has_item') { + html += 'item:'; + html += ''; + html += ''; + } else if (typeKey === 'min_credits') { + html += 'cr:'; + html += ''; + html += ''; + } + + html += ''; + html += '
'; + 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 '
' + + '' + + '
'; + } + + return '
' + + '
' + + 'Action' + + '' + + '
' + + renderActionForm(action, prefix, nodeKey, optIdx) + + '
'; + } + + function renderActionForm(action, prefix, nodeKey, optIdx) { + var html = '
'; + + html += '
'; + ACTION_FIELDS.forEach(function(f) { + var fpath = prefix + '.' + f.key; + var val = (action && action[f.key] !== undefined) ? action[f.key] : ''; + html += '
' + + '' + esc(f.label) + '' + + '' + + '
'; + }); + html += '
'; + + html += '
'; + ACTION_BOOLS.forEach(function(b) { + html += ''; + }); + html += '
'; + + 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 += '
'; // .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 = '
'; + html += '
'; + html += '' + esc(label) + ''; + html += '
'; + keys.forEach(function(k) { + html += '
' + + '' + + 'key: ' + esc(k) + '' + + '' + + '
'; }); + html += '
' + + '' + + '' + + '' + + '' + + '
'; + html += '
'; + 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 = '
'; + html += '
Shop
'; + + if (hasShop) { + html += '
' + + 'Shop Message' + + '' + + '
'; + + var items = shop.items || []; + html += '
Items
'; + items.forEach(function(item, si) { + var ip = prefix + '.shop.items.' + si; + html += '
' + + '' + + '' + + '' + + '' + + '
'; + }); + html += ''; + } else { + html += ''; + } + + html += '
'; + return html; + } + + // ---- Options section ---- + + function renderOptionsSection(nodeKey, node) { + var options = node.options || []; + var html = '
'; + html += 'Options'; + + if (options.length > 0) { + options.forEach(function(opt, idx) { + html += '
'; + html += ''; + html += ''; + html += ''; + + html += '
'; + if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) { + html += 'cond'; + } else { + html += ''; + } + if (opt.action) { + html += 'act'; + } else { + html += ''; + } + html += '
'; + + html += ''; + html += '
'; + + if (opt.condition && typeof opt.condition === 'object' && !Array.isArray(opt.condition)) { + html += ''; + } + if (opt.action) { + html += ''; + } + }); + } else { + html += '
none
'; + } + + html += '
' + + '' + + '' + + '' + + '' + + '' + + '
'; + + html += '
'; // .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; + } + }); + +})(); diff --git a/internal/admin/templates/items.html b/internal/admin/templates/items.html index 4a74ff5..5d82ace 100644 --- a/internal/admin/templates/items.html +++ b/internal/admin/templates/items.html @@ -10,6 +10,7 @@ + + + {{end}} diff --git a/internal/admin/templates/objects.html b/internal/admin/templates/objects.html index ac9b6be..5294c4e 100644 --- a/internal/admin/templates/objects.html +++ b/internal/admin/templates/objects.html @@ -10,7 +10,7 @@ - +