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 68ca570..8defbfc 100644 --- a/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts +++ b/apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts @@ -541,6 +541,288 @@ describe("normalizeFoundryCreature", () => { }); }); + describe("equipment normalization", () => { + it("normalizes a weapon with traits and description", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "w1", + name: "Flaming Longsword", + type: "weapon", + system: { + level: { value: 5 }, + traits: { value: ["magical", "fire"] }, + description: { + value: "

This sword blazes with fire.

", + }, + }, + }, + ], + }), + ); + expect(creature.equipment).toHaveLength(1); + const item = creature.equipment?.[0]; + expect(item?.name).toBe("Flaming Longsword"); + expect(item?.level).toBe(5); + expect(item?.traits).toEqual(["magical", "fire"]); + expect(item?.description).toBe("This sword blazes with fire."); + }); + + it("normalizes a consumable potion with description", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "c1", + name: "Healing Potion (Moderate)", + type: "consumable", + system: { + level: { value: 6 }, + traits: { value: ["consumable", "healing", "magical"] }, + description: { + value: "

Restores 3d8+10 Hit Points.

", + }, + category: "potion", + }, + }, + ], + }), + ); + expect(creature.equipment).toHaveLength(1); + const item = creature.equipment?.[0]; + expect(item?.name).toBe("Healing Potion (Moderate)"); + expect(item?.category).toBe("potion"); + expect(item?.description).toBe("Restores 3d8+10 Hit Points."); + }); + + it("extracts scroll embedded spell name and rank", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "s1", + name: "Scroll of Teleport (Rank 6)", + type: "consumable", + system: { + level: { value: 11 }, + traits: { value: ["consumable", "magical", "scroll"] }, + description: { value: "

A scroll.

" }, + category: "scroll", + spell: { + name: "Teleport", + system: { level: { value: 6 } }, + }, + }, + }, + ], + }), + ); + const item = creature.equipment?.[0]; + expect(item?.spellName).toBe("Teleport"); + expect(item?.spellRank).toBe(6); + }); + + it("extracts wand embedded spell name and rank", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "w1", + name: "Wand of Dispel Magic (Rank 2)", + type: "consumable", + system: { + level: { value: 5 }, + traits: { value: ["consumable", "magical", "wand"] }, + description: { value: "

A wand.

" }, + category: "wand", + spell: { + name: "Dispel Magic", + system: { level: { value: 2 } }, + }, + }, + }, + ], + }), + ); + const item = creature.equipment?.[0]; + expect(item?.spellName).toBe("Dispel Magic"); + expect(item?.spellRank).toBe(2); + }); + + it("filters magical equipment into equipment field", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "e1", + name: "Ring of Energy Resistance (Fire)", + type: "equipment", + system: { + level: { value: 6 }, + traits: { value: ["magical", "invested"] }, + description: { + value: "

Grants fire resistance 5.

", + }, + }, + }, + ], + }), + ); + expect(creature.equipment).toHaveLength(1); + expect(creature.equipment?.[0]?.name).toBe( + "Ring of Energy Resistance (Fire)", + ); + expect(creature.items).toBeUndefined(); + }); + + it("filters mundane items into items string", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "w1", + name: "Longsword", + type: "weapon", + system: { + level: { value: 0 }, + traits: { value: [] }, + description: { value: "" }, + }, + }, + { + _id: "a1", + name: "Leather Armor", + type: "armor", + system: { + level: { value: 0 }, + traits: { value: [] }, + description: { value: "" }, + }, + }, + ], + }), + ); + expect(creature.items).toBe("Longsword, Leather Armor"); + expect(creature.equipment).toBeUndefined(); + }); + + it("omits equipment when no detailed items exist", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "w1", + name: "Dagger", + type: "weapon", + system: { + level: { value: 0 }, + traits: { value: [] }, + description: { value: "" }, + }, + }, + ], + }), + ); + expect(creature.equipment).toBeUndefined(); + }); + + it("omits items when no mundane items exist", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "c1", + name: "Giant Wasp Venom", + type: "consumable", + system: { + level: { value: 7 }, + traits: { value: ["consumable", "poison"] }, + description: { + value: "

A deadly poison.

", + }, + category: "poison", + }, + }, + ], + }), + ); + expect(creature.items).toBeUndefined(); + expect(creature.equipment).toHaveLength(1); + }); + + it("includes armor with special material in equipment", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "a1", + name: "Adamantine Full Plate", + type: "armor", + system: { + level: { value: 0 }, + traits: { value: [] }, + description: { + value: "

Full plate made of adamantine.

", + }, + material: { type: "adamantine", grade: "standard" }, + }, + }, + ], + }), + ); + expect(creature.equipment).toHaveLength(1); + expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate"); + }); + + it("excludes mundane armor from equipment (goes to items)", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "a1", + name: "Chain Mail", + type: "armor", + system: { + level: { value: 0 }, + traits: { value: [] }, + description: { value: "" }, + }, + }, + ], + }), + ); + expect(creature.equipment).toBeUndefined(); + expect(creature.items).toBe("Chain Mail"); + }); + + it("strips Foundry HTML tags from equipment descriptions", () => { + const creature = normalizeFoundryCreature( + minimalCreature({ + items: [ + { + _id: "c1", + name: "Potion of Speed", + type: "consumable", + system: { + level: { value: 10 }, + traits: { value: ["consumable", "magical"] }, + description: { + value: + "

Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.

", + }, + category: "potion", + }, + }, + ], + }), + ); + const desc = creature.equipment?.[0]?.description; + expect(desc).toBe("Gain quickened for 1 minute."); + expect(desc).not.toContain("@UUID"); + }); + }); + describe("spellcasting normalization", () => { it("normalizes prepared spells by rank", () => { const creature = normalizeFoundryCreature( diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts index cc0001b..8c9152f 100644 --- a/apps/web/src/adapters/bestiary-cache.ts +++ b/apps/web/src/adapters/bestiary-cache.ts @@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb"; const DB_NAME = "initiative-bestiary"; const STORE_NAME = "sources"; -// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared -const DB_VERSION = 6; +// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared +const DB_VERSION = 7; 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 a211e24..47d39bb 100644 --- a/apps/web/src/adapters/pf2e-bestiary-adapter.ts +++ b/apps/web/src/adapters/pf2e-bestiary-adapter.ts @@ -1,5 +1,6 @@ import type { CreatureId, + EquipmentItem, Pf2eCreature, SpellcastingBlock, SpellReference, @@ -114,6 +115,73 @@ interface SpellSystem { >; } +interface ConsumableSystem { + level?: { value: number }; + traits?: { value: string[] }; + description?: { value: string }; + category?: string; + spell?: { + name: string; + system?: { level?: { value: number } }; + } | null; +} + +const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]); + +/** Items shown in the Equipment section with popovers. */ +function isDetailedEquipment(item: RawFoundryItem): boolean { + if (!EQUIPMENT_TYPES.has(item.type)) return false; + const sys = item.system; + const level = (sys.level as { value: number } | undefined)?.value ?? 0; + const traits = (sys.traits as { value: string[] } | undefined)?.value ?? []; + // All consumables are tactically relevant (potions, scrolls, poisons, etc.) + if (item.type === "consumable") return true; + // Magical/invested items + if (traits.includes("magical") || traits.includes("invested")) return true; + // Special material armor/equipment + const material = sys.material as { type: string | null } | undefined; + if (material?.type) return true; + // Higher-level items + if (level > 0) return true; + return false; +} + +/** Items shown on the "Items" line as plain names. */ +function isMundaneItem(item: RawFoundryItem): boolean { + return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item); +} + +function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem { + const sys = item.system; + const level = (sys.level as { value: number } | undefined)?.value ?? 0; + const traits = (sys.traits as { value: string[] } | undefined)?.value; + const rawDesc = (sys.description as { value: string } | undefined)?.value; + const description = rawDesc + ? stripFoundryTags(rawDesc) || undefined + : undefined; + const category = sys.category as string | undefined; + + let spellName: string | undefined; + let spellRank: number | undefined; + if (item.type === "consumable") { + const spell = (sys as unknown as ConsumableSystem).spell; + if (spell) { + spellName = spell.name; + spellRank = spell.system?.level?.value; + } + } + + return { + name: item.name, + level, + category: category || undefined, + traits: traits && traits.length > 0 ? traits : undefined, + description, + spellName, + spellRank, + }; +} + const SIZE_MAP: Record = { tiny: "tiny", sm: "small", @@ -633,6 +701,14 @@ export function normalizeFoundryCreature( ), abilitiesBot: orUndefined(actionsByCategory(items, "offensive")), spellcasting: orUndefined(normalizeSpellcasting(items)), + items: + items + .filter(isMundaneItem) + .map((i) => i.name) + .join(", ") || undefined, + equipment: orUndefined( + items.filter(isDetailedEquipment).map(normalizeEquipmentItem), + ), }; } diff --git a/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx b/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx new file mode 100644 index 0000000..888e29f --- /dev/null +++ b/apps/web/src/components/__tests__/equipment-detail-popover.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import type { EquipmentItem } from "@initiative/domain"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EquipmentDetailPopover } from "../equipment-detail-popover.js"; + +afterEach(cleanup); + +const POISON: EquipmentItem = { + name: "Giant Wasp Venom", + level: 7, + category: "poison", + traits: ["consumable", "poison", "injury"], + description: "A deadly poison extracted from giant wasps.", +}; + +const SCROLL: EquipmentItem = { + name: "Scroll of Teleport", + level: 11, + category: "scroll", + traits: ["consumable", "magical", "scroll"], + description: "A scroll containing Teleport.", + spellName: "Teleport", + spellRank: 6, +}; + +const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20); +const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/; +const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/; + +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(), + })), + }); +}); + +describe("EquipmentDetailPopover", () => { + it("renders item name, level, traits, and description", () => { + render( + {}} + />, + ); + expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument(); + expect(screen.getByText("7")).toBeInTheDocument(); + expect(screen.getByText("consumable")).toBeInTheDocument(); + expect(screen.getByText("poison")).toBeInTheDocument(); + expect(screen.getByText("injury")).toBeInTheDocument(); + expect( + screen.getByText("A deadly poison extracted from giant wasps."), + ).toBeInTheDocument(); + }); + + it("renders scroll/wand spell info", () => { + render( + {}} + />, + ); + expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument(); + }); + + it("calls onClose when Escape is pressed", () => { + const onClose = vi.fn(); + render( + , + ); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("uses the dialog role with the item name as label", () => { + render( + {}} + />, + ); + expect( + screen.getByRole("dialog", { + name: DIALOG_LABEL_REGEX, + }), + ).toBeInTheDocument(); + }); +}); 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 59f6cb0..100f5f2 100644 --- a/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx +++ b/apps/web/src/components/__tests__/pf2e-stat-block.test.tsx @@ -31,6 +31,7 @@ const RK_DC_25_REGEX = /DC 25/; const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/; const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/; const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/; +const SCROLL_NAME_REGEX = /Scroll of Teleport/; const GOBLIN_WARRIOR: Pf2eCreature = { system: "pf2e", @@ -338,6 +339,79 @@ describe("Pf2eStatBlock", () => { }); }); + describe("equipment section", () => { + const CREATURE_WITH_EQUIPMENT: Pf2eCreature = { + ...GOBLIN_WARRIOR, + id: creatureId("test:equipped"), + name: "Equipped NPC", + items: "longsword, leather armor", + equipment: [ + { + name: "Giant Wasp Venom", + level: 7, + category: "poison", + traits: ["consumable", "poison"], + description: "A deadly poison extracted from giant wasps.", + }, + { + name: "Scroll of Teleport", + level: 11, + category: "scroll", + traits: ["consumable", "magical", "scroll"], + description: "A scroll containing Teleport.", + spellName: "Teleport", + spellRank: 6, + }, + { + name: "Plain Talisman", + level: 1, + traits: ["magical"], + }, + ], + }; + + it("renders Equipment section with item names", () => { + renderStatBlock(CREATURE_WITH_EQUIPMENT); + expect( + screen.getByRole("heading", { name: "Equipment" }), + ).toBeInTheDocument(); + expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument(); + }); + + it("renders scroll name as-is from Foundry data", () => { + renderStatBlock(CREATURE_WITH_EQUIPMENT); + expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument(); + }); + + it("does not render Equipment section when creature has no equipment", () => { + renderStatBlock(GOBLIN_WARRIOR); + expect( + screen.queryByRole("heading", { name: "Equipment" }), + ).not.toBeInTheDocument(); + }); + + it("renders equipment items with descriptions as clickable buttons", () => { + renderStatBlock(CREATURE_WITH_EQUIPMENT); + expect( + screen.getByRole("button", { name: "Giant Wasp Venom" }), + ).toBeInTheDocument(); + }); + + it("renders equipment items without descriptions as plain text", () => { + renderStatBlock(CREATURE_WITH_EQUIPMENT); + expect( + screen.queryByRole("button", { name: "Plain Talisman" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Plain Talisman")).toBeInTheDocument(); + }); + + it("renders Items line with mundane item names", () => { + renderStatBlock(CREATURE_WITH_EQUIPMENT); + expect(screen.getByText("Items")).toBeInTheDocument(); + expect(screen.getByText("longsword, leather armor")).toBeInTheDocument(); + }); + }); + describe("clickable spells", () => { const SPELLCASTER: Pf2eCreature = { ...NAUNET, diff --git a/apps/web/src/components/detail-popover.tsx b/apps/web/src/components/detail-popover.tsx new file mode 100644 index 0000000..33f45f0 --- /dev/null +++ b/apps/web/src/components/detail-popover.tsx @@ -0,0 +1,141 @@ +import type { ReactNode } from "react"; +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"; + +interface DetailPopoverProps { + readonly anchorRect: DOMRect; + readonly onClose: () => void; + readonly ariaLabel: string; + readonly children: ReactNode; +} + +function DesktopPanel({ + anchorRect, + onClose, + ariaLabel, + children, +}: 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 ( +
+ {children} +
+ ); +} + +function MobileSheet({ + onClose, + ariaLabel, + children, +}: Readonly>) { + 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 ( +
+ + ); +} + export function Pf2eStatBlock({ creature }: Readonly) { const [openSpell, setOpenSpell] = useState<{ spell: SpellReference; @@ -112,6 +146,15 @@ export function Pf2eStatBlock({ creature }: Readonly) { [], ); const handleCloseSpell = useCallback(() => setOpenSpell(null), []); + const [openEquipment, setOpenEquipment] = useState<{ + item: EquipmentItem; + rect: DOMRect; + } | null>(null); + const handleOpenEquipment = useCallback( + (item: EquipmentItem, rect: DOMRect) => setOpenEquipment({ item, rect }), + [], + ); + const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []); const rk = recallKnowledge(creature.level, creature.traits); @@ -256,6 +299,19 @@ export function Pf2eStatBlock({ creature }: Readonly) { ))} )} + {creature.equipment && creature.equipment.length > 0 && ( + <> + +

Equipment

+
+ {creature.equipment.map((item) => ( +
+ +
+ ))} +
+ + )} {openSpell ? ( ) { onClose={handleCloseSpell} /> ) : null} + {openEquipment ? ( + + ) : null}
); } diff --git a/apps/web/src/components/spell-detail-popover.tsx b/apps/web/src/components/spell-detail-popover.tsx index d0bc487..0bd31ff 100644 --- a/apps/web/src/components/spell-detail-popover.tsx +++ b/apps/web/src/components/spell-detail-popover.tsx @@ -1,9 +1,5 @@ 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 { DetailPopover } from "./detail-popover.js"; import { ActivityIcon } from "./stat-block-parts.js"; interface SpellDetailPopoverProps { @@ -178,119 +174,18 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) { ); } -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 ( -
-