558 lines
18 KiB
JavaScript
558 lines
18 KiB
JavaScript
"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 = "<tr><th>List</th><th>Weight</th><th></th></tr>";
|
|
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 = `
|
|
<div>
|
|
<h3 class="item-title">${item.item}</h3>
|
|
<span class="badge">${item.category}</span>
|
|
</div>
|
|
`;
|
|
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"}<br>
|
|
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);
|