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 930ab97..5eaf0a6 100644 --- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts @@ -197,7 +197,8 @@ describe("normalizePf2eBestiary", () => { }); const ability = creature.abilitiesBot?.[0]; expect(ability).toBeDefined(); - expect(ability?.name).toBe("\u25C6 Change Shape"); + expect(ability?.name).toBe("Change Shape"); + expect(ability?.activity).toEqual({ number: 1, unit: "action" }); expect(ability?.segments[0]).toEqual( expect.objectContaining({ type: "text", @@ -208,7 +209,7 @@ describe("normalizePf2eBestiary", () => { ); }); - it("shows free action icon", () => { + it("parses free action activity", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ @@ -224,10 +225,12 @@ describe("normalizePf2eBestiary", () => { }), ], }); - expect(creature.abilitiesBot?.[0]?.name).toBe("\u25C7 Adaptive Strike"); + const ability = creature.abilitiesBot?.[0]; + expect(ability?.name).toBe("Adaptive Strike"); + expect(ability?.activity).toEqual({ number: 1, unit: "free" }); }); - it("shows reaction icon", () => { + it("parses reaction activity", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ @@ -243,12 +246,12 @@ describe("normalizePf2eBestiary", () => { }), ], }); - expect(creature.abilitiesMid?.[0]?.name).toBe( - "\u21BA Attack of Opportunity", - ); + const ability = creature.abilitiesMid?.[0]; + expect(ability?.name).toBe("Attack of Opportunity"); + expect(ability?.activity).toEqual({ number: 1, unit: "reaction" }); }); - it("shows multi-action icons", () => { + it("parses multi-action activity", () => { const [creature] = normalizePf2eBestiary({ creature: [ minimalCreature({ @@ -264,9 +267,9 @@ describe("normalizePf2eBestiary", () => { }), ], }); - expect(creature.abilitiesBot?.[0]?.name).toBe( - "\u25C6\u25C6 Breath Weapon", - ); + const ability = creature.abilitiesBot?.[0]; + expect(ability?.name).toBe("Breath Weapon"); + expect(ability?.activity).toEqual({ number: 2, unit: "action" }); }); it("renders ability without activity or traits normally", () => { @@ -287,6 +290,7 @@ describe("normalizePf2eBestiary", () => { const ability = creature.abilitiesBot?.[0]; expect(ability).toBeDefined(); expect(ability?.name).toBe("Constrict"); + expect(ability?.activity).toBeUndefined(); expect(ability?.segments[0]).toEqual( expect.objectContaining({ type: "text", @@ -294,6 +298,35 @@ describe("normalizePf2eBestiary", () => { }), ); }); + + it("includes trigger text before entries", () => { + const [creature] = normalizePf2eBestiary({ + creature: [ + minimalCreature({ + abilities: { + mid: [ + { + name: "Wing Deflection", + activity: { number: 1, unit: "reaction" }, + trigger: "The dragon is targeted with an attack.", + entries: ["The dragon raises its wing."], + }, + ], + }, + }), + ], + }); + const ability = creature.abilitiesMid?.[0]; + expect(ability).toBeDefined(); + expect(ability?.activity).toEqual({ number: 1, unit: "reaction" }); + expect(ability?.trigger).toBe("The dragon is targeted with an attack."); + expect(ability?.segments[0]).toEqual( + expect.objectContaining({ + type: "text", + value: "The dragon raises its wing.", + }), + ); + }); }); describe("resistances formatting", () => { diff --git a/apps/web/src/adapters/__tests__/strip-tags.test.ts b/apps/web/src/adapters/__tests__/strip-tags.test.ts index ff9f635..d86c69e 100644 --- a/apps/web/src/adapters/__tests__/strip-tags.test.ts +++ b/apps/web/src/adapters/__tests__/strip-tags.test.ts @@ -138,12 +138,20 @@ describe("stripTags", () => { ); }); - it("handles nested tags gracefully", () => { + it("handles sibling tags in the same string", () => { expect( stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."), ).toBe("The spell Fireball deals 8d6."); }); + it("handles nested tags (outer wrapping inner)", () => { + expect( + stripTags( + "{@b Arcane Innate Spells DC 24; 3rd {@spell fireball}, {@spell slow}}", + ), + ).toBe("Arcane Innate Spells DC 24; 3rd fireball, slow"); + }); + it("handles text with no tags", () => { expect(stripTags("Just plain text.")).toBe("Just plain text."); }); diff --git a/apps/web/src/adapters/pf2e-bestiary-adapter.ts b/apps/web/src/adapters/pf2e-bestiary-adapter.ts index 9c985f1..ed4d25c 100644 --- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts +++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts @@ -47,6 +47,7 @@ interface RawDefenses { interface RawAbility { name?: string; activity?: { number?: number; unit?: string }; + trigger?: string; traits?: string[]; entries?: RawEntry[]; } @@ -81,20 +82,15 @@ function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } -function formatActivityIcon( +function parseActivity( 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 ""; +): { number: number; unit: "action" | "free" | "reaction" } | undefined { + if (!activity?.unit) return undefined; + const unit = activity.unit; + if (unit === "action" || unit === "free" || unit === "reaction") { + return { number: activity.number ?? 1, unit }; } + return undefined; } function stripAngleBrackets(s: string): string { @@ -262,28 +258,34 @@ function normalizeAbilities( .filter((a) => a.name) .map((a) => { const raw = a as Record; - const icon = formatActivityIcon(a.activity); + const activity = parseActivity(a.activity); + const trigger = a.trigger ? stripTags(a.trigger) : undefined; const traits = a.traits && a.traits.length > 0 ? `(${a.traits.map((t) => capitalize(stripAngleBrackets(stripTags(t)))).join(", ")}) ` : ""; + const prefix = traits; 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") { + const name = stripTags(a.name as string); + if (prefix && body.length > 0 && body[0].type === "text") { return { name, + activity, + trigger, segments: [ - { type: "text" as const, value: traits + body[0].value }, + { type: "text" as const, value: prefix + body[0].value }, ...body.slice(1), ], }; } return { name, - segments: traits - ? [{ type: "text" as const, value: traits }, ...body] + activity, + trigger, + segments: prefix + ? [{ type: "text" as const, value: prefix }, ...body] : body, }; }); @@ -306,6 +308,7 @@ function normalizeAttacks( : ""; return { name: capitalize(stripTags(a.name)), + activity: { number: 1, unit: "action" as const }, segments: [ { type: "text" as const, diff --git a/apps/web/src/adapters/strip-tags.ts b/apps/web/src/adapters/strip-tags.ts index c8b7145..687392f 100644 --- a/apps/web/src/adapters/strip-tags.ts +++ b/apps/web/src/adapters/strip-tags.ts @@ -98,20 +98,26 @@ export function stripTags(text: string): string { // Generic tags: {@tag Display|Source|...} → Display (first segment before |) // Covers: spell, condition, damage, dice, variantrule, action, skill, // creature, hazard, status, plus any unknown tags - result = result.replaceAll( - /\{@(\w+)\s+([^}]+)\}/g, - (_, tag: string, content: string) => { - // For tags with Display|Source format, extract first segment - const segments = content.split("|"); + // Run in a loop to resolve nested tags (e.g. {@b ... {@spell fireball} ...}) + // from innermost to outermost. + const tagPattern = /\{@(\w+)\s+([^}]+)\}/g; + while (tagPattern.test(result)) { + result = result.replaceAll( + tagPattern, + (_, tag: string, content: string) => { + const segments = content.split("|"); - // Some tags have a third segment as display text: {@variantrule Name|Source|Display} - if ((tag === "variantrule" || tag === "action") && segments.length >= 3) { - return segments[2]; - } + if ( + (tag === "variantrule" || tag === "action") && + segments.length >= 3 + ) { + return segments[2]; + } - return segments[0]; - }, - ); + return segments[0]; + }, + ); + } return result; } diff --git a/apps/web/src/components/stat-block-parts.tsx b/apps/web/src/components/stat-block-parts.tsx index 66fb352..4e042a7 100644 --- a/apps/web/src/components/stat-block-parts.tsx +++ b/apps/web/src/components/stat-block-parts.tsx @@ -1,4 +1,8 @@ -import type { TraitBlock, TraitSegment } from "@initiative/domain"; +import type { + ActivityCost, + TraitBlock, + TraitSegment, +} from "@initiative/domain"; export function PropertyLine({ label, @@ -57,10 +61,91 @@ function TraitSegments({ ); } +const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 28 L78 50 L48 72 Z"; +const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z"; +const FREE_ACTION_DIAMOND = + "M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z"; +const FREE_ACTION_CHEVRON = "M48 28 L78 50 L48 72 Z"; +const REACTION_ARROW = + "M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z"; + +function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) { + const cls = "inline-block h-[1em] align-[-0.1em]"; + if (activity.unit === "free") { + return ( + + ); + } + if (activity.unit === "reaction") { + return ( + + ); + } + const count = activity.number; + if (count === 1) { + return ( + + ); + } + if (count === 2) { + return ( + + ); + } + return ( + + ); +} + export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) { return (
- {trait.name}. + + {trait.name} + {trait.activity ? null : "."} + {trait.activity ? ( + <> + {" "} + + + ) : null} + + {trait.trigger ? ( + <> + {" "} + Trigger {trait.trigger} + {trait.segments.length > 0 ? ( + <> + {" "} + Effect + + ) : null} + + ) : null}
); diff --git a/packages/domain/src/creature-types.ts b/packages/domain/src/creature-types.ts index 6d7544d..90485ab 100644 --- a/packages/domain/src/creature-types.ts +++ b/packages/domain/src/creature-types.ts @@ -14,8 +14,15 @@ export interface TraitListItem { readonly text: string; } +export interface ActivityCost { + readonly number: number; + readonly unit: "action" | "free" | "reaction"; +} + export interface TraitBlock { readonly name: string; + readonly activity?: ActivityCost; + readonly trigger?: string; readonly segments: readonly TraitSegment[]; } diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 1364310..e8e3dc9 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -24,6 +24,7 @@ export { createPlayerCharacter, } from "./create-player-character.js"; export { + type ActivityCost, type AnyCreature, type BestiaryIndex, type BestiaryIndexEntry,