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,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)"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user