diff --git a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts index 4f83c3a..92ade12 100644 --- a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts @@ -181,17 +181,20 @@ describe("normalizeBestiary", () => { 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?.atWill).toEqual([ + { name: "Detect Magic" }, + { name: "Mage Hand" }, + ]); expect(sc?.daily).toHaveLength(2); expect(sc?.daily).toContainEqual({ uses: 2, each: true, - spells: ["Fireball"], + spells: [{ name: "Fireball" }], }); expect(sc?.daily).toContainEqual({ uses: 1, each: false, - spells: ["Dimension Door"], + spells: [{ name: "Dimension Door" }], }); }); 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 55b78cd..68ca570 100644 --- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts @@ -593,11 +593,11 @@ describe("normalizeFoundryCreature", () => { const sc = creature.spellcasting?.[0]; expect(sc?.name).toBe("Primal Prepared Spells"); expect(sc?.headerText).toBe("DC 30, attack +22"); - expect(sc?.daily).toEqual([ - { uses: 6, each: true, spells: ["Earthquake"] }, - { uses: 3, each: true, spells: ["Heal"] }, - ]); - expect(sc?.atWill).toEqual(["Detect Magic"]); + expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]); + expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]); + expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]); + expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]); + expect(sc?.atWill?.[0]?.rank).toBe(1); }); it("normalizes innate spells with uses", () => { @@ -633,13 +633,334 @@ describe("normalizeFoundryCreature", () => { ); const sc = creature.spellcasting?.[0]; expect(sc?.headerText).toBe("DC 32"); - expect(sc?.daily).toEqual([ - { - uses: 1, - each: true, - spells: ["Sure Strike (\u00d73)"], - }, - ]); + expect(sc?.daily).toHaveLength(1); + const spell = sc?.daily?.[0]?.spells[0]; + expect(spell?.name).toBe("Sure Strike"); + expect(spell?.usesPerDay).toBe(3); + expect(spell?.rank).toBe(1); + }); + + it("preserves full spell data including description and heightening", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Divine Innate Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "divine" }, + prepared: { value: "innate" }, + spelldc: { dc: 35, value: 27 }, + }, + }, + { + _id: "s1", + name: "Heal", + type: "spell", + system: { + slug: "heal", + location: { value: "entry1" }, + level: { value: 6 }, + traits: { + rarity: "common", + value: ["healing", "vitality"], + traditions: ["divine", "primal"], + }, + description: { + value: + "

You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.

", + }, + range: { value: "30 feet" }, + target: { value: "1 willing creature" }, + duration: { value: "" }, + defense: undefined, + time: { value: "1" }, + heightening: { + type: "interval", + interval: 1, + damage: { value: "2d8" }, + }, + }, + }, + { + _id: "s2", + name: "Force Barrage", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 1 }, + traits: { value: ["concentrate", "manipulate"] }, + }, + }, + ], + }), + ); + const sc = creature.spellcasting?.[0]; + expect(sc).toBeDefined(); + const heal = sc?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Heal"); + expect(heal).toBeDefined(); + expect(heal?.slug).toBe("heal"); + expect(heal?.rank).toBe(6); + expect(heal?.range).toBe("30 feet"); + expect(heal?.target).toBe("1 willing creature"); + expect(heal?.traits).toEqual(["healing", "vitality"]); + expect(heal?.traditions).toEqual(["divine", "primal"]); + expect(heal?.actionCost).toBe("1"); + // Foundry tags stripped from description + expect(heal?.description).toContain("positive"); + expect(heal?.description).not.toContain("@UUID"); + expect(heal?.description).not.toContain("@Damage"); + // Interval heightening formatted and not duplicated in description + expect(heal?.heightening).toBe("Heightened (+1) damage increases by 2d8"); + + // Spell without optional data still has name + rank + const fb = sc?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Force Barrage"); + expect(fb).toBeDefined(); + expect(fb?.rank).toBe(1); + expect(fb?.description).toBeUndefined(); + expect(fb?.usesPerDay).toBeUndefined(); + }); + + it("formats fixed-type heightening levels", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Divine Prepared Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "divine" }, + prepared: { value: "prepared" }, + spelldc: { dc: 30 }, + }, + }, + { + _id: "s1", + name: "Magic Missile", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 1 }, + traits: { value: [] }, + heightening: { + type: "fixed", + levels: { + "3": { text: "

You shoot two more missiles.

" }, + "5": { text: "

You shoot four more missiles.

" }, + }, + }, + }, + }, + ], + }), + ); + const spell = creature.spellcasting?.[0]?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Magic Missile"); + expect(spell?.heightening).toContain( + "Heightened (3) You shoot two more missiles.", + ); + expect(spell?.heightening).toContain( + "Heightened (5) You shoot four more missiles.", + ); + }); + + it("formats save defense", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Arcane Innate Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "arcane" }, + prepared: { value: "innate" }, + spelldc: { dc: 25 }, + }, + }, + { + _id: "s1", + name: "Fireball", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 3 }, + traits: { value: ["fire"] }, + area: { type: "burst", value: 20 }, + defense: { + save: { statistic: "reflex", basic: true }, + }, + }, + }, + ], + }), + ); + const fireball = creature.spellcasting?.[0]?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Fireball"); + expect(fireball?.defense).toBe("basic Reflex"); + expect(fireball?.area).toBe("20-foot burst"); + }); + + it("strips inline heightening text from description when structured heightening exists", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Arcane Prepared Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "arcane" }, + prepared: { value: "prepared" }, + spelldc: { dc: 30 }, + }, + }, + { + _id: "s1", + name: "Chain Lightning", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 6 }, + traits: { value: ["electricity"] }, + description: { + value: + "

