import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; // Usage: node scripts/generate-pf2e-bestiary-index.mjs // // Requires a local clone of https://github.com/foundryvtt/pf2e (v13-dev branch). // // Example: // git clone --depth 1 --branch v13-dev https://github.com/foundryvtt/pf2e.git /tmp/foundry-pf2e // node scripts/generate-pf2e-bestiary-index.mjs /tmp/foundry-pf2e const FOUNDRY_ROOT = process.argv[2]; if (!FOUNDRY_ROOT) { console.error( "Usage: node scripts/generate-pf2e-bestiary-index.mjs ", ); process.exit(1); } const PROJECT_ROOT = join(import.meta.dirname, ".."); const PACKS_DIR = join(FOUNDRY_ROOT, "packs/pf2e"); const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/pf2e-index.json"); // Legacy bestiaries superseded by Monster Core / Monster Core 2 const EXCLUDED_PACKS = new Set([ "pathfinder-bestiary", "pathfinder-bestiary-2", "pathfinder-bestiary-3", ]); // PFS (Pathfinder Society) scenario packs — organized play content not on // Archives of Nethys, mostly reskinned variants for specific scenarios. const isPfsPack = (name) => name.startsWith("pfs-"); // Pack directory → display name mapping. Foundry pack directories are stable // identifiers; new ones are added ~2-3 times per year with new AP volumes. // Run the script with an unknown pack to see unmapped entries in the output. const SOURCE_NAMES = { "abomination-vaults-bestiary": "Abomination Vaults", "age-of-ashes-bestiary": "Age of Ashes", "agents-of-edgewatch-bestiary": "Agents of Edgewatch", "battlecry-bestiary": "Battlecry!", "blog-bestiary": "Pathfinder Blog", "blood-lords-bestiary": "Blood Lords", "book-of-the-dead-bestiary": "Book of the Dead", "claws-of-the-tyrant-bestiary": "Claws of the Tyrant", "crown-of-the-kobold-king-bestiary": "Crown of the Kobold King", "curtain-call-bestiary": "Curtain Call", "extinction-curse-bestiary": "Extinction Curse", "fall-of-plaguestone": "The Fall of Plaguestone", "fists-of-the-ruby-phoenix-bestiary": "Fists of the Ruby Phoenix", "gatewalkers-bestiary": "Gatewalkers", "hellbreakers-bestiary": "Hellbreakers", "howl-of-the-wild-bestiary": "Howl of the Wild", "kingmaker-bestiary": "Kingmaker", "lost-omens-bestiary": "Lost Omens", "malevolence-bestiary": "Malevolence", "menace-under-otari-bestiary": "Beginner Box", "myth-speaker-bestiary": "Myth Speaker", "night-of-the-gray-death-bestiary": "Night of the Gray Death", "npc-gallery": "NPC Gallery", "one-shot-bestiary": "One-Shots", "outlaws-of-alkenstar-bestiary": "Outlaws of Alkenstar", "pathfinder-dark-archive": "Dark Archive", "pathfinder-monster-core": "Monster Core", "pathfinder-monster-core-2": "Monster Core 2", "pathfinder-npc-core": "NPC Core", "prey-for-death-bestiary": "Prey for Death", "quest-for-the-frozen-flame-bestiary": "Quest for the Frozen Flame", "rage-of-elements-bestiary": "Rage of Elements", "revenge-of-the-runelords-bestiary": "Revenge of the Runelords", "rusthenge-bestiary": "Rusthenge", "season-of-ghosts-bestiary": "Season of Ghosts", "seven-dooms-for-sandpoint-bestiary": "Seven Dooms for Sandpoint", "shades-of-blood-bestiary": "Shades of Blood", "shadows-at-sundown-bestiary": "Shadows at Sundown", "sky-kings-tomb-bestiary": "Sky King's Tomb", "spore-war-bestiary": "Spore War", "standalone-adventures": "Standalone Adventures", "stolen-fate-bestiary": "Stolen Fate", "strength-of-thousands-bestiary": "Strength of Thousands", "the-enmity-cycle-bestiary": "The Enmity Cycle", "the-slithering-bestiary": "The Slithering", "triumph-of-the-tusk-bestiary": "Triumph of the Tusk", "troubles-in-otari-bestiary": "Troubles in Otari", "war-of-immortals-bestiary": "War of Immortals", "wardens-of-wildwood-bestiary": "Wardens of Wildwood", }; // Size code mapping from Foundry abbreviations to full names const SIZE_MAP = { tiny: "tiny", sm: "small", med: "medium", lg: "large", huge: "huge", grg: "gargantuan", }; // Creature type traits 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", ]); // --- Helpers --- /** Recursively collect all .json files (excluding _*.json metadata files). */ function collectJsonFiles(dir) { const results = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith("_")) continue; const full = join(dir, entry.name); if (entry.isDirectory()) { results.push(...collectJsonFiles(full)); } else if (entry.name.endsWith(".json")) { results.push(full); } } return results; } // --- Main --- const packDirs = readdirSync(PACKS_DIR, { withFileTypes: true }) .filter( (d) => d.isDirectory() && !EXCLUDED_PACKS.has(d.name) && !isPfsPack(d.name), ) .map((d) => d.name) .sort(); const creatures = []; const sources = {}; const missingData = []; for (const packDir of packDirs) { const packPath = join(PACKS_DIR, packDir); let files; try { files = collectJsonFiles(packPath).sort(); } catch { continue; } for (const filePath of files) { let raw; try { raw = JSON.parse(readFileSync(filePath, "utf-8")); } catch { continue; } // Only include NPC-type creatures if (raw.type !== "npc") continue; const system = raw.system; if (!system) continue; const name = raw.name; const level = system.details?.level?.value ?? 0; const ac = system.attributes?.ac?.value ?? 0; const hp = system.attributes?.hp?.max ?? 0; const perception = system.perception?.mod ?? 0; const sizeCode = system.traits?.size?.value ?? "med"; const size = SIZE_MAP[sizeCode] ?? "medium"; const traits = system.traits?.value ?? []; const type = traits.find((t) => CREATURE_TYPES.has(t.toLowerCase()))?.toLowerCase() ?? ""; const relativePath = relative(PACKS_DIR, filePath); const license = system.details?.publication?.license ?? ""; if (!name || ac === 0 || hp === 0) { missingData.push(`${relativePath}: name=${name} ac=${ac} hp=${hp}`); } creatures.push({ n: name, s: packDir, lv: level, ac, hp, pc: perception, sz: size, tp: type, f: relativePath, li: license, }); } if (creatures.some((c) => c.s === packDir)) { sources[packDir] = SOURCE_NAMES[packDir] ?? packDir; } } // Sort by name then source for stable output creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s)); 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 = Object.keys(sources).filter((s) => !SOURCE_NAMES[s]); if (unmapped.length > 0) { console.log( `\nUnmapped packs (using directory name as-is): ${unmapped.join(", ")}`, ); } if (missingData.length > 0) { console.log(`\nCreatures with missing data (${missingData.length}):`); for (const msg of missingData.slice(0, 20)) { console.log(` ${msg}`); } if (missingData.length > 20) { console.log(` ... and ${missingData.length - 20} more`); } }