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>
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user