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,359 +1,645 @@
import { describe, expect, it } from "vitest";
import { normalizePf2eBestiary } from "../pf2e-bestiary-adapter.js";
import { normalizeFoundryCreature } from "../pf2e-bestiary-adapter.js";
function minimalCreature(overrides?: Record<string, unknown>) {
return {
_id: "test-id",
name: "Test Creature",
source: "TST",
type: "npc",
system: {
abilities: {
str: { mod: 3 },
dex: { mod: 2 },
con: { mod: 1 },
int: { mod: 0 },
wis: { mod: -1 },
cha: { mod: -2 },
},
attributes: {
ac: { value: 18 },
hp: { max: 45 },
speed: { value: 25 },
},
details: {
level: { value: 3 },
languages: { value: ["common"] },
publication: {
license: "ORC",
remaster: true,
title: "Test Source",
},
},
perception: { mod: 8 },
saves: {
fortitude: { value: 10 },
reflex: { value: 8 },
will: { value: 6 },
},
skills: {},
traits: { rarity: "common", size: { value: "med" }, value: [] },
},
items: [],
...overrides,
};
}
describe("normalizePf2eBestiary", () => {
describe("weaknesses formatting", () => {
it("formats weakness with numeric amount", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
weaknesses: [{ name: "fire", amount: 5 }],
},
}),
],
});
expect(creature.weaknesses).toBe("Fire 5");
describe("normalizeFoundryCreature", () => {
describe("basic fields", () => {
it("maps top-level fields correctly", () => {
const creature = normalizeFoundryCreature(minimalCreature());
expect(creature.system).toBe("pf2e");
expect(creature.name).toBe("Test Creature");
expect(creature.level).toBe(3);
expect(creature.ac).toBe(18);
expect(creature.hp).toBe(45);
expect(creature.perception).toBe(8);
expect(creature.saveFort).toBe(10);
expect(creature.saveRef).toBe(8);
expect(creature.saveWill).toBe(6);
});
it("formats weakness without amount (qualitative)", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
weaknesses: [{ name: "smoke susceptibility" }],
},
}),
],
it("maps ability modifiers", () => {
const creature = normalizeFoundryCreature(minimalCreature());
expect(creature.abilityMods).toEqual({
str: 3,
dex: 2,
con: 1,
int: 0,
wis: -1,
cha: -2,
});
expect(creature.weaknesses).toBe("Smoke susceptibility");
});
it("formats weakness with note and amount", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
weaknesses: [
{ name: "cold iron", amount: 5, note: "except daggers" },
],
it("maps AC conditional from details", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
ac: { value: 20, details: "+2 with shield raised" },
},
}),
],
});
expect(creature.weaknesses).toBe("Cold iron 5 (except daggers)");
});
it("formats weakness with note but no amount", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
weaknesses: [{ name: "smoke susceptibility", note: "see below" }],
},
}),
],
});
expect(creature.weaknesses).toBe("Smoke susceptibility (see below)");
});
it("returns undefined when no weaknesses", () => {
const [creature] = normalizePf2eBestiary({
creature: [minimalCreature({})],
});
expect(creature.weaknesses).toBeUndefined();
},
}),
);
expect(creature.acConditional).toBe("+2 with shield raised");
});
});
describe("senses formatting", () => {
it("strips tags and includes type and range", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
senses: [
{
type: "imprecise",
name: "{@ability tremorsense}",
range: 30,
},
],
}),
],
});
expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet");
});
it("formats sense with only a name", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
senses: [{ name: "darkvision" }],
}),
],
});
it("formats darkvision", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
perception: {
mod: 8,
senses: [{ type: "darkvision" }],
},
},
}),
);
expect(creature.senses).toBe("Darkvision");
});
it("formats sense with name and range but no type", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
senses: [{ name: "scent", range: 60 }],
}),
],
});
it("formats sense with acuity and range", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
perception: {
mod: 8,
senses: [{ type: "tremorsense", acuity: "imprecise", range: 30 }],
},
},
}),
);
expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet");
});
it("omits precise acuity", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
perception: {
mod: 8,
senses: [{ type: "scent", acuity: "precise", range: 60 }],
},
},
}),
);
expect(creature.senses).toBe("Scent 60 feet");
});
});
describe("attack formatting", () => {
it("strips angle brackets from traits", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
attacks: [
{
name: "stinger",
range: "Melee",
attack: 11,
traits: ["deadly <d8>"],
damage: "1d6+4 piercing",
},
],
}),
],
});
const attack = creature.attacks?.[0];
expect(attack).toBeDefined();
expect(attack?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: expect.stringContaining("(deadly d8)"),
describe("languages formatting", () => {
it("formats language list", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
details: {
...minimalCreature().system.details,
languages: { value: ["common", "draconic"] },
},
},
}),
);
expect(creature.languages).toBe("Common, Draconic");
});
it("includes details", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
details: {
...minimalCreature().system.details,
languages: {
value: ["common"],
details: "telepathy 100 feet",
},
},
},
}),
);
expect(creature.languages).toBe("Common (telepathy 100 feet)");
});
});
describe("skills formatting", () => {
it("formats and sorts skills", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
skills: {
stealth: { base: 10 },
athletics: { base: 8 },
},
},
}),
);
expect(creature.skills).toBe("Athletics +8, Stealth +10");
});
});
describe("defenses formatting", () => {
it("formats immunities with exceptions", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
immunities: [
{ type: "paralyzed", exceptions: [] },
{
type: "physical",
exceptions: ["adamantine"],
},
],
},
},
}),
);
expect(creature.immunities).toBe(
"Paralyzed, Physical (except Adamantine)",
);
});
it("strips angle brackets from reach values in traits", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
attacks: [
{
name: "tentacle",
range: "Melee",
attack: 18,
traits: ["agile", "chaotic", "magical", "reach <10 feet>"],
damage: "2d8+6 piercing",
it("formats resistances with value", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
resistances: [{ type: "fire", value: 10, exceptions: [] }],
},
},
}),
);
expect(creature.resistances).toBe("Fire 10");
});
it("formats weaknesses", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
weaknesses: [{ type: "cold-iron", value: 5 }],
},
},
}),
);
expect(creature.weaknesses).toBe("Cold iron 5");
});
});
describe("speed formatting", () => {
it("formats base speed", () => {
const creature = normalizeFoundryCreature(minimalCreature());
expect(creature.speed).toBe("25 feet");
});
it("includes other speeds", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
speed: {
value: 40,
otherSpeeds: [
{ type: "fly", value: 120 },
{ type: "swim", value: 40 },
],
},
],
}),
],
});
},
},
}),
);
expect(creature.speed).toBe("40 feet, Fly 120 feet, Swim 40 feet");
});
it("includes speed details", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
attributes: {
...minimalCreature().system.attributes,
speed: {
value: 25,
details: "ignores difficult terrain",
},
},
},
}),
);
expect(creature.speed).toBe("25 feet (ignores difficult terrain)");
});
});
describe("attack normalization", () => {
it("normalizes melee attacks with traits and damage", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "atk1",
name: "dogslicer",
type: "melee",
system: {
bonus: { value: 7 },
damageRolls: {
abc: {
damage: "1d6",
damageType: "slashing",
},
},
traits: {
value: ["agile", "backstabber", "finesse"],
},
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack).toBeDefined();
expect(attack?.name).toBe("Dogslicer");
expect(attack?.activity).toEqual({ number: 1, unit: "action" });
expect(attack?.segments[0]).toEqual({
type: "text",
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
});
});
it("expands slugified trait names", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "atk1",
name: "claw",
type: "melee",
system: {
bonus: { value: 18 },
damageRolls: {
abc: {
damage: "2d8+6",
damageType: "slashing",
},
},
traits: {
value: ["reach-10", "deadly-d10", "versatile-p"],
},
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack?.segments[0]).toEqual({
type: "text",
value: "+18 (reach 10 feet, deadly d10, versatile P), 2d8+6 slashing",
});
});
it("handles multiple damage types", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "atk1",
name: "flaming sword",
type: "melee",
system: {
bonus: { value: 15 },
damageRolls: {
abc: {
damage: "2d8+5",
damageType: "slashing",
},
def: {
damage: "1d6",
damageType: "fire",
},
},
traits: { value: [] },
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: expect.stringContaining(
"(agile, chaotic, magical, reach 10 feet)",
),
value: "+15, 2d8+5 slashing plus 1d6 fire",
}),
);
});
});
describe("ability formatting", () => {
it("includes traits from abilities in the text", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Change Shape",
activity: { number: 1, unit: "action" },
traits: [
"concentrate",
"divine",
"polymorph",
"transmutation",
],
entries: [
"The naunet can take the appearance of any creature.",
],
},
],
describe("ability normalization", () => {
it("routes abilities by category", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Sense Motive",
type: "action",
system: {
category: "interaction",
actionType: { value: "passive" },
actions: { value: null },
traits: { value: [] },
description: { value: "<p>Can sense lies.</p>" },
},
},
}),
],
});
const ability = creature.abilitiesBot?.[0];
expect(ability).toBeDefined();
expect(ability?.name).toBe("Change Shape");
expect(ability?.activity).toEqual({ number: 1, unit: "action" });
expect(ability?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: expect.stringContaining(
"(Concentrate, Divine, Polymorph, Transmutation)",
),
{
_id: "a2",
name: "Shield Block",
type: "action",
system: {
category: "defensive",
actionType: { value: "reaction" },
actions: { value: null },
traits: { value: [] },
description: {
value: "<p>Blocks with shield.</p>",
},
},
},
{
_id: "a3",
name: "Breath Weapon",
type: "action",
system: {
category: "offensive",
actionType: { value: "action" },
actions: { value: 2 },
traits: { value: ["arcane", "fire"] },
description: {
value:
"<p>@Damage[8d6[fire]] in a @Template[cone|distance:40].</p>",
},
},
},
],
}),
);
expect(creature.abilitiesTop).toHaveLength(1);
expect(creature.abilitiesTop?.[0]?.name).toBe("Sense Motive");
expect(creature.abilitiesTop?.[0]?.activity).toBeUndefined();
expect(creature.abilitiesMid).toHaveLength(1);
expect(creature.abilitiesMid?.[0]?.name).toBe("Shield Block");
expect(creature.abilitiesMid?.[0]?.activity).toEqual({
number: 1,
unit: "reaction",
});
expect(creature.abilitiesBot).toHaveLength(1);
expect(creature.abilitiesBot?.[0]?.name).toBe("Breath Weapon");
expect(creature.abilitiesBot?.[0]?.activity).toEqual({
number: 2,
unit: "action",
});
});
it("strips Foundry enrichment tags from descriptions", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Flame Burst",
type: "action",
system: {
category: "offensive",
actionType: { value: "action" },
actions: { value: 2 },
traits: { value: [] },
description: {
value:
"<p>Deal @Damage[3d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
},
},
},
],
}),
);
expect(
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
? creature.abilitiesBot[0].segments[0].value
: undefined,
).toBe("Deal 3d6 fire damage, DC 20 basic Reflex save.");
});
it("parses free action activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Adaptive Strike",
activity: { number: 1, unit: "free" },
entries: ["The naunet chooses adamantine."],
},
],
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Quick Draw",
type: "action",
system: {
category: "offensive",
actionType: { value: "free" },
actions: { value: null },
traits: { value: [] },
description: { value: "" },
},
},
}),
],
});
const ability = creature.abilitiesBot?.[0];
expect(ability?.name).toBe("Adaptive Strike");
expect(ability?.activity).toEqual({ number: 1, unit: "free" });
});
it("parses reaction activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
mid: [
{
name: "Attack of Opportunity",
activity: { number: 1, unit: "reaction" },
entries: ["Trigger description."],
},
],
},
}),
],
});
const ability = creature.abilitiesMid?.[0];
expect(ability?.name).toBe("Attack of Opportunity");
expect(ability?.activity).toEqual({ number: 1, unit: "reaction" });
});
it("parses multi-action activity", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Breath Weapon",
activity: { number: 2, unit: "action" },
entries: ["Fire breath."],
},
],
},
}),
],
});
const ability = creature.abilitiesBot?.[0];
expect(ability?.name).toBe("Breath Weapon");
expect(ability?.activity).toEqual({ number: 2, unit: "action" });
});
it("renders ability without activity or traits normally", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
bot: [
{
name: "Constrict",
entries: ["1d8+8 bludgeoning, DC 26"],
},
],
},
}),
],
});
const ability = creature.abilitiesBot?.[0];
expect(ability).toBeDefined();
expect(ability?.name).toBe("Constrict");
expect(ability?.activity).toBeUndefined();
expect(ability?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: "1d8+8 bludgeoning, DC 26",
],
}),
);
expect(creature.abilitiesBot?.[0]?.activity).toEqual({
number: 1,
unit: "free",
});
});
it("includes trigger text before entries", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
abilities: {
mid: [
{
name: "Wing Deflection",
activity: { number: 1, unit: "reaction" },
trigger: "The dragon is targeted with an attack.",
entries: ["The dragon raises its wing."],
it("includes traits in ability text", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Change Shape",
type: "action",
system: {
category: "offensive",
actionType: { value: "action" },
actions: { value: 1 },
traits: {
value: ["concentrate", "polymorph"],
},
],
description: {
value: "<p>Takes a new form.</p>",
},
},
},
}),
],
});
const ability = creature.abilitiesMid?.[0];
expect(ability).toBeDefined();
expect(ability?.activity).toEqual({ number: 1, unit: "reaction" });
expect(ability?.trigger).toBe("The dragon is targeted with an attack.");
expect(ability?.segments[0]).toEqual(
expect.objectContaining({
type: "text",
value: "The dragon raises its wing.",
],
}),
);
expect(
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
? creature.abilitiesBot[0].segments[0].value
: undefined,
).toBe("(Concentrate, Polymorph) Takes a new form.");
});
});
describe("resistances formatting", () => {
it("formats resistance without amount", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
resistances: [{ name: "physical" }],
describe("spellcasting normalization", () => {
it("normalizes prepared spells by rank", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Primal Prepared Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "primal" },
prepared: { value: "prepared" },
spelldc: { dc: 30, value: 22 },
},
},
}),
],
});
expect(creature.resistances).toBe("Physical");
{
_id: "s1",
name: "Earthquake",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 6 },
traits: { value: [] },
},
},
{
_id: "s2",
name: "Heal",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 3 },
traits: { value: [] },
},
},
{
_id: "s3",
name: "Detect Magic",
type: "spell",
system: {
location: { value: "entry1" },
level: { value: 1 },
traits: { value: ["cantrip"] },
},
},
],
}),
);
expect(creature.spellcasting).toHaveLength(1);
const sc = creature.spellcasting?.[0];
expect(sc?.name).toBe("Primal Prepared Spells");
expect(sc?.headerText).toBe("DC 30, attack +22");
expect(sc?.daily).toEqual([
{ uses: 6, each: true, spells: ["Earthquake"] },
{ uses: 3, each: true, spells: ["Heal"] },
]);
expect(sc?.atWill).toEqual(["Detect Magic"]);
});
it("formats resistance with amount", () => {
const [creature] = normalizePf2eBestiary({
creature: [
minimalCreature({
defenses: {
resistances: [{ name: "fire", amount: 10 }],
it("normalizes innate spells with uses", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "entry1",
name: "Divine Innate Spells",
type: "spellcastingEntry",
system: {
tradition: { value: "divine" },
prepared: { value: "innate" },
spelldc: { dc: 32 },
},
},
}),
],
});
expect(creature.resistances).toBe("Fire 10");
{
_id: "s1",
name: "Sure Strike",
type: "spell",
system: {
location: {
value: "entry1",
heightenedLevel: 1,
uses: { max: 3, value: 3 },
},
level: { value: 1 },
traits: { value: [] },
},
},
],
}),
);
const sc = creature.spellcasting?.[0];
expect(sc?.headerText).toBe("DC 32");
expect(sc?.daily).toEqual([
{
uses: 1,
each: true,
spells: ["Sure Strike (\u00d73)"],
},
]);
});
});
});

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
const JSON_EXTENSION = /\.json$/;
import {
getAllPf2eSourceCodes,
getCreaturePathsForSource,
getDefaultPf2eFetchUrl,
getPf2eSourceDisplayName,
loadPf2eBestiaryIndex,
@@ -30,7 +35,15 @@ describe("loadPf2eBestiaryIndex", () => {
it("contains a substantial number of creatures", () => {
const index = loadPf2eBestiaryIndex();
expect(index.creatures.length).toBeGreaterThan(2000);
expect(index.creatures.length).toBeGreaterThan(2500);
});
it("creatures have size and type populated", () => {
const index = loadPf2eBestiaryIndex();
const withSize = index.creatures.filter((c) => c.size !== "");
const withType = index.creatures.filter((c) => c.type !== "");
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
});
it("returns the same cached instance on subsequent calls", () => {
@@ -49,20 +62,42 @@ describe("getAllPf2eSourceCodes", () => {
});
describe("getDefaultPf2eFetchUrl", () => {
it("returns Pf2eTools GitHub URL with lowercase source code", () => {
const url = getDefaultPf2eFetchUrl("B1");
it("returns Foundry VTT PF2e base URL", () => {
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
expect(url).toBe(
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
);
});
it("normalizes custom base URL with trailing slash", () => {
const url = getDefaultPf2eFetchUrl(
"pathfinder-monster-core",
"https://example.com/pf2e",
);
expect(url).toBe("https://example.com/pf2e/");
});
});
describe("getPf2eSourceDisplayName", () => {
it("returns display name for a known source", () => {
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
expect(name).toBe("Monster Core");
});
it("falls back to source code for unknown source", () => {
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
});
});
describe("getCreaturePathsForSource", () => {
it("returns file paths for a known source", () => {
const paths = getCreaturePathsForSource("pathfinder-monster-core");
expect(paths.length).toBeGreaterThan(100);
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
expect(paths[0]).toMatch(JSON_EXTENSION);
});
it("returns empty array for unknown source", () => {
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
});
});

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { stripFoundryTags } from "../strip-foundry-tags.js";
describe("stripFoundryTags", () => {
describe("@Damage tags", () => {
it("formats damage with type bracket", () => {
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
});
it("prefers display text when present", () => {
expect(
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
).toBe("3d6+10 fire damage");
});
it("handles multiple damage types", () => {
expect(
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
).toBe("2d8+5 slashing plus 1d6 fire");
});
});
describe("@Check tags", () => {
it("formats basic saving throw", () => {
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
"DC 33 basic Reflex",
);
});
it("formats non-basic check", () => {
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
"DC 25 Athletics",
);
});
it("formats check without DC", () => {
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
});
});
describe("@UUID tags", () => {
it("extracts display text", () => {
expect(
stripFoundryTags(
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
),
).toBe("Grabbed");
});
it("extracts last segment when no display text", () => {
expect(
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
).toBe("Grabbed");
});
});
describe("@Template tags", () => {
it("formats cone template", () => {
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
"40-foot cone",
);
});
it("formats emanation template", () => {
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
"10-foot emanation",
);
});
it("prefers display text", () => {
expect(
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
).toBe("40-foot cone");
});
});
describe("unknown @Tag patterns", () => {
it("uses display text for unknown tags", () => {
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
"Some Text",
);
});
it("strips unknown tags without display text", () => {
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
});
});
describe("HTML stripping", () => {
it("strips paragraph tags", () => {
expect(stripFoundryTags("<p>text</p>")).toBe("text");
});
it("converts br to newline", () => {
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
});
it("converts hr to newline", () => {
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
});
it("strips strong and em tags", () => {
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
"bold italic",
);
});
it("converts p-to-p transitions to newlines", () => {
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
"first\nsecond",
);
});
it("strips action-glyph spans", () => {
expect(
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
).toBe("Strike");
});
});
describe("HTML entities", () => {
it("decodes &amp;", () => {
expect(stripFoundryTags("fire &amp; ice")).toBe("fire & ice");
});
it("decodes &lt; and &gt;", () => {
expect(stripFoundryTags("&lt;tag&gt;")).toBe("<tag>");
});
it("decodes &quot;", () => {
expect(stripFoundryTags("&quot;hello&quot;")).toBe('"hello"');
});
});
describe("whitespace handling", () => {
it("collapses multiple spaces", () => {
expect(stripFoundryTags("a b c")).toBe("a b c");
});
it("collapses multiple blank lines", () => {
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
});
it("trims leading and trailing whitespace", () => {
expect(stripFoundryTags(" hello ")).toBe("hello");
});
});
describe("combined/edge cases", () => {
it("handles enrichment tags inside HTML", () => {
expect(
stripFoundryTags(
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
),
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
});
it("handles empty string", () => {
expect(stripFoundryTags("")).toBe("");
});
});
});