390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const qs = (sel) => document.querySelector(sel);
|
|
|
|
const DIFFICULTY_ORDER = {
|
|
Normal: 0,
|
|
Hard: 1,
|
|
"Very Hard": 2,
|
|
Ultimate: 3,
|
|
};
|
|
|
|
const NUMERIC_COLUMNS = new Set([
|
|
"hp", "atp", "dfp", "mst", "ata", "evp", "lck", "esp", "exp",
|
|
"efr", "eic", "eth", "elt", "edk",
|
|
]);
|
|
|
|
const BP_TIERS = {
|
|
0: { hp: 1.00, atp: 1.00, exp: 1.00 },
|
|
1: { hp: 1.10, atp: 1.01, exp: 1.10 },
|
|
2: { hp: 1.15, atp: 1.02, exp: 1.15 },
|
|
3: { hp: 1.20, atp: 1.03, exp: 1.20 },
|
|
4: { hp: 1.30, atp: 1.04, exp: 1.30 },
|
|
5: { hp: 1.40, atp: 1.05, exp: 1.40 },
|
|
6: { hp: 1.50, atp: 1.06, exp: 1.50 },
|
|
7: { hp: 1.75, atp: 1.07, exp: 1.75 },
|
|
8: { hp: 2.00, atp: 1.08, exp: 2.00 },
|
|
9: { hp: 2.50, atp: 1.09, exp: 2.50 },
|
|
10: { hp: 3.00, atp: 1.10, exp: 3.00 },
|
|
11: { hp: 4.00, atp: 1.10, exp: 3.00 },
|
|
};
|
|
|
|
const COLUMN_GROUPS = {
|
|
stats: [
|
|
["enemy", "Enemy"],
|
|
["mode", "Mode"],
|
|
["episode", "Episode"],
|
|
["difficulty", "Difficulty"],
|
|
["hp", "HP"],
|
|
["atp", "ATP"],
|
|
["dfp", "DFP"],
|
|
["ata", "ATA"],
|
|
["evp", "EVP"],
|
|
["lck", "LCK"],
|
|
["exp", "EXP"],
|
|
],
|
|
resists: [
|
|
["enemy", "Enemy"],
|
|
["mode", "Mode"],
|
|
["episode", "Episode"],
|
|
["difficulty", "Difficulty"],
|
|
["efr", "EFR"],
|
|
["eic", "EIC"],
|
|
["eth", "ETH"],
|
|
["elt", "ELT"],
|
|
["edk", "EDK"],
|
|
["esp", "ESP"],
|
|
],
|
|
all: [
|
|
["enemy", "Enemy"],
|
|
["mode", "Mode"],
|
|
["episode", "Episode"],
|
|
["difficulty", "Difficulty"],
|
|
["hp", "HP"],
|
|
["atp", "ATP"],
|
|
["dfp", "DFP"],
|
|
["mst", "MST"],
|
|
["ata", "ATA"],
|
|
["evp", "EVP"],
|
|
["lck", "LCK"],
|
|
["esp", "ESP"],
|
|
["exp", "EXP"],
|
|
["efr", "EFR"],
|
|
["eic", "EIC"],
|
|
["eth", "ETH"],
|
|
["elt", "ELT"],
|
|
["edk", "EDK"],
|
|
],
|
|
};
|
|
|
|
function currentColumns() {
|
|
const view = qs("#bestiary-view")?.value || "stats";
|
|
return COLUMN_GROUPS[view] || COLUMN_GROUPS.stats;
|
|
}
|
|
|
|
const state = {
|
|
rows: [],
|
|
index: null,
|
|
table: null,
|
|
page: 1,
|
|
pageSize: 100,
|
|
sort: {
|
|
key: "enemy",
|
|
dir: "asc",
|
|
},
|
|
filters: {
|
|
mode: "",
|
|
episode: "",
|
|
difficulty: "",
|
|
search: "",
|
|
},
|
|
};
|
|
|
|
function esc(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
async function fetchJson(path) {
|
|
const res = await fetch(path, { cache: "no-store" });
|
|
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function setStatus(message, kind = "") {
|
|
const box = qs("#bestiary-placeholder");
|
|
if (!box) return;
|
|
box.innerHTML = `<div class="drops-status ${kind ? `drops-status--${kind}` : ""}">${esc(message)}</div>`;
|
|
}
|
|
|
|
function uniqueSorted(rows, key) {
|
|
return [...new Set(rows.map((row) => row[key]).filter(Boolean))]
|
|
.sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
|
|
}
|
|
|
|
function fillSelect(select, values, allLabel) {
|
|
if (!select) return;
|
|
const previous = select.value;
|
|
select.innerHTML = `<option value="">${esc(allLabel)}</option>`;
|
|
for (const value of values) {
|
|
const opt = document.createElement("option");
|
|
opt.value = value;
|
|
opt.textContent = value;
|
|
select.appendChild(opt);
|
|
}
|
|
if ([...select.options].some((opt) => opt.value === previous)) {
|
|
select.value = previous;
|
|
}
|
|
}
|
|
|
|
function orderedDifficulties(rows) {
|
|
const present = new Set(rows.map((row) => row.difficulty).filter(Boolean));
|
|
return Object.keys(DIFFICULTY_ORDER).filter((value) => present.has(value));
|
|
}
|
|
|
|
function bpTier() {
|
|
const select = qs("#bestiary-tier");
|
|
const raw = String(select?.value || "");
|
|
const label = String(select?.selectedOptions?.[0]?.textContent || "");
|
|
const match = raw.match(/(\d+)$/) || label.match(/(\d+)/);
|
|
const tier = match ? Number(match[1]) : 0;
|
|
return BP_TIERS[tier] || BP_TIERS[0];
|
|
}
|
|
|
|
function xpBonusMultiplier() {
|
|
return Number(qs("#bestiary-xp-bonus")?.value || 1);
|
|
}
|
|
|
|
function displayValue(row, key) {
|
|
const value = row[key];
|
|
|
|
if (key === "exp" && row.difficulty !== "Ultimate") {
|
|
return Math.round(Number(value || 0) * xpBonusMultiplier());
|
|
}
|
|
|
|
if (row.difficulty !== "Ultimate") {
|
|
return value ?? "";
|
|
}
|
|
|
|
const tier = bpTier();
|
|
|
|
if (key === "hp") {
|
|
return Math.round(Number(value || 0) * tier.hp);
|
|
}
|
|
|
|
if (key === "atp") {
|
|
return Math.round(Number(value || 0) * tier.atp);
|
|
}
|
|
|
|
if (key === "exp" && tier.exp !== null) {
|
|
return Math.round(Number(value || 0) * tier.exp * xpBonusMultiplier());
|
|
}
|
|
|
|
return value ?? "";
|
|
}
|
|
|
|
function hasAnyBestiaryValue(row) {
|
|
const keys = ["hp", "atp", "dfp", "mst", "ata", "evp", "lck", "esp", "exp", "efr", "eic", "eth", "elt", "edk"];
|
|
return keys.some((key) => Number(row[key] || 0) !== 0);
|
|
}
|
|
|
|
function visibleRows() {
|
|
const search = state.filters.search.trim().toLowerCase();
|
|
|
|
return state.rows.filter((row) => {
|
|
if (!hasAnyBestiaryValue(row)) return false;
|
|
if (state.filters.mode && row.mode !== state.filters.mode) return false;
|
|
if (state.filters.episode && row.episode !== state.filters.episode) return false;
|
|
if (state.filters.difficulty && row.difficulty !== state.filters.difficulty) return false;
|
|
|
|
if (search) {
|
|
const haystack = [row.enemy, row.enemy_key, row.mode, row.episode, row.difficulty]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
if (!haystack.includes(search)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function sortValue(row, key) {
|
|
if (key === "difficulty") return DIFFICULTY_ORDER[row.difficulty] ?? 999;
|
|
if (NUMERIC_COLUMNS.has(key)) return Number(displayValue(row, key) || 0);
|
|
return row[key] || "";
|
|
}
|
|
|
|
function sortedRows(rows) {
|
|
const { key, dir } = state.sort;
|
|
const factor = dir === "desc" ? -1 : 1;
|
|
|
|
return [...rows].sort((a, b) => {
|
|
const av = sortValue(a, key);
|
|
const bv = sortValue(b, key);
|
|
|
|
if (typeof av === "number" && typeof bv === "number") {
|
|
return (av - bv) * factor;
|
|
}
|
|
|
|
return String(av).localeCompare(String(bv), undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
}) * factor;
|
|
});
|
|
}
|
|
|
|
function sortHeader(key, label) {
|
|
const active = state.sort.key === key;
|
|
const arrow = active ? (state.sort.dir === "asc" ? "▲" : "▼") : "";
|
|
const ariaSort = active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none";
|
|
|
|
return `<th aria-sort="${ariaSort}">
|
|
<button class="drops-sort-button ${active ? "is-active" : ""}" type="button" data-bestiary-sort="${esc(key)}">
|
|
<span>${esc(label)}</span>
|
|
<span class="drops-sort-arrow" aria-hidden="true">${arrow}</span>
|
|
</button>
|
|
</th>`;
|
|
}
|
|
|
|
function renderTable() {
|
|
const box = qs("#bestiary-placeholder");
|
|
if (!box) return;
|
|
|
|
const rows = sortedRows(visibleRows());
|
|
const totalPages = Math.max(1, Math.ceil(rows.length / state.pageSize));
|
|
state.page = Math.min(Math.max(1, state.page), totalPages);
|
|
|
|
const start = (state.page - 1) * state.pageSize;
|
|
const shown = rows.slice(start, start + state.pageSize);
|
|
const end = start + shown.length;
|
|
|
|
const columns = currentColumns();
|
|
if (!columns.some(([key]) => key === state.sort.key)) {
|
|
state.sort.key = "enemy";
|
|
state.sort.dir = "asc";
|
|
}
|
|
|
|
const body = shown.map((row) => `
|
|
<tr>
|
|
${columns.map(([key]) => `<td data-label="${esc(key)}">${esc(displayValue(row, key))}</td>`).join("")}
|
|
</tr>
|
|
`).join("");
|
|
|
|
const rangeText = rows.length
|
|
? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.`
|
|
: "";
|
|
|
|
box.innerHTML = `
|
|
<div class="drops-summary">
|
|
<div>
|
|
<strong>BB Bestiary</strong>
|
|
<span>${rows.length.toLocaleString()} matching rows.${rangeText}</span>
|
|
</div>
|
|
<span>${state.rows.length.toLocaleString()} total rows</span>
|
|
</div>
|
|
<div class="drops-table-wrap">
|
|
<table class="drops-table bestiary-table">
|
|
<thead>
|
|
<tr>${columns.map(([key, label]) => sortHeader(key, label)).join("")}</tr>
|
|
</thead>
|
|
<tbody>${body || `<tr><td colspan="${columns.length}">No enemies match these filters.</td></tr>`}</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="leaderboard-pager drops-pager">
|
|
<button type="button" data-bestiary-page="prev" ${state.page <= 1 ? "disabled" : ""}>Previous</button>
|
|
<span>Page ${state.page} of ${totalPages}</span>
|
|
<button type="button" data-bestiary-page="next" ${state.page >= totalPages ? "disabled" : ""}>Next</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function populateFilters() {
|
|
fillSelect(qs("#bestiary-mode"), uniqueSorted(state.rows, "mode"), "All modes");
|
|
fillSelect(qs("#bestiary-episode"), uniqueSorted(state.rows, "episode"), "All episodes");
|
|
fillSelect(qs("#bestiary-difficulty"), orderedDifficulties(state.rows), "All difficulties");
|
|
}
|
|
|
|
async function loadBestiary() {
|
|
setStatus("Loading BB bestiary...");
|
|
|
|
state.index = await fetchJson("generated/bestiary/bb/index.json");
|
|
state.table = state.index.tables?.[0] || null;
|
|
state.rows = await fetchJson(`generated/bestiary/bb/${state.table.path}`);
|
|
|
|
populateFilters();
|
|
renderTable();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
qs("#bestiary-mode")?.addEventListener("change", (event) => {
|
|
state.filters.mode = event.target.value;
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-episode")?.addEventListener("change", (event) => {
|
|
state.filters.episode = event.target.value;
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-difficulty")?.addEventListener("change", (event) => {
|
|
state.filters.difficulty = event.target.value;
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-view")?.addEventListener("change", () => {
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-search")?.addEventListener("input", (event) => {
|
|
state.filters.search = event.target.value;
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-tier")?.addEventListener("change", () => {
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-xp-bonus")?.addEventListener("change", () => {
|
|
renderTable();
|
|
});
|
|
|
|
qs("#bestiary-placeholder")?.addEventListener("click", (event) => {
|
|
const pageButton = event.target.closest("[data-bestiary-page]");
|
|
if (pageButton) {
|
|
state.page += pageButton.dataset.bestiaryPage === "next" ? 1 : -1;
|
|
renderTable();
|
|
return;
|
|
}
|
|
|
|
const button = event.target.closest("[data-bestiary-sort]");
|
|
if (!button) return;
|
|
|
|
const key = button.dataset.bestiarySort;
|
|
if (state.sort.key === key) {
|
|
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
|
} else {
|
|
state.sort.key = key;
|
|
state.sort.dir = "asc";
|
|
}
|
|
|
|
state.page = 1;
|
|
renderTable();
|
|
});
|
|
|
|
loadBestiary().catch((err) => {
|
|
setStatus(err?.message || "Unable to load bestiary.", "error");
|
|
});
|
|
});
|
|
})();
|