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", () => {
|
describe("spellcasting normalization", () => {
|
||||||
it("normalizes prepared spells by rank", () => {
|
it("normalizes prepared spells by rank", () => {
|
||||||
const creature = normalizeFoundryCreature(
|
const creature = normalizeFoundryCreature(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
|
// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
|
||||||
const DB_VERSION = 6;
|
const DB_VERSION = 7;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
EquipmentItem,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
SpellReference,
|
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> = {
|
const SIZE_MAP: Record<string, string> = {
|
||||||
tiny: "tiny",
|
tiny: "tiny",
|
||||||
sm: "small",
|
sm: "small",
|
||||||
@@ -633,6 +701,14 @@ export function normalizeFoundryCreature(
|
|||||||
),
|
),
|
||||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||||
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
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 ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
const CANTRIPS_REGEX = /Cantrips:/;
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
const AC_REGEX = /16/;
|
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 = {
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
system: "pf2e",
|
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", () => {
|
describe("perception and senses", () => {
|
||||||
it("renders perception modifier and senses", () => {
|
it("renders perception modifier and senses", () => {
|
||||||
renderStatBlock(GOBLIN_WARRIOR);
|
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", () => {
|
describe("clickable spells", () => {
|
||||||
const SPELLCASTER: Pf2eCreature = {
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
...NAUNET,
|
...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 type {
|
||||||
import { formatInitiativeModifier } from "@initiative/domain";
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellReference,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
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>) {
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
const [openSpell, setOpenSpell] = useState<{
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
spell: SpellReference;
|
spell: SpellReference;
|
||||||
@@ -112,6 +146,17 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
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 = [
|
const abilityEntries = [
|
||||||
{ label: "Str", mod: creature.abilityMods.str },
|
{ 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">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
{creature.sourceDisplayName}
|
{creature.sourceDisplayName}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<SectionDivider />
|
<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 ? (
|
{openSpell ? (
|
||||||
<SpellDetailPopover
|
<SpellDetailPopover
|
||||||
spell={openSpell.spell}
|
spell={openSpell.spell}
|
||||||
@@ -255,6 +319,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
onClose={handleCloseSpell}
|
onClose={handleCloseSpell}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{openEquipment ? (
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={openEquipment.item}
|
||||||
|
anchorRect={openEquipment.rect}
|
||||||
|
onClose={handleCloseEquipment}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
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";
|
import { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
interface SpellDetailPopoverProps {
|
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({
|
export function SpellDetailPopover({
|
||||||
spell,
|
spell,
|
||||||
anchorRect,
|
anchorRect,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<SpellDetailPopoverProps>) {
|
}: Readonly<SpellDetailPopoverProps>) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
return (
|
||||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
<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;
|
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 {
|
export interface DailySpells {
|
||||||
readonly uses: number;
|
readonly uses: number;
|
||||||
readonly each: boolean;
|
readonly each: boolean;
|
||||||
@@ -201,6 +214,7 @@ export interface Pf2eCreature {
|
|||||||
readonly abilitiesMid?: readonly TraitBlock[];
|
readonly abilitiesMid?: readonly TraitBlock[];
|
||||||
readonly abilitiesBot?: readonly TraitBlock[];
|
readonly abilitiesBot?: readonly TraitBlock[];
|
||||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||||
|
readonly equipment?: readonly EquipmentItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnyCreature = Creature | Pf2eCreature;
|
export type AnyCreature = Creature | Pf2eCreature;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export {
|
|||||||
type CreatureId,
|
type CreatureId,
|
||||||
creatureId,
|
creatureId,
|
||||||
type DailySpells,
|
type DailySpells,
|
||||||
|
type EquipmentItem,
|
||||||
type LegendaryBlock,
|
type LegendaryBlock,
|
||||||
type Pf2eBestiaryIndex,
|
type Pf2eBestiaryIndex,
|
||||||
type Pf2eBestiaryIndexEntry,
|
type Pf2eBestiaryIndexEntry,
|
||||||
@@ -108,6 +109,10 @@ export {
|
|||||||
VALID_PLAYER_COLORS,
|
VALID_PLAYER_COLORS,
|
||||||
VALID_PLAYER_ICONS,
|
VALID_PLAYER_ICONS,
|
||||||
} from "./player-character-types.js";
|
} from "./player-character-types.js";
|
||||||
|
export {
|
||||||
|
type RecallKnowledge,
|
||||||
|
recallKnowledge,
|
||||||
|
} from "./recall-knowledge.js";
|
||||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||||
export {
|
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.
|
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
|
### Requirements
|
||||||
|
|
||||||
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
- **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.
|
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.
|
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.
|
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
|
### 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.
|
- 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.
|
- 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).
|
- 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-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-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-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
|
### Acceptance Scenarios
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user