Merge pull request 'feature/bestiary-table-viewer' (#8) from feature/bestiary-table-viewer into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-13 19:53:56 -04:00
11 changed files with 308479 additions and 1031 deletions
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import json
from pathlib import Path
SOURCE = Path("source-bestiary/local-newserv/system/tables/battle-params.json")
OUT = Path("site/generated/bestiary/bb")
DIFFICULTY_ORDER = {
"Normal": 0,
"Hard": 1,
"Very Hard": 2,
"Ultimate": 3,
}
def label_episode(table_name):
ep, mode = table_name.split("-", 1)
return ep.replace("Episode", "Episode "), mode
def enemy_label(value):
return str(value).replace("_", " ").title()
def main():
data = json.loads(SOURCE.read_text())
OUT.mkdir(parents=True, exist_ok=True)
rows = []
for table_name, difficulties in data.items():
episode, mode = label_episode(table_name)
for difficulty, entries in difficulties.items():
for entry in entries:
stats = entry.get("Stats", {})
resist = entry.get("ResistData", {})
enemies = stats.get("Enemies") or []
for enemy in enemies:
rows.append({
"version": "bb",
"episode": episode,
"mode": mode,
"difficulty": difficulty,
"enemy": enemy_label(enemy),
"enemy_key": enemy,
"bp_index": entry.get("BPIndex"),
"hp": stats.get("HP", 0),
"atp": stats.get("ATP", 0),
"dfp": stats.get("DFP", 0),
"mst": stats.get("MST", 0),
"ata": stats.get("ATA", 0),
"evp": stats.get("EVP", 0),
"lck": stats.get("LCK", 0),
"esp": stats.get("ESP", 0),
"exp": stats.get("EXP", 0),
"meseta": stats.get("Meseta", 0),
"efr": resist.get("EFR", 0),
"eic": resist.get("EIC", 0),
"eth": resist.get("ETH", 0),
"elt": resist.get("ELT", 0),
"edk": resist.get("EDK", 0),
})
rows.sort(key=lambda row: (
row["episode"],
row["mode"],
DIFFICULTY_ORDER.get(row["difficulty"], 999),
row["enemy"],
row["bp_index"] if row["bp_index"] is not None else 9999,
))
(OUT / "bb.json").write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
(OUT / "index.json").write_text(json.dumps({
"mode": "bestiary",
"label": "BB",
"tables": [{
"version": "bb",
"label": "BB",
"path": "bb.json",
"rows": len(rows),
}],
}, indent=2, sort_keys=True) + "\n")
print(f"bb: {len(rows)} rows -> {OUT / 'bb.json'}")
print(f"index -> {OUT / 'index.json'}")
if __name__ == "__main__":
main()
+367
View File
@@ -0,0 +1,367 @@
(() => {
"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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 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 `<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-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");
});
});
})();
+43 -10
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=bestiary-table-viewer-20260613-3">
<script src="app.js?v=saves-synced-20260609-2" defer></script>
</head>
<body>
@@ -33,18 +33,51 @@
<form class="placeholder-form">
<label for="bestiary-version">Version</label>
<select id="bestiary-version">
<option>V2</option>
<option>V3</option>
<option>V4</option>
<option value="bb">BB</option>
</select>
<label for="bestiary-episode" data-bestiary-episode-wrap hidden>Episode</label>
<select id="bestiary-episode" data-bestiary-episode-wrap hidden>
<option>Episode 1</option>
<option>Episode 2</option>
<option>Episode 4</option>
<label for="bestiary-mode">Mode</label>
<select id="bestiary-mode">
<option value="">All modes</option>
</select>
<label for="bestiary-episode">Episode</label>
<select id="bestiary-episode">
<option value="">All episodes</option>
</select>
<label for="bestiary-difficulty">Difficulty</label>
<select id="bestiary-difficulty">
<option value="">All difficulties</option>
</select>
<label for="bestiary-bp-tier">BP Tier</label>
<select id="bestiary-bp-tier">
<option value="0">No modifier</option>
<option value="1">Brutal Peeps +1</option>
<option value="2">Brutal Peeps +2</option>
<option value="3">Brutal Peeps +3</option>
<option value="4">Brutal Peeps +4</option>
<option value="5">Brutal Peeps +5</option>
<option value="6">Brutal Peeps +6</option>
<option value="7">Brutal Peeps +7</option>
<option value="8">Brutal Peeps +8</option>
<option value="9">Brutal Peeps +9</option>
<option value="10">Brutal Peeps +10</option>
<option value="11">Brutal Peeps +11</option>
</select>
<p class="drops-field-note">Applies HP, ATP, and EXP modifiers to Ultimate rows only.</p>
<label for="bestiary-view">View</label>
<select id="bestiary-view">
<option value="stats">Stats</option>
<option value="resists">Resists</option>
<option value="all">All</option>
</select>
<label for="bestiary-search">Search</label>
<input id="bestiary-search" type="search" placeholder="Enemy name...">
<label for="bestiary-tier" data-bestiary-tier-wrap>BP Tier</label>
<select id="bestiary-tier" data-bestiary-tier-wrap>
<option>BP+1</option><option>BP+2</option><option>BP+3</option>
@@ -89,6 +122,6 @@
</div>
</footer>
</div>
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
<script src="bestiary-tables.js?v=bestiary-table-viewer-20260613-3" defer></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"label": "BB",
"mode": "bestiary",
"tables": [
{
"label": "BB",
"path": "bb.json",
"rows": 3216,
"version": "bb"
}
]
}
+6 -1021
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
# Raw binary battle parameter files are local source artifacts.
*.dat
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
/opt/newserv-hardcore/system/blueburst/BattleParamEntry.dat
/opt/newserv-hardcore/system/blueburst/BattleParamEntry_ep4.dat
/opt/newserv-hardcore/system/blueburst/BattleParamEntry_ep4_on.dat
/opt/newserv-hardcore/system/blueburst/BattleParamEntry_lab.dat
/opt/newserv-hardcore/system/blueburst/BattleParamEntry_lab_on.dat
/opt/newserv-hardcore/system/blueburst/BattleParamEntry_on.dat
/opt/newserv-hardcore/system/client-functions.disabled-hardcore-20260523T032841Z/EnemyHPBarsBB.s
/opt/newserv-hardcore/system/client-functions.disabled-hardcore-20260523T032841Z/EnemyHPBarsGC.s
/opt/newserv-hardcore/system/client-functions.disabled-hardcore-20260523T032841Z/EnemyHPBarsXB.s
/opt/newserv-hardcore/system/client-functions/EnemyDamageSyncBB.s
/opt/newserv-hardcore/system/client-functions/EnemyDamageSyncGC.s
/opt/newserv-hardcore/system/client-functions/EnemyDamageSyncXB.s
/opt/newserv-hardcore/system/client-functions/System/GetEnemyEntity.inc.s
/opt/newserv-hardcore/system/maps/gc-ep3/map_city_on_battle_e.dat
/opt/newserv-hardcore/system/maps/gc-ep3/map_city_on_battle_o.dat
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSyncBB.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSync/EnemyDamageSync.3___.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSync/EnemyDamageSync.4___.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSync/EnemyDamageSync.59NL.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSyncGC.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyDamageSyncXB.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBarsBB.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBars/EnemyHPBars.3___.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBars/EnemyHPBars.4___.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBars/EnemyHPBars.59NL.patch.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBarsGC.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/EnemyHPBarsXB.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/System/GetEnemyEntity-59NL.x86.inc.s
/opt/newserv-hardcore/system/psopeeps-backups/client-functions-before-test-sync-20260516T021108Z/System/GetEnemyEntity.inc.s
/opt/newserv-hardcore/system/tables/battle-params.json
/opt/newserv-hardcore/system/tables/item-parameter-table-bb-v4.json
/opt/newserv-hardcore/system/tables/item-parameter-table-dc-11-2000.json
/opt/newserv-hardcore/system/tables/item-parameter-table-dc-nte.json
/opt/newserv-hardcore/system/tables/item-parameter-table-dc-v1.json
/opt/newserv-hardcore/system/tables/item-parameter-table-gc-nte.json
/opt/newserv-hardcore/system/tables/item-parameter-table-gc-v3.json
/opt/newserv-hardcore/system/tables/item-parameter-table-pc-v2.json
/opt/newserv-hardcore/system/tables/item-parameter-table-xb-v3.json
@@ -0,0 +1,22 @@
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyDamageSyncBB.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyDamageSyncGC.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyDamageSyncXB.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyHPBarsBB.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyHPBarsGC.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/EnemyHPBarsXB.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/client-functions/System/GetEnemyEntity.inc.s
/home/rbatty/.local/share/github/psopeeps-newserv/system/maps/gc-ep3/map_city_on_battle_e.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/maps/gc-ep3/map_city_on_battle_o.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/patch-pc-10x/Media/PSO/BattleParamEntry.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/patch-pc-10x/Media/PSO/BattleParamEntry_on.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/patch-pc-5x/Media/PSO/BattleParamEntry.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/patch-pc-5x/Media/PSO/BattleParamEntry_on.dat
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/battle-params.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-bb-v4.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-dc-11-2000.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-dc-nte.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-dc-v1.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-gc-nte.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-gc-v3.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-pc-v2.json
/home/rbatty/.local/share/github/psopeeps-newserv/system/tables/item-parameter-table-xb-v3.json
File diff suppressed because it is too large Load Diff