Files
initiative/scripts/generate-bestiary-index.mjs

168 lines
4.9 KiB
JavaScript

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(", ")}`);
}