You discharge a bolt of lightning. The damage is 8d12.

Heightened (+1) The damage increases by 1d12.

", + }, + heightening: { + type: "interval", + interval: 1, + damage: { value: "1d12" }, + }, + }, + }, + ], + }), + ); + const spell = creature.spellcasting?.[0]?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Chain Lightning"); + expect(spell?.description).toBe( + "You discharge a bolt of lightning. The damage is 8d12.", + ); + expect(spell?.description).not.toContain("Heightened"); + expect(spell?.heightening).toBe( + "Heightened (+1) damage increases by 1d12", + ); + }); + + it("formats overlays when heightening is absent", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Arcane Innate Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "arcane" }, + prepared: { value: "innate" }, + spelldc: { dc: 28 }, + }, + }, + { + _id: "s1", + name: "Force Barrage", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 1 }, + traits: { value: ["force", "manipulate"] }, + description: { + value: "

You fire darts of force.

", + }, + overlays: { + variant1: { + name: "2 actions", + system: { + description: { + value: "

You fire two darts.

", + }, + }, + }, + variant2: { + name: "3 actions", + system: { + description: { + value: "

You fire three darts.

", + }, + }, + }, + }, + }, + }, + ], + }), + ); + const spell = creature.spellcasting?.[0]?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Force Barrage"); + expect(spell?.heightening).toContain("2 actions: You fire two darts."); + expect(spell?.heightening).toContain("3 actions: You fire three darts."); + }); + + it("prefers heightening over overlays when both present", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "entry1", + name: "Arcane Prepared Spells", + type: "spellcastingEntry", + system: { + tradition: { value: "arcane" }, + prepared: { value: "prepared" }, + spelldc: { dc: 30 }, + }, + }, + { + _id: "s1", + name: "Test Spell", + type: "spell", + system: { + location: { value: "entry1" }, + level: { value: 1 }, + traits: { value: [] }, + heightening: { + type: "interval", + interval: 2, + damage: { value: "1d6" }, + }, + overlays: { + variant1: { + name: "Variant", + system: { + description: { + value: "

Should be ignored.

", + }, + }, + }, + }, + }, + }, + ], + }), + ); + const spell = creature.spellcasting?.[0]?.daily + ?.flatMap((d) => d.spells) + .find((s) => s.name === "Test Spell"); + expect(spell?.heightening).toBe( + "Heightened (+2) damage increases by 1d6", + ); + expect(spell?.heightening).not.toContain("Should be ignored"); }); }); }); diff --git a/apps/web/src/adapters/bestiary-adapter.ts b/apps/web/src/adapters/bestiary-adapter.ts index 4f709dd..0bb21aa 100644 --- a/apps/web/src/adapters/bestiary-adapter.ts +++ b/apps/web/src/adapters/bestiary-adapter.ts @@ -4,6 +4,7 @@ import type { DailySpells, LegendaryBlock, SpellcastingBlock, + SpellReference, TraitBlock, TraitListItem, TraitSegment, @@ -385,7 +386,7 @@ function normalizeSpellcasting( const block: { name: string; headerText: string; - atWill?: string[]; + atWill?: SpellReference[]; daily?: DailySpells[]; restLong?: DailySpells[]; } = { @@ -396,7 +397,7 @@ function normalizeSpellcasting( const hidden = new Set(sc.hidden ?? []); if (sc.will && !hidden.has("will")) { - block.atWill = sc.will.map((s) => stripTags(s)); + block.atWill = sc.will.map((s) => ({ name: stripTags(s) })); } if (sc.daily) { @@ -418,7 +419,7 @@ function parseDailyMap(map: Record): DailySpells[] { return { uses, each, - spells: spells.map((s) => stripTags(s)), + spells: spells.map((s) => ({ name: stripTags(s) })), }; }); } diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts index 5f4de43..cc0001b 100644 --- a/apps/web/src/adapters/bestiary-cache.ts +++ b/apps/web/src/adapters/bestiary-cache.ts @@ -3,7 +3,8 @@ import { type IDBPDatabase, openDB } from "idb"; const DB_NAME = "initiative-bestiary"; const STORE_NAME = "sources"; -const DB_VERSION = 5; +// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared +const DB_VERSION = 6; interface CachedSourceInfo { readonly sourceCode: string; diff --git a/apps/web/src/adapters/pf2e-bestiary-adapter.ts b/apps/web/src/adapters/pf2e-bestiary-adapter.ts index 34dbbc9..a211e24 100644 --- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts +++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts @@ -2,6 +2,7 @@ import type { CreatureId, Pf2eCreature, SpellcastingBlock, + SpellReference, TraitBlock, } from "@initiative/domain"; import { creatureId } from "@initiative/domain"; @@ -78,13 +79,39 @@ interface SpellcastingEntrySystem { } interface SpellSystem { + slug?: string; location?: { value: string; heightenedLevel?: number; uses?: { max: number; value: number }; }; level?: { value: number }; - traits?: { value: string[] }; + traits?: { rarity?: string; value: string[]; traditions?: string[] }; + description?: { value: string }; + range?: { value: string }; + target?: { value: string }; + area?: { type?: string; value?: number; details?: string }; + duration?: { value: string; sustained?: boolean }; + time?: { value: string }; + defense?: { + save?: { statistic: string; basic?: boolean }; + passive?: { statistic: string }; + }; + heightening?: + | { + type: "fixed"; + levels: Record; + } + | { + type: "interval"; + interval: number; + damage?: { value: string }; + } + | undefined; + overlays?: Record< + string, + { name?: string; system?: { description?: { value: string } } } + >; } const SIZE_MAP: Record = { @@ -311,18 +338,102 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock { // -- Spellcasting normalization -- -function classifySpell(spell: RawFoundryItem): { - isCantrip: boolean; - rank: number; - label: string; -} { - const sys = spell.system as unknown as SpellSystem; - const isCantrip = (sys.traits?.value ?? []).includes("cantrip"); +function formatRange(range: { value: string } | undefined): string | undefined { + if (!range?.value) return undefined; + return range.value; +} + +function formatArea( + area: { type?: string; value?: number; details?: string } | undefined, +): string | undefined { + if (!area) return undefined; + if (area.value && area.type) return `${area.value}-foot ${area.type}`; + return area.details ?? undefined; +} + +function formatDefense(defense: SpellSystem["defense"]): string | undefined { + if (!defense) return undefined; + if (defense.save) { + const stat = capitalize(defense.save.statistic); + return defense.save.basic ? `basic ${stat}` : stat; + } + if (defense.passive) return capitalize(defense.passive.statistic); + return undefined; +} + +function formatHeightening( + heightening: SpellSystem["heightening"], +): string | undefined { + if (!heightening) return undefined; + if (heightening.type === "fixed") { + const parts = Object.entries(heightening.levels) + .filter(([, lvl]) => lvl.text) + .map( + ([rank, lvl]) => + `Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`, + ); + return parts.length > 0 ? parts.join("\n") : undefined; + } + if (heightening.type === "interval") { + const dmg = heightening.damage?.value + ? ` damage increases by ${heightening.damage.value}` + : ""; + return `Heightened (+${heightening.interval})${dmg}`; + } + return undefined; +} + +function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined { + if (!overlays) return undefined; + const parts: string[] = []; + for (const overlay of Object.values(overlays)) { + const desc = overlay.system?.description?.value; + if (!desc) continue; + const label = overlay.name ? `${overlay.name}: ` : ""; + parts.push(`${label}${stripFoundryTags(desc)}`); + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +/** + * Foundry descriptions often include heightening rules inline at the end. + * When we extract heightening into a structured field, strip that trailing + * text to avoid duplication. + */ +const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/; + +function normalizeSpell(item: RawFoundryItem): SpellReference { + const sys = item.system as unknown as SpellSystem; + const usesMax = sys.location?.uses?.max; const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0; - const uses = sys.location?.uses; - const label = - uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name; - return { isCantrip, rank, label }; + const heightening = + formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays); + + let description: string | undefined; + if (sys.description?.value) { + let text = stripFoundryTags(sys.description.value); + if (heightening) { + text = text.replace(HEIGHTENED_SUFFIX, "").trim(); + } + description = text || undefined; + } + + return { + name: item.name, + slug: sys.slug, + rank, + description, + traits: sys.traits?.value, + traditions: sys.traits?.traditions, + range: formatRange(sys.range), + target: sys.target?.value || undefined, + area: formatArea(sys.area), + duration: sys.duration?.value || undefined, + defense: formatDefense(sys.defense), + actionCost: sys.time?.value || undefined, + heightening, + usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined, + }; } function normalizeSpellcastingEntry( @@ -342,26 +453,31 @@ function normalizeSpellcastingEntry( (s) => (s.system as unknown as SpellSystem).location?.value === entry._id, ); - const byRank = new Map(); - const cantrips: string[] = []; + const byRank = new Map(); + const cantrips: SpellReference[] = []; for (const spell of linkedSpells) { - const { isCantrip, rank, label } = classifySpell(spell); + const ref = normalizeSpell(spell); + const isCantrip = + (spell.system as unknown as SpellSystem).traits?.value?.includes( + "cantrip", + ) ?? false; if (isCantrip) { - cantrips.push(spell.name); + cantrips.push(ref); continue; } + const rank = ref.rank ?? 0; const existing = byRank.get(rank) ?? []; - existing.push(label); + existing.push(ref); byRank.set(rank, existing); } const daily = [...byRank.entries()] .sort(([a], [b]) => b - a) - .map(([rank, spellNames]) => ({ + .map(([rank, spells]) => ({ uses: rank, each: true, - spells: spellNames, + spells, })); return { diff --git a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx index 603fc1b..c95d220 100644 --- a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx +++ b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx @@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest"; import type { Pf2eCreature } from "@initiative/domain"; import { creatureId } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Pf2eStatBlock } from "../pf2e-stat-block.js"; afterEach(cleanup); +const USES_PER_DAY_REGEX = /×3/; +const HEAL_DESCRIPTION_REGEX = /channel positive energy/; const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/; const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/; const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/; @@ -90,9 +93,13 @@ const NAUNET: Pf2eCreature = { name: "Divine Innate Spells", headerText: "DC 25, attack +17", daily: [ - { uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] }, + { + uses: 4, + each: true, + spells: [{ name: "Unfettered Movement (Constant)" }], + }, ], - atWill: ["Detect Magic"], + atWill: [{ name: "Detect Magic" }], }, ], }; @@ -277,4 +284,87 @@ describe("Pf2eStatBlock", () => { expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument(); }); }); + + describe("clickable spells", () => { + const SPELLCASTER: Pf2eCreature = { + ...NAUNET, + id: creatureId("test:spellcaster"), + name: "Spellcaster", + spellcasting: [ + { + name: "Divine Innate Spells", + headerText: "DC 30, attack +20", + atWill: [{ name: "Detect Magic", rank: 1 }], + daily: [ + { + uses: 4, + each: true, + spells: [ + { + name: "Heal", + description: "You channel positive energy to heal.", + rank: 4, + usesPerDay: 3, + }, + { name: "Restoration", rank: 4 }, + ], + }, + ], + }, + ], + }; + + beforeEach(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + media: "(min-width: 1024px)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + it("renders a spell with a description as a clickable button", () => { + renderStatBlock(SPELLCASTER); + expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument(); + }); + + it("renders a spell without description as plain text (not a button)", () => { + renderStatBlock(SPELLCASTER); + expect( + screen.queryByRole("button", { name: "Restoration" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Restoration")).toBeInTheDocument(); + }); + + it("renders usesPerDay as plain text alongside the spell button", () => { + renderStatBlock(SPELLCASTER); + expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument(); + }); + + it("opens the spell popover when a spell button is clicked", async () => { + const user = userEvent.setup(); + renderStatBlock(SPELLCASTER); + await user.click(screen.getByRole("button", { name: "Heal" })); + expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument(); + }); + + it("closes the popover when Escape is pressed", async () => { + const user = userEvent.setup(); + renderStatBlock(SPELLCASTER); + await user.click(screen.getByRole("button", { name: "Heal" })); + expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect( + screen.queryByText(HEAL_DESCRIPTION_REGEX), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/__tests__/spell-detail-popover.test.tsx b/apps/web/src/components/__tests__/spell-detail-popover.test.tsx new file mode 100644 index 0000000..cd92f2f --- /dev/null +++ b/apps/web/src/components/__tests__/spell-detail-popover.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import type { SpellReference } from "@initiative/domain"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SpellDetailPopover } from "../spell-detail-popover.js"; + +afterEach(cleanup); + +const FIREBALL: SpellReference = { + name: "Fireball", + slug: "fireball", + rank: 3, + description: "A spark leaps from your fingertip to the target.", + traits: ["fire", "manipulate"], + traditions: ["arcane", "primal"], + range: "500 feet", + area: "20-foot burst", + defense: "basic Reflex", + actionCost: "2", + heightening: "Heightened (+1) The damage increases by 2d6.", +}; + +const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20); + +const SPARK_LEAPS_REGEX = /spark leaps/; +const HEIGHTENED_REGEX = /Heightened.*2d6/; +const RANGE_REGEX = /500 feet/; +const AREA_REGEX = /20-foot burst/; +const DEFENSE_REGEX = /basic Reflex/; +const NO_DESCRIPTION_REGEX = /No description available/; +const DIALOG_LABEL_REGEX = /Spell details: Fireball/; + +beforeEach(() => { + // Force desktop variant in jsdom + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + configurable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + media: "(min-width: 1024px)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +describe("SpellDetailPopover", () => { + it("renders spell name, rank, traits, and description", () => { + render( + {}} + />, + ); + expect(screen.getByText("Fireball")).toBeInTheDocument(); + expect(screen.getByText("3rd")).toBeInTheDocument(); + expect(screen.getByText("fire")).toBeInTheDocument(); + expect(screen.getByText("manipulate")).toBeInTheDocument(); + expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument(); + }); + + it("renders heightening rules when present", () => { + render( + {}} + />, + ); + expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument(); + }); + + it("renders range, area, and defense", () => { + render( + {}} + />, + ); + expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument(); + expect(screen.getByText(AREA_REGEX)).toBeInTheDocument(); + expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument(); + }); + + it("calls onClose when Escape is pressed", () => { + const onClose = vi.fn(); + render( + , + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows placeholder when description is missing", () => { + const spell: SpellReference = { name: "Mystery", rank: 1 }; + render( + {}} + />, + ); + expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument(); + }); + + it("renders the action cost as an icon when it is a numeric action count", () => { + render( + {}} + />, + ); + // Action cost "2" renders as an SVG ActivityIcon (portaled to body) + const dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("svg")).toBeInTheDocument(); + }); + + it("renders non-numeric action cost as text", () => { + const spell: SpellReference = { + ...FIREBALL, + actionCost: "1 minute", + }; + render( + {}} + />, + ); + expect(screen.getByText("1 minute")).toBeInTheDocument(); + }); + + it("uses the dialog role with the spell name as label", () => { + render( + {}} + />, + ); + expect( + screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/__tests__/stat-block.test.tsx b/apps/web/src/components/__tests__/stat-block.test.tsx index b2bab5f..07ca001 100644 --- a/apps/web/src/components/__tests__/stat-block.test.tsx +++ b/apps/web/src/components/__tests__/stat-block.test.tsx @@ -111,9 +111,15 @@ const DRAGON: Creature = { { name: "Innate Spellcasting", headerText: "The dragon's spellcasting ability is Charisma.", - atWill: ["detect magic", "suggestion"], - daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }], - restLong: [{ uses: 1, each: false, spells: ["wish"] }], + atWill: [{ name: "detect magic" }, { name: "suggestion" }], + daily: [ + { + uses: 3, + each: true, + spells: [{ name: "fireball" }, { name: "wall of fire" }], + }, + ], + restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }], }, ], }; diff --git a/apps/web/src/components/dnd-stat-block.tsx b/apps/web/src/components/dnd-stat-block.tsx index ad98fbf..ea759b8 100644 --- a/apps/web/src/components/dnd-stat-block.tsx +++ b/apps/web/src/components/dnd-stat-block.tsx @@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly) { {sc.atWill && sc.atWill.length > 0 && (
At Will:{" "} - {sc.atWill.join(", ")} + {sc.atWill.map((s) => s.name).join(", ")}
)} {sc.daily?.map((d) => ( @@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly) { {d.uses}/day {d.each ? " each" : ""}: {" "} - {d.spells.join(", ")} + {d.spells.map((s) => s.name).join(", ")} ))} {sc.restLong?.map((d) => ( @@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly) { {d.uses}/long rest {d.each ? " each" : ""}: {" "} - {d.spells.join(", ")} + {d.spells.map((s) => s.name).join(", ")} ))} diff --git a/apps/web/src/components/pf2e-stat-block.tsx b/apps/web/src/components/pf2e-stat-block.tsx index e202a82..2ec8194 100644 --- a/apps/web/src/components/pf2e-stat-block.tsx +++ b/apps/web/src/components/pf2e-stat-block.tsx @@ -1,5 +1,7 @@ -import type { Pf2eCreature } from "@initiative/domain"; +import type { Pf2eCreature, SpellReference } from "@initiative/domain"; import { formatInitiativeModifier } from "@initiative/domain"; +import { useCallback, useRef, useState } from "react"; +import { SpellDetailPopover } from "./spell-detail-popover.js"; import { PropertyLine, SectionDivider, @@ -34,7 +36,83 @@ function formatMod(mod: number): string { return mod >= 0 ? `+${mod}` : `${mod}`; } +interface SpellLinkProps { + readonly spell: SpellReference; + readonly onOpen: (spell: SpellReference, rect: DOMRect) => void; +} + +function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) { + if (count === undefined || count <= 1) return null; + return (×{count}); +} + +function SpellLink({ spell, onOpen }: Readonly) { + const ref = useRef(null); + const handleClick = useCallback(() => { + if (!spell.description) return; + const rect = ref.current?.getBoundingClientRect(); + if (rect) onOpen(spell, rect); + }, [spell, onOpen]); + + if (!spell.description) { + return ( + + {spell.name} + + + ); + } + + return ( + <> + + + + ); +} + +interface SpellListLineProps { + readonly label: string; + readonly spells: readonly SpellReference[]; + readonly onOpen: (spell: SpellReference, rect: DOMRect) => void; +} + +function SpellListLine({ + label, + spells, + onOpen, +}: Readonly) { + return ( +
+ {label}:{" "} + {spells.map((spell, i) => ( + + {i > 0 ? ", " : ""} + + + ))} +
+ ); +} + export function Pf2eStatBlock({ creature }: Readonly) { + const [openSpell, setOpenSpell] = useState<{ + spell: SpellReference; + rect: DOMRect; + } | null>(null); + const handleOpenSpell = useCallback( + (spell: SpellReference, rect: DOMRect) => setOpenSpell({ spell, rect }), + [], + ); + const handleCloseSpell = useCallback(() => setOpenSpell(null), []); + const abilityEntries = [ { label: "Str", mod: creature.abilityMods.str }, { label: "Dex", mod: creature.abilityMods.dex }, @@ -152,23 +230,31 @@ export function Pf2eStatBlock({ creature }: Readonly) { {sc.headerText} {sc.daily?.map((d) => ( -
- - {d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}: - {" "} - {d.spells.join(", ")} -
+ ))} {sc.atWill && sc.atWill.length > 0 && ( -
- Cantrips:{" "} - {sc.atWill.join(", ")} -
+ )} ))} )} + {openSpell ? ( + + ) : null} ); } diff --git a/apps/web/src/components/spell-detail-popover.tsx b/apps/web/src/components/spell-detail-popover.tsx new file mode 100644 index 0000000..d0bc487 --- /dev/null +++ b/apps/web/src/components/spell-detail-popover.tsx @@ -0,0 +1,296 @@ +import type { ActivityCost, SpellReference } from "@initiative/domain"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useClickOutside } from "../hooks/use-click-outside.js"; +import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js"; +import { cn } from "../lib/utils.js"; +import { ActivityIcon } from "./stat-block-parts.js"; + +interface SpellDetailPopoverProps { + readonly spell: SpellReference; + readonly anchorRect: DOMRect; + readonly onClose: () => void; +} + +const RANK_LABELS = [ + "Cantrip", + "1st", + "2nd", + "3rd", + "4th", + "5th", + "6th", + "7th", + "8th", + "9th", + "10th", +]; + +function formatRank(rank: number | undefined): string { + if (rank === undefined) return ""; + return RANK_LABELS[rank] ?? `Rank ${rank}`; +} + +function parseActionCost(cost: string): ActivityCost | null { + if (cost === "free") return { number: 1, unit: "free" }; + if (cost === "reaction") return { number: 1, unit: "reaction" }; + const n = Number(cost); + if (n >= 1 && n <= 3) return { number: n, unit: "action" }; + return null; +} + +function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) { + if (!cost) return null; + const activity = parseActionCost(cost); + if (activity) { + return ( + + + + ); + } + return {cost}; +} + +function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) { + return ( +
+

