import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; // Usage: node scripts/generate-pf2e-bestiary-index.mjs // // Requires a local clone/checkout of https://github.com/Pf2eToolsOrg/Pf2eTools (dev branch) // with at least data/bestiary/. // // Example: // git clone --depth 1 --branch dev --sparse https://github.com/Pf2eToolsOrg/Pf2eTools.git /tmp/pf2etools // cd /tmp/pf2etools && git sparse-checkout set data/bestiary data // node scripts/generate-pf2e-bestiary-index.mjs /tmp/pf2etools const TOOLS_ROOT = process.argv[2]; if (!TOOLS_ROOT) { console.error( "Usage: node scripts/generate-pf2e-bestiary-index.mjs ", ); process.exit(1); } const PROJECT_ROOT = join(import.meta.dirname, ".."); const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary"); const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/pf2e-index.json"); // --- Source display names --- // Pf2eTools doesn't have a single books.json with all adventure paths. // We map known source codes to display names here. const SOURCE_NAMES = { B1: "Bestiary", B2: "Bestiary 2", B3: "Bestiary 3", CRB: "Core Rulebook", GMG: "Gamemastery Guide", LOME: "Lost Omens: The Mwangi Expanse", LOMM: "Lost Omens: Monsters of Myth", LOIL: "Lost Omens: Impossible Lands", LOCG: "Lost Omens: Character Guide", LOSK: "Lost Omens: Knights of Lastwall", LOTXWG: "Lost Omens: Travel Guide", LOACLO: "Lost Omens: Absalom, City of Lost Omens", LOHh: "Lost Omens: Highhelm", AoA1: "Age of Ashes #1: Hellknight Hill", AoA2: "Age of Ashes #2: Cult of Cinders", AoA3: "Age of Ashes #3: Tomorrow Must Burn", AoA4: "Age of Ashes #4: Fires of the Haunted City", AoA5: "Age of Ashes #5: Against the Scarlet Triad", AoA6: "Age of Ashes #6: Broken Promises", AoE1: "Agents of Edgewatch #1", AoE2: "Agents of Edgewatch #2", AoE3: "Agents of Edgewatch #3", AoE4: "Agents of Edgewatch #4", AoE5: "Agents of Edgewatch #5", AoE6: "Agents of Edgewatch #6", EC1: "Extinction Curse #1", EC2: "Extinction Curse #2", EC3: "Extinction Curse #3", EC4: "Extinction Curse #4", EC5: "Extinction Curse #5", EC6: "Extinction Curse #6", AV1: "Abomination Vaults #1", AV2: "Abomination Vaults #2", AV3: "Abomination Vaults #3", FRP1: "Fists of the Ruby Phoenix #1", FRP2: "Fists of the Ruby Phoenix #2", FRP3: "Fists of the Ruby Phoenix #3", SoT1: "Strength of Thousands #1", SoT2: "Strength of Thousands #2", SoT3: "Strength of Thousands #3", SoT4: "Strength of Thousands #4", SoT5: "Strength of Thousands #5", SoT6: "Strength of Thousands #6", OoA1: "Outlaws of Alkenstar #1", OoA2: "Outlaws of Alkenstar #2", OoA3: "Outlaws of Alkenstar #3", BotD: "Book of the Dead", DA: "Dark Archive", FoP: "The Fall of Plaguestone", LTiBA: "Little Trouble in Big Absalom", Sli: "The Slithering", TiO: "Troubles in Otari", NGD: "Night of the Gray Death", BB: "Beginner Box", SoG1: "Sky King's Tomb #1", SoG2: "Sky King's Tomb #2", SoG3: "Sky King's Tomb #3", GW1: "Gatewalkers #1", GW2: "Gatewalkers #2", GW3: "Gatewalkers #3", WoW1: "Wardens of Wildwood #1", WoW2: "Wardens of Wildwood #2", WoW3: "Wardens of Wildwood #3", SF1: "Season of Ghosts #1", SF2: "Season of Ghosts #2", SF3: "Season of Ghosts #3", POS1: "Pathfinder One-Shots", AFoF: "A Fistful of Flowers", TaL: "Threshold of Knowledge", ToK: "Threshold of Knowledge", DaLl: "Dinner at Lionlodge", MotM: "Monsters of the Multiverse", Mal: "Malevolence", TEC: "The Enmity Cycle", SaS: "Shadows at Sundown", Rust: "Rusthenge", CotT: "Crown of the Kobold King", SoM: "Secrets of Magic", }; // --- Size extraction from traits --- const SIZES = new Set([ "tiny", "small", "medium", "large", "huge", "gargantuan", ]); // Creature type traits (PF2e types are lowercase in the traits array) const CREATURE_TYPES = new Set([ "aberration", "animal", "astral", "beast", "celestial", "construct", "dragon", "dream", "elemental", "ethereal", "fey", "fiend", "fungus", "giant", "humanoid", "monitor", "ooze", "petitioner", "plant", "spirit", "time", "undead", ]); function extractSize(traits) { if (!Array.isArray(traits)) return "medium"; const found = traits.find((t) => SIZES.has(t.toLowerCase())); return found ? found.toLowerCase() : "medium"; } function extractType(traits) { if (!Array.isArray(traits)) return ""; const found = traits.find((t) => CREATURE_TYPES.has(t.toLowerCase())); return found ? found.toLowerCase() : ""; } // --- Main --- const files = readdirSync(BESTIARY_DIR).filter( (f) => f.startsWith("creatures-") && f.endsWith(".json"), ); const creatures = []; const seenSources = new Set(); for (const file of files.sort()) { const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8")); const entries = raw.creature ?? []; for (const c of entries) { // Skip copies/references if (c._copy) continue; const source = c.source ?? ""; seenSources.add(source); const ac = c.defenses?.ac?.std ?? 0; const hp = c.defenses?.hp?.[0]?.hp ?? 0; const perception = c.perception?.std ?? 0; creatures.push({ n: c.name, s: source, lv: c.level ?? 0, ac, hp, pc: perception, sz: extractSize(c.traits), tp: extractType(c.traits), }); } } // Sort by name then source for stable output creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s)); // Build source map from seen sources const sources = {}; for (const code of [...seenSources].sort()) { sources[code] = SOURCE_NAMES[code] ?? code; } const output = { sources, creatures }; writeFileSync(OUTPUT_PATH, JSON.stringify(output)); const rawSize = Buffer.byteLength(JSON.stringify(output)); console.log(`Sources: ${Object.keys(sources).length}`); console.log(`Creatures: ${creatures.length}`); console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`); const unmapped = [...seenSources].filter((s) => !SOURCE_NAMES[s]); if (unmapped.length > 0) { console.log(`Unmapped sources: ${unmapped.sort().join(", ")}`); }