diff options
| author | historia <[not public]> | 2026-06-30 19:27:37 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-30 19:27:37 -0400 |
| commit | fee376102192dc6f24297a7b11324636ace3a07d (patch) | |
| tree | 6c777cc71208b429eebdbdc5864b353d4af34a09 /internal/admin/static/objecteditor.js | |
| parent | 98bdef072c843fdd8ccdf85a9b54ab9d32d34187 (diff) | |
| download | thehouseoficarus-fee376102192dc6f24297a7b11324636ace3a07d.tar.gz | |
feat: admin gui object CRUD is much nicer
Diffstat (limited to 'internal/admin/static/objecteditor.js')
| -rw-r--r-- | internal/admin/static/objecteditor.js | 997 |
1 files changed, 997 insertions, 0 deletions
diff --git a/internal/admin/static/objecteditor.js b/internal/admin/static/objecteditor.js new file mode 100644 index 0000000..3e944c4 --- /dev/null +++ b/internal/admin/static/objecteditor.js @@ -0,0 +1,997 @@ +var objectData = null; +var objectID = null; +var expandedCards = {}; + +var SECTIONS = [ + { + id: 'core', label: 'Core', always: true, + fields: [ + ['name', 'Name', 'text', 'copper rock'], + ['color', 'Color', 'color', 'B2'], + ['aliases', 'Aliases', 'text', 'rock, ore'], + ['hidden', 'Hidden', 'checkbox'], + ['inroom_description', 'In-Room Description', 'text', 'A copper rock juts from the ground.'], + ['description', 'Description', 'textarea', 'A vein of copper runs through this rock.'], + ['removal_item', 'Removal Item ID', 'search', 'e.g. ashes', '', null, 'items'], + ] + }, + { + id: 'steal', label: 'Steal', + detect: function(d) { return d.steal_table || d.steal_level || d.steal_speed || d.steal_xp || d.guard_mob; }, + fields: [ + ['steal_table', 'Table ID', 'text'], + ['steal_level', 'Level', 'number', '', 'narrow'], + ['steal_xp', 'XP', 'number', '', 'narrow'], + ['steal_speed', 'Speed', 'number', '', 'narrow'], + ['guard_mob', 'Guard Mob', 'text'], + ] + }, + { + id: 'gather', label: 'Gather', path: 'gather', + detect: function(d) { return d.gather; }, + fields: [ + ['skill', 'Skill', 'select', 'mining|woodcutting|fishing|crafting'], + ['bait', 'Bait Item', 'search', 'fishing_bait', '', function(d) { return d.skill === 'fishing'; }, 'items'], + ['gather_message', 'Gather Message', 'text', 'You swing your pickaxe at the rock...', 'wide'], + ['fail_message', 'Fail Message', 'text', 'You chip away but get nothing useful.', 'wide'], + ['depleted_message', 'Depleted Message', 'text', "You don't see any ore in this rock.", 'wide'], + ['exhausted_message', 'Exhausted Message', 'text', 'The tree comes crashing down!', 'wide', function(d) { return d.skill === 'woodcutting'; }], + ['respawn_broadcast', 'Respawn Broadcast', 'text', 'A glint of copper catches your eye from some {name}.', 'wide'], + ], + inline: [ + {label:'Success', path:'success', row:0, fields:[ + ['base', 'Base', 'number', '0.5', 'narrow'], ['per_level', 'Per Lvl', 'number', '0.01', 'narrow'], ['cap', 'Cap', 'number', '0.95', 'narrow'] + ]}, + {label:'Other', row:0, fields:[ + ['respawn_timer', 'Respawn', 'number', '50', 'narrow'], + ['deplete_timer', 'Deplete', 'number', '45', 'narrow', function(d) { return d.skill === 'woodcutting'; }], + ['nest_chance', 'Nest 1/n', 'number', '256', 'narrow', function(d) { return d.skill === 'woodcutting'; }], + ]}, + {label:'Tools', key:'tools', type:'tags'}, + ], + subtables: [ + {label:'Drops', key:'drops', fields:[ + ['item_id', 'Item ID', 'search', 'copper_ore', '', 'items'], ['table', 'Table', 'search', 'gem_table', '', 'drops'], + ['weight', 'Weight', 'number', '90', 'narrow'], ['level', 'Level', 'number', '1', 'narrow'], ['xp', 'XP', 'number', '17', 'narrow'], + ['quantity', 'Quantity', 'number', '1', 'narrow'], ['depletes', 'Depletes node', 'checkbox'], + ['message', 'Message', 'text', 'You manage to mine some copper ore.'], + ]} + ] + }, + { + id: 'use', label: 'Use (Station)', path: 'use', + detect: function(d) { return d.use; }, + fields: [ + ['message', 'Message', 'text'], + ['wait', 'Wait', 'number', '', 'narrow'], + ['skill', 'Skill', 'text'], + ['level', 'Level', 'number', '', 'narrow'], + ['xp', 'XP', 'number', '', 'narrow'], + ['fail_message', 'Fail Message', 'text'], + ], + inline: [ + {label:'Success', path:'success', fields:[ + ['base', 'Base', 'number', '', 'narrow'], ['per_level', 'Per Lvl', 'number', '', 'narrow'], ['cap', 'Cap', 'number', '', 'narrow'] + ]}, + {label:'Consume', key:'consume', type:'kv', valType:'number'}, + ], + subtables: [ + {label:'Reward', key:'reward', single:true, fields:[ + ['item_id', 'Item ID', 'search', '', '', 'items'], ['weight', 'Weight', 'number'], ['quantity', 'Quantity', 'number'], + ['message', 'Message', 'text'], + ]} + ] + }, + { + id: 'safespot', label: 'Safespot', path: 'safespot', + detect: function(d) { return d.safespot; }, + fields: [ + ['tier', 'Tier', 'number', '', 'narrow'], + ['max_block_size', 'Max Block Size', 'text'], + ['max_occupants', 'Max Occupants', 'number', '', 'narrow'], + ['unsafe_chance', 'Unsafe Chance', 'number', '', 'narrow'], + ['decay_ticks', 'Decay Ticks', 'number', '', 'narrow'], + ['decay_chance', 'Decay Chance', 'number', '', 'narrow'], + ['respawn_on_hide', 'Respawn on Hide', 'checkbox'], + ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], + ], + subtables: [ + {label:'Levels', key:'levels', fields:[ + ['message', 'Message', 'text'], ['degrade_message', 'Degrade Message', 'text'] + ]} + ] + }, + { + id: 'use_interactions', label: 'Use Interactions', path: 'use_interactions', isArray: true, + detect: function(d) { return d.use_interactions && d.use_interactions.length; }, + fields: [ + ['item_id', 'Item ID', 'text'], + ['message', 'Message', 'text'], + ['condition', 'Condition', 'json'], + ['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; }, + fields: [ + ['set_flags', 'Set Flags', 'json'], + ['set_player_flags', 'Set Player Flags', 'json'], + ['give_item', 'Give Item', 'text'], + ['take_item', 'Take Item', 'text'], + ['teleport', 'Teleport', 'number', '', 'narrow'], + ['heal', 'Heal', 'number', '', 'narrow'], + ['cost', 'Cost', 'number', '', 'narrow'], + ['assign_task', 'Assign Task', 'checkbox'], + ['skip_task', 'Skip Task', 'checkbox'], + ['extend_task', 'Extend Task', 'checkbox'], + ['reputation_cost', 'Reputation Cost', 'number', '', 'narrow'], + ['sawmill', 'Sawmill', 'checkbox'], + ['aps_node', 'APS Node', 'checkbox'], + ] + }, +]; + +function initObjectEditor() { + editorType = 'objects'; + editorFields = []; + loadList(); + if (window.location.hash) loadObject(window.location.hash.substring(1)); +} + +window.onTalkTreeChange = function(data) { + objectData.talk = data.talk; +}; + +function loadObject(id) { + objectID = id; + window.location.hash = id; + renderList(''); + API.get('/api/objects/' + encodeURIComponent(id)).then(function(data) { + objectData = data; + renderObjectEditor(id, data); + }).catch(function(e) { + $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>'; + }); +} + +function renderObjectEditor(id, data) { + var html = '<h2>Object: ' + 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">'; + + SECTIONS.forEach(function(sec) { + if (!sec.always && sec.detect && !sec.detect(data)) return; + html += renderCard(sec, data); + }); + + html += '</div>'; + + html += '<div class="add-section-bar">'; + html += '<select id="addSectionSelect">'; + html += '<option value="">+ Add Section...</option>'; + SECTIONS.forEach(function(sec) { + if (sec.always) return; + if (sec.detect && sec.detect(data)) return; + html += '<option value="' + sec.id + '">' + esc(sec.label) + '</option>'; + }); + html += '</select>'; + html += '<button class="btn btn-primary btn-sm" onclick="addSection()">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="saveObject()">Save</button>'; + html += '<button class="btn" onclick="duplicateObject()">Duplicate</button>'; + html += '<button class="btn btn-danger" onclick="deleteObject()">Delete Object</button>'; + html += '</div>'; + + $('#editorMain').innerHTML = html; + setupSearchFields(); + 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) { + return sec.path || ''; +} + +function getVal(data, sec, key) { + var p = cardPath(sec); + if (p === 'on_look' || sec.isArray) { + 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; +} + +function 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 fieldIDSub(sec, subKey, idx, fieldKey) { + return 'f_' + sec.id + '_' + subKey + '_' + idx + '_' + fieldKey; +} + +function renderCard(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="toggleCard(\'' + sec.id + '\')">'; + h += '<span class="obj-card-arrow">' + (collapsed ? '▶' : '▼') + '</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();removeSection(\'' + sec.id + '\')">X</button>'; + } + h += '</div>'; + if (!collapsed) { + h += '<div class="obj-card-body">'; + + if (sec.isArray) { + h += renderArraySection(sec, data); + } else if (sec.id === 'core') { + h += renderCoreCard(data); + } else { + h += '<div class="obj-card-grid">'; + sec.fields.forEach(function(f) { + h += renderField(sec, f, data, -1); + }); + h += '</div>'; + + if (sec.inline) { + var rowGroups = {}; + var noRow = []; + sec.inline.forEach(function(il) { + if (il.row !== undefined) { + if (!rowGroups[il.row]) rowGroups[il.row] = []; + rowGroups[il.row].push(il); + } else { + noRow.push(il); + } + }); + Object.keys(rowGroups).sort().forEach(function(r) { + h += '<div style="display:flex;gap:10px">'; + rowGroups[r].forEach(function(il) { + if (il.type === 'tags') { + h += '<div class="obj-inline-group" style="flex:1">'; + h += renderTags(sec, il, data); + h += '</div>'; + } else if (il.type === 'kv') { + h += '<div class="obj-inline-group" style="flex:1">'; + h += renderKV(sec, il, data); + h += '</div>'; + } else { + h += '<div class="obj-inline-group obj-inline-compact" style="flex:1">'; + 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 += renderField(sec, f, data, -1, il.path); + }); + h += '</div>'; + h += '</div>'; + } + }); + h += '</div>'; + }); + noRow.forEach(function(il) { + if (il.type === 'tags') { + h += renderTags(sec, il, data); + } else if (il.type === 'kv') { + h += renderKV(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 += renderField(sec, f, data, -1, il.path); + }); + h += '</div>'; + h += '</div>'; + } + }); + } + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + h += renderSubtable(sec, st, data); + }); + } + + if (sec.id === 'talk') { + h += '<div id="talktree"></div>'; + } + } + h += '</div>'; + } + h += '</div>'; + return h; +} + +function renderField(sec, f, data, idx, subPath, optStyle) { + 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 = fieldID(sec, fp, idx); + var val; + + if (sec.isArray && idx >= 0) { + var arr = objectData[cardPath(sec)] || []; + var item = arr[idx] || {}; + val = item[key]; + } else if (subPath) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var subObj = sectionRoot[subPath] || {}; + val = subObj[key]; + } else { + val = getVal(objectData, sec, key); + } + val = val === undefined || val === null ? '' : val; + + var hiddenStyle = ''; + if (showIf) { + var sectionData = objectData[cardPath(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 + '" onchange="toggleGatherConditionals()">'; + 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 + '>'); +} + +function renderCoreCard(data) { + var sec = SECTIONS[0]; + var h = '<div class="obj-core-row">'; + h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1'); // name + h += renderField(sec, sec.fields[2], data, -1, null, 'flex:2'); // aliases + h += renderField(sec, sec.fields[1], data, -1); // color + h += renderField(sec, sec.fields[3], data, -1); // hidden + h += '</div>'; + h += '<div style="display:flex;flex-direction:column;gap:8px">'; + h += renderField(sec, sec.fields[5], data, -1); // description + h += renderField(sec, sec.fields[4], data, -1); // in-room description + h += renderField(sec, sec.fields[6], data, -1); // removal item + h += '</div>'; + return h; +} + +function renderArraySection(sec, data) { + var arr = data[cardPath(sec)] || []; + var h = ''; + arr.forEach(function(item, idx) { + h += '<div class="obj-sub-row" data-idx="' + idx + '">'; + h += '<div class="obj-card-grid">'; + sec.fields.forEach(function(f) { + h += renderField(sec, f, data, idx); + }); + h += '</div>'; + h += '<button class="btn btn-sm btn-danger obj-sub-remove" onclick="removeArrayItem(\'' + sec.id + '\',' + idx + ')">X</button>'; + h += '</div>'; + }); + h += '<button class="btn btn-sm obj-sub-add" onclick="addArrayItem(\'' + sec.id + '\')">+ Add ' + esc(sec.label).slice(0,-1) + '</button>'; + return h; +} + +function renderSubtable(sec, st, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var arr; + if (st.single) { + arr = sectionRoot[st.key] ? [sectionRoot[st.key]] : []; + } else { + arr = sectionRoot[st.key] || []; + } + var isDrops = sec.id === 'gather' && st.key === 'drops'; + 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"' + (isDrops ? ' style="flex-direction:column;align-items:stretch"' : '') + '>'; + if (isDrops) { + var isItemDrop = item._type !== undefined ? item._type !== 't' : !item.table; + var showField = isItemDrop ? 'item_id' : 'table'; + var hideField = isItemDrop ? 'table' : 'item_id'; + h += '<div class="obj-sub-row-line">'; + h += '<input type="hidden" id="' + fieldIDSub(sec, st.key, idx, hideField) + '" value="' + escAttr(item[hideField] || '') + '">'; + st.fields.forEach(function(f) { + if (f[0] === 'message' || f[0] === hideField) return; + var fid = fieldIDSub(sec, st.key, idx, f[0]); + var val = item[f[0]]; + val = val === undefined || val === null ? '' : val; + var extraCls = ''; + if (f[4] === 'narrow') extraCls = ' obj-narrow'; + if (f[2] === 'search') { + var entity = f[5] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + h += '<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>'; + } else if (f[2] === 'checkbox') { + h += '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>'; + } else { + h += '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '" placeholder="' + escAttr(f[3] || '') + '"></label>'; + } + }); + if (!st.single) { + h += '<button class="btn btn-sm btn-danger obj-sub-remove" style="margin-left:auto" onclick="removeSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>'; + } + h += '</div>'; + h += '<div class="obj-sub-row-line">'; + var fidMsg = fieldIDSub(sec, st.key, idx, 'message'); + var msgVal = item.message !== undefined && item.message !== null ? item.message : ''; + var msgField = st.fields.find(function(f){return f[0]==='message';}); + h += '<label class="obj-field obj-grow"><span>' + esc(msgField ? msgField[1] : 'Message') + '</span><input id="' + fidMsg + '" value="' + escAttr(String(msgVal)) + '" placeholder="' + escAttr(msgField ? msgField[3] : '') + '"></label>'; + h += '</div>'; + } else { + st.fields.forEach(function(f) { + var fid = fieldIDSub(sec, st.key, idx, f[0]); + var val = item[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'; + if (f[2] === 'search') { + var entity = f[5] || 'items'; + var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; + h += '<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>'; + } else if (f[2] === 'checkbox') { + h += '<label class="obj-field obj-check' + extraCls + '"><input type="checkbox" id="' + fid + '"' + (val ? ' checked' : '') + '> <span>' + esc(f[1]) + '</span></label>'; + } else { + h += '<label class="obj-field' + extraCls + '"><span>' + esc(f[1]) + '</span><input id="' + fid + '" value="' + escAttr(String(val)) + '"></label>'; + } + }); + if (!st.single) { + h += '<button class="btn btn-sm btn-danger obj-sub-remove" onclick="removeSubRow(\'' + sec.id + '\',\'' + st.key + '\',' + idx + ')">X</button>'; + } + } + h += '</div>'; + }); + if (isDrops) { + h += '<div style="display:flex;gap:6px;padding:6px 10px">'; + h += '<button class="btn btn-sm obj-sub-add" style="flex:1;margin-top:0" onclick="addDropRow(\'' + sec.id + '\',\'' + st.key + '\',\'item\')">+ Add Item Drop</button>'; + h += '<button class="btn btn-sm obj-sub-add" style="flex:1;margin-top:0" onclick="addDropRow(\'' + sec.id + '\',\'' + st.key + '\',\'table\')">+ Add Table Drop</button>'; + h += '</div>'; + } else if (!st.single || arr.length === 0) { + h += '<button class="btn btn-sm obj-sub-add" onclick="addSubRow(\'' + sec.id + '\',\'' + st.key + '\')">+ Add</button>'; + } + h += '</div>'; + return h; +} + +function renderTags(sec, il, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var arr = sectionRoot[il.key] || []; + var str = Array.isArray(arr) ? arr.join(', ') : String(arr || ''); + var fid = 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>'; +} + +function renderKV(sec, il, data) { + var sectionRoot = objectData[cardPath(sec)] || {}; + var map = 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="addKVRow(\'' + sec.id + '\',\'' + il.key + '\')">+ Add</button>'; + h += '</div>'; + return h; +} + +function toggleCard(id) { + expandedCards[id] = expandedCards[id] === false ? true : false; + renderCurrent(); +} + +function removeSection(id) { + var sec = SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + if (sec.path) { + delete objectData[sec.path]; + } else if (id === 'steal') { + delete objectData.steal_table; + delete objectData.steal_level; + delete objectData.steal_xp; + delete objectData.steal_speed; + delete objectData.guard_mob; + } + renderCurrent(); +} + +function addSection() { + var sel = $('#addSectionSelect'); + var id = sel.value; + if (!id) return; + var sec = SECTIONS.find(function(s) { return s.id === id; }); + if (!sec) return; + + if (sec.path) { + if (sec.isArray) { + objectData[sec.path] = []; + } else if (sec.id === 'on_look') { + objectData[sec.path] = {}; + } else if (sec.id === 'gather') { + objectData[sec.path] = {skill:'', tools:[], success:{base:0, per_level:0, cap:1}, drops:[], gather_message:'', fail_message:'', respawn_timer:0, respawn_broadcast:'', deplete_timer:0, nest_chance:0}; + } else if (sec.id === 'use') { + 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] = {}; + } + } + expandedCards[id] = true; + renderCurrent(); +} + +function addArrayItem(cardID) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec || !sec.isArray) return; + var arr = objectData[sec.path] || []; + var empty = {}; + sec.fields.forEach(function(f) { + empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' || f[2] === 'search' ? 0 : ''); + }); + arr.push(empty); + objectData[sec.path] = arr; + renderCurrent(); +} + +function removeArrayItem(cardID, idx) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec || !sec.isArray) return; + var arr = objectData[sec.path] || []; + arr.splice(idx, 1); + renderCurrent(); +} + +function addSubRow(cardID, subKey) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + 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' || f[2] === 'search' ? 0 : ''); + }); + } + arr.push(empty); + sectionRoot[subKey] = arr; + renderCurrent(); +} + +function removeSubRow(cardID, subKey, idx) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + arr.splice(idx, 1); + renderCurrent(); +} + +function 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); +} + +function addDropRow(cardID, subKey, dropType) { + var sec = SECTIONS.find(function(s) { return s.id === cardID; }); + if (!sec) return; + var sectionRoot = objectData[sec.path || ''] || objectData; + var arr = sectionRoot[subKey] || []; + var empty = { item_id: '', table: '', weight: 0, level: 0, xp: 0, quantity: 0, depletes: false, message: '', _type: (dropType === 'table' ? 't' : 'i') }; + arr.push(empty); + sectionRoot[subKey] = arr; + renderCurrent(); +} + +function validateDropRow(idx) { + // No longer needed — drops are either item or table type, never both. +} + +function renderCurrent() { + if (!objectData || !objectID) return; + renderObjectEditor(objectID, objectData); +} + +function toggleGatherConditionals() { + var skillEl = document.getElementById('f_gather_skill'); + var skill = skillEl ? skillEl.value : ''; + var fishing = skill === 'fishing'; + var wood = skill === 'woodcutting'; + var els = ['bait', 'exhausted_message', 'deplete_timer', 'nest_chance']; + var vis = {bait: fishing, exhausted_message: wood, deplete_timer: wood, nest_chance: wood}; + els.forEach(function(key) { + var el = document.getElementById('f_gather_' + key); + if (el) el.closest('.obj-field').style.display = vis[key] ? '' : 'none'; + }); +} + +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 = ''; 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; + }).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); + }); + }); + + 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 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() { + 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 saveObject() { + if (!validateFields()) return; + + var out = {}; + + SECTIONS.forEach(function(sec) { + if (sec.isArray) { + var arr = []; + var card = document.querySelector('.obj-card[data-card="' + sec.id + '"]'); + if (!card) return; + var rows = card.querySelectorAll('.obj-sub-row'); + rows.forEach(function(row, idx) { + var item = {}; + sec.fields.forEach(function(f) { + var fid = fieldID(sec, f[0], idx); + var el = document.getElementById(fid); + if (el) item[f[0]] = collectFieldValue(el, f[2]); + }); + if (Object.keys(item).length > 0) arr.push(item); + }); + if (arr.length > 0) out[sec.path] = arr; + return; + } + + if (sec.id === 'talk') { + if (objectData.talk) out.talk = objectData.talk; + return; + } + + var sectionObj = {}; + var hasValues = sec.always; + + sec.fields.forEach(function(f) { + var fid = fieldID(sec, f[0]); + var el = document.getElementById(fid); + if (!el) return; + var val = collectFieldValue(el, f[2]); + if (val !== '' && val !== false && val !== 0 && val !== null) hasValues = true; + sectionObj[f[0]] = val; + }); + + if (sec.subtables) { + sec.subtables.forEach(function(st) { + var items = []; + for (var i = 0; ; i++) { + var item = {}; + var found = false; + st.fields.forEach(function(f) { + var fid = fieldIDSub(sec, st.key, i, f[0]); + var el = document.getElementById(fid); + if (el) found = true; + if (el) item[f[0]] = 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]; hasValues = true; } + } else { + if (items.length > 0) { sectionObj[st.key] = items; hasValues = true; } + } + }); + } + + if (sec.inline) { + sec.inline.forEach(function(il) { + if (il.type === 'tags') { + var fid = fieldID(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 = fieldID(sec, fp); + var el = document.getElementById(fid); + if (el) sHas = true; + if (el) ssec[f[0]] = collectFieldValue(el, f[2]); + }); + if (sHas) { sectionObj[il.path] = ssec; hasValues = true; } + } + }); + } + + if (sec.always) { + Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); + if (typeof out.aliases === 'string') { + out.aliases = out.aliases ? out.aliases.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }) : []; + } + } else if (hasValues && sec.path) { + out[sec.path] = sectionObj; + } + }); + + out.id = objectID; + + API.put('/api/objects/' + encodeURIComponent(objectID), out).then(function() { + notify('Object ' + objectID + ' saved', 'success'); + updateUndoBar(); + }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); +} + +function duplicateObject() { + var baseID = objectID + '_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(objectData)); + delete copy._raw; + delete copy._path; + copy.id = newID; + + API.post('/api/objects', {id: newID}).then(function() { + return API.put('/api/objects/' + encodeURIComponent(newID), copy); + }).then(function() { + notify('Duplicated as ' + newID, 'success'); + updateUndoBar(); + loadList(); + loadObject(newID); + }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); }); +} + +function deleteObject() { + if (!confirm('Delete object "' + objectID + '"?')) return; + API.del('/api/objects/' + encodeURIComponent(objectID)).then(function() { + notify('Object ' + objectID + ' deleted', 'success'); + updateUndoBar(); + objectID = null; + objectData = 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'); }); +} + +// Re-exports for editor.js compatibility +var currentID = null; +Object.defineProperty(window, 'currentID', { get: function() { return objectID; }, set: function(v) { objectID = v; } }); + +function loadItem(id) { loadObject(id); } +function createNew() { + var name = prompt('Object ID:'); + if (!name) return; + API.post('/api/objects', {id: name}).then(function() { + notify('Object ' + name + ' created', 'success'); + updateUndoBar(); + loadList(); + loadObject(name); + }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); }); +} |