{spell.name}

+ +
+ ); +} + +function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) { + if (traits.length === 0) return null; + return ( +
+ {traits.map((t) => ( + + {t} + + ))} +
+ ); +} + +function LabeledValue({ + label, + value, +}: Readonly<{ label: string; value: string }>) { + return ( + <> + {label} {value} + + ); +} + +function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) { + const items: { label: string; value: string }[] = []; + if (spell.range) items.push({ label: "Range", value: spell.range }); + if (spell.target) items.push({ label: "Target", value: spell.target }); + if (spell.area) items.push({ label: "Area", value: spell.area }); + + if (items.length === 0) return null; + return ( +
+ {items.map((item, i) => ( + + {i > 0 ? "; " : ""} + + + ))} +
+ ); +} + +function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) { + const hasTraditions = + spell.traditions !== undefined && spell.traditions.length > 0; + return ( +
+ {spell.rank === undefined ? null : ( +
+ {formatRank(spell.rank)} + {hasTraditions ? ( + + {" "} + ({spell.traditions?.join(", ")}) + + ) : null} +
+ )} + + {spell.duration ? ( +
+ +
+ ) : null} + {spell.defense ? ( +
+ +
+ ) : null} +
+ ); +} + +const SAVE_OUTCOME_REGEX = + /(Critical Success|Critical Failure|Success|Failure)/g; + +function SpellDescription({ text }: Readonly<{ text: string }>) { + const parts = text.split(SAVE_OUTCOME_REGEX); + const elements: React.ReactNode[] = []; + let offset = 0; + for (const part of parts) { + if (SAVE_OUTCOME_REGEX.test(part)) { + elements.push({part}); + } else if (part) { + elements.push({part}); + } + offset += part.length; + } + return

{elements}

; +} + +function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) { + return ( +
+ + + + {spell.description ? ( + + ) : ( +

+ No description available. +

+ )} + {spell.heightening ? ( +

+ {spell.heightening} +

+ ) : null} +
+ ); +} + +function DesktopPopover({ + spell, + anchorRect, + onClose, +}: Readonly) { + const ref = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const popover = el.getBoundingClientRect(); + const vw = document.documentElement.clientWidth; + const vh = document.documentElement.clientHeight; + // Prefer placement to the LEFT of the anchor (panel is on the right edge) + let left = anchorRect.left - popover.width - 8; + if (left < 8) { + left = anchorRect.right + 8; + } + if (left + popover.width > vw - 8) { + left = vw - popover.width - 8; + } + let top = anchorRect.top; + if (top + popover.height > vh - 8) { + top = vh - popover.height - 8; + } + if (top < 8) top = 8; + setPos({ top, left }); + }, [anchorRect]); + + useClickOutside(ref, onClose); + + return ( +
+ +
+ ); +} + +function MobileSheet({ + spell, + onClose, +}: Readonly<{ spell: SpellReference; onClose: () => void }>) { + const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+