import { describe, expect, it } from "vitest"; import { normalizeFoundryCreature } from "../pf2e-bestiary-adapter.js"; function minimalCreature(overrides?: Record) { 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: "

Can sense lies.

" }, }, }, { _id: "a2", name: "Shield Block", type: "action", system: { category: "defensive", actionType: { value: "reaction" }, actions: { value: null }, traits: { value: [] }, description: { value: "

Blocks with shield.

", }, }, }, { _id: "a3", name: "Breath Weapon", type: "action", system: { category: "offensive", actionType: { value: "action" }, actions: { value: 2 }, traits: { value: ["arcane", "fire"] }, description: { value: "

@Damage[8d6[fire]] in a @Template[cone|distance:40].

", }, }, }, ], }), ); 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: "

Deal @Damage[3d6[fire]] damage, @Check[reflex|dc:20|basic] save.

", }, }, }, ], }), ); 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: "

Takes a new form.

", }, }, }, ], }), ); 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: "

Drains the soul.

" }, 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: "

Frequency once per day

\n
\n

Effect The lich opens their spell tome.

", }, 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: "

6th rank

\n
\n

Frequency once per day

\n
\n

Effect The lich taps into their soul cage.

", }, 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: "

Strikes.

" }, }, }, ], }), ); 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: "

This sword blazes with fire.

", }, }, }, ], }), ); 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: "

Restores 3d8+10 Hit Points.

", }, 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: "

A scroll.

" }, 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: "

A wand.

" }, 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: "

Grants fire resistance 5.

", }, }, }, ], }), ); 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: "

A deadly poison.

", }, 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: "

Full plate made of adamantine.

", }, 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: "

Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.

", }, 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: "

You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.

", }, 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: "

You shoot two more missiles.

" }, "5": { text: "

You shoot four more missiles.

" }, }, }, }, }, ], }), ); 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: "

You discharge a bolt of lightning. The damage is 8d12.

Heightened (+1) The damage increases by 1d12.

", }, 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: "

You fire darts of force.

", }, overlays: { variant1: { name: "2 actions", system: { description: { value: "

You fire two darts.

", }, }, }, variant2: { name: "3 actions", system: { description: { value: "

You fire three darts.

", }, }, }, }, }, }, ], }), ); 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: "

Should be ignored.

", }, }, }, }, }, }, ], }), ); 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"); }); }); });