Files
initiative/apps/web/src/adapters/pf2e-bestiary-adapter.ts
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

532 lines
15 KiB
TypeScript

import type {
CreatureId,
Pf2eCreature,
SpellcastingBlock,
TraitBlock,
} from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import { stripFoundryTags } from "./strip-foundry-tags.js";
// -- Raw Foundry VTT types (minimal, for parsing) --
interface RawFoundryCreature {
_id: string;
name: string;
type: string;
system: {
abilities: Record<string, { mod: number }>;
attributes: {
ac: { value: number; details?: string };
hp: { max: number; details?: string };
speed: {
value: number;
otherSpeeds?: { type: string; value: number }[];
details?: string;
};
immunities?: { type: string; exceptions?: string[] }[];
resistances?: { type: string; value: number; exceptions?: string[] }[];
weaknesses?: { type: string; value: number }[];
allSaves?: { value: string };
};
details: {
level: { value: number };
languages: { value?: string[]; details?: string };
publication: { license: string; remaster: boolean; title: string };
};
perception: {
mod: number;
details?: string;
senses?: { type: string; acuity?: string; range?: number }[];
};
saves: {
fortitude: { value: number; saveDetail?: string };
reflex: { value: number; saveDetail?: string };
will: { value: number; saveDetail?: string };
};
skills: Record<string, { base: number; note?: string }>;
traits: { rarity: string; size: { value: string }; value: string[] };
};
items: RawFoundryItem[];
}
interface RawFoundryItem {
_id: string;
name: string;
type: string;
system: Record<string, unknown>;
sort?: number;
}
interface MeleeSystem {
bonus?: { value: number };
damageRolls?: Record<string, { damage: string; damageType: string }>;
traits?: { value: string[] };
}
interface ActionSystem {
category?: string;
actionType?: { value: string };
actions?: { value: number | null };
traits?: { value: string[] };
description?: { value: string };
}
interface SpellcastingEntrySystem {
tradition?: { value: string };
prepared?: { value: string };
spelldc?: { dc: number; value?: number };
}
interface SpellSystem {
location?: {
value: string;
heightenedLevel?: number;
uses?: { max: number; value: number };
};
level?: { value: number };
traits?: { value: string[] };
}
const SIZE_MAP: Record<string, string> = {
tiny: "tiny",
sm: "small",
med: "medium",
lg: "large",
huge: "huge",
grg: "gargantuan",
};
// -- Helpers --
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return creatureId(`${source}:${slug}`);
}
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
const LETTER_SLUG = /^(.+)-([a-z])$/;
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
reach: (n) => `reach ${n} feet`,
range: (n) => `range ${n} feet`,
"range-increment": (n) => `range increment ${n} feet`,
versatile: (n) => `versatile ${n}`,
deadly: (n) => `deadly d${n}`,
fatal: (n) => `fatal d${n}`,
"fatal-aim": (n) => `fatal aim d${n}`,
reload: (n) => `reload ${n}`,
};
/** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */
const LETTER_TRAIT_FORMATS: Record<string, (l: string) => string> = {
versatile: (l) => `versatile ${l.toUpperCase()}`,
deadly: (l) => `deadly d${l}`,
};
/** Expand slugified trait names: "reach-10" → "reach 10 feet" */
function formatTrait(slug: string): string {
const numMatch = NUMERIC_SLUG.exec(slug);
if (numMatch) {
const [, base, num] = numMatch;
const fmt = NUMERIC_TRAIT_FORMATS[base];
return fmt ? fmt(num) : `${base} ${num}`;
}
const letterMatch = LETTER_SLUG.exec(slug);
if (letterMatch) {
const [, base, letter] = letterMatch;
const fmt = LETTER_TRAIT_FORMATS[base];
if (fmt) return fmt(letter);
}
return slug.replaceAll("-", " ");
}
// -- Formatting --
function formatSenses(
senses: { type: string; acuity?: string; range?: number }[] | undefined,
): string | undefined {
if (!senses || senses.length === 0) return undefined;
return senses
.map((s) => {
const parts = [capitalize(s.type.replaceAll("-", " "))];
if (s.acuity && s.acuity !== "precise") {
parts.push(`(${s.acuity})`);
}
if (s.range != null) parts.push(`${s.range} feet`);
return parts.join(" ");
})
.join(", ");
}
function formatLanguages(
languages: { value?: string[]; details?: string } | undefined,
): string | undefined {
if (!languages?.value || languages.value.length === 0) return undefined;
const list = languages.value.map(capitalize).join(", ");
return languages.details ? `${list} (${languages.details})` : list;
}
function formatSkills(
skills: Record<string, { base: number; note?: string }> | undefined,
): string | undefined {
if (!skills) return undefined;
const entries = Object.entries(skills);
if (entries.length === 0) return undefined;
return entries
.map(([name, val]) => {
const label = capitalize(name.replaceAll("-", " "));
return `${label} +${val.base}`;
})
.sort()
.join(", ");
}
function formatImmunities(
immunities: { type: string; exceptions?: string[] }[] | undefined,
): string | undefined {
if (!immunities || immunities.length === 0) return undefined;
return immunities
.map((i) => {
const base = capitalize(i.type.replaceAll("-", " "));
if (i.exceptions && i.exceptions.length > 0) {
return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
}
return base;
})
.join(", ");
}
function formatResistances(
resistances:
| { type: string; value: number; exceptions?: string[] }[]
| undefined,
): string | undefined {
if (!resistances || resistances.length === 0) return undefined;
return resistances
.map((r) => {
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
if (r.exceptions && r.exceptions.length > 0) {
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
}
return base;
})
.join(", ");
}
function formatWeaknesses(
weaknesses: { type: string; value: number }[] | undefined,
): string | undefined {
if (!weaknesses || weaknesses.length === 0) return undefined;
return weaknesses
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
.join(", ");
}
function formatSpeed(speed: {
value: number;
otherSpeeds?: { type: string; value: number }[];
details?: string;
}): string {
const parts = [`${speed.value} feet`];
if (speed.otherSpeeds) {
for (const s of speed.otherSpeeds) {
parts.push(`${capitalize(s.type)} ${s.value} feet`);
}
}
const base = parts.join(", ");
return speed.details ? `${base} (${speed.details})` : base;
}
// -- Attack normalization --
function normalizeAttack(item: RawFoundryItem): TraitBlock {
const sys = item.system as unknown as MeleeSystem;
const bonus = sys.bonus?.value ?? 0;
const traits = sys.traits?.value ?? [];
const damageEntries = Object.values(sys.damageRolls ?? {});
const damage = damageEntries
.map((d) => `${d.damage} ${d.damageType}`)
.join(" plus ");
const traitStr =
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
return {
name: capitalize(item.name),
activity: { number: 1, unit: "action" },
segments: [
{
type: "text",
value: `+${bonus}${traitStr}, ${damage}`,
},
],
};
}
function parseActivity(
actionType: string | undefined,
actionCount: number | null | undefined,
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
if (actionType === "action") {
return { number: actionCount ?? 1, unit: "action" };
}
if (actionType === "reaction") {
return { number: 1, unit: "reaction" };
}
if (actionType === "free") {
return { number: 1, unit: "free" };
}
return undefined;
}
// -- Ability normalization --
function normalizeAbility(item: RawFoundryItem): TraitBlock {
const sys = item.system as unknown as ActionSystem;
const actionType = sys.actionType?.value;
const actionCount = sys.actions?.value;
const description = stripFoundryTags(sys.description?.value ?? "");
const traits = sys.traits?.value ?? [];
const activity = parseActivity(actionType, actionCount);
const traitStr =
traits.length > 0
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
: "";
const text = traitStr ? `${traitStr}${description}` : description;
const segments: { type: "text"; value: string }[] = text
? [{ type: "text", value: text }]
: [];
return { name: item.name, activity, segments };
}
// -- Spellcasting normalization --
function classifySpell(spell: RawFoundryItem): {
isCantrip: boolean;
rank: number;
label: string;
} {
const sys = spell.system as unknown as SpellSystem;
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
const uses = sys.location?.uses;
const label =
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
return { isCantrip, rank, label };
}
function normalizeSpellcastingEntry(
entry: RawFoundryItem,
allSpells: readonly RawFoundryItem[],
): SpellcastingBlock {
const sys = entry.system as unknown as SpellcastingEntrySystem;
const tradition = capitalize(sys.tradition?.value ?? "");
const prepared = sys.prepared?.value ?? "";
const dc = sys.spelldc?.dc ?? 0;
const attack = sys.spelldc?.value ?? 0;
const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`;
const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`;
const linkedSpells = allSpells.filter(
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
);
const byRank = new Map<number, string[]>();
const cantrips: string[] = [];
for (const spell of linkedSpells) {
const { isCantrip, rank, label } = classifySpell(spell);
if (isCantrip) {
cantrips.push(spell.name);
continue;
}
const existing = byRank.get(rank) ?? [];
existing.push(label);
byRank.set(rank, existing);
}
const daily = [...byRank.entries()]
.sort(([a], [b]) => b - a)
.map(([rank, spellNames]) => ({
uses: rank,
each: true,
spells: spellNames,
}));
return {
name,
headerText,
atWill: orUndefined(cantrips),
daily: orUndefined(daily),
};
}
function normalizeSpellcasting(
items: readonly RawFoundryItem[],
): SpellcastingBlock[] {
const entries = items.filter((i) => i.type === "spellcastingEntry");
const spells = items.filter((i) => i.type === "spell");
return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
}
// -- Main normalization --
function orUndefined<T>(arr: T[]): T[] | undefined {
return arr.length > 0 ? arr : undefined;
}
/** Build display traits: [rarity (if not common), size, ...type traits] */
function buildTraits(traits: {
rarity: string;
size: { value: string };
value: string[];
}): string[] {
const result: string[] = [];
if (traits.rarity && traits.rarity !== "common") {
result.push(traits.rarity);
}
const size = SIZE_MAP[traits.size.value] ?? "medium";
result.push(size);
result.push(...traits.value);
return result;
}
const HEALING_GLOSSARY =
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/;
/** Glossary-only abilities that duplicate structured data shown elsewhere. */
const REDUNDANT_GLOSSARY =
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/;
const STRIP_GLOSSARY_AND_P = /<p>@Localize\[[^\]]+\]<\/p>|<\/?p>/g;
/** True when the description has no user-visible content beyond glossary tags. */
function isGlossaryOnly(desc: string | undefined): boolean {
if (!desc) return true;
return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === "";
}
function isRedundantAbility(
item: RawFoundryItem,
excludeName: string | undefined,
hpDetails: string | undefined,
): boolean {
const sys = item.system as unknown as ActionSystem;
const desc = sys.description?.value;
// Ability duplicates the allSaves line — suppress only if glossary-only
if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) {
return isGlossaryOnly(desc);
}
if (!desc) return false;
// Healing/regen glossary when hp.details already shows the info
if (hpDetails && HEALING_GLOSSARY.test(desc)) return true;
// Spell mechanic glossary reminders shown in the spellcasting section
if (REDUNDANT_GLOSSARY.test(desc)) return true;
return false;
}
function actionsByCategory(
items: readonly RawFoundryItem[],
category: string,
excludeName?: string,
hpDetails?: string,
): TraitBlock[] {
return items
.filter(
(a) =>
a.type === "action" &&
(a.system as unknown as ActionSystem).category === category &&
!isRedundantAbility(a, excludeName, hpDetails),
)
.map(normalizeAbility);
}
function extractAbilityMods(
mods: Record<string, { mod: number }>,
): Pf2eCreature["abilityMods"] {
return {
str: mods.str?.mod ?? 0,
dex: mods.dex?.mod ?? 0,
con: mods.con?.mod ?? 0,
int: mods.int?.mod ?? 0,
wis: mods.wis?.mod ?? 0,
cha: mods.cha?.mod ?? 0,
};
}
export function normalizeFoundryCreature(
raw: unknown,
sourceCode?: string,
sourceDisplayName?: string,
): Pf2eCreature {
const r = raw as RawFoundryCreature;
const sys = r.system;
const publication = sys.details?.publication;
const source = sourceCode ?? publication?.title ?? "";
const items = r.items ?? [];
const allSavesText = sys.attributes.allSaves?.value ?? "";
return {
system: "pf2e",
id: makeCreatureId(source, r.name),
name: r.name,
source,
sourceDisplayName: sourceDisplayName ?? publication?.title ?? "",
level: sys.details?.level?.value ?? 0,
traits: buildTraits(sys.traits),
perception: sys.perception?.mod ?? 0,
senses: formatSenses(sys.perception?.senses),
languages: formatLanguages(sys.details?.languages),
skills: formatSkills(sys.skills),
abilityMods: extractAbilityMods(sys.abilities ?? {}),
ac: sys.attributes.ac.value,
acConditional: sys.attributes.ac.details || undefined,
saveFort: sys.saves.fortitude.value,
saveRef: sys.saves.reflex.value,
saveWill: sys.saves.will.value,
saveConditional: allSavesText || undefined,
hp: sys.attributes.hp.max,
hpDetails: sys.attributes.hp.details || undefined,
immunities: formatImmunities(sys.attributes.immunities),
resistances: formatResistances(sys.attributes.resistances),
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
speed: formatSpeed(sys.attributes.speed),
attacks: orUndefined(
items.filter((i) => i.type === "melee").map(normalizeAttack),
),
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
abilitiesMid: orUndefined(
actionsByCategory(
items,
"defensive",
allSavesText || undefined,
sys.attributes.hp.details || undefined,
),
),
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
spellcasting: orUndefined(normalizeSpellcasting(items)),
};
}
export function normalizeFoundryCreatures(
rawCreatures: unknown[],
sourceCode?: string,
sourceDisplayName?: string,
): Pf2eCreature[] {
return rawCreatures.map((raw) =>
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
);
}