import { beforeAll, describe, expect, it } from "vitest"; import { normalizeBestiary, setSourceDisplayNames, } from "../bestiary-adapter.js"; beforeAll(() => { setSourceDisplayNames({ XMM: "MM 2024" }); }); describe("normalizeBestiary", () => { it("normalizes a simple creature", () => { const raw = { monster: [ { name: "Goblin Warrior", source: "XMM", size: ["S"], type: { type: "fey", tags: ["goblinoid"] }, alignment: ["C", "N"], ac: [15], hp: { average: 10, formula: "3d6" }, speed: { walk: 30 }, str: 8, dex: 15, con: 10, int: 10, wis: 8, cha: 8, skill: { stealth: "+6" }, senses: ["Darkvision 60 ft."], passive: 9, languages: ["Common", "Goblin"], cr: "1/4", action: [ { name: "Scimitar", entries: [ "{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.", ], }, ], bonus: [ { name: "Nimble Escape", entries: [ "The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.", ], }, ], }, ], }; const creatures = normalizeBestiary(raw); expect(creatures).toHaveLength(1); const c = creatures[0]; expect(c.id).toBe("xmm:goblin-warrior"); expect(c.name).toBe("Goblin Warrior"); expect(c.source).toBe("XMM"); expect(c.sourceDisplayName).toBe("MM 2024"); expect(c.size).toBe("Small"); expect(c.type).toBe("Fey (Goblinoid)"); expect(c.alignment).toBe("Chaotic Neutral"); expect(c.ac).toBe(15); expect(c.hp).toEqual({ average: 10, formula: "3d6" }); expect(c.speed).toBe("30 ft."); expect(c.abilities.dex).toBe(15); expect(c.cr).toBe("1/4"); expect(c.proficiencyBonus).toBe(2); expect(c.passive).toBe(9); expect(c.skills).toBe("Stealth +6"); expect(c.senses).toBe("Darkvision 60 ft."); expect(c.languages).toBe("Common, Goblin"); expect(c.actions).toHaveLength(1); expect(c.actions?.[0].text).toContain("Melee Attack Roll:"); expect(c.actions?.[0].text).not.toContain("{@"); expect(c.bonusActions).toHaveLength(1); expect(c.bonusActions?.[0].text).toContain("Disengage"); expect(c.bonusActions?.[0].text).not.toContain("{@"); }); it("normalizes a creature with legendary actions", () => { const raw = { monster: [ { name: "Aboleth", source: "XMM", size: ["L"], type: "aberration", alignment: ["L", "E"], ac: [17], hp: { average: 135, formula: "18d10 + 36" }, speed: { walk: 10, swim: 40 }, str: 21, dex: 9, con: 15, int: 18, wis: 15, cha: 18, save: { con: "+6", int: "+8", wis: "+6" }, senses: ["Darkvision 120 ft."], passive: 12, languages: ["Deep Speech", "Telepathy 120 ft."], cr: "10", legendary: [ { name: "Lash", entries: ["The aboleth makes one Tentacle attack."], }, ], }, ], }; const creatures = normalizeBestiary(raw); const c = creatures[0]; expect(c.legendaryActions).toBeDefined(); expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions"); expect(c.legendaryActions?.entries).toHaveLength(1); expect(c.legendaryActions?.entries[0].name).toBe("Lash"); }); it("normalizes a creature with spellcasting", () => { const raw = { monster: [ { name: "Test Caster", source: "XMM", size: ["M"], type: "humanoid", ac: [12], hp: { average: 40, formula: "9d8" }, speed: { walk: 30 }, str: 10, dex: 14, con: 10, int: 17, wis: 12, cha: 11, passive: 11, cr: "6", spellcasting: [ { name: "Spellcasting", headerEntries: [ "The caster casts spells using Intelligence (spell save {@dc 15}):", ], will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"], daily: { "2e": ["{@spell Fireball|XPHB}"], "1": ["{@spell Dimension Door|XPHB}"], }, }, ], }, ], }; const creatures = normalizeBestiary(raw); const c = creatures[0]; expect(c.spellcasting).toHaveLength(1); const sc = c.spellcasting?.[0]; expect(sc).toBeDefined(); expect(sc?.name).toBe("Spellcasting"); expect(sc?.headerText).toContain("DC 15"); expect(sc?.headerText).not.toContain("{@"); expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]); expect(sc?.daily).toHaveLength(2); expect(sc?.daily).toContainEqual({ uses: 2, each: true, spells: ["Fireball"], }); expect(sc?.daily).toContainEqual({ uses: 1, each: false, spells: ["Dimension Door"], }); }); it("normalizes a creature with object-type type field", () => { const raw = { monster: [ { name: "Swarm of Bats", source: "XMM", size: ["L"], type: { type: "beast", swarmSize: "T" }, ac: [12], hp: { average: 11, formula: "2d10" }, speed: { walk: 5, fly: 30 }, str: 5, dex: 15, con: 10, int: 2, wis: 12, cha: 4, passive: 11, resist: ["bludgeoning", "piercing", "slashing"], conditionImmune: ["charmed", "frightened"], cr: "1/4", }, ], }; const creatures = normalizeBestiary(raw); const c = creatures[0]; expect(c.type).toBe("Swarm of Tiny Beasts"); expect(c.resist).toBe("Bludgeoning, Piercing, Slashing"); expect(c.conditionImmune).toBe("Charmed, Frightened"); }); it("normalizes a creature with conditional resistances", () => { const raw = { monster: [ { name: "Half-Dragon", source: "XMM", size: ["M"], type: "humanoid", ac: [18], hp: { average: 65, formula: "10d8 + 20" }, speed: { walk: 30 }, str: 16, dex: 13, con: 14, int: 10, wis: 11, cha: 10, passive: 10, cr: "5", resist: [ { special: "Damage type chosen for the Draconic Origin trait", }, ], }, ], }; const creatures = normalizeBestiary(raw); const c = creatures[0]; expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait"); }); it("normalizes a creature with multiple sizes", () => { const raw = { monster: [ { name: "Aberrant Cultist", source: "XMM", size: ["S", "M"], type: "humanoid", ac: [13], hp: { average: 22, formula: "4d8 + 4" }, speed: { walk: 30 }, str: 11, dex: 14, con: 12, int: 10, wis: 13, cha: 8, passive: 11, cr: "1/2", }, ], }; const creatures = normalizeBestiary(raw); expect(creatures[0].size).toBe("Small or Medium"); }); it("normalizes a creature with CR as object", () => { const raw = { monster: [ { name: "Dragon", source: "XMM", size: ["H"], type: "dragon", ac: [19], hp: { average: 256, formula: "19d12 + 133" }, speed: { walk: 40 }, str: 27, dex: 10, con: 25, int: 16, wis: 13, cha: 23, passive: 23, cr: { cr: "17", xpLair: 20000 }, }, ], }; const creatures = normalizeBestiary(raw); expect(creatures[0].cr).toBe("17"); expect(creatures[0].proficiencyBonus).toBe(6); }); it("handles fly speed with hover condition", () => { const raw = { monster: [ { name: "Air Elemental", source: "XMM", size: ["L"], type: "elemental", ac: [15], hp: { average: 90, formula: "12d10 + 24" }, speed: { walk: 10, fly: { number: 90, condition: "(hover)" }, canHover: true, }, str: 14, dex: 20, con: 14, int: 6, wis: 10, cha: 6, passive: 10, cr: "5", }, ], }; const creatures = normalizeBestiary(raw); expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)"); }); });