var itemData = null; var itemID = null; var expandedCards = {}; var addedSections = {}; var SECTIONS = [ { id: 'core', label: 'Core', always: true, fields: [ ['name', 'Name', 'text', 'Copper Ore'], ['color', 'Color', 'color', 'B2'], ['value', 'Value', 'number', '0'], ['stackable', 'Stackable', 'checkbox'], ['quality', 'Quality', 'number', ''], ['max_quality', 'Max Quality', 'number', ''], ['aliases', 'Aliases', 'text', 'comma separated'], ['description', 'Description', 'textarea', 'A chunk of copper ore.'], ] }, { id: 'equipment', label: 'Equipment', path: 'equipment', detect: function(d) { return d.equipment; }, fields: [] }, { id: 'tool', label: 'Tool', path: 'tool', detect: function(d) { return d.tool; }, fields: [ ['type', 'Type', 'text', 'pickaxe, axe, hammer'], ['speed', 'Speed', 'number', '1.0', 'narrow'], ] }, { id: 'firemaking', label: 'Firemaking', detect: function(d) { return d.burn_ticks || d.fire_level || d.fire_xp; }, fields: [ ['burn_ticks', 'Burn Ticks', 'number', '100', 'narrow'], ['fire_level', 'Fire Level', 'number', '1', 'narrow'], ['fire_xp', 'Fire XP', 'number', '40', 'narrow'], ] }, { id: 'crafting', label: 'Crafting', detect: function(d) { return d.craft; }, fields: [], subtables: [ { label: 'Recipes to Craft This Item', key: 'craft', fields: [ ['type', 'Type', 'select', 'smithing|cooking|crafting|fletching|combine|pharmacy|construction'], ['subtype', 'Subtype', 'select', '|smelting|cleaning'], ['level', 'Level', 'number', '', 'narrow'], ['xp', 'XP', 'number', '', 'narrow'], ['ticks_per_cycle', 'Ticks/Cycle', 'number', '', 'narrow'], ['output_qty', 'Output Qty', 'number', '', 'narrow'], ['station', 'Station', 'select', '|anvil|cooking_range|fire|furnace|pottery_oven|pottery_wheel|spinning_wheel|workbench'], ['tool', 'Tool', 'select', '|pickaxe|axe|fire|chisel|hammer|knife|needle|rake|saw|shears|spade|watering_can|fishing_rod|fly_rod|harpoon|small_net|big_net|lobster_pot'], ['fail', 'Fail Item', 'search', '', '', null, 'items'], ['success_base', 'Base', 'number', '', 'narrow'], ['success_per_level', 'Per Lvl', 'number', '', 'narrow'], ['success_cap', 'Cap', 'number', '', 'narrow'], ['start_message', 'Start Message', 'text', 'e.g. You begin smithing %i1.', 'wide'], ['success_message', 'Success Message', 'text', 'e.g. You smith a %n.', 'wide'], ['fail_message', 'Fail Message', 'text', 'e.g. You accidentally burn the %i1.', 'wide', function(item) { return item.type === 'cooking' || item.subtype === 'smelt'; }], ['end_message', 'End Message', 'text', 'e.g. You\'ve finished smithing.', 'wide'], ], fieldGroups: [ ['type', 'subtype', 'level', 'xp', 'ticks_per_cycle', 'output_qty'], {note: 'Output Qty is the quantity produced per cycle, only relevant for stackable items.'}, ['station', 'tool'], {label: 'Success', fields: ['success_base', 'success_per_level', 'success_cap', 'fail']}, ['start_message', 'success_message', 'fail_message', 'end_message'], ], subSubtables: [ {label:'Ingredients', key:'ingredients', fields:[ ['quantity', 'Qty', 'number', '1', 'narrow'], ]}, {label:'Steps', key:'steps', fields:[ ['delay', 'Delay (ticks)', 'number', '', 'narrow'], ['message', 'Message', 'text', '', 'wide'], ]}, ], } ] }, { id: 'search', label: 'Search', detect: function(d) { return d.search_table || d.search_ticks; }, fields: [ ['search_table', 'Search Table', 'search', '', '', null, 'drops'], ['search_ticks', 'Search Ticks', 'number', '', 'narrow'], ['search_message', 'Search Message', 'text', 'You search...', 'wide'], ] }, { id: 'food', label: 'Food', detect: function(d) { return d.heal_value || d.eat_message; }, fields: [ ['heal_value', '+HP', 'number', '', 'narrow'], ['eat_message', 'Eat Message', 'text', 'You eat the...', 'wide'], ] }, { id: 'farming', label: 'Farming', detect: function(d) { return d.farm_patch_type; }, fields: [ ['farm_patch_type', 'Patch Type', 'select', 'herb|allotment|flower|bush|tree'], ['farm_level', 'Farm Level', 'number', '', 'narrow'], ['farm_plant_xp', 'Plant XP', 'number', '', 'narrow'], ['farm_harvest_xp', 'Harvest XP', 'number', '', 'narrow'], ['farm_stages', 'Stages', 'number', '', 'narrow'], ['farm_product', 'Product', 'search', '', '', null, 'items'], ['farm_min_yield', 'Min Yield', 'number', '', 'narrow'], ['farm_max_yield', 'Max Yield', 'number', '', 'narrow'], ] }, { id: 'potions', label: 'Potions', detect: function(d) { return d.potion_effect; }, fields: [ ['potion_effect', 'Effect', 'text', 'strength, accuracy, heal'], ['potion_bonus', 'Bonus', 'number', '', 'narrow'], ['potion_duration', 'Duration', 'number', '', 'narrow'], ] }, ]; function initItemEditor() { editorType = 'items'; editorFields = []; loadList(); if (window.location.hash) loadItemEditor(window.location.hash.substring(1)); } function cardPath(sec) { return sec.path || ''; } function getVal(data, sec, key) { var p = cardPath(sec); if (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 fieldIDSubSub(sec, subKey, rowIdx, subSubKey, subSubIdx, fieldKey) { return 'f_' + sec.id + '_' + subKey + '_' + rowIdx + '__' + subSubKey + '_' + subSubIdx + '_' + fieldKey; } function loadItemEditor(id) { itemID = id; currentID = id; renderList(''); window.location.hash = id; API.get('/api/items/' + encodeURIComponent(id)).then(function(data) { itemData = data; renderItemEditor(id, data); }).catch(function(e) { $('#editorMain').innerHTML = '

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

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

Item: ' + esc(id) + '

'; html += '
'; html += '
'; html += '
'; html += '
'; SECTIONS.forEach(function(sec) { if (!sec.always && sec.detect && !sec.detect(data) && !addedSections[sec.id]) return; html += renderCard(sec, data); }); html += '
'; html += '
'; html += ''; html += ''; html += '
'; html += '
'; html += ''; html += '
'; html += ''; html += ''; html += ''; html += '
'; $('#editorMain').innerHTML = html; setupSearchFields(); setupCraftTypeChange(); updateOutputQtyVisibility(); setTimeout(function() { document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); }); }, 50); } function renderCurrent() { if (!itemData || !itemID) return; renderItemEditor(itemID, itemData); } // ========================================================================= // Card rendering // ========================================================================= function renderCard(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.isArray) { h += renderArraySection(sec, data); } else if (sec.id === 'core') { h += renderCoreCard(data); } else if (sec.id === 'equipment') { h += renderEquipmentCard(data); } else { h += '
'; if (sec.fields) sec.fields.forEach(function(f) { h += renderField(sec, f, data, -1); }); h += '
'; if (sec.inline) { sec.inline.forEach(function(il) { if (il.type === 'tags') { h += renderTags(sec, il, data); } else if (il.type === 'kv') { h += renderKV(sec, il, data); } else { h += '
'; h += '' + esc(il.label) + ''; h += '
'; il.fields.forEach(function(f) { h += renderField(sec, f, data, -1, il.path); }); h += '
'; h += '
'; } }); } if (sec.subtables) { sec.subtables.forEach(function(st) { h += renderSubtable(sec, st, data); }); } } h += '
'; } h += '
'; return h; } function renderCoreCard(data) { var sec = SECTIONS[0]; var h = '
'; h += renderField(sec, sec.fields[0], data, -1, null, 'flex:1'); h += renderField(sec, sec.fields[1], data, -1); h += renderField(sec, sec.fields[6], data, -1, null, 'flex:1'); h += '
'; h += '
'; h += renderField(sec, sec.fields[2], data, -1); h += '
'; h += renderField(sec, sec.fields[3], data, -1); h += '
'; h += '
'; h += renderField(sec, sec.fields[4], data, -1); h += renderField(sec, sec.fields[5], data, -1); h += '
'; h += '
'; var descFid = fieldID(sec, 'description', -1); var descVal = getVal(itemData, sec, 'description'); var dstr = typeof descVal === 'object' ? (Array.isArray(descVal) ? descVal.map(function(v){return v.text||'';}).join('\n') : JSON.stringify(descVal)) : String(descVal); h += ''; return h; } // ========================================================================= // Equipment card (custom render with 3 stat blocks) // ========================================================================= var EQUIP_STAT_FIELDS = { attack: [ ['stab', 'Stab', 'number', '', 'narrow'], ['slash', 'Slash', 'number', '', 'narrow'], ['crush', 'Crush', 'number', '', 'narrow'], ['ranged', 'Ranged', 'number', '', 'narrow'], ['science', 'Science', 'number', '', 'narrow'], ], defense: [ ['stab', 'Stab', 'number', '', 'narrow'], ['slash', 'Slash', 'number', '', 'narrow'], ['crush', 'Crush', 'number', '', 'narrow'], ['ranged', 'Ranged', 'number', '', 'narrow'], ['science', 'Science', 'number', '', 'narrow'], ], other: [ ['strength', 'Strength', 'number', '', 'narrow'], ['ranged', 'Ranged', 'number', '', 'narrow'], ['science', 'Science', 'number', '', 'narrow'], ['technology', 'Tech', 'number', '', 'narrow'], ], }; function equipFieldID(key) { return 'f_equipment_' + key; } function renderEquipField(key, label, type, hint, val, wide, searchEntity) { var fid = equipFieldID(key); val = val === undefined || val === null ? '' : val; function wrap(cls, inner) { var w = (wide === 'wide') ? ' obj-wide' : ''; if (wide === 'narrow') w = ' obj-narrow'; return ''; } if (type === 'select') { var opts = hint ? hint.split('|') : []; var inner = '' + esc(label) + ''; return wrap('obj-field', inner); } 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) + ''); } function renderEquipmentCard(data) { var eq = data.equipment || {}; var h = ''; var isAmmo = eq.type === 'ammo'; // Row 1: type, slot h += '
'; h += renderEquipField('type', 'Type', 'select', 'melee_weapon|ranged_weapon|tech_weapon|armor|tool|ammo', eq.type); if (!isAmmo) { h += renderEquipField('slot', 'Slot', 'select', 'head|neck|torso|legs|hands|feet|back|ammo|main_hand|off_hand|ring', eq.slot); } else { h += ''; } h += '
'; // Ammo: recoverable checkbox if (isAmmo) { h += ''; } // Row 2: attack_type, speed, junk var isMelee = eq.type === 'melee_weapon'; var isWeapon = ['melee_weapon', 'ranged_weapon', 'tech_weapon'].indexOf(eq.type) >= 0; h += ''; // 3 buttons row var btns = []; if (!eq.attack) btns.push(''); if (!eq.defense) btns.push(''); if (!eq.other) btns.push(''); if (btns.length > 0) { h += '
' + btns.join('') + '
'; } // Stat blocks if (eq.attack) h += renderEquipStatBlock('Attack Stats', 'attack', eq.attack, EQUIP_STAT_FIELDS.attack); if (eq.defense) h += renderEquipStatBlock('Defense Stats', 'defense', eq.defense, EQUIP_STAT_FIELDS.defense); if (eq.other) h += renderEquipStatBlock('Other Stats', 'other', eq.other, EQUIP_STAT_FIELDS.other); // Requirements KV (at root level, stored in itemData.requirements) h += '
'; h += renderKV({id: 'equipment_req'}, {label:'Requirements', key:'requirements', type:'kv', valType:'number'}, data); h += '
'; return h; } function renderEquipStatBlock(label, blockKey, blockData, fields) { var h = '
'; h += '
'; h += '' + esc(label) + ''; h += ''; h += '
'; h += '
'; fields.forEach(function(f) { var fid = 'f_equipment_' + blockKey + '_' + f[0]; var val = blockData[f[0]]; val = val === undefined || val === null ? '' : val; var extraCls = f[4] === 'narrow' ? ' obj-narrow' : ''; h += ''; }); h += '
'; h += '
'; return h; } function addEquipBlock(blockKey) { if (!itemData.equipment) itemData.equipment = {}; var empty = {}; EQUIP_STAT_FIELDS[blockKey].forEach(function(f) { empty[f[0]] = 0; }); itemData.equipment[blockKey] = empty; renderCurrent(); } function removeEquipBlock(blockKey) { if (itemData.equipment) delete itemData.equipment[blockKey]; renderCurrent(); } // ========================================================================= // Generic field render (unchanged) // ========================================================================= 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 = itemData[cardPath(sec)] || []; var item = arr[idx] || {}; val = item[key]; } else if (subPath) { var sectionRoot = itemData[cardPath(sec)] || {}; var subObj = sectionRoot[subPath] || {}; val = subObj[key]; } else { val = getVal(itemData, sec, key); } val = val === undefined || val === null ? '' : val; var hiddenStyle = ''; if (showIf) { var sectionData = itemData[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 ''; } 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) + ''); } // ========================================================================= // Array section rendering // ========================================================================= function renderArraySection(sec, data) { var arr = data[cardPath(sec)] || []; var h = ''; arr.forEach(function(item, idx) { h += '
'; h += '
'; sec.fields.forEach(function(f) { h += renderField(sec, f, data, idx); }); h += '
'; h += ''; h += '
'; }); h += ''; return h; } // ========================================================================= // Subtable rendering // ========================================================================= function renderSubFieldHTML(sec, stKey, idx, f, val) { var fid = fieldIDSub(sec, stKey, idx, f[0]); val = val === undefined || val === null ? '' : val; var extraCls = ''; var extraStyle = ''; if (f[4] === 'narrow') extraCls = ' obj-narrow'; else if (f[4] === 'grow') extraCls = ' obj-grow'; if (f[4] === 'wide') extraStyle = ' style="flex:1 1 100%"'; 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 ''; } if (f[2] === 'select') { var opts = f[3] ? f[3].split('|') : []; var sel = ''; return sel; } return ''; } function renderSubtable(sec, st, data) { var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; 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 hasSubSubtables = st.subSubtables && st.subSubtables.length > 0; var h = '
'; h += '
' + esc(st.label) + '
'; arr.forEach(function(item, idx) { h += '
'; function renderGroup(fieldKeys) { fieldKeys.forEach(function(fieldKey) { if (fieldKey === '|') { h += '|'; return; } var f = st.fields.find(function(sf) { return sf[0] === fieldKey; }); if (!f) return; if (f[5] && !f[5](item)) return; var val = item[f[0]]; h += renderSubFieldHTML(sec, st.key, idx, f, val); }); } if (st.fieldGroups) { st.fieldGroups.forEach(function(group) { if (group.note) { h += '
' + esc(group.note) + '
'; } else if (group.label) { if (group.label === 'Success') { var hasSuccess = !!(item.fail || (item.success && (item.success.base || item.success.per_level || item.success.cap)) || item.success_base || item.success_per_level || item.success_cap); var gid = 'sg_' + sec.id + '_' + st.key + '_' + idx; h += '
'; h += '
'; h += 'Success'; h += ''; h += '
'; h += '
'; renderGroup(group.fields); h += '
'; h += '
'; h += ''; } else { h += '
'; h += '' + esc(group.label) + ''; h += '
'; renderGroup(group.fields); h += '
'; h += '
'; } } else { var rightStyle = (group && group.right) ? ' style="justify-content:flex-end"' : ''; h += '
'; var keys = Array.isArray(group) ? group : group.fields; renderGroup(keys); h += '
'; } }); } else { h += '
'; st.fields.forEach(function(f) { if (f[5] && !f[5](item)) return; var val = item[f[0]]; h += renderSubFieldHTML(sec, st.key, idx, f, val); }); h += '
'; } if (hasSubSubtables) { st.subSubtables.forEach(function(sst) { if (sst.key === 'steps') { var stepsArr = item[sst.key]; if (!stepsArr || !Array.isArray(stepsArr) || stepsArr.length === 0) { h += ''; return; } } h += renderSubSubtable(sec, st, idx, sst, item); }); } if (!st.single) { h += ''; } h += '
'; }); if (!st.single || arr.length === 0) { h += ''; } h += '
'; return h; } function ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, fieldKey) { return 'f_' + sec.id + '_' + subKey + '_' + rowIdx + '__' + subSubKey + '_' + subIdx + '_' + fieldKey + '_' + pairIdx; } function renderConsumeSearchHTML(fid, val, entity, placeholder) { val = val === undefined || val === null ? '' : val; return ''; } function addConsumePair(secID, subKey, rowIdx, subSubKey, subIdx) { var sec = SECTIONS.find(function(s) { return s.id === secID; }); var existing = document.querySelectorAll('.ingredients-pair[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"]'); var pairIdx = existing.length; var btnRow = document.querySelector('.ingredients-pair-add[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"]'); var itemFid = ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, 'items'); var bypFid = ingredientPairID(sec, subKey, rowIdx, subSubKey, subIdx, pairIdx, 'byproducts'); var row = document.createElement('div'); row.className = 'ingredients-pair'; row.setAttribute('data-sec', secID); row.setAttribute('data-subkey', subKey); row.setAttribute('data-row', rowIdx); row.setAttribute('data-subsub', subSubKey); row.setAttribute('data-subidx', subIdx); row.setAttribute('data-pair', pairIdx); row.innerHTML = '
' + '
' + '
' + renderConsumeSearchHTML(itemFid, '', 'items', 'Search items...') + '
' + '
' + renderConsumeSearchHTML(bypFid, '', 'items', '(optional)') + '
' + '
'; btnRow.parentNode.insertBefore(row, btnRow); setupSearchFields(); } function removeConsumePair(secID, subKey, rowIdx, subSubKey, subIdx, pairIdx) { var el = document.querySelector('.ingredients-pair[data-sec="' + secID + '"][data-subkey="' + subKey + '"][data-row="' + rowIdx + '"][data-subsub="' + subSubKey + '"][data-subidx="' + subIdx + '"][data-pair="' + pairIdx + '"]'); if (!el) return; var ingredient = el.parentElement; var remaining = ingredient.querySelectorAll('.ingredients-pair'); if (remaining.length <= 1) { ingredient.remove(); } else { el.remove(); } } function renderSubSubtable(sec, st, rowIdx, sst, parentItem) { var arr = parentItem[sst.key] || []; var isIngredients = sst.key === 'ingredients'; var isSteps = sst.key === 'steps'; var stepsAttr = isSteps ? ' data-section="steps-section"' : ''; var h = '
'; if (!isIngredients) { h += '
' + esc(sst.label) + '
'; } if (isIngredients) { h += '
'; h += '
'; h += '
#
'; h += '
Qty
'; h += '
Items
'; h += '
Byproducts
'; h += '
'; h += '
'; var ingredientNum = 0; arr.forEach(function(subItem, subIdx) { ingredientNum++; var itemsArr = Array.isArray(subItem.items) ? subItem.items : (subItem.items ? [subItem.items] : ['']); var bypArr = Array.isArray(subItem.byproducts) ? subItem.byproducts : []; var pairs = Math.max(itemsArr.length, bypArr.length, 1); h += '
'; var qtyVal = subItem.quantity !== undefined && subItem.quantity !== null ? subItem.quantity : ''; var qtyFid = fieldIDSubSub(sec, st.key, rowIdx, sst.key, subIdx, 'quantity'); for (var p = 0; p < pairs; p++) { h += '
'; if (p === 0) { h += '
' + ingredientNum + '
'; } else { h += '
'; } if (p === 0) { h += '
'; } else { h += '
'; } var itemVal = itemsArr[p] || ''; var itemFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'items'); h += '
' + renderConsumeSearchHTML(itemFid, itemVal, 'items', 'Search items...') + '
'; var bypVal = bypArr[p] || ''; var bypFid = ingredientPairID(sec, st.key, rowIdx, sst.key, subIdx, p, 'byproducts'); h += '
' + renderConsumeSearchHTML(bypFid, bypVal, 'items', '(optional)') + '
'; h += '
'; h += '
'; } h += '
'; h += ''; h += '
'; h += '
'; }); h += '
'; h += ''; } else { arr.forEach(function(subItem, subIdx) { h += '
'; h += '
'; sst.fields.forEach(function(f) { var fid = fieldIDSubSub(sec, st.key, rowIdx, sst.key, subIdx, f[0]); var val = subItem[f[0]]; val = val === undefined || val === null ? '' : val; var extraCls = ''; if (f[4] === 'narrow') extraCls = ' obj-narrow'; if (f[2] === 'search') { var entity = f[6] || 'items'; var ph = f[3] ? ' placeholder="' + escAttr(f[3]) + '"' : ' placeholder="Search ' + entity + '..."'; h += ''; } else if (f[2] === 'checkbox') { h += ''; } else { h += ''; } }); h += '
'; h += ''; h += '
'; }); h += ''; } h += '
'; return h; } // ========================================================================= // Tags / KV rendering // ========================================================================= function renderTags(sec, il, data) { var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; var arr = sectionRoot[il.key] || []; var str = Array.isArray(arr) ? arr.join(', ') : String(arr || ''); var fid = fieldID(sec, il.key); return ''; } function renderKV(sec, il, data) { var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; var map = 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; } // ========================================================================= // Card toggle / section add-remove // ========================================================================= 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 itemData[sec.path]; } else { if (sec.fields) sec.fields.forEach(function(f) { delete itemData[f[0]]; }); if (sec.inline) sec.inline.forEach(function(il) { if (il.key) delete itemData[il.key]; }); if (sec.subtables) sec.subtables.forEach(function(st) { delete itemData[st.key]; }); } delete addedSections[id]; 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.id === 'equipment') { itemData.equipment = {type:'', slot:'', attack_type:'', speed:0, junk:''}; } else { itemData[sec.path] = {}; } } addedSections[id] = true; expandedCards[id] = true; renderCurrent(); } // ========================================================================= // Array item add/remove // ========================================================================= function addArrayItem(cardID) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec || !sec.isArray) return; var arr = itemData[sec.path] || []; var empty = {}; sec.fields.forEach(function(f) { empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); }); arr.push(empty); itemData[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 = itemData[sec.path] || []; arr.splice(idx, 1); renderCurrent(); } // ========================================================================= // Subtable row add/remove // ========================================================================= function addSubRow(cardID, subKey) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; 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; if (sectionRoot === itemData) { delete itemData._raw; delete itemData._path; } renderCurrent(); } function removeSubRow(cardID, subKey, idx) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; 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); sectionRoot[subKey] = arr; renderCurrent(); } // ========================================================================= // Sub-subtable row add/remove // ========================================================================= function addSubSubRow(cardID, subKey, rowIdx, subSubKey) { try { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) { console.warn('addSubSubRow: section not found', cardID); return; } var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; var arr = sectionRoot[subKey] || []; if (rowIdx >= arr.length) { console.warn('addSubSubRow: row index out of range', rowIdx, arr.length); return; } if (!arr[rowIdx]) { arr[rowIdx] = {}; } var subArr = arr[rowIdx][subSubKey] || []; var empty = {}; var st = (sec.subtables || []).find(function(s) { return s.key === subKey; }); if (st && st.subSubtables) { var sst = st.subSubtables.find(function(ss) { return ss.key === subSubKey; }); if (sst) { sst.fields.forEach(function(f) { empty[f[0]] = f[2] === 'checkbox' ? false : (f[2] === 'number' ? 0 : ''); }); } } subArr.push(empty); arr[rowIdx][subSubKey] = subArr; sectionRoot[subKey] = arr; renderCurrent(); } catch(e) { console.error('addSubSubRow error:', e); notify('Failed to add row: ' + e.message, 'error'); } } function removeSubSubRow(cardID, subKey, rowIdx, subSubKey, subSubIdx) { var sec = SECTIONS.find(function(s) { return s.id === cardID; }); if (!sec) return; var p = cardPath(sec); var sectionRoot = p ? (itemData[p] || {}) : itemData; var arr = sectionRoot[subKey] || []; if (rowIdx >= arr.length) return; var subArr = arr[rowIdx][subSubKey] || []; subArr.splice(subSubIdx, 1); renderCurrent(); } // ========================================================================= // KV row add // ========================================================================= 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 = ''; el.appendChild(row); } // ========================================================================= // Search autocomplete // ========================================================================= function setupCraftTypeChange() { document.querySelectorAll('.obj-subtable select[id$="_type"]').forEach(function(typeSelect) { var id = typeSelect.id; var subtypeSel = document.getElementById(id.replace(/_type$/, '_subtype')); var levelField = document.getElementById(id.replace(/_type$/, '_level')); var xpField = document.getElementById(id.replace(/_type$/, '_xp')); var stationField = document.getElementById(id.replace(/_type$/, '_station')); var combineHideFields = ['success_message', 'fail_message', 'tool', 'ticks_per_cycle', 'success_base', 'success_per_level', 'success_cap', 'fail']; var recipeRow = typeSelect.closest('.obj-sub-row'); function update() { var t = typeSelect.value; [levelField, xpField].forEach(function(el) { if (!el) return; var lbl = el.closest('.obj-field'); if (lbl) lbl.style.display = t === 'combine' ? 'none' : ''; }); if (stationField) { var sLbl = stationField.closest('.obj-field'); if (sLbl) sLbl.style.display = (t === 'fletching' || t === 'combine' || t === 'pharmacy') ? 'none' : ''; } combineHideFields.forEach(function(fKey) { var el = document.getElementById(id.replace(/_type$/, '_' + fKey)); if (!el) return; var lbl = el.closest('.obj-field'); if (lbl) lbl.style.display = t === 'combine' ? 'none' : ''; }); var sepEl = recipeRow.querySelector('.craft-sep'); if (sepEl) sepEl.style.display = t === 'combine' ? 'none' : ''; var successGroup = document.querySelector('.success-group'); if (successGroup) successGroup.style.display = t === 'combine' ? 'none' : ''; var successBtn = document.getElementById(successGroup ? successGroup.id + '_btn' : ''); if (successBtn) successBtn.style.display = t === 'combine' ? 'none' : ''; var stepsSection = recipeRow.querySelector('[data-section="steps-section"]'); var stepsAddBtn = recipeRow.querySelector('[data-section^="steps-add-"]'); var showSteps = t === 'combine'; if (stepsSection) stepsSection.style.display = showSteps ? '' : 'none'; if (stepsAddBtn) stepsAddBtn.style.display = showSteps ? '' : 'none'; if (subtypeSel) { var sLbl = subtypeSel.closest('.obj-field'); if (t === 'smithing') { if (sLbl) sLbl.style.display = ''; subtypeSel.innerHTML = ''; } else if (t === 'pharmacy') { if (sLbl) sLbl.style.display = ''; subtypeSel.innerHTML = ''; } else { if (sLbl) sLbl.style.display = 'none'; subtypeSel.value = ''; } } } typeSelect.addEventListener('change', update); update(); }); } function updateOutputQtyVisibility() { var isStackable = false; var stackableEl = document.getElementById('f_core_stackable'); if (stackableEl) { isStackable = stackableEl.checked; if (!stackableEl._hasOutputQtyListener) { stackableEl._hasOutputQtyListener = true; stackableEl.addEventListener('change', updateOutputQtyVisibility); } } document.querySelectorAll('[id$="_output_qty"]').forEach(function(el) { var lbl = el.closest('.obj-field'); if (lbl) lbl.style.display = isStackable ? '' : 'none'; }); document.querySelectorAll('.output-qty-note').forEach(function(el) { el.style.display = isStackable ? '' : 'none'; }); } function setupSearchFields() { ced_setupSearchFields(); } function selectSearchResult(el) { ced_selectSearchResult(el); } function updateSearchHighlight(results) { ced_updateSearchHighlight(results); } // ========================================================================= // Validation // ========================================================================= function validateFields() { var errors = []; document.querySelectorAll('.obj-field-error').forEach(function(field) { field.classList.remove('obj-field-error'); }); document.querySelectorAll('input[id]:not([type="checkbox"])').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.+)$/); var subSubMatch = id.match(/^f_(\w+)_(\w+)_(\d+)__(\w+)_(\d+)_(\w+)$/); var equipStatMatch = id.match(/^f_equipment_(\w+)_(\w+)$/); if (subSubMatch) { var ssSecId = subSubMatch[1], ssSubKey = subSubMatch[2]; var ssSubSubKey = subSubMatch[4], ssFieldKey = subSubMatch[6]; var st = (sec.subtables || []).find(function(s) { return s.key === ssSubKey; }); if (st && st.subSubtables) { var sst = st.subSubtables.find(function(ss) { return ss.key === ssSubSubKey; }); if (sst) { var nf = sst.fields.find(function(f) { return f[0] === ssFieldKey && f[2] === 'number'; }); if (nf) { isNumber = true; labelText = nf[1]; } } } } else 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 (equipStatMatch) { var blockKey = equipStatMatch[1], statField = equipStatMatch[2]; var fields = EQUIP_STAT_FIELDS[blockKey]; if (fields) { var nf = fields.find(function(f) { return f[0] === statField; }); 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 = fieldID(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 = fieldID(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; } // ========================================================================= // Field value collector // ========================================================================= function collectFieldValue(el, type) { return ced_collectFieldValue(el, type); } // ========================================================================= // Save // ========================================================================= function saveItem() { 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 === 'equipment') { var eq = {}; var hasEq = false; ['type', 'slot', 'attack_type', 'junk'].forEach(function(k) { var el = document.getElementById('f_equipment_' + k); if (!el) return; var val = collectFieldValue(el, k === 'type' || k === 'slot' ? 'select' : (k === 'junk' ? 'search' : 'text')); if (val !== '' && val !== false && val !== 0 && val !== null) hasEq = true; eq[k] = val; }); if (eq.type === 'ammo') eq.slot = 'ammo'; var speedEl = document.getElementById('f_equipment_speed'); if (speedEl) { var spd = parseFloat(speedEl.value); if (!isNaN(spd) && spd !== 0) { eq.speed = spd; hasEq = true; } } EQUIP_STAT_FIELDS.attack.forEach(function(f) { saveEquipBlock(eq, 'attack', f[0]); }); EQUIP_STAT_FIELDS.defense.forEach(function(f) { saveEquipBlock(eq, 'defense', f[0]); }); EQUIP_STAT_FIELDS.other.forEach(function(f) { saveEquipBlock(eq, 'other', f[0]); }); if (hasEq && Object.keys(eq).length > 0) out.equipment = eq; var recovEl = document.getElementById('f_core_recoverable'); if (recovEl) out.recoverable = recovEl.checked; var kvEl = document.querySelector('.obj-kv-list[data-sec="equipment_req"][data-key="requirements"]'); 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) out.requirements = map; } return; } var sectionObj = {}; var hasValues = sec.always; if (sec.fields) 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 (item.success_base !== undefined || item.success_per_level !== undefined || item.success_cap !== undefined) { var sBase = Number(item.success_base) || 0; var sPer = Number(item.success_per_level) || 0; var sCap = Number(item.success_cap) || 0; delete item.success_base; delete item.success_per_level; delete item.success_cap; if (sBase || sPer || sCap) { item.success = {base: sBase, per_level: sPer, cap: sCap}; } } if (item.station !== undefined && typeof item.station === 'string') { item.station = item.station.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); } if (st.subSubtables) { st.subSubtables.forEach(function(sst) { var isIngredients = sst.key === 'ingredients'; var subItems = []; for (var j = 0; ; j++) { var subItem = {}; var subFound = false; sst.fields.forEach(function(f) { var fid = fieldIDSubSub(sec, st.key, i, sst.key, j, f[0]); var el = document.getElementById(fid); if (el) subFound = true; if (el) subItem[f[0]] = collectFieldValue(el, f[2]); }); if (!subFound) break; if (isIngredients) { var itemsArr = []; var bypArr = []; for (var k = 0; ; k++) { var itemFid = ingredientPairID(sec, st.key, i, sst.key, j, k, 'items'); var itemEl = document.getElementById(itemFid); if (!itemEl) break; var itemVal = itemEl.value.trim(); itemsArr.push(itemVal); var bypFid = ingredientPairID(sec, st.key, i, sst.key, j, k, 'byproducts'); var bypEl = document.getElementById(bypFid); var bypVal = bypEl ? bypEl.value.trim() : ''; bypArr.push(bypVal); } var hasItems = itemsArr.some(function(v) { return v !== ''; }); if (hasItems) { subItem.items = itemsArr.filter(function(v) { return v !== ''; }); if (bypArr.some(function(v) { return v !== ''; })) subItem.byproducts = bypArr; } if (!subItem.items) { subFound = false; } } else { if (subItem.items !== undefined && typeof subItem.items === 'string') { subItem.items = subItem.items.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); } if (subItem.byproducts !== undefined && typeof subItem.byproducts === 'string') { subItem.byproducts = subItem.byproducts.split(',').map(function(s) { return s.trim(); }).filter(function(s) { return s; }); } } if (Object.keys(subItem).length > 0) subItems.push(subItem); } if (subItems.length > 0) item[sst.key] = subItems; }); } 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]; }); } else if (hasValues && sec.path) { out[sec.path] = sectionObj; } else if (hasValues && !sec.path) { Object.keys(sectionObj).forEach(function(k) { out[k] = sectionObj[k]; }); } }); cleanOutput(out); out.id = itemID; API.put('/api/items/' + encodeURIComponent(itemID), out).then(function(resp) { notify('Item ' + itemID + ' saved', 'success'); updateUndoBar(); if (resp && resp._raw) itemData._raw = resp._raw; }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); }); } function saveEquipBlock(eq, blockKey, fieldKey) { var fid = 'f_equipment_' + blockKey + '_' + fieldKey; var el = document.getElementById(fid); if (!el) return; var val = parseFloat(el.value); if (isNaN(val)) return; if (!eq[blockKey]) eq[blockKey] = {}; eq[blockKey][fieldKey] = val; } function 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)) { cleanOutput(val); if (Object.keys(val).length === 0) delete obj[key]; } else if (Array.isArray(val) && val.length === 0) { delete obj[key]; } } } // ========================================================================= // Duplicate // ========================================================================= function duplicateItem() { var baseID = itemID + '_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(itemData)); delete copy._raw; delete copy._path; copy.id = newID; API.post('/api/items', {id: newID}).then(function() { return API.put('/api/items/' + encodeURIComponent(newID), copy); }).then(function() { notify('Duplicated as ' + newID, 'success'); updateUndoBar(); loadList(); loadItem(newID); }).catch(function(e) { notify('Duplicate failed: ' + e.message, 'error'); }); } // ========================================================================= // Delete // ========================================================================= function deleteItem() { if (!confirm('Delete item "' + itemID + '"?')) return; API.del('/api/items/' + encodeURIComponent(itemID)).then(function() { notify('Item ' + itemID + ' deleted', 'success'); updateUndoBar(); itemID = null; currentID = null; itemData = null; $('#editorMain').innerHTML = '

Deleted

'; loadList(); }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); }); } // ========================================================================= // Editor.js compatibility re-exports // ========================================================================= function loadItem(id) { loadItemEditor(id); }