import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; // Usage: node scripts/generate-bestiary-index.mjs // // 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(", ")}`); }