Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e44e56b09b | ||
|
|
e2e8297c95 |
@@ -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: "<p>This sword blazes with fire.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
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: "<p>Restores 3d8+10 Hit Points.</p>",
|
||||
},
|
||||
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: "<p>A scroll.</p>" },
|
||||
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: "<p>A wand.</p>" },
|
||||
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: "<p>Grants fire resistance 5.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
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: "<p>A deadly poison.</p>",
|
||||
},
|
||||
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: "<p>Full plate made of adamantine.</p>",
|
||||
},
|
||||
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:
|
||||
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
|
||||
},
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<EquipmentDetailPopover
|
||||
item={POISON}
|
||||
anchorRect={ANCHOR}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<EquipmentDetailPopover
|
||||
item={SCROLL}
|
||||
anchorRect={ANCHOR}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<EquipmentDetailPopover
|
||||
item={POISON}
|
||||
anchorRect={ANCHOR}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the dialog role with the item name as label", () => {
|
||||
render(
|
||||
<EquipmentDetailPopover
|
||||
item={POISON}
|
||||
anchorRect={ANCHOR}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", {
|
||||
name: DIALOG_LABEL_REGEX,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,13 @@ const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||
const CANTRIPS_REGEX = /Cantrips:/;
|
||||
const AC_REGEX = /16/;
|
||||
const RK_DC_13_REGEX = /DC 13/;
|
||||
const RK_DC_15_REGEX = /DC 15/;
|
||||
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",
|
||||
@@ -154,6 +161,53 @@ describe("Pf2eStatBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recall knowledge", () => {
|
||||
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for uncommon rarity", () => {
|
||||
const uncommonCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["uncommon", "small", "humanoid"],
|
||||
};
|
||||
renderStatBlock(uncommonCreature);
|
||||
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for rare rarity", () => {
|
||||
const rareCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
level: 5,
|
||||
traits: ["rare", "medium", "undead"],
|
||||
};
|
||||
renderStatBlock(rareCreature);
|
||||
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows multiple skills for types with dual skill mapping", () => {
|
||||
const beastCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "beast"],
|
||||
};
|
||||
renderStatBlock(beastCreature);
|
||||
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||
const noTypeCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "goblin"],
|
||||
};
|
||||
renderStatBlock(noTypeCreature);
|
||||
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("perception and senses", () => {
|
||||
it("renders perception modifier and senses", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
@@ -285,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,
|
||||
|
||||
141
apps/web/src/components/detail-popover.tsx
Normal file
141
apps/web/src/components/detail-popover.tsx
Normal file
@@ -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<DetailPopoverProps>) {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSheet({
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||
!isSwiping && "animate-slide-in-bottom",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailPopover({
|
||||
anchorRect,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<DetailPopoverProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Portal to document.body to escape any CSS transforms on ancestors
|
||||
// (the side panel uses translate-x for collapse animation, which would
|
||||
// otherwise become the containing block for fixed-positioned children).
|
||||
const content = isDesktop ? (
|
||||
<DesktopPanel
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</DesktopPanel>
|
||||
) : (
|
||||
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
||||
{children}
|
||||
</MobileSheet>
|
||||
);
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
70
apps/web/src/components/equipment-detail-popover.tsx
Normal file
70
apps/web/src/components/equipment-detail-popover.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { EquipmentItem } from "@initiative/domain";
|
||||
import { DetailPopover } from "./detail-popover.js";
|
||||
|
||||
interface EquipmentDetailPopoverProps {
|
||||
readonly item: EquipmentItem;
|
||||
readonly anchorRect: DOMRect;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<h3 className="font-bold text-lg text-stat-heading">{item.name}</h3>
|
||||
{item.traits && item.traits.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.traits.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>
|
||||
<span className="font-semibold">Level</span> {item.level}
|
||||
</div>
|
||||
{item.category ? (
|
||||
<div>
|
||||
<span className="font-semibold">Category</span>{" "}
|
||||
{item.category.charAt(0).toUpperCase() + item.category.slice(1)}
|
||||
</div>
|
||||
) : null}
|
||||
{item.spellName ? (
|
||||
<div>
|
||||
<span className="font-semibold">Spell</span> {item.spellName}
|
||||
{item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<p className="whitespace-pre-line text-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">
|
||||
No description available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentDetailPopover({
|
||||
item,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: Readonly<EquipmentDetailPopoverProps>) {
|
||||
return (
|
||||
<DetailPopover
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={`Equipment details: ${item.name}`}
|
||||
>
|
||||
<EquipmentDetailContent item={item} />
|
||||
</DetailPopover>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
||||
import { formatInitiativeModifier } from "@initiative/domain";
|
||||
import type {
|
||||
EquipmentItem,
|
||||
Pf2eCreature,
|
||||
SpellReference,
|
||||
} from "@initiative/domain";
|
||||
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||
import {
|
||||
PropertyLine,
|
||||
@@ -102,6 +107,35 @@ function SpellListLine({
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentLinkProps {
|
||||
readonly item: EquipmentItem;
|
||||
readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const handleClick = useCallback(() => {
|
||||
if (!item.description) return;
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (rect) onOpen(item, rect);
|
||||
}, [item, onOpen]);
|
||||
|
||||
if (!item.description) {
|
||||
return <span>{item.name}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
const [openSpell, setOpenSpell] = useState<{
|
||||
spell: SpellReference;
|
||||
@@ -112,6 +146,17 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
[],
|
||||
);
|
||||
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);
|
||||
|
||||
const abilityEntries = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
@@ -147,6 +192,12 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
{rk && (
|
||||
<p className="mt-1 text-sm">
|
||||
<span className="font-semibold">Recall Knowledge</span> DC {rk.dc}{" "}
|
||||
• {capitalize(rk.type)} ({rk.skills.join("/")})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
@@ -248,6 +299,19 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{creature.equipment && creature.equipment.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
{creature.equipment.map((item) => (
|
||||
<div key={item.name}>
|
||||
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{openSpell ? (
|
||||
<SpellDetailPopover
|
||||
spell={openSpell.spell}
|
||||
@@ -255,6 +319,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
onClose={handleCloseSpell}
|
||||
/>
|
||||
) : null}
|
||||
{openEquipment ? (
|
||||
<EquipmentDetailPopover
|
||||
item={openEquipment.item}
|
||||
anchorRect={openEquipment.rect}
|
||||
onClose={handleCloseEquipment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SpellDetailPopoverProps>) {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||
role="dialog"
|
||||
aria-label={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<SpellDetailContent spell={spell} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-label="Close spell details"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||
!isSwiping && "animate-slide-in-bottom",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
role="dialog"
|
||||
aria-label={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||
<SpellDetailContent spell={spell} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellDetailPopover({
|
||||
spell,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: Readonly<SpellDetailPopoverProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
return (
|
||||
<DetailPopover
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<SpellDetailContent spell={spell} />
|
||||
</DetailPopover>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Portal to document.body to escape any CSS transforms on ancestors
|
||||
// (the side panel uses translate-x for collapse animation, which would
|
||||
// otherwise become the containing block for fixed-positioned children).
|
||||
const content = isDesktop ? (
|
||||
<DesktopPopover spell={spell} anchorRect={anchorRect} onClose={onClose} />
|
||||
) : (
|
||||
<MobileSheet spell={spell} onClose={onClose} />
|
||||
);
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
99
packages/domain/src/__tests__/recall-knowledge.test.ts
Normal file
99
packages/domain/src/__tests__/recall-knowledge.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recallKnowledge } from "../recall-knowledge.js";
|
||||
|
||||
describe("recallKnowledge", () => {
|
||||
it("returns null when no type trait is recognized", () => {
|
||||
expect(recallKnowledge(5, ["small", "goblin"])).toBeNull();
|
||||
});
|
||||
|
||||
it("calculates DC for a common creature from the DC-by-level table", () => {
|
||||
const result = recallKnowledge(5, ["humanoid"]);
|
||||
expect(result).toEqual({ dc: 20, type: "humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level -1", () => {
|
||||
const result = recallKnowledge(-1, ["humanoid"]);
|
||||
expect(result).toEqual({ dc: 13, type: "humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level 0", () => {
|
||||
const result = recallKnowledge(0, ["animal"]);
|
||||
expect(result).toEqual({ dc: 14, type: "animal", skills: ["Nature"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level 25 (max table entry)", () => {
|
||||
const result = recallKnowledge(25, ["dragon"]);
|
||||
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("clamps DC for levels beyond the table", () => {
|
||||
const result = recallKnowledge(30, ["dragon"]);
|
||||
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("adjusts DC for uncommon rarity (+2)", () => {
|
||||
const result = recallKnowledge(5, ["uncommon", "medium", "undead"]);
|
||||
expect(result).toEqual({
|
||||
dc: 22,
|
||||
type: "undead",
|
||||
skills: ["Religion"],
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts DC for rare rarity (+5)", () => {
|
||||
const result = recallKnowledge(5, ["rare", "large", "dragon"]);
|
||||
expect(result).toEqual({ dc: 25, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("adjusts DC for unique rarity (+10)", () => {
|
||||
const result = recallKnowledge(5, ["unique", "medium", "humanoid"]);
|
||||
expect(result).toEqual({
|
||||
dc: 30,
|
||||
type: "humanoid",
|
||||
skills: ["Society"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns multiple skills for beast type", () => {
|
||||
const result = recallKnowledge(3, ["beast"]);
|
||||
expect(result).toEqual({
|
||||
dc: 18,
|
||||
type: "beast",
|
||||
skills: ["Arcana", "Nature"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns multiple skills for construct type", () => {
|
||||
const result = recallKnowledge(1, ["construct"]);
|
||||
expect(result).toEqual({
|
||||
dc: 15,
|
||||
type: "construct",
|
||||
skills: ["Arcana", "Crafting"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches type traits case-insensitively", () => {
|
||||
const result = recallKnowledge(5, ["Humanoid"]);
|
||||
expect(result).toEqual({ dc: 20, type: "Humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("uses the first matching type trait when multiple are present", () => {
|
||||
const result = recallKnowledge(7, ["large", "monitor", "protean"]);
|
||||
expect(result).toEqual({
|
||||
dc: 23,
|
||||
type: "monitor",
|
||||
skills: ["Religion"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves original trait casing in the returned type", () => {
|
||||
const result = recallKnowledge(1, ["Fey"]);
|
||||
expect(result?.type).toBe("Fey");
|
||||
});
|
||||
|
||||
it("ignores common rarity (no adjustment)", () => {
|
||||
// "common" is not included in traits by the normalization pipeline
|
||||
const result = recallKnowledge(5, ["medium", "humanoid"]);
|
||||
expect(result?.dc).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,19 @@ export interface SpellReference {
|
||||
readonly usesPerDay?: number;
|
||||
}
|
||||
|
||||
/** A carried equipment item on a PF2e creature (weapon, consumable, magic item, etc.). */
|
||||
export interface EquipmentItem {
|
||||
readonly name: string;
|
||||
readonly level: number;
|
||||
readonly category?: string;
|
||||
readonly traits?: readonly string[];
|
||||
readonly description?: string;
|
||||
/** For scrolls/wands: the embedded spell name. */
|
||||
readonly spellName?: string;
|
||||
/** For scrolls/wands: the embedded spell rank. */
|
||||
readonly spellRank?: number;
|
||||
}
|
||||
|
||||
export interface DailySpells {
|
||||
readonly uses: number;
|
||||
readonly each: boolean;
|
||||
@@ -201,6 +214,7 @@ export interface Pf2eCreature {
|
||||
readonly abilitiesMid?: readonly TraitBlock[];
|
||||
readonly abilitiesBot?: readonly TraitBlock[];
|
||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||
readonly equipment?: readonly EquipmentItem[];
|
||||
}
|
||||
|
||||
export type AnyCreature = Creature | Pf2eCreature;
|
||||
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
type CreatureId,
|
||||
creatureId,
|
||||
type DailySpells,
|
||||
type EquipmentItem,
|
||||
type LegendaryBlock,
|
||||
type Pf2eBestiaryIndex,
|
||||
type Pf2eBestiaryIndexEntry,
|
||||
@@ -108,6 +109,10 @@ export {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RecallKnowledge,
|
||||
recallKnowledge,
|
||||
} from "./recall-knowledge.js";
|
||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||
export {
|
||||
|
||||
118
packages/domain/src/recall-knowledge.ts
Normal file
118
packages/domain/src/recall-knowledge.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PF2e Recall Knowledge DC calculation and type-to-skill mapping.
|
||||
*
|
||||
* DC is derived from creature level using the standard DC-by-level table
|
||||
* (Player Core / GM Core), adjusted for rarity.
|
||||
*/
|
||||
|
||||
/** Standard DC-by-level table from PF2e GM Core. Index = level + 1 (level -1 → index 0). */
|
||||
const DC_BY_LEVEL: readonly number[] = [
|
||||
13, // level -1
|
||||
14, // level 0
|
||||
15, // level 1
|
||||
16, // level 2
|
||||
18, // level 3
|
||||
19, // level 4
|
||||
20, // level 5
|
||||
22, // level 6
|
||||
23, // level 7
|
||||
24, // level 8
|
||||
26, // level 9
|
||||
27, // level 10
|
||||
28, // level 11
|
||||
30, // level 12
|
||||
31, // level 13
|
||||
32, // level 14
|
||||
34, // level 15
|
||||
35, // level 16
|
||||
36, // level 17
|
||||
38, // level 18
|
||||
39, // level 19
|
||||
40, // level 20
|
||||
42, // level 21
|
||||
44, // level 22
|
||||
46, // level 23
|
||||
48, // level 24
|
||||
50, // level 25
|
||||
];
|
||||
|
||||
const RARITY_ADJUSTMENT: Readonly<Record<string, number>> = {
|
||||
uncommon: 2,
|
||||
rare: 5,
|
||||
unique: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping from PF2e creature type traits to the skill(s) used for
|
||||
* Recall Knowledge. Types that map to multiple skills list all of them.
|
||||
*/
|
||||
const TYPE_TO_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
||||
aberration: ["Occultism"],
|
||||
animal: ["Nature"],
|
||||
astral: ["Occultism"],
|
||||
beast: ["Arcana", "Nature"],
|
||||
celestial: ["Religion"],
|
||||
construct: ["Arcana", "Crafting"],
|
||||
dragon: ["Arcana"],
|
||||
dream: ["Occultism"],
|
||||
elemental: ["Arcana", "Nature"],
|
||||
ethereal: ["Occultism"],
|
||||
fey: ["Nature"],
|
||||
fiend: ["Religion"],
|
||||
fungus: ["Nature"],
|
||||
giant: ["Society"],
|
||||
humanoid: ["Society"],
|
||||
monitor: ["Religion"],
|
||||
ooze: ["Occultism"],
|
||||
plant: ["Nature"],
|
||||
undead: ["Religion"],
|
||||
};
|
||||
|
||||
export interface RecallKnowledge {
|
||||
readonly dc: number;
|
||||
readonly type: string;
|
||||
readonly skills: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Recall Knowledge DC, type, and skill(s) for a PF2e creature.
|
||||
*
|
||||
* Returns `null` when no recognized type trait is found in the creature's
|
||||
* traits array, indicating the Recall Knowledge line should be omitted.
|
||||
*/
|
||||
export function recallKnowledge(
|
||||
level: number,
|
||||
traits: readonly string[],
|
||||
): RecallKnowledge | null {
|
||||
// Find the first type trait that maps to a skill
|
||||
let matchedType: string | undefined;
|
||||
let skills: readonly string[] | undefined;
|
||||
|
||||
for (const trait of traits) {
|
||||
const lower = trait.toLowerCase();
|
||||
const mapped = TYPE_TO_SKILLS[lower];
|
||||
if (mapped) {
|
||||
matchedType = trait;
|
||||
skills = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedType || !skills) return null;
|
||||
|
||||
// Calculate DC from level
|
||||
const clampedIndex = Math.max(0, Math.min(level + 1, DC_BY_LEVEL.length - 1));
|
||||
let dc = DC_BY_LEVEL[clampedIndex];
|
||||
|
||||
// Apply rarity adjustment (rarity traits are included in the traits array
|
||||
// for non-common creatures by the normalization pipeline)
|
||||
for (const trait of traits) {
|
||||
const adjustment = RARITY_ADJUSTMENT[trait.toLowerCase()];
|
||||
if (adjustment) {
|
||||
dc += adjustment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dc, type: matchedType, skills };
|
||||
}
|
||||
@@ -103,6 +103,11 @@ As a DM running a PF2e encounter, I want to click a spell name in a creature's s
|
||||
|
||||
A click on any spell name in the spellcasting section opens a popover (desktop) or bottom sheet (mobile) showing the spell's description, level, traits, range, action cost, target/area, duration, defense/save, and heightening rules. The data is read directly from the cached creature data (already embedded in NPC JSON from Foundry VTT) — no additional network fetch is required, and the feature works offline once the source has been loaded. Dismiss with click-outside, Escape, or (on mobile) swipe-down.
|
||||
|
||||
**US-D5 — View Recall Knowledge DC and Skill (P2)**
|
||||
As a DM running a PF2e encounter, I want to see the Recall Knowledge DC and associated skill on a creature's stat block so I can quickly tell players the DC and which skill to roll without looking it up in external tools.
|
||||
|
||||
The Recall Knowledge line appears below the trait tags, showing the DC (calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity) and the skill determined by the creature's type trait. The line is omitted for creatures with no recognized type trait and never shown for D&D creatures.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
||||
@@ -148,6 +153,10 @@ A click on any spell name in the spellcasting section opens a popover (desktop)
|
||||
15. **Given** the spell description bottom sheet is open on mobile, **When** the DM swipes the sheet down, **Then** the sheet dismisses.
|
||||
16. **Given** a creature from a legacy (non-remastered) PF2e source has spells with pre-remaster names (e.g., "Magic Missile", "True Strike"), **When** the DM clicks one of those spell names, **Then** the spell description still displays correctly using the embedded data.
|
||||
17. **Given** a spell name appears as "Heal (×3)" in the stat block, **When** the DM looks at the rendered output, **Then** "Heal" is the clickable element and "(×3)" appears as plain text next to it.
|
||||
18. **Given** a PF2e creature with level 5 and common rarity is displayed, **When** the DM views the stat block, **Then** a "Recall Knowledge" line appears below the trait tags showing the DC calculated from level 5 (DC 20) and the skill derived from the creature's type trait.
|
||||
19. **Given** a PF2e creature with rare rarity is displayed, **When** the DM views the stat block, **Then** the Recall Knowledge DC is the standard DC for its level +5.
|
||||
20. **Given** a PF2e creature with the "Undead" type trait is displayed, **When** the DM views the stat block, **Then** the Recall Knowledge line shows "Religion" as the associated skill.
|
||||
21. **Given** a D&D creature is displayed, **When** the DM views the stat block, **Then** no Recall Knowledge line is shown.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -156,6 +165,8 @@ A click on any spell name in the spellcasting section opens a popover (desktop)
|
||||
- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer.
|
||||
- Embedded spell item missing description text: the popover/sheet shows the available metadata (level, traits, range, etc.) and a placeholder note for the missing description.
|
||||
- Cached source data from before the spell description feature was added: existing cached entries lack the new per-spell data fields. The IndexedDB schema version MUST be bumped to invalidate old caches and trigger re-fetch (re-normalization from raw Foundry data is not possible because the original raw JSON is not retained).
|
||||
- Creature with no recognized type trait (e.g., a creature whose only traits are not in the type-to-skill mapping): the Recall Knowledge line is omitted entirely.
|
||||
- Creature with a type trait that maps to multiple skills (e.g., Beast → Arcana/Nature): both skills are shown.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,6 +228,11 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
||||
- **FR-075**: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
|
||||
- **FR-076**: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
|
||||
- **FR-084**: The PF2e normalization pipeline MUST preserve per-spell data (slug, level, traits, range, time, target, area, duration, defense, description, heightening/overlays) from embedded `items[type=spell]` entries on NPCs, in addition to the spell name. This data MUST be stored in the cached source data and persisted across browser sessions.
|
||||
- **FR-085**: PF2e stat blocks MUST display a "Recall Knowledge" line below the trait tags showing the DC and the associated skill (e.g., "Recall Knowledge DC 18 • Undead (Religion)").
|
||||
- **FR-086**: The Recall Knowledge DC MUST be calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity: uncommon +2, rare +5, unique +10.
|
||||
- **FR-087**: The Recall Knowledge skill MUST be derived from the creature's type trait using the standard PF2e mapping (e.g., Aberration → Occultism, Animal → Nature, Astral → Occultism, Beast → Arcana/Nature, Celestial → Religion, Construct → Arcana/Crafting, Dragon → Arcana, Dream → Occultism, Elemental → Arcana/Nature, Ethereal → Occultism, Fey → Nature, Fiend → Religion, Fungus → Nature, Giant → Society, Humanoid → Society, Monitor → Religion, Ooze → Occultism, Plant → Nature, Undead → Religion).
|
||||
- **FR-088**: Creatures with no recognized type trait MUST omit the Recall Knowledge line entirely rather than showing incorrect data.
|
||||
- **FR-089**: The Recall Knowledge line MUST NOT be shown for D&D creatures.
|
||||
|
||||
### Acceptance Scenarios
|
||||
|
||||
|
||||
Reference in New Issue
Block a user