(() => { "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: null }, }; 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 = `
${esc(message)}
`; } 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 = ``; 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 tier = Number(qs("#bestiary-bp-tier")?.value || 0); return BP_TIERS[tier] || BP_TIERS[0]; } function displayValue(row, key) { const value = row[key]; 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); } return value ?? ""; } function visibleRows() { const search = state.filters.search.trim().toLowerCase(); return state.rows.filter((row) => { 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 ` `; } 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) => ` ${columns.map(([key]) => `${esc(displayValue(row, key))}`).join("")} `).join(""); const rangeText = rows.length ? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.` : ""; box.innerHTML = `
BB Bestiary ${rows.length.toLocaleString()} matching rows.${rangeText}
${state.rows.length.toLocaleString()} total rows
${columns.map(([key, label]) => sortHeader(key, label)).join("")}${body || ``}
No enemies match these filters.
Page ${state.page} of ${totalPages}
`; } 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-bp-tier")?.addEventListener("change", () => { state.page = 1; 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"); }); }); })();