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>
243 lines
7.2 KiB
JavaScript
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`);
|
|
}
|
|
}
|