From 65e4db153b2525663a05e5906c9a6406f5299e6a Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 7 Apr 2026 12:23:08 +0200 Subject: [PATCH] Fix PF2e stat block senses and attack trait rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Format senses with type (imprecise/precise) and range in feet, and strip {@ability} tags (e.g. tremorsense) - Strip angle-bracket dice notation in attack traits ( → d8) - Fix existing weakness/resistance tests to nest under defenses - Fix non-null assertions in 5e bestiary adapter tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/bestiary-adapter.test.ts | 33 +++--- .../__tests__/pf2e-bestiary-adapter.test.ts | 101 ++++++++++++++++-- .../web/src/adapters/pf2e-bestiary-adapter.ts | 20 +++- 3 files changed, 124 insertions(+), 30 deletions(-) diff --git a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts index 517c622..4f83c3a 100644 --- a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts @@ -6,7 +6,8 @@ import { } from "../bestiary-adapter.js"; /** Flatten segments to a single string for simple text assertions. */ -function flatText(trait: TraitBlock): string { +function flatText(trait: TraitBlock | undefined): string { + if (!trait) return ""; return trait.segments .map((s) => s.type === "text" @@ -88,11 +89,11 @@ describe("normalizeBestiary", () => { expect(c.senses).toBe("Darkvision 60 ft."); expect(c.languages).toBe("Common, Goblin"); expect(c.actions).toHaveLength(1); - expect(flatText(c.actions![0])).toContain("Melee Attack Roll:"); - expect(flatText(c.actions![0])).not.toContain("{@"); + expect(flatText(c.actions?.[0])).toContain("Melee Attack Roll:"); + expect(flatText(c.actions?.[0])).not.toContain("{@"); expect(c.bonusActions).toHaveLength(1); - expect(flatText(c.bonusActions![0])).toContain("Disengage"); - expect(flatText(c.bonusActions![0])).not.toContain("{@"); + expect(flatText(c.bonusActions?.[0])).toContain("Disengage"); + expect(flatText(c.bonusActions?.[0])).not.toContain("{@"); }); it("normalizes a creature with legendary actions", () => { @@ -347,9 +348,9 @@ describe("normalizeBestiary", () => { const creatures = normalizeBestiary(raw); const bite = creatures[0].actions?.[0]; - expect(flatText(bite!)).toContain("Melee Weapon Attack:"); - expect(flatText(bite!)).not.toContain("mw"); - expect(flatText(bite!)).not.toContain("{@"); + expect(flatText(bite)).toContain("Melee Weapon Attack:"); + expect(flatText(bite)).not.toContain("mw"); + expect(flatText(bite)).not.toContain("{@"); }); it("handles fly speed with hover condition", () => { @@ -438,14 +439,15 @@ describe("normalizeBestiary", () => { }; const creatures = normalizeBestiary(raw); - const trait = creatures[0].traits![0]; - expect(trait.name).toBe("Confusing Burble"); - expect(trait.segments).toHaveLength(2); - expect(trait.segments[0]).toEqual({ + const trait = creatures[0].traits?.[0]; + expect(trait).toBeDefined(); + expect(trait?.name).toBe("Confusing Burble"); + expect(trait?.segments).toHaveLength(2); + expect(trait?.segments[0]).toEqual({ type: "text", value: expect.stringContaining("d4"), }); - expect(trait.segments[1]).toEqual({ + expect(trait?.segments[1]).toEqual({ type: "list", items: [ { label: "1-2", text: "The creature does nothing." }, @@ -498,8 +500,9 @@ describe("normalizeBestiary", () => { }; const creatures = normalizeBestiary(raw); - const trait = creatures[0].traits![0]; - expect(trait.segments[1]).toEqual({ + const trait = creatures[0].traits?.[0]; + expect(trait).toBeDefined(); + expect(trait?.segments[1]).toEqual({ type: "list", items: [ { label: "1", text: "Nothing happens." }, diff --git a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts index 3b1e718..9e964ab 100644 --- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { normalizePf2eBestiary } from "../pf2e-bestiary-adapter.js"; -function minimalCreature(defenses?: Record) { +function minimalCreature(overrides?: Record) { return { name: "Test Creature", source: "TST", - defenses, + ...overrides, }; } @@ -15,7 +15,9 @@ describe("normalizePf2eBestiary", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - weaknesses: [{ name: "fire", amount: 5 }], + defenses: { + weaknesses: [{ name: "fire", amount: 5 }], + }, }), ], }); @@ -26,7 +28,9 @@ describe("normalizePf2eBestiary", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - weaknesses: [{ name: "smoke susceptibility" }], + defenses: { + weaknesses: [{ name: "smoke susceptibility" }], + }, }), ], }); @@ -37,9 +41,11 @@ describe("normalizePf2eBestiary", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - weaknesses: [ - { name: "cold iron", amount: 5, note: "except daggers" }, - ], + defenses: { + weaknesses: [ + { name: "cold iron", amount: 5, note: "except daggers" }, + ], + }, }), ], }); @@ -50,7 +56,9 @@ describe("normalizePf2eBestiary", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - weaknesses: [{ name: "smoke susceptibility", note: "see below" }], + defenses: { + weaknesses: [{ name: "smoke susceptibility", note: "see below" }], + }, }), ], }); @@ -65,12 +73,83 @@ describe("normalizePf2eBestiary", () => { }); }); + describe("senses formatting", () => { + it("strips tags and includes type and range", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + senses: [ + { + type: "imprecise", + name: "{@ability tremorsense}", + range: 30, + }, + ], + }), + ], + }); + expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet"); + }); + + it("formats sense with only a name", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + senses: [{ name: "darkvision" }], + }), + ], + }); + expect(creature.senses).toBe("Darkvision"); + }); + + it("formats sense with name and range but no type", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + senses: [{ name: "scent", range: 60 }], + }), + ], + }); + expect(creature.senses).toBe("Scent 60 feet"); + }); + }); + + describe("attack traits formatting", () => { + it("strips angle-bracket dice notation from traits", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + attacks: [ + { + name: "stinger", + range: "Melee", + attack: 11, + traits: ["deadly "], + damage: "1d6+4 piercing", + }, + ], + }), + ], + }); + const attack = creature.attacks?.[0]; + expect(attack).toBeDefined(); + expect(attack?.segments[0]).toEqual( + expect.objectContaining({ + type: "text", + value: expect.stringContaining("(deadly d8)"), + }), + ); + }); + }); + describe("resistances formatting", () => { it("formats resistance without amount", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - resistances: [{ name: "physical" }], + defenses: { + resistances: [{ name: "physical" }], + }, }), ], }); @@ -81,7 +160,9 @@ describe("normalizePf2eBestiary", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ - resistances: [{ name: "fire", amount: 10 }], + defenses: { + resistances: [{ name: "fire", amount: 10 }], + }, }), ], }); diff --git a/apps/web/src/adapters/pf2e-bestiary-adapter.ts b/apps/web/src/adapters/pf2e-bestiary-adapter.ts index 4776d91..2c1b7ba 100644 --- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts +++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts @@ -15,7 +15,7 @@ interface RawPf2eCreature { level?: number; traits?: string[]; perception?: { std?: number }; - senses?: { name?: string; type?: string }[]; + senses?: { name?: string; type?: string; range?: number }[]; languages?: { languages?: string[] }; skills?: Record; abilityMods?: Record; @@ -79,6 +79,10 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } +function stripDiceBrackets(s: string): string { + return s.replaceAll(/<(\d*d\d+)>/g, "$1"); +} + function makeCreatureId(source: string, name: string): CreatureId { const slug = name .toLowerCase() @@ -119,13 +123,19 @@ function formatSkills( } function formatSenses( - senses: readonly { name?: string; type?: string }[] | undefined, + senses: + | readonly { name?: string; type?: string; range?: number }[] + | undefined, ): string | undefined { if (!senses || senses.length === 0) return undefined; return senses .map((s) => { - const label = s.name ?? s.type ?? ""; - return label ? capitalize(label) : ""; + const label = stripTags(s.name ?? s.type ?? ""); + if (!label) return ""; + const parts = [capitalize(label)]; + if (s.type && s.name) parts.push(`(${s.type})`); + if (s.range != null) parts.push(`${s.range} feet`); + return parts.join(" "); }) .filter(Boolean) .join(", "); @@ -253,7 +263,7 @@ function normalizeAttacks( const attackMod = a.attack == null ? "" : ` +${a.attack}`; const traits = a.traits && a.traits.length > 0 - ? ` (${a.traits.map((t) => stripTags(t)).join(", ")})` + ? ` (${a.traits.map((t) => stripDiceBrackets(stripTags(t))).join(", ")})` : ""; const damage = a.damage ? `, ${stripTags(a.damage)}` : ""; return {