Files
initiative/scripts/generate-pf2e-bestiary-index.mjs
Lukas 1c107a500b
All checks were successful
CI / check (push) Successful in 2m25s
CI / build-image (push) Successful in 23s
Switch PF2e data source from Pf2eTools to Foundry VTT PF2e
Replace the stagnant Pf2eTools bestiary with Foundry VTT PF2e system
data (github.com/foundryvtt/pf2e, v13-dev branch). This gives us 4,355
remaster-era creatures across 49 sources including Monster Core 1+2 and
all adventure paths.

Changes:
- Rewrite index generation script to walk Foundry pack directories
- Rewrite PF2e normalization adapter for Foundry JSON shape (system.*
  fields, items[] for attacks/abilities/spells)
- Add stripFoundryTags utility for Foundry HTML + enrichment syntax
- Implement multi-file source fetching (one request per creature file)
- Add spellcasting section to PF2e stat block (ranked spells + cantrips)
- Add saveConditional and hpDetails to PF2e domain type and stat block
- Add size and rarity to PF2e trait tags
- Filter redundant glossary abilities (healing when in hp.details,
  spell mechanic reminders, allSaves duplicates)
- Add PF2e stat block component tests (22 tests)
- Bump IndexedDB cache version to 5 for clean migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:05:00 +02:00

243 lines
7.2 KiB
JavaScript

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