Files
OpinionatedFirearms/webapp/app.js
2026-02-11 18:22:33 -05:00

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);