168 lines
4.9 KiB
JavaScript
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(", ")}`);
|
|
}
|