Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
167
scripts/generate-bestiary-index.mjs
Normal file
167
scripts/generate-bestiary-index.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Usage: node scripts/generate-bestiary-index.mjs <path-to-5etools-src>
|
||||
//
|
||||
// Requires a local clone/checkout of https://github.com/5etools-mirror-3/5etools-src
|
||||
// with at least data/bestiary/, data/books.json, and data/adventures.json.
|
||||
//
|
||||
// Example:
|
||||
// git clone --depth 1 --sparse https://github.com/5etools-mirror-3/5etools-src.git /tmp/5etools
|
||||
// cd /tmp/5etools && git sparse-checkout set data/bestiary data
|
||||
// node scripts/generate-bestiary-index.mjs /tmp/5etools
|
||||
|
||||
const TOOLS_ROOT = process.argv[2];
|
||||
if (!TOOLS_ROOT) {
|
||||
console.error(
|
||||
"Usage: node scripts/generate-bestiary-index.mjs <5etools-src-path>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
||||
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
|
||||
const BOOKS_PATH = join(TOOLS_ROOT, "data/books.json");
|
||||
const ADVENTURES_PATH = join(TOOLS_ROOT, "data/adventures.json");
|
||||
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/index.json");
|
||||
|
||||
// --- Build source display name map from books.json + adventures.json ---
|
||||
|
||||
function buildSourceMap() {
|
||||
const map = {};
|
||||
|
||||
const books = JSON.parse(readFileSync(BOOKS_PATH, "utf-8"));
|
||||
for (const book of books.book ?? []) {
|
||||
if (book.source && book.name) {
|
||||
map[book.source] = book.name;
|
||||
}
|
||||
// Some books use "id" instead of "source"
|
||||
if (book.id && book.name && !map[book.id]) {
|
||||
map[book.id] = book.name;
|
||||
}
|
||||
}
|
||||
|
||||
const adventures = JSON.parse(readFileSync(ADVENTURES_PATH, "utf-8"));
|
||||
for (const adv of adventures.adventure ?? []) {
|
||||
if (adv.source && adv.name) {
|
||||
map[adv.source] = adv.name;
|
||||
}
|
||||
if (adv.id && adv.name && !map[adv.id]) {
|
||||
map[adv.id] = adv.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Manual additions for sources missing from books.json / adventures.json
|
||||
const manual = {
|
||||
ESK: "Essentials Kit",
|
||||
MCV1SC: "Monstrous Compendium Volume 1: Spelljammer Creatures",
|
||||
MCV2DC: "Monstrous Compendium Volume 2: Dragonlance Creatures",
|
||||
MCV3MC: "Monstrous Compendium Volume 3: Minecraft Creatures",
|
||||
MCV4EC: "Monstrous Compendium Volume 4: Eldraine Creatures",
|
||||
MFF: "Mordenkainen's Fiendish Folio",
|
||||
MisMV1: "Misplaced Monsters: Volume 1",
|
||||
SADS: "Sapphire Anniversary Dice Set",
|
||||
TftYP: "Tales from the Yawning Portal",
|
||||
VD: "Vecna Dossier",
|
||||
};
|
||||
for (const [k, v] of Object.entries(manual)) {
|
||||
if (!map[k]) map[k] = v;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Extract type string from raw type field ---
|
||||
|
||||
function extractType(type) {
|
||||
if (typeof type === "string") return type;
|
||||
if (typeof type?.type === "string") return type.type;
|
||||
if (typeof type?.type === "object" && Array.isArray(type.type.choose)) {
|
||||
return type.type.choose.join("/");
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// --- Extract AC from raw ac field ---
|
||||
|
||||
function extractAc(ac) {
|
||||
if (!Array.isArray(ac) || ac.length === 0) return 0;
|
||||
const first = ac[0];
|
||||
if (typeof first === "number") return first;
|
||||
if (typeof first === "object" && typeof first.ac === "number")
|
||||
return first.ac;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Extract CR from raw cr field ---
|
||||
|
||||
function extractCr(cr) {
|
||||
if (typeof cr === "string") return cr;
|
||||
if (typeof cr === "object" && typeof cr.cr === "string") return cr.cr;
|
||||
return "0";
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const sourceMap = buildSourceMap();
|
||||
const files = readdirSync(BESTIARY_DIR).filter(
|
||||
(f) => f.startsWith("bestiary-") && f.endsWith(".json"),
|
||||
);
|
||||
|
||||
const creatures = [];
|
||||
const unmappedSources = new Set();
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
|
||||
const monsters = raw.monster ?? [];
|
||||
|
||||
for (const m of monsters) {
|
||||
// Skip creatures that are copies/references (no actual stats)
|
||||
if (m._copy || m.hp == null || m.ac == null) continue;
|
||||
|
||||
const source = m.source ?? "";
|
||||
if (source && !sourceMap[source]) {
|
||||
unmappedSources.add(source);
|
||||
}
|
||||
|
||||
creatures.push({
|
||||
n: m.name,
|
||||
s: source,
|
||||
ac: extractAc(m.ac),
|
||||
hp: m.hp.average ?? 0,
|
||||
dx: m.dex ?? 10,
|
||||
cr: extractCr(m.cr),
|
||||
ip: m.initiative?.proficiency ?? 0,
|
||||
sz: Array.isArray(m.size) ? m.size[0] : (m.size ?? "M"),
|
||||
tp: extractType(m.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by name then source for stable output
|
||||
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
||||
|
||||
// Filter sourceMap to only include sources that appear in the index
|
||||
const usedSources = new Set(creatures.map((c) => c.s));
|
||||
const filteredSourceMap = {};
|
||||
for (const [key, value] of Object.entries(sourceMap)) {
|
||||
if (usedSources.has(key)) {
|
||||
filteredSourceMap[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
sources: filteredSourceMap,
|
||||
creatures,
|
||||
};
|
||||
|
||||
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
||||
|
||||
// Stats
|
||||
const rawSize = Buffer.byteLength(JSON.stringify(output));
|
||||
console.log(`Sources: ${Object.keys(filteredSourceMap).length}`);
|
||||
console.log(`Creatures: ${creatures.length}`);
|
||||
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
||||
if (unmappedSources.size > 0) {
|
||||
console.log(`Unmapped sources: ${[...unmappedSources].sort().join(", ")}`);
|
||||
}
|
||||
Reference in New Issue
Block a user