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>
646 lines
15 KiB
TypeScript
646 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { normalizeFoundryCreature } from "../pf2e-bestiary-adapter.js";
|
|
|
|
function minimalCreature(overrides?: Record<string, unknown>) {
|
|
return {
|
|
_id: "test-id",
|
|
name: "Test Creature",
|
|
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("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("maps ability modifiers", () => {
|
|
const creature = normalizeFoundryCreature(minimalCreature());
|
|
expect(creature.abilityMods).toEqual({
|
|
str: 3,
|
|
dex: 2,
|
|
con: 1,
|
|
int: 0,
|
|
wis: -1,
|
|
cha: -2,
|
|
});
|
|
});
|
|
|
|
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.acConditional).toBe("+2 with shield raised");
|
|
});
|
|
});
|
|
|
|
describe("senses formatting", () => {
|
|
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 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("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("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: "+15, 2d8+5 slashing plus 1d6 fire",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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>" },
|
|
},
|
|
},
|
|
{
|
|
_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 = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Quick Draw",
|
|
type: "action",
|
|
system: {
|
|
category: "offensive",
|
|
actionType: { value: "free" },
|
|
actions: { value: null },
|
|
traits: { value: [] },
|
|
description: { value: "" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.abilitiesBot?.[0]?.activity).toEqual({
|
|
number: 1,
|
|
unit: "free",
|
|
});
|
|
});
|
|
|
|
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>",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(
|
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
|
? creature.abilitiesBot[0].segments[0].value
|
|
: undefined,
|
|
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
|
});
|
|
});
|
|
|
|
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 },
|
|
},
|
|
},
|
|
{
|
|
_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("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 },
|
|
},
|
|
},
|
|
{
|
|
_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)"],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|