Show inline on-hit effects on attack lines (e.g., "plus Grab"), frequency limits on abilities (e.g., "(1/day)"), and perception details text alongside senses. Strip redundant frequency lines from Foundry descriptions. Also add resilient PF2e source fetching: batched requests with retry, graceful handling of ad-blocker-blocked creature files (partial success with toast warning and re-fetch prompt for missing creatures). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1486 lines
37 KiB
TypeScript
1486 lines
37 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");
|
|
});
|
|
|
|
it("extracts perception details", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
system: {
|
|
...minimalCreature().system,
|
|
perception: {
|
|
mod: 35,
|
|
details: "smoke vision",
|
|
senses: [{ type: "darkvision" }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(creature.perceptionDetails).toBe("smoke vision");
|
|
expect(creature.senses).toBe("Darkvision");
|
|
});
|
|
|
|
it("omits perception details when empty", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
system: {
|
|
...minimalCreature().system,
|
|
perception: {
|
|
mod: 8,
|
|
details: "",
|
|
senses: [{ type: "darkvision" }],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(creature.perceptionDetails).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes attack effects in damage text", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "atk1",
|
|
name: "talon",
|
|
type: "melee",
|
|
system: {
|
|
bonus: { value: 14 },
|
|
damageRolls: {
|
|
abc: {
|
|
damage: "1d10+6",
|
|
damageType: "piercing",
|
|
},
|
|
},
|
|
traits: { value: [] },
|
|
attackEffects: { value: ["grab"] },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const attack = creature.attacks?.[0];
|
|
expect(attack?.segments[0]).toEqual({
|
|
type: "text",
|
|
value: "+14, 1d10+6 piercing plus Grab",
|
|
});
|
|
});
|
|
|
|
it("joins multiple attack effects with 'and'", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "atk1",
|
|
name: "claw",
|
|
type: "melee",
|
|
system: {
|
|
bonus: { value: 18 },
|
|
damageRolls: {
|
|
abc: {
|
|
damage: "2d8+6",
|
|
damageType: "slashing",
|
|
},
|
|
},
|
|
traits: { value: [] },
|
|
attackEffects: {
|
|
value: ["grab", "knockdown"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const attack = creature.attacks?.[0];
|
|
expect(attack?.segments[0]).toEqual({
|
|
type: "text",
|
|
value: "+18, 2d8+6 slashing plus Grab and Knockdown",
|
|
});
|
|
});
|
|
|
|
it("strips creature-name prefix from attack effect slugs", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
name: "Lich",
|
|
items: [
|
|
{
|
|
_id: "atk1",
|
|
name: "hand",
|
|
type: "melee",
|
|
system: {
|
|
bonus: { value: 24 },
|
|
damageRolls: {
|
|
abc: {
|
|
damage: "2d12+7",
|
|
damageType: "negative",
|
|
},
|
|
},
|
|
traits: { value: [] },
|
|
attackEffects: {
|
|
value: ["lich-siphon-life"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const attack = creature.attacks?.[0];
|
|
expect(attack?.segments[0]).toEqual({
|
|
type: "text",
|
|
value: "+24, 2d12+7 negative plus Siphon Life",
|
|
});
|
|
});
|
|
});
|
|
|
|
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.");
|
|
});
|
|
|
|
it("extracts frequency from ability", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Drain Soul Cage",
|
|
type: "action",
|
|
system: {
|
|
category: "offensive",
|
|
actionType: { value: "free" },
|
|
actions: { value: null },
|
|
traits: { value: [] },
|
|
description: { value: "<p>Drains the soul.</p>" },
|
|
frequency: { max: 1, per: "day" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.abilitiesBot?.[0]?.frequency).toBe("1/day");
|
|
});
|
|
|
|
it("strips redundant frequency line from description", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Consult the Text",
|
|
type: "action",
|
|
system: {
|
|
category: "offensive",
|
|
actionType: { value: "action" },
|
|
actions: { value: 1 },
|
|
traits: { value: [] },
|
|
description: {
|
|
value:
|
|
"<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich opens their spell tome.</p>",
|
|
},
|
|
frequency: { max: 1, per: "day" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const text =
|
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
|
? creature.abilitiesBot[0].segments[0].value
|
|
: "";
|
|
expect(text).not.toContain("Frequency");
|
|
expect(text).toContain("The lich opens their spell tome.");
|
|
});
|
|
|
|
it("strips frequency line even when preceded by other text", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Drain Soul Cage",
|
|
type: "action",
|
|
system: {
|
|
category: "offensive",
|
|
actionType: { value: "free" },
|
|
actions: { value: null },
|
|
traits: { value: [] },
|
|
description: {
|
|
value:
|
|
"<p>6th rank</p>\n<hr />\n<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich taps into their soul cage.</p>",
|
|
},
|
|
frequency: { max: 1, per: "day" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const text =
|
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
|
? creature.abilitiesBot[0].segments[0].value
|
|
: "";
|
|
expect(text).not.toContain("Frequency");
|
|
expect(text).toContain("6th rank");
|
|
expect(text).toContain("The lich taps into their soul cage.");
|
|
});
|
|
|
|
it("omits frequency when not present", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Strike",
|
|
type: "action",
|
|
system: {
|
|
category: "offensive",
|
|
actionType: { value: "action" },
|
|
actions: { value: 1 },
|
|
traits: { value: [] },
|
|
description: { value: "<p>Strikes.</p>" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.abilitiesBot?.[0]?.frequency).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("equipment normalization", () => {
|
|
it("normalizes a weapon with traits and description", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "w1",
|
|
name: "Flaming Longsword",
|
|
type: "weapon",
|
|
system: {
|
|
level: { value: 5 },
|
|
traits: { value: ["magical", "fire"] },
|
|
description: {
|
|
value: "<p>This sword blazes with fire.</p>",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toHaveLength(1);
|
|
const item = creature.equipment?.[0];
|
|
expect(item?.name).toBe("Flaming Longsword");
|
|
expect(item?.level).toBe(5);
|
|
expect(item?.traits).toEqual(["magical", "fire"]);
|
|
expect(item?.description).toBe("This sword blazes with fire.");
|
|
});
|
|
|
|
it("normalizes a consumable potion with description", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "c1",
|
|
name: "Healing Potion (Moderate)",
|
|
type: "consumable",
|
|
system: {
|
|
level: { value: 6 },
|
|
traits: { value: ["consumable", "healing", "magical"] },
|
|
description: {
|
|
value: "<p>Restores 3d8+10 Hit Points.</p>",
|
|
},
|
|
category: "potion",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toHaveLength(1);
|
|
const item = creature.equipment?.[0];
|
|
expect(item?.name).toBe("Healing Potion (Moderate)");
|
|
expect(item?.category).toBe("potion");
|
|
expect(item?.description).toBe("Restores 3d8+10 Hit Points.");
|
|
});
|
|
|
|
it("extracts scroll embedded spell name and rank", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "s1",
|
|
name: "Scroll of Teleport (Rank 6)",
|
|
type: "consumable",
|
|
system: {
|
|
level: { value: 11 },
|
|
traits: { value: ["consumable", "magical", "scroll"] },
|
|
description: { value: "<p>A scroll.</p>" },
|
|
category: "scroll",
|
|
spell: {
|
|
name: "Teleport",
|
|
system: { level: { value: 6 } },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const item = creature.equipment?.[0];
|
|
expect(item?.spellName).toBe("Teleport");
|
|
expect(item?.spellRank).toBe(6);
|
|
});
|
|
|
|
it("extracts wand embedded spell name and rank", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "w1",
|
|
name: "Wand of Dispel Magic (Rank 2)",
|
|
type: "consumable",
|
|
system: {
|
|
level: { value: 5 },
|
|
traits: { value: ["consumable", "magical", "wand"] },
|
|
description: { value: "<p>A wand.</p>" },
|
|
category: "wand",
|
|
spell: {
|
|
name: "Dispel Magic",
|
|
system: { level: { value: 2 } },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const item = creature.equipment?.[0];
|
|
expect(item?.spellName).toBe("Dispel Magic");
|
|
expect(item?.spellRank).toBe(2);
|
|
});
|
|
|
|
it("filters magical equipment into equipment field", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "e1",
|
|
name: "Ring of Energy Resistance (Fire)",
|
|
type: "equipment",
|
|
system: {
|
|
level: { value: 6 },
|
|
traits: { value: ["magical", "invested"] },
|
|
description: {
|
|
value: "<p>Grants fire resistance 5.</p>",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toHaveLength(1);
|
|
expect(creature.equipment?.[0]?.name).toBe(
|
|
"Ring of Energy Resistance (Fire)",
|
|
);
|
|
expect(creature.items).toBeUndefined();
|
|
});
|
|
|
|
it("filters mundane items into items string", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "w1",
|
|
name: "Longsword",
|
|
type: "weapon",
|
|
system: {
|
|
level: { value: 0 },
|
|
traits: { value: [] },
|
|
description: { value: "" },
|
|
},
|
|
},
|
|
{
|
|
_id: "a1",
|
|
name: "Leather Armor",
|
|
type: "armor",
|
|
system: {
|
|
level: { value: 0 },
|
|
traits: { value: [] },
|
|
description: { value: "" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.items).toBe("Longsword, Leather Armor");
|
|
expect(creature.equipment).toBeUndefined();
|
|
});
|
|
|
|
it("omits equipment when no detailed items exist", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "w1",
|
|
name: "Dagger",
|
|
type: "weapon",
|
|
system: {
|
|
level: { value: 0 },
|
|
traits: { value: [] },
|
|
description: { value: "" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toBeUndefined();
|
|
});
|
|
|
|
it("omits items when no mundane items exist", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "c1",
|
|
name: "Giant Wasp Venom",
|
|
type: "consumable",
|
|
system: {
|
|
level: { value: 7 },
|
|
traits: { value: ["consumable", "poison"] },
|
|
description: {
|
|
value: "<p>A deadly poison.</p>",
|
|
},
|
|
category: "poison",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.items).toBeUndefined();
|
|
expect(creature.equipment).toHaveLength(1);
|
|
});
|
|
|
|
it("includes armor with special material in equipment", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Adamantine Full Plate",
|
|
type: "armor",
|
|
system: {
|
|
level: { value: 0 },
|
|
traits: { value: [] },
|
|
description: {
|
|
value: "<p>Full plate made of adamantine.</p>",
|
|
},
|
|
material: { type: "adamantine", grade: "standard" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toHaveLength(1);
|
|
expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate");
|
|
});
|
|
|
|
it("excludes mundane armor from equipment (goes to items)", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "a1",
|
|
name: "Chain Mail",
|
|
type: "armor",
|
|
system: {
|
|
level: { value: 0 },
|
|
traits: { value: [] },
|
|
description: { value: "" },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(creature.equipment).toBeUndefined();
|
|
expect(creature.items).toBe("Chain Mail");
|
|
});
|
|
|
|
it("strips Foundry HTML tags from equipment descriptions", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "c1",
|
|
name: "Potion of Speed",
|
|
type: "consumable",
|
|
system: {
|
|
level: { value: 10 },
|
|
traits: { value: ["consumable", "magical"] },
|
|
description: {
|
|
value:
|
|
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
|
|
},
|
|
category: "potion",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const desc = creature.equipment?.[0]?.description;
|
|
expect(desc).toBe("Gain quickened for 1 minute.");
|
|
expect(desc).not.toContain("@UUID");
|
|
});
|
|
});
|
|
|
|
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?.map((d) => d.uses)).toEqual([6, 3]);
|
|
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
|
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
|
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
|
// Cantrip rank auto-heightens to ceil(creatureLevel / 2) = ceil(3/2) = 2
|
|
expect(sc?.atWill?.[0]?.rank).toBe(2);
|
|
});
|
|
|
|
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).toHaveLength(1);
|
|
const spell = sc?.daily?.[0]?.spells[0];
|
|
expect(spell?.name).toBe("Sure Strike");
|
|
expect(spell?.usesPerDay).toBe(3);
|
|
expect(spell?.rank).toBe(1);
|
|
});
|
|
|
|
it("preserves full spell data including description and heightening", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Divine Innate Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "divine" },
|
|
prepared: { value: "innate" },
|
|
spelldc: { dc: 35, value: 27 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Heal",
|
|
type: "spell",
|
|
system: {
|
|
slug: "heal",
|
|
location: { value: "entry1" },
|
|
level: { value: 6 },
|
|
traits: {
|
|
rarity: "common",
|
|
value: ["healing", "vitality"],
|
|
traditions: ["divine", "primal"],
|
|
},
|
|
description: {
|
|
value:
|
|
"<p>You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.</p>",
|
|
},
|
|
range: { value: "30 feet" },
|
|
target: { value: "1 willing creature" },
|
|
duration: { value: "" },
|
|
defense: undefined,
|
|
time: { value: "1" },
|
|
heightening: {
|
|
type: "interval",
|
|
interval: 1,
|
|
damage: { value: "2d8" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
_id: "s2",
|
|
name: "Force Barrage",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 1 },
|
|
traits: { value: ["concentrate", "manipulate"] },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const sc = creature.spellcasting?.[0];
|
|
expect(sc).toBeDefined();
|
|
const heal = sc?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Heal");
|
|
expect(heal).toBeDefined();
|
|
expect(heal?.slug).toBe("heal");
|
|
expect(heal?.rank).toBe(6);
|
|
expect(heal?.range).toBe("30 feet");
|
|
expect(heal?.target).toBe("1 willing creature");
|
|
expect(heal?.traits).toEqual(["healing", "vitality"]);
|
|
expect(heal?.traditions).toEqual(["divine", "primal"]);
|
|
expect(heal?.actionCost).toBe("1");
|
|
// Foundry tags stripped from description
|
|
expect(heal?.description).toContain("positive");
|
|
expect(heal?.description).not.toContain("@UUID");
|
|
expect(heal?.description).not.toContain("@Damage");
|
|
// Interval heightening formatted and not duplicated in description
|
|
expect(heal?.heightening).toBe("Heightened (+1) damage increases by 2d8");
|
|
|
|
// Spell without optional data still has name + rank
|
|
const fb = sc?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Force Barrage");
|
|
expect(fb).toBeDefined();
|
|
expect(fb?.rank).toBe(1);
|
|
expect(fb?.description).toBeUndefined();
|
|
expect(fb?.usesPerDay).toBeUndefined();
|
|
});
|
|
|
|
it("formats fixed-type heightening levels", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Divine Prepared Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "divine" },
|
|
prepared: { value: "prepared" },
|
|
spelldc: { dc: 30 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Magic Missile",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 1 },
|
|
traits: { value: [] },
|
|
heightening: {
|
|
type: "fixed",
|
|
levels: {
|
|
"3": { text: "<p>You shoot two more missiles.</p>" },
|
|
"5": { text: "<p>You shoot four more missiles.</p>" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const spell = creature.spellcasting?.[0]?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Magic Missile");
|
|
expect(spell?.heightening).toContain(
|
|
"Heightened (3) You shoot two more missiles.",
|
|
);
|
|
expect(spell?.heightening).toContain(
|
|
"Heightened (5) You shoot four more missiles.",
|
|
);
|
|
});
|
|
|
|
it("formats save defense", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Arcane Innate Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "arcane" },
|
|
prepared: { value: "innate" },
|
|
spelldc: { dc: 25 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Fireball",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 3 },
|
|
traits: { value: ["fire"] },
|
|
area: { type: "burst", value: 20 },
|
|
defense: {
|
|
save: { statistic: "reflex", basic: true },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const fireball = creature.spellcasting?.[0]?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Fireball");
|
|
expect(fireball?.defense).toBe("basic Reflex");
|
|
expect(fireball?.area).toBe("20-foot burst");
|
|
});
|
|
|
|
it("strips inline heightening text from description when structured heightening exists", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Arcane Prepared Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "arcane" },
|
|
prepared: { value: "prepared" },
|
|
spelldc: { dc: 30 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Chain Lightning",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 6 },
|
|
traits: { value: ["electricity"] },
|
|
description: {
|
|
value:
|
|
"<p>You discharge a bolt of lightning. The damage is 8d12.</p><p>Heightened (+1) The damage increases by 1d12.</p>",
|
|
},
|
|
heightening: {
|
|
type: "interval",
|
|
interval: 1,
|
|
damage: { value: "1d12" },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const spell = creature.spellcasting?.[0]?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Chain Lightning");
|
|
expect(spell?.description).toBe(
|
|
"You discharge a bolt of lightning. The damage is 8d12.",
|
|
);
|
|
expect(spell?.description).not.toContain("Heightened");
|
|
expect(spell?.heightening).toBe(
|
|
"Heightened (+1) damage increases by 1d12",
|
|
);
|
|
});
|
|
|
|
it("formats overlays when heightening is absent", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Arcane Innate Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "arcane" },
|
|
prepared: { value: "innate" },
|
|
spelldc: { dc: 28 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Force Barrage",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 1 },
|
|
traits: { value: ["force", "manipulate"] },
|
|
description: {
|
|
value: "<p>You fire darts of force.</p>",
|
|
},
|
|
overlays: {
|
|
variant1: {
|
|
name: "2 actions",
|
|
system: {
|
|
description: {
|
|
value: "<p>You fire two darts.</p>",
|
|
},
|
|
},
|
|
},
|
|
variant2: {
|
|
name: "3 actions",
|
|
system: {
|
|
description: {
|
|
value: "<p>You fire three darts.</p>",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const spell = creature.spellcasting?.[0]?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Force Barrage");
|
|
expect(spell?.heightening).toContain("2 actions: You fire two darts.");
|
|
expect(spell?.heightening).toContain("3 actions: You fire three darts.");
|
|
});
|
|
|
|
it("prefers heightening over overlays when both present", () => {
|
|
const creature = normalizeFoundryCreature(
|
|
minimalCreature({
|
|
items: [
|
|
{
|
|
_id: "entry1",
|
|
name: "Arcane Prepared Spells",
|
|
type: "spellcastingEntry",
|
|
system: {
|
|
tradition: { value: "arcane" },
|
|
prepared: { value: "prepared" },
|
|
spelldc: { dc: 30 },
|
|
},
|
|
},
|
|
{
|
|
_id: "s1",
|
|
name: "Test Spell",
|
|
type: "spell",
|
|
system: {
|
|
location: { value: "entry1" },
|
|
level: { value: 1 },
|
|
traits: { value: [] },
|
|
heightening: {
|
|
type: "interval",
|
|
interval: 2,
|
|
damage: { value: "1d6" },
|
|
},
|
|
overlays: {
|
|
variant1: {
|
|
name: "Variant",
|
|
system: {
|
|
description: {
|
|
value: "<p>Should be ignored.</p>",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const spell = creature.spellcasting?.[0]?.daily
|
|
?.flatMap((d) => d.spells)
|
|
.find((s) => s.name === "Test Spell");
|
|
expect(spell?.heightening).toBe(
|
|
"Heightened (+2) damage increases by 1d6",
|
|
);
|
|
expect(spell?.heightening).not.toContain("Should be ignored");
|
|
});
|
|
});
|
|
});
|