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 925747e..930ab97 100644 --- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts @@ -170,7 +170,7 @@ describe("normalizePf2eBestiary", () => { }); }); - describe("ability traits formatting", () => { + describe("ability formatting", () => { it("includes traits from abilities in the text", () => { const [creature] = normalizePf2eBestiary({ creature: [ @@ -179,6 +179,7 @@ describe("normalizePf2eBestiary", () => { bot: [ { name: "Change Shape", + activity: { number: 1, unit: "action" }, traits: [ "concentrate", "divine", @@ -196,6 +197,7 @@ describe("normalizePf2eBestiary", () => { }); const ability = creature.abilitiesBot?.[0]; expect(ability).toBeDefined(); + expect(ability?.name).toBe("\u25C6 Change Shape"); expect(ability?.segments[0]).toEqual( expect.objectContaining({ type: "text", @@ -206,7 +208,68 @@ describe("normalizePf2eBestiary", () => { ); }); - it("renders ability without traits normally", () => { + it("shows free action icon", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + abilities: { + bot: [ + { + name: "Adaptive Strike", + activity: { number: 1, unit: "free" }, + entries: ["The naunet chooses adamantine."], + }, + ], + }, + }), + ], + }); + expect(creature.abilitiesBot?.[0]?.name).toBe("\u25C7 Adaptive Strike"); + }); + + it("shows reaction icon", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + abilities: { + mid: [ + { + name: "Attack of Opportunity", + activity: { number: 1, unit: "reaction" }, + entries: ["Trigger description."], + }, + ], + }, + }), + ], + }); + expect(creature.abilitiesMid?.[0]?.name).toBe( + "\u21BA Attack of Opportunity", + ); + }); + + it("shows multi-action icons", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + abilities: { + bot: [ + { + name: "Breath Weapon", + activity: { number: 2, unit: "action" }, + entries: ["Fire breath."], + }, + ], + }, + }), + ], + }); + expect(creature.abilitiesBot?.[0]?.name).toBe( + "\u25C6\u25C6 Breath Weapon", + ); + }); + + it("renders ability without activity or traits normally", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ @@ -223,6 +286,7 @@ describe("normalizePf2eBestiary", () => { }); const ability = creature.abilitiesBot?.[0]; expect(ability).toBeDefined(); + expect(ability?.name).toBe("Constrict"); expect(ability?.segments[0]).toEqual( expect.objectContaining({ type: "text", diff --git a/apps/web/src/adapters/pf2e-bestiary-adapter.ts b/apps/web/src/adapters/pf2e-bestiary-adapter.ts index 9f73fc1..9c985f1 100644 --- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts +++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts @@ -46,6 +46,7 @@ interface RawDefenses { interface RawAbility { name?: string; + activity?: { number?: number; unit?: string }; traits?: string[]; entries?: RawEntry[]; } @@ -80,6 +81,22 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } +function formatActivityIcon( + activity: { number?: number; unit?: string } | undefined, +): string { + if (!activity) return ""; + switch (activity.unit) { + case "free": + return "\u25C7 "; + case "reaction": + return "\u21BA "; + case "action": + return "\u25C6".repeat(activity.number ?? 1) + " "; + default: + return ""; + } +} + function stripAngleBrackets(s: string): string { return s.replaceAll(/<([^>]+)>/g, "$1"); } @@ -245,6 +262,7 @@ function normalizeAbilities( .filter((a) => a.name) .map((a) => { const raw = a as Record; + const icon = formatActivityIcon(a.activity); const traits = a.traits && a.traits.length > 0 ? `(${a.traits.map((t) => capitalize(stripAngleBrackets(stripTags(t)))).join(", ")}) ` @@ -252,9 +270,10 @@ function normalizeAbilities( const body = Array.isArray(a.entries) ? segmentizeEntries(a.entries) : formatAffliction(raw); + const name = icon + stripTags(a.name as string); if (traits && body.length > 0 && body[0].type === "text") { return { - name: stripTags(a.name as string), + name, segments: [ { type: "text" as const, value: traits + body[0].value }, ...body.slice(1), @@ -262,7 +281,7 @@ function normalizeAbilities( }; } return { - name: stripTags(a.name as string), + name, segments: traits ? [{ type: "text" as const, value: traits }, ...body] : body,