(() => { "use strict"; const qs = (sel) => document.querySelector(sel); const state = { index: null, rows: [], table: null, filters: { mode: "", episode: "", difficulty: "", section: "", search: "", }, sort: { key: "source", dir: "asc", }, page: 1, pageSize: 100, }; const RARE_MODIFIER_VERSIONS = new Set(["v2", "bb"]); function esc(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function labelValue(value) { return String(value || "") .replace(/^Episode(\d+)$/, "Episode $1") .replace(/^VeryHard$/, "Very Hard") .replaceAll("_", " "); } function setStatus(message, kind = "") { const box = qs("#drops-placeholder"); if (!box) return; box.innerHTML = `
${esc(message)}
`; } function currentVersion() { return qs("#drops-version")?.value || "v1"; } function rareModifierEnabled() { const selectedMode = qs("#drops-mode")?.value || "peeps"; return selectedMode === "peeps" && RARE_MODIFIER_VERSIONS.has(currentVersion()); } function currentRareModifier() { const select = qs("#drops-rare-modifier"); const pct = rareModifierEnabled() ? Number(select?.value || 0) : 0; const label = select?.selectedOptions?.[0]?.textContent || "No modifier"; return { pct, label, multiplier: 1 + (pct / 100) }; } function modifierMultiplierLabel(modifier) { return `x${modifier.multiplier.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}`; } function updateRareModifierControls() { const wrap = qs("#drops-rare-modifier-wrap"); const v2Note = qs("#drops-v2-note"); const select = qs("#drops-rare-modifier"); const enabled = rareModifierEnabled(); if (wrap) wrap.hidden = !enabled; if (v2Note) v2Note.hidden = currentVersion() !== "v2"; if (!enabled && select) { select.value = "0"; } } function formatOddsDenominator(value) { if (!Number.isFinite(value) || value <= 0) return "—"; if (value >= 1000) { return Math.round(value).toLocaleString(); } if (value >= 100) { return value.toFixed(1).replace(/\.0$/, ""); } return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); } function adjustedRate(rate) { const text = String(rate || ""); const match = text.match(/^(\d+)\/(\d+)$/); if (!match) return text || "—"; const num = Number(match[1]); const den = Number(match[2]); const modifier = currentRareModifier(); if (!rareModifierEnabled() || modifier.pct <= 0) { return text; } const baseProbability = num / den; const adjustedProbability = Math.min(1, baseProbability * modifier.multiplier); if (adjustedProbability >= 1) { return "1/1"; } return `1/${formatOddsDenominator(1 / adjustedProbability)}`; } function rateCellHtml(rate) { const base = String(rate || "—"); const adjusted = adjustedRate(base); const modifier = currentRareModifier(); if (!rareModifierEnabled() || modifier.pct <= 0 || adjusted === base) { return esc(base); } return esc(adjusted); } 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 uniqueSorted(rows, key) { return [...new Set(rows.map((row) => row[key]).filter(Boolean))] .sort((a, b) => String(a).localeCompare(String(b))); } 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 = labelValue(value); select.appendChild(opt); } if ([...select.options].some((opt) => opt.value === previous)) { select.value = previous; } } function populateVersions(index) { const select = qs("#drops-version"); if (!select) return; const previous = select.value || "v1"; select.innerHTML = ""; for (const table of index.tables || []) { const opt = document.createElement("option"); opt.value = table.version; opt.textContent = table.label; select.appendChild(opt); } if ([...select.options].some((opt) => opt.value === previous)) { select.value = previous; } } function orderedDifficultyValues(rows) { const order = ["Normal", "Hard", "VeryHard", "Ultimate"]; const present = new Set(rows.map((row) => row.difficulty).filter(Boolean)); const ordered = order.filter((value) => present.has(value)); const extras = [...present] .filter((value) => !order.includes(value)) .sort((a, b) => String(a).localeCompare(String(b))); return [...ordered, ...extras]; } function populateFilters(rows) { fillSelect(qs("#drops-rare-mode"), uniqueSorted(rows, "mode"), "All modes"); fillSelect(qs("#drops-episode"), uniqueSorted(rows, "episode"), "All episodes"); fillSelect(qs("#drops-difficulty"), orderedDifficultyValues(rows), "All difficulties"); fillSelect(qs("#drops-section"), uniqueSorted(rows, "section_id"), "All Section IDs"); } const SORT_COLUMNS = [ ["mode", "Mode"], ["episode", "Episode"], ["difficulty", "Difficulty"], ["section_id", "SECID"], ["source", "Source"], ["item", "Item"], ["item_code", "Code"], ["rate", "Rate"], ]; const DIFFICULTY_SORT_ORDER = { Normal: 0, Hard: 1, VeryHard: 2, Ultimate: 3, }; function rateSortValue(rate) { const text = String(rate || "").replaceAll(",", ""); const match = text.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/); if (!match) return Number.NEGATIVE_INFINITY; const num = Number(match[1]); const den = Number(match[2]); if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) { return Number.NEGATIVE_INFINITY; } // Sort by actual probability, same as percentage conversion. // 5/8 => 0.625, 1/8192 => 0.000122... return num / den; } function sortValue(row, key) { if (key === "difficulty") { return DIFFICULTY_SORT_ORDER[row.difficulty] ?? 999; } if (key === "rate") { return rateSortValue(adjustedRate(row.rate)); } if (key === "item") { return row.item || row.item_code || ""; } 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 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 (state.filters.section && row.section_id !== state.filters.section) return false; if (search) { const haystack = [ row.mode, row.episode, row.difficulty, row.section_id, row.source, row.item, row.item_code, row.rate, ].join(" ").toLowerCase(); if (!haystack.includes(search)) return false; } return true; }); } function renderTable() { const box = qs("#drops-placeholder"); if (!box) return; const rows = sortedRows(visibleRows()); const tableLabel = state.table?.label || "BB"; const groupLabel = state.table?.groupLabel || "Peeps"; 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 body = shown.map((row) => { const item = row.item || row.item_code || "—"; const itemCode = row.item_code || "—"; return ` ${esc(labelValue(row.mode))} ${esc(labelValue(row.episode))} ${esc(labelValue(row.difficulty))} ${esc(row.section_id || "—")} ${esc(labelValue(row.source || "—"))} ${esc(item)} ${esc(itemCode)} ${rateCellHtml(row.rate || "—")} `; }).join(""); const rangeText = rows.length ? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.` : ""; const modifier = currentRareModifier(); const modifierNote = rareModifierEnabled() && modifier.pct > 0 ? `Rate modifier: ${esc(modifier.label)} / ${esc(modifierMultiplierLabel(modifier))}` : ""; box.innerHTML = `
${esc(groupLabel)} ${esc(tableLabel)} drop table ${rows.length.toLocaleString()} matching rows.${rangeText} ${modifierNote}
${state.rows.length.toLocaleString()} total rows
${SORT_COLUMNS.map(([key, label]) => sortHeader(key, label)).join("")} ${body || ``}
No drops match these filters.
Page ${state.page} of ${totalPages}
`; } async function loadDropGroup() { const selectedMode = qs("#drops-mode")?.value || "peeps"; const groupPath = selectedMode === "hardcore" ? "hardcore" : "peeps"; const groupLabel = selectedMode === "hardcore" ? "Hardcore" : "Peeps"; setStatus(`Loading ${groupLabel} drop tables...`); state.index = await fetchJson(`generated/drops/${groupPath}/index.json`); if (selectedMode === "peeps") { populateVersions(state.index); } const version = selectedMode === "hardcore" ? "bb" : currentVersion(); const table = (state.index.tables || []).find((entry) => entry.version === version); if (!table) { setStatus(`No ${groupLabel} drop table is configured.`, "error"); return; } state.table = { ...table, groupLabel }; state.rows = await fetchJson(`generated/drops/${groupPath}/${table.path}`); state.filters.mode = ""; state.filters.episode = ""; state.filters.difficulty = ""; state.filters.section = ""; state.filters.search = ""; state.page = 1; if (qs("#drops-search")) qs("#drops-search").value = ""; populateFilters(state.rows); updateRareModifierControls(); if (qs("#drops-rare-mode")) qs("#drops-rare-mode").value = ""; if (qs("#drops-episode")) qs("#drops-episode").value = ""; if (qs("#drops-difficulty")) qs("#drops-difficulty").value = ""; if (qs("#drops-section")) qs("#drops-section").value = ""; renderTable(); } async function updateMode() { const mode = qs("#drops-mode")?.value || "peeps"; const versionSelect = qs("#drops-version"); const versionLabel = document.querySelector('label[for="drops-version"]'); const v2Note = qs("#drops-v2-note"); const hardcoreNote = qs("#drops-hardcore-note"); if (versionSelect) versionSelect.hidden = mode === "hardcore"; if (versionLabel) versionLabel.hidden = mode === "hardcore"; if (v2Note) v2Note.hidden = true; if (hardcoreNote) hardcoreNote.hidden = mode !== "hardcore"; try { await loadDropGroup(); } catch (err) { setStatus(err?.message || "Unable to load drop table.", "error"); } } document.addEventListener("DOMContentLoaded", () => { qs("#drops-mode")?.addEventListener("change", updateMode); qs("#drops-version")?.addEventListener("change", loadDropGroup); qs("#drops-rare-modifier")?.addEventListener("change", () => { state.page = 1; state.page = 1; renderTable(); }); qs("#drops-rare-mode")?.addEventListener("change", (event) => { state.filters.mode = event.target.value; state.page = 1; renderTable(); }); qs("#drops-episode")?.addEventListener("change", (event) => { state.filters.episode = event.target.value; state.page = 1; renderTable(); }); qs("#drops-difficulty")?.addEventListener("change", (event) => { state.filters.difficulty = event.target.value; state.page = 1; renderTable(); }); qs("#drops-section")?.addEventListener("change", (event) => { state.filters.section = event.target.value; state.page = 1; renderTable(); }); qs("#drops-search")?.addEventListener("input", (event) => { state.filters.search = event.target.value; state.page = 1; renderTable(); }); qs("#drops-placeholder")?.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-drops-page]"); if (pageButton) { state.page += pageButton.dataset.dropsPage === "next" ? 1 : -1; renderTable(); return; } const button = event.target.closest("[data-drops-sort]"); if (!button) return; const key = button.dataset.dropsSort; if (state.sort.key === key) { state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; } else { state.sort.key = key; state.sort.dir = "asc"; } renderTable(); }); updateMode(); }); })();