"use strict"; const state = { catalog: null, profileByItem: {}, selectedItem: null, }; const dom = { statusText: document.getElementById("statusText"), catalogFile: document.getElementById("catalogFile"), profileFile: document.getElementById("profileFile"), exportProfile: document.getElementById("exportProfile"), searchInput: document.getElementById("searchInput"), categoryFilter: document.getElementById("categoryFilter"), spawnFilter: document.getElementById("spawnFilter"), itemTableBody: document.getElementById("itemTableBody"), selectedDetails: document.getElementById("selectedDetails"), resetSelected: document.getElementById("resetSelected"), }; function setStatus(text) { dom.statusText.textContent = text; } function readFileText(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); reader.readAsText(file); }); } function safeNumber(value, fallback = 0) { const n = Number.parseFloat(value); return Number.isFinite(n) ? n : fallback; } function normalizeItemType(item) { if (typeof item !== "string") { return null; } const trimmed = item.trim(); if (!trimmed) { return null; } return trimmed.includes(".") ? trimmed : `Base.${trimmed}`; } function normalizeCatalog(raw) { if (!raw || !Array.isArray(raw.items)) { throw new Error("Invalid catalog format. Missing items array."); } const lists = new Set(Array.isArray(raw.lists) ? raw.lists : []); const items = []; for (const rawItem of raw.items) { const itemType = normalizeItemType(rawItem.item); if (!itemType) { continue; } const aggregated = Array.isArray(rawItem.aggregatedPlacements) ? rawItem.aggregatedPlacements : []; const placements = {}; for (const placement of aggregated) { const listName = placement && typeof placement.list === "string" ? placement.list.trim() : ""; const weight = safeNumber(placement && placement.weight, 0); if (!listName || weight <= 0) { continue; } placements[listName] = Number(weight.toFixed(6)); lists.add(listName); } items.push({ item: itemType, shortId: typeof rawItem.shortId === "string" ? rawItem.shortId : itemType.replace(/^Base\./, ""), category: typeof rawItem.category === "string" ? rawItem.category : "unknown", defaultEnabled: rawItem.defaultEnabled !== false, spawnControlKeys: Array.isArray(rawItem.spawnControlKeys) ? rawItem.spawnControlKeys : [], defaultPlacements: placements, }); } items.sort((a, b) => a.item.localeCompare(b.item)); return { generatedAt: raw.generatedAt || null, source: raw.source || {}, items, lists: Array.from(lists).sort(), }; } function initializeProfileFromCatalog() { const profileByItem = {}; for (const item of state.catalog.items) { const placements = {}; for (const [listName, weight] of Object.entries(item.defaultPlacements)) { placements[listName] = weight; } profileByItem[item.item] = { item: item.item, category: item.category, enabled: item.defaultEnabled, placements, }; } state.profileByItem = profileByItem; } function getProfileEntry(itemType) { return state.profileByItem[itemType] || null; } function updateProfileEntry(itemType, updater) { const current = getProfileEntry(itemType); if (!current) { return; } updater(current); } function getFilteredItems() { if (!state.catalog) { return []; } const search = dom.searchInput.value.trim().toLowerCase(); const category = dom.categoryFilter.value; const spawnState = dom.spawnFilter.value; return state.catalog.items.filter((item) => { const entry = getProfileEntry(item.item); if (!entry) { return false; } if (category !== "all" && item.category !== category) { return false; } if (spawnState === "enabled" && !entry.enabled) { return false; } if (spawnState === "disabled" && entry.enabled) { return false; } if (!search) { return true; } return ( item.item.toLowerCase().includes(search) || item.shortId.toLowerCase().includes(search) ); }); } function renderItemTable() { const filteredItems = getFilteredItems(); dom.itemTableBody.innerHTML = ""; for (const item of filteredItems) { const entry = getProfileEntry(item.item); const row = document.createElement("tr"); if (state.selectedItem === item.item) { row.classList.add("selected"); } const spawnTd = document.createElement("td"); const spawnCheck = document.createElement("input"); spawnCheck.type = "checkbox"; spawnCheck.checked = entry.enabled; spawnCheck.addEventListener("click", (event) => event.stopPropagation()); spawnCheck.addEventListener("change", () => { updateProfileEntry(item.item, (target) => { target.enabled = spawnCheck.checked; }); renderItemTable(); renderSelectedDetails(); }); spawnTd.appendChild(spawnCheck); const itemTd = document.createElement("td"); itemTd.textContent = item.shortId; const categoryTd = document.createElement("td"); categoryTd.textContent = item.category; const listsTd = document.createElement("td"); listsTd.textContent = String(Object.keys(entry.placements).length); row.appendChild(spawnTd); row.appendChild(itemTd); row.appendChild(categoryTd); row.appendChild(listsTd); row.addEventListener("click", () => { state.selectedItem = item.item; renderItemTable(); renderSelectedDetails(); }); dom.itemTableBody.appendChild(row); } } function renderPlacementsTable(item, entry, container) { const table = document.createElement("table"); table.className = "placements-table"; const thead = document.createElement("thead"); thead.innerHTML = "ListWeight"; table.appendChild(thead); const tbody = document.createElement("tbody"); const placementNames = Object.keys(entry.placements).sort(); for (const listName of placementNames) { const weight = entry.placements[listName]; const tr = document.createElement("tr"); const listTd = document.createElement("td"); listTd.textContent = listName; const weightTd = document.createElement("td"); const weightInput = document.createElement("input"); weightInput.type = "number"; weightInput.min = "0"; weightInput.step = "0.001"; weightInput.value = String(weight); weightInput.addEventListener("change", () => { const next = safeNumber(weightInput.value, 0); if (next <= 0) { delete entry.placements[listName]; } else { entry.placements[listName] = Number(next.toFixed(6)); } renderItemTable(); renderSelectedDetails(); }); weightTd.appendChild(weightInput); const actionTd = document.createElement("td"); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "small-btn remove"; removeBtn.textContent = "Remove"; removeBtn.addEventListener("click", () => { delete entry.placements[listName]; renderItemTable(); renderSelectedDetails(); }); actionTd.appendChild(removeBtn); tr.appendChild(listTd); tr.appendChild(weightTd); tr.appendChild(actionTd); tbody.appendChild(tr); } table.appendChild(tbody); container.appendChild(table); } function renderSelectedDetails() { const selected = state.selectedItem; dom.selectedDetails.innerHTML = ""; if (!state.catalog || !selected) { dom.selectedDetails.textContent = "Select an item to edit placements and spawn rate."; dom.selectedDetails.className = "details-empty"; return; } const item = state.catalog.items.find((it) => it.item === selected); const entry = getProfileEntry(selected); if (!item || !entry) { dom.selectedDetails.textContent = "Selected item not found."; dom.selectedDetails.className = "details-empty"; return; } dom.selectedDetails.className = "details-body"; const itemHeader = document.createElement("div"); itemHeader.className = "item-header"; itemHeader.innerHTML = `

${item.item}

${item.category}
`; dom.selectedDetails.appendChild(itemHeader); const enabledRow = document.createElement("div"); enabledRow.className = "inline-row"; const enabledInput = document.createElement("input"); enabledInput.type = "checkbox"; enabledInput.checked = entry.enabled; enabledInput.addEventListener("change", () => { entry.enabled = enabledInput.checked; renderItemTable(); }); const enabledLabel = document.createElement("label"); enabledLabel.textContent = "Spawn enabled"; enabledLabel.prepend(enabledInput); enabledLabel.style.display = "inline-flex"; enabledLabel.style.alignItems = "center"; enabledLabel.style.gap = "0.35rem"; enabledRow.appendChild(enabledLabel); dom.selectedDetails.appendChild(enabledRow); const placementsLabel = document.createElement("p"); placementsLabel.textContent = "Placements (distribution list + spawn rate weight):"; placementsLabel.style.margin = "0 0 0.4rem"; dom.selectedDetails.appendChild(placementsLabel); renderPlacementsTable(item, entry, dom.selectedDetails); const addRow = document.createElement("div"); addRow.className = "inline-row"; const listSelect = document.createElement("select"); const usedLists = new Set(Object.keys(entry.placements)); const availableLists = state.catalog.lists.filter((listName) => !usedLists.has(listName)); for (const listName of availableLists) { const option = document.createElement("option"); option.value = listName; option.textContent = listName; listSelect.appendChild(option); } const customInput = document.createElement("input"); customInput.type = "text"; customInput.placeholder = "or custom list name"; const weightInput = document.createElement("input"); weightInput.type = "number"; weightInput.min = "0"; weightInput.step = "0.001"; weightInput.value = "1"; const addButton = document.createElement("button"); addButton.type = "button"; addButton.className = "small-btn"; addButton.textContent = "Add Placement"; addButton.addEventListener("click", () => { const custom = customInput.value.trim(); const selectedList = custom || listSelect.value; const weight = safeNumber(weightInput.value, 0); if (!selectedList || weight <= 0) { return; } entry.placements[selectedList] = Number(weight.toFixed(6)); if (!state.catalog.lists.includes(selectedList)) { state.catalog.lists.push(selectedList); state.catalog.lists.sort(); } renderItemTable(); renderSelectedDetails(); }); addRow.appendChild(listSelect); addRow.appendChild(customInput); addRow.appendChild(weightInput); addRow.appendChild(addButton); dom.selectedDetails.appendChild(addRow); const resetRow = document.createElement("div"); resetRow.className = "inline-row"; const restoreButton = document.createElement("button"); restoreButton.type = "button"; restoreButton.className = "small-btn"; restoreButton.textContent = "Restore Catalog Placements"; restoreButton.addEventListener("click", () => { entry.enabled = item.defaultEnabled; entry.placements = { ...item.defaultPlacements }; renderItemTable(); renderSelectedDetails(); }); const clearButton = document.createElement("button"); clearButton.type = "button"; clearButton.className = "small-btn remove"; clearButton.textContent = "Clear Placements"; clearButton.addEventListener("click", () => { entry.placements = {}; renderItemTable(); renderSelectedDetails(); }); resetRow.appendChild(restoreButton); resetRow.appendChild(clearButton); dom.selectedDetails.appendChild(resetRow); const meta = document.createElement("p"); meta.className = "meta"; meta.innerHTML = ` Sandbox weight controls: ${item.spawnControlKeys.length ? item.spawnControlKeys.join(", ") : "none"}
Catalog list count: ${Object.keys(item.defaultPlacements).length} `; dom.selectedDetails.appendChild(meta); } function buildExportProfile() { const entries = Object.keys(state.profileByItem) .sort() .map((itemType) => { const entry = state.profileByItem[itemType]; const placements = Object.keys(entry.placements) .sort() .map((listName) => ({ list: listName, weight: Number(entry.placements[listName]), })); return { item: itemType, category: entry.category, enabled: entry.enabled, placements, }; }); return { formatVersion: 1, generatedAt: new Date().toISOString(), sourceCatalog: { generatedAt: state.catalog ? state.catalog.generatedAt : null, source: state.catalog ? state.catalog.source : {}, }, entries, }; } function downloadTextFile(fileName, content) { const blob = new Blob([content], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); } async function onCatalogFileSelected() { const file = dom.catalogFile.files[0]; if (!file) { return; } try { const text = await readFileText(file); const rawCatalog = JSON.parse(text); state.catalog = normalizeCatalog(rawCatalog); initializeProfileFromCatalog(); state.selectedItem = state.catalog.items.length ? state.catalog.items[0].item : null; setStatus( `Catalog loaded (${state.catalog.items.length} items, ${state.catalog.lists.length} lists).` ); renderItemTable(); renderSelectedDetails(); } catch (error) { setStatus(`Catalog load failed: ${error.message}`); } finally { dom.catalogFile.value = ""; } } async function onProfileFileSelected() { if (!state.catalog) { setStatus("Load a catalog first."); dom.profileFile.value = ""; return; } const file = dom.profileFile.files[0]; if (!file) { return; } try { const text = await readFileText(file); const raw = JSON.parse(text); if (!Array.isArray(raw.entries)) { throw new Error("Profile must contain an entries array."); } for (const row of raw.entries) { const itemType = normalizeItemType(row.item); if (!itemType || !state.profileByItem[itemType]) { continue; } const entry = state.profileByItem[itemType]; entry.enabled = row.enabled !== false; entry.placements = {}; if (Array.isArray(row.placements)) { for (const placement of row.placements) { const listName = placement && typeof placement.list === "string" ? placement.list.trim() : ""; const weight = safeNumber(placement && placement.weight, 0); if (!listName || weight <= 0) { continue; } entry.placements[listName] = Number(weight.toFixed(6)); if (!state.catalog.lists.includes(listName)) { state.catalog.lists.push(listName); } } } } state.catalog.lists.sort(); setStatus("Profile loaded and applied to current catalog."); renderItemTable(); renderSelectedDetails(); } catch (error) { setStatus(`Profile load failed: ${error.message}`); } finally { dom.profileFile.value = ""; } } function onExportProfile() { if (!state.catalog) { setStatus("Load a catalog before exporting."); return; } const payload = buildExportProfile(); downloadTextFile("of-spawn-profile.json", `${JSON.stringify(payload, null, 2)}\n`); setStatus("Profile exported."); } function onResetSelected() { if (!state.catalog || !state.selectedItem) { return; } const item = state.catalog.items.find((it) => it.item === state.selectedItem); const entry = getProfileEntry(state.selectedItem); if (!item || !entry) { return; } entry.enabled = item.defaultEnabled; entry.placements = { ...item.defaultPlacements }; renderItemTable(); renderSelectedDetails(); setStatus(`Reset ${item.shortId} to catalog defaults.`); } dom.catalogFile.addEventListener("change", onCatalogFileSelected); dom.profileFile.addEventListener("change", onProfileFileSelected); dom.exportProfile.addEventListener("click", onExportProfile); dom.searchInput.addEventListener("input", renderItemTable); dom.categoryFilter.addEventListener("change", renderItemTable); dom.spawnFilter.addEventListener("change", renderItemTable); dom.resetSelected.addEventListener("click", onResetSelected);