diff --git a/scripts/import-newserv-drop-tables.py b/scripts/import-newserv-drop-tables.py
new file mode 100755
index 0000000..63399ef
--- /dev/null
+++ b/scripts/import-newserv-drop-tables.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+import json
+import re
+from pathlib import Path
+
+NEWSERV = Path.home() / ".local/share/github/psopeeps-newserv"
+PEEPS_OUT = Path("site/generated/drops/peeps")
+HARDCORE_OUT = Path("site/generated/drops/hardcore")
+
+PEEPS_TABLES = {
+ "v1": NEWSERV / "system/tables/rare-table-v1.json",
+ "v2": NEWSERV / "system/tables/rare-table-v2.json",
+ "v3": NEWSERV / "system/tables/rare-table-v3.json",
+ "bb": NEWSERV / "system/tables/rare-table-v4.json",
+}
+
+HARDCORE_TABLES = {
+ "bb": Path("source-drops/hardcore/rare-table-v4.json"),
+}
+
+def strip_json_comments(text):
+ out = []
+ in_str = False
+ esc = False
+ i = 0
+ while i < len(text):
+ c = text[i]
+ n = text[i + 1] if i + 1 < len(text) else ""
+
+ if in_str:
+ out.append(c)
+ if esc:
+ esc = False
+ elif c == "\\":
+ esc = True
+ elif c == '"':
+ in_str = False
+ i += 1
+ continue
+
+ if c == '"':
+ in_str = True
+ out.append(c)
+ i += 1
+ continue
+
+ if c == "/" and n == "/":
+ while i < len(text) and text[i] not in "\r\n":
+ i += 1
+ continue
+
+ out.append(c)
+ i += 1
+
+ return "".join(out)
+
+def quote_hex_numbers(text):
+ return re.sub(r'(?= 3 and drop[2] else "",
+ })
+
+ return rows
+
+def write_group(mode, label, tables, out_dir):
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ index = {
+ "mode": mode,
+ "label": label,
+ "tables": [],
+ }
+
+ labels = {"v1": "V1", "v2": "V2", "v3": "V3", "bb": "BB"}
+
+ for version, src in tables.items():
+ table = load_newserv_jsonish(src)
+ rows = flatten_table(version, table)
+
+ out_name = f"{version}.json"
+ out_path = out_dir / out_name
+ out_path.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
+
+ index["tables"].append({
+ "version": version,
+ "label": labels[version],
+ "path": out_name,
+ "rows": len(rows),
+ })
+
+ print(f"{mode} {version}: {len(rows)} rows -> {out_path}")
+
+ index_path = out_dir / "index.json"
+ index_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n")
+ print(f"{mode} index -> {index_path}")
+
+def main():
+ write_group("peeps", "Peeps", PEEPS_TABLES, PEEPS_OUT)
+ write_group("hardcore", "Hardcore", HARDCORE_TABLES, HARDCORE_OUT)
+
+if __name__ == "__main__":
+ main()
diff --git a/site/drop-tables.js b/site/drop-tables.js
new file mode 100644
index 0000000..b1fe411
--- /dev/null
+++ b/site/drop-tables.js
@@ -0,0 +1,498 @@
+(() => {
+ "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. |
`}
+
+
+
+ `;
+ }
+
+ 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();
+ });
+})();
diff --git a/site/drops.html b/site/drops.html
index 4316214..da162e6 100644
--- a/site/drops.html
+++ b/site/drops.html
@@ -8,7 +8,7 @@
-
+
@@ -37,25 +37,64 @@
-
-
+
+
+
+
V2 drop tables apply to PSO PC only.
+
Hardcore uses the BB/V4 drop table.
-
-
+
+
+
+
Applies to the Rate column for V2 PC and BB/V4.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- Drop table placeholder
+ Drop table placeholder
@@ -88,6 +127,6 @@
-
+