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

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