Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
326
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
326
apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
||||
|
||||
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)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user