Switch PF2e data source from Pf2eTools to Foundry VTT PF2e
All checks were successful
CI / check (push) Successful in 2m25s
CI / build-image (push) Successful in 23s

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>
This commit is contained in:
Lukas
2026-04-08 21:05:00 +02:00
parent 0c235112ee
commit 1c107a500b
20 changed files with 1872 additions and 25871 deletions

View File

@@ -1,123 +1,103 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { join, relative } from "node:path";
// Usage: node scripts/generate-pf2e-bestiary-index.mjs <path-to-Pf2eTools>
// Usage: node scripts/generate-pf2e-bestiary-index.mjs <path-to-foundry-pf2e>
//
// Requires a local clone/checkout of https://github.com/Pf2eToolsOrg/Pf2eTools (dev branch)
// with at least data/bestiary/.
// Requires a local clone of https://github.com/foundryvtt/pf2e (v13-dev branch).
//
// 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
// 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 TOOLS_ROOT = process.argv[2];
if (!TOOLS_ROOT) {
const FOUNDRY_ROOT = process.argv[2];
if (!FOUNDRY_ROOT) {
console.error(
"Usage: node scripts/generate-pf2e-bestiary-index.mjs <Pf2eTools-path>",
"Usage: node scripts/generate-pf2e-bestiary-index.mjs <foundry-pf2e-path>",
);
process.exit(1);
}
const PROJECT_ROOT = join(import.meta.dirname, "..");
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
const PACKS_DIR = join(FOUNDRY_ROOT, "packs/pf2e");
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",
// Legacy bestiaries superseded by Monster Core / Monster Core 2
const EXCLUDED_PACKS = new Set([
"pathfinder-bestiary",
"pathfinder-bestiary-2",
"pathfinder-bestiary-3",
]);
// Creature type traits (PF2e types are lowercase in the traits array)
// 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",
@@ -143,64 +123,99 @@ const CREATURE_TYPES = new Set([
"undead",
]);
function extractSize(traits) {
if (!Array.isArray(traits)) return "medium";
const found = traits.find((t) => SIZES.has(t.toLowerCase()));
return found ? found.toLowerCase() : "medium";
}
// --- Helpers ---
function extractType(traits) {
if (!Array.isArray(traits)) return "";
const found = traits.find((t) => CREATURE_TYPES.has(t.toLowerCase()));
return found ? found.toLowerCase() : "";
/** 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 files = readdirSync(BESTIARY_DIR).filter(
(f) => f.startsWith("creatures-") && f.endsWith(".json"),
);
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 seenSources = new Set();
const sources = {};
const missingData = [];
for (const file of files.sort()) {
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
const entries = raw.creature ?? [];
for (const packDir of packDirs) {
const packPath = join(PACKS_DIR, packDir);
let files;
try {
files = collectJsonFiles(packPath).sort();
} catch {
continue;
}
for (const c of entries) {
// Skip copies/references
if (c._copy) continue;
for (const filePath of files) {
let raw;
try {
raw = JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
continue;
}
const source = c.source ?? "";
seenSources.add(source);
// Only include NPC-type creatures
if (raw.type !== "npc") continue;
const ac = c.defenses?.ac?.std ?? 0;
const hp = c.defenses?.hp?.[0]?.hp ?? 0;
const perception = c.perception?.std ?? 0;
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: c.name,
s: source,
lv: c.level ?? 0,
n: name,
s: packDir,
lv: level,
ac,
hp,
pc: perception,
sz: extractSize(c.traits),
tp: extractType(c.traits),
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));
// 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));
@@ -209,7 +224,19 @@ 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]);
const unmapped = Object.keys(sources).filter((s) => !SOURCE_NAMES[s]);
if (unmapped.length > 0) {
console.log(`Unmapped sources: ${unmapped.sort().join(", ")}`);
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`);
}
}