diff --git a/scripts/import-newserv-drop-tables.py b/scripts/import-newserv-drop-tables.py
new file mode 100755
index 0000000..9e19535
--- /dev/null
+++ b/scripts/import-newserv-drop-tables.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+import json
+import re
+from pathlib import Path
+
+NEWSERV = Path.home() / ".local/share/github/psopeeps-newserv"
+OUT = Path("site/generated/drops/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",
+}
+
+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 main():
+ OUT.mkdir(parents=True, exist_ok=True)
+
+ index = {
+ "mode": "peeps",
+ "label": "Peeps",
+ "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 / 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"{version}: {len(rows)} rows -> {out_path}")
+
+ index_path = OUT / "index.json"
+ index_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n")
+ print(f"index -> {index_path}")
+
+if __name__ == "__main__":
+ main()
diff --git a/site/drop-tables.js b/site/drop-tables.js
new file mode 100644
index 0000000..cb7ea53
--- /dev/null
+++ b/site/drop-tables.js
@@ -0,0 +1,255 @@
+(() => {
+ "use strict";
+
+ const qs = (sel) => document.querySelector(sel);
+
+ const state = {
+ index: null,
+ rows: [],
+ table: null,
+ filters: {
+ episode: "",
+ difficulty: "",
+ section: "",
+ search: "",
+ },
+ };
+
+ function esc(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
+
+ function labelValue(value) {
+ return String(value || "")
+ .replace(/^Episode(\d+)$/, "Episode $1")
+ .replaceAll("_", " ");
+ }
+
+ function setStatus(message, kind = "") {
+ const box = qs("#drops-placeholder");
+ if (!box) return;
+ box.innerHTML = `
${esc(message)}
`;
+ }
+
+ 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 populateFilters(rows) {
+ fillSelect(qs("#drops-episode"), uniqueSorted(rows, "episode"), "All episodes");
+ fillSelect(qs("#drops-difficulty"), uniqueSorted(rows, "difficulty"), "All difficulties");
+ fillSelect(qs("#drops-section"), uniqueSorted(rows, "section_id"), "All Section IDs");
+ }
+
+ function visibleRows() {
+ const search = state.filters.search.trim().toLowerCase();
+
+ return state.rows.filter((row) => {
+ 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 = visibleRows();
+ const tableLabel = state.table?.label || "Peeps";
+ const shown = rows.slice(0, 1000);
+
+ 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)} |
+ ${esc(row.rate || "—")} |
+
+ `;
+ }).join("");
+
+ const truncation = rows.length > shown.length
+ ? ` Showing first ${shown.length.toLocaleString()}.`
+ : "";
+
+ box.innerHTML = `
+
+
+ Peeps ${esc(tableLabel)} drop table
+ ${rows.length.toLocaleString()} matching rows.${truncation}
+
+
${state.rows.length.toLocaleString()} total rows
+
+
+
+
+
+ | Mode |
+ Episode |
+ Difficulty |
+ Section ID |
+ Source |
+ Item |
+ Item Code |
+ Rate |
+
+
+ ${body || `| No drops match these filters. |
`}
+
+
+ `;
+ }
+
+ async function loadPeeps() {
+ setStatus("Loading Peeps drop tables...");
+
+ if (!state.index) {
+ state.index = await fetchJson("generated/drops/peeps/index.json");
+ populateVersions(state.index);
+ }
+
+ const version = qs("#drops-version")?.value || "v1";
+ const table = (state.index.tables || []).find((entry) => entry.version === version);
+
+ if (!table) {
+ setStatus("No drop table is configured for that version.", "error");
+ return;
+ }
+
+ state.table = table;
+ state.rows = await fetchJson(`generated/drops/peeps/${table.path}`);
+
+ state.filters.episode = "";
+ state.filters.difficulty = "";
+ state.filters.section = "";
+ state.filters.search = "";
+
+ if (qs("#drops-search")) qs("#drops-search").value = "";
+
+ populateFilters(state.rows);
+
+ 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 peepsControls = qs("#drops-peeps-controls");
+
+ if (mode === "hardcore") {
+ if (peepsControls) peepsControls.hidden = true;
+ setStatus("Hardcore drop tables coming next.");
+ return;
+ }
+
+ if (peepsControls) peepsControls.hidden = false;
+
+ try {
+ await loadPeeps();
+ } 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", loadPeeps);
+
+ qs("#drops-episode")?.addEventListener("change", (event) => {
+ state.filters.episode = event.target.value;
+ renderTable();
+ });
+
+ qs("#drops-difficulty")?.addEventListener("change", (event) => {
+ state.filters.difficulty = event.target.value;
+ renderTable();
+ });
+
+ qs("#drops-section")?.addEventListener("change", (event) => {
+ state.filters.section = event.target.value;
+ renderTable();
+ });
+
+ qs("#drops-search")?.addEventListener("input", (event) => {
+ state.filters.search = event.target.value;
+ renderTable();
+ });
+
+ updateMode();
+ });
+})();
diff --git a/site/drops.html b/site/drops.html
index 4316214..4a0c6cc 100644
--- a/site/drops.html
+++ b/site/drops.html
@@ -8,7 +8,7 @@
-
+
@@ -37,25 +37,38 @@
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
- Drop table placeholder
+ Drop table placeholder
@@ -88,6 +101,6 @@
-
+