Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e8297c95 | ||
|
|
e161645228 | ||
|
|
9b0cb38897 |
@@ -33,8 +33,7 @@ async function addCombatant(
|
|||||||
opts?: { maxHp?: string },
|
opts?: { maxHp?: string },
|
||||||
) {
|
) {
|
||||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
const input = inputs.at(-1) ?? inputs[0];
|
||||||
const input = inputs.at(-1)!;
|
|
||||||
await user.type(input, name);
|
await user.type(input, name);
|
||||||
|
|
||||||
if (opts?.maxHp) {
|
if (opts?.maxHp) {
|
||||||
|
|||||||
@@ -198,21 +198,23 @@ describe("ConfirmButton", () => {
|
|||||||
|
|
||||||
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
function Wrapper() {
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
return (
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
<button type="button" onKeyDown={parentHandler}>
|
||||||
<div onKeyDown={parentHandler}>
|
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={vi.fn()}
|
onConfirm={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</button>
|
||||||
);
|
);
|
||||||
const button = screen.getByRole("button");
|
}
|
||||||
|
render(<Wrapper />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const confirmButton = buttons.at(-1) ?? buttons[0];
|
||||||
|
|
||||||
fireEvent.keyDown(button, { key: "Enter" });
|
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||||
fireEvent.keyDown(button, { key: " " });
|
fireEvent.keyDown(confirmButton, { key: " " });
|
||||||
|
|
||||||
expect(parentHandler).not.toHaveBeenCalled();
|
expect(parentHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,17 +181,20 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(sc?.name).toBe("Spellcasting");
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
expect(sc?.headerText).toContain("DC 15");
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
expect(sc?.headerText).not.toContain("{@");
|
expect(sc?.headerText).not.toContain("{@");
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
expect(sc?.atWill).toEqual([
|
||||||
|
{ name: "Detect Magic" },
|
||||||
|
{ name: "Mage Hand" },
|
||||||
|
]);
|
||||||
expect(sc?.daily).toHaveLength(2);
|
expect(sc?.daily).toHaveLength(2);
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 2,
|
uses: 2,
|
||||||
each: true,
|
each: true,
|
||||||
spells: ["Fireball"],
|
spells: [{ name: "Fireball" }],
|
||||||
});
|
});
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 1,
|
uses: 1,
|
||||||
each: false,
|
each: false,
|
||||||
spells: ["Dimension Door"],
|
spells: [{ name: "Dimension Door" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -593,11 +593,11 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.name).toBe("Primal Prepared Spells");
|
expect(sc?.name).toBe("Primal Prepared Spells");
|
||||||
expect(sc?.headerText).toBe("DC 30, attack +22");
|
expect(sc?.headerText).toBe("DC 30, attack +22");
|
||||||
expect(sc?.daily).toEqual([
|
expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]);
|
||||||
{ uses: 6, each: true, spells: ["Earthquake"] },
|
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
||||||
{ uses: 3, each: true, spells: ["Heal"] },
|
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
||||||
]);
|
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic"]);
|
expect(sc?.atWill?.[0]?.rank).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes innate spells with uses", () => {
|
it("normalizes innate spells with uses", () => {
|
||||||
@@ -633,13 +633,334 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
);
|
);
|
||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.headerText).toBe("DC 32");
|
expect(sc?.headerText).toBe("DC 32");
|
||||||
expect(sc?.daily).toEqual([
|
expect(sc?.daily).toHaveLength(1);
|
||||||
|
const spell = sc?.daily?.[0]?.spells[0];
|
||||||
|
expect(spell?.name).toBe("Sure Strike");
|
||||||
|
expect(spell?.usesPerDay).toBe(3);
|
||||||
|
expect(spell?.rank).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves full spell data including description and heightening", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
uses: 1,
|
_id: "entry1",
|
||||||
each: true,
|
name: "Divine Innate Spells",
|
||||||
spells: ["Sure Strike (\u00d73)"],
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "divine" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 35, value: 27 },
|
||||||
},
|
},
|
||||||
]);
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Heal",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
slug: "heal",
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: {
|
||||||
|
rarity: "common",
|
||||||
|
value: ["healing", "vitality"],
|
||||||
|
traditions: ["divine", "primal"],
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.</p>",
|
||||||
|
},
|
||||||
|
range: { value: "30 feet" },
|
||||||
|
target: { value: "1 willing creature" },
|
||||||
|
duration: { value: "" },
|
||||||
|
defense: undefined,
|
||||||
|
time: { value: "1" },
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 1,
|
||||||
|
damage: { value: "2d8" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s2",
|
||||||
|
name: "Force Barrage",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: ["concentrate", "manipulate"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sc = creature.spellcasting?.[0];
|
||||||
|
expect(sc).toBeDefined();
|
||||||
|
const heal = sc?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Heal");
|
||||||
|
expect(heal).toBeDefined();
|
||||||
|
expect(heal?.slug).toBe("heal");
|
||||||
|
expect(heal?.rank).toBe(6);
|
||||||
|
expect(heal?.range).toBe("30 feet");
|
||||||
|
expect(heal?.target).toBe("1 willing creature");
|
||||||
|
expect(heal?.traits).toEqual(["healing", "vitality"]);
|
||||||
|
expect(heal?.traditions).toEqual(["divine", "primal"]);
|
||||||
|
expect(heal?.actionCost).toBe("1");
|
||||||
|
// Foundry tags stripped from description
|
||||||
|
expect(heal?.description).toContain("positive");
|
||||||
|
expect(heal?.description).not.toContain("@UUID");
|
||||||
|
expect(heal?.description).not.toContain("@Damage");
|
||||||
|
// Interval heightening formatted and not duplicated in description
|
||||||
|
expect(heal?.heightening).toBe("Heightened (+1) damage increases by 2d8");
|
||||||
|
|
||||||
|
// Spell without optional data still has name + rank
|
||||||
|
const fb = sc?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Force Barrage");
|
||||||
|
expect(fb).toBeDefined();
|
||||||
|
expect(fb?.rank).toBe(1);
|
||||||
|
expect(fb?.description).toBeUndefined();
|
||||||
|
expect(fb?.usesPerDay).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats fixed-type heightening levels", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Divine Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "divine" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Magic Missile",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
heightening: {
|
||||||
|
type: "fixed",
|
||||||
|
levels: {
|
||||||
|
"3": { text: "<p>You shoot two more missiles.</p>" },
|
||||||
|
"5": { text: "<p>You shoot four more missiles.</p>" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Magic Missile");
|
||||||
|
expect(spell?.heightening).toContain(
|
||||||
|
"Heightened (3) You shoot two more missiles.",
|
||||||
|
);
|
||||||
|
expect(spell?.heightening).toContain(
|
||||||
|
"Heightened (5) You shoot four more missiles.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats save defense", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Fireball",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 3 },
|
||||||
|
traits: { value: ["fire"] },
|
||||||
|
area: { type: "burst", value: 20 },
|
||||||
|
defense: {
|
||||||
|
save: { statistic: "reflex", basic: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const fireball = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Fireball");
|
||||||
|
expect(fireball?.defense).toBe("basic Reflex");
|
||||||
|
expect(fireball?.area).toBe("20-foot burst");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips inline heightening text from description when structured heightening exists", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Chain Lightning",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["electricity"] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>You discharge a bolt of lightning. The damage is 8d12.</p><p>Heightened (+1) The damage increases by 1d12.</p>",
|
||||||
|
},
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 1,
|
||||||
|
damage: { value: "1d12" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Chain Lightning");
|
||||||
|
expect(spell?.description).toBe(
|
||||||
|
"You discharge a bolt of lightning. The damage is 8d12.",
|
||||||
|
);
|
||||||
|
expect(spell?.description).not.toContain("Heightened");
|
||||||
|
expect(spell?.heightening).toBe(
|
||||||
|
"Heightened (+1) damage increases by 1d12",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats overlays when heightening is absent", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 28 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Force Barrage",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: ["force", "manipulate"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire darts of force.</p>",
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
variant1: {
|
||||||
|
name: "2 actions",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire two darts.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant2: {
|
||||||
|
name: "3 actions",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire three darts.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Force Barrage");
|
||||||
|
expect(spell?.heightening).toContain("2 actions: You fire two darts.");
|
||||||
|
expect(spell?.heightening).toContain("3 actions: You fire three darts.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers heightening over overlays when both present", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Test Spell",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 2,
|
||||||
|
damage: { value: "1d6" },
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
variant1: {
|
||||||
|
name: "Variant",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>Should be ignored.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Test Spell");
|
||||||
|
expect(spell?.heightening).toBe(
|
||||||
|
"Heightened (+2) damage increases by 1d6",
|
||||||
|
);
|
||||||
|
expect(spell?.heightening).not.toContain("Should be ignored");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DailySpells,
|
DailySpells,
|
||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
TraitListItem,
|
TraitListItem,
|
||||||
TraitSegment,
|
TraitSegment,
|
||||||
@@ -385,7 +386,7 @@ function normalizeSpellcasting(
|
|||||||
const block: {
|
const block: {
|
||||||
name: string;
|
name: string;
|
||||||
headerText: string;
|
headerText: string;
|
||||||
atWill?: string[];
|
atWill?: SpellReference[];
|
||||||
daily?: DailySpells[];
|
daily?: DailySpells[];
|
||||||
restLong?: DailySpells[];
|
restLong?: DailySpells[];
|
||||||
} = {
|
} = {
|
||||||
@@ -396,7 +397,7 @@ function normalizeSpellcasting(
|
|||||||
const hidden = new Set(sc.hidden ?? []);
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
if (sc.will && !hidden.has("will")) {
|
if (sc.will && !hidden.has("will")) {
|
||||||
block.atWill = sc.will.map((s) => stripTags(s));
|
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sc.daily) {
|
if (sc.daily) {
|
||||||
@@ -418,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
|||||||
return {
|
return {
|
||||||
uses,
|
uses,
|
||||||
each,
|
each,
|
||||||
spells: spells.map((s) => stripTags(s)),
|
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +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";
|
||||||
const DB_VERSION = 5;
|
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
|
||||||
|
const DB_VERSION = 6;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
CreatureId,
|
CreatureId,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
@@ -78,13 +79,39 @@ interface SpellcastingEntrySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpellSystem {
|
interface SpellSystem {
|
||||||
|
slug?: string;
|
||||||
location?: {
|
location?: {
|
||||||
value: string;
|
value: string;
|
||||||
heightenedLevel?: number;
|
heightenedLevel?: number;
|
||||||
uses?: { max: number; value: number };
|
uses?: { max: number; value: number };
|
||||||
};
|
};
|
||||||
level?: { value: number };
|
level?: { value: number };
|
||||||
traits?: { value: string[] };
|
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
range?: { value: string };
|
||||||
|
target?: { value: string };
|
||||||
|
area?: { type?: string; value?: number; details?: string };
|
||||||
|
duration?: { value: string; sustained?: boolean };
|
||||||
|
time?: { value: string };
|
||||||
|
defense?: {
|
||||||
|
save?: { statistic: string; basic?: boolean };
|
||||||
|
passive?: { statistic: string };
|
||||||
|
};
|
||||||
|
heightening?:
|
||||||
|
| {
|
||||||
|
type: "fixed";
|
||||||
|
levels: Record<string, { text?: string }>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "interval";
|
||||||
|
interval: number;
|
||||||
|
damage?: { value: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
overlays?: Record<
|
||||||
|
string,
|
||||||
|
{ name?: string; system?: { description?: { value: string } } }
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP: Record<string, string> = {
|
const SIZE_MAP: Record<string, string> = {
|
||||||
@@ -311,18 +338,102 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
|||||||
|
|
||||||
// -- Spellcasting normalization --
|
// -- Spellcasting normalization --
|
||||||
|
|
||||||
function classifySpell(spell: RawFoundryItem): {
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||||
isCantrip: boolean;
|
if (!range?.value) return undefined;
|
||||||
rank: number;
|
return range.value;
|
||||||
label: string;
|
}
|
||||||
} {
|
|
||||||
const sys = spell.system as unknown as SpellSystem;
|
function formatArea(
|
||||||
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
|
area: { type?: string; value?: number; details?: string } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!area) return undefined;
|
||||||
|
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||||
|
return area.details ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
||||||
|
if (!defense) return undefined;
|
||||||
|
if (defense.save) {
|
||||||
|
const stat = capitalize(defense.save.statistic);
|
||||||
|
return defense.save.basic ? `basic ${stat}` : stat;
|
||||||
|
}
|
||||||
|
if (defense.passive) return capitalize(defense.passive.statistic);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeightening(
|
||||||
|
heightening: SpellSystem["heightening"],
|
||||||
|
): string | undefined {
|
||||||
|
if (!heightening) return undefined;
|
||||||
|
if (heightening.type === "fixed") {
|
||||||
|
const parts = Object.entries(heightening.levels)
|
||||||
|
.filter(([, lvl]) => lvl.text)
|
||||||
|
.map(
|
||||||
|
([rank, lvl]) =>
|
||||||
|
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
||||||
|
);
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
if (heightening.type === "interval") {
|
||||||
|
const dmg = heightening.damage?.value
|
||||||
|
? ` damage increases by ${heightening.damage.value}`
|
||||||
|
: "";
|
||||||
|
return `Heightened (+${heightening.interval})${dmg}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||||
|
if (!overlays) return undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const overlay of Object.values(overlays)) {
|
||||||
|
const desc = overlay.system?.description?.value;
|
||||||
|
if (!desc) continue;
|
||||||
|
const label = overlay.name ? `${overlay.name}: ` : "";
|
||||||
|
parts.push(`${label}${stripFoundryTags(desc)}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundry descriptions often include heightening rules inline at the end.
|
||||||
|
* When we extract heightening into a structured field, strip that trailing
|
||||||
|
* text to avoid duplication.
|
||||||
|
*/
|
||||||
|
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||||
|
|
||||||
|
function normalizeSpell(item: RawFoundryItem): SpellReference {
|
||||||
|
const sys = item.system as unknown as SpellSystem;
|
||||||
|
const usesMax = sys.location?.uses?.max;
|
||||||
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
||||||
const uses = sys.location?.uses;
|
const heightening =
|
||||||
const label =
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||||
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
|
|
||||||
return { isCantrip, rank, label };
|
let description: string | undefined;
|
||||||
|
if (sys.description?.value) {
|
||||||
|
let text = stripFoundryTags(sys.description.value);
|
||||||
|
if (heightening) {
|
||||||
|
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
||||||
|
}
|
||||||
|
description = text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: sys.slug,
|
||||||
|
rank,
|
||||||
|
description,
|
||||||
|
traits: sys.traits?.value,
|
||||||
|
traditions: sys.traits?.traditions,
|
||||||
|
range: formatRange(sys.range),
|
||||||
|
target: sys.target?.value || undefined,
|
||||||
|
area: formatArea(sys.area),
|
||||||
|
duration: sys.duration?.value || undefined,
|
||||||
|
defense: formatDefense(sys.defense),
|
||||||
|
actionCost: sys.time?.value || undefined,
|
||||||
|
heightening,
|
||||||
|
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSpellcastingEntry(
|
function normalizeSpellcastingEntry(
|
||||||
@@ -342,26 +453,31 @@ function normalizeSpellcastingEntry(
|
|||||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byRank = new Map<number, string[]>();
|
const byRank = new Map<number, SpellReference[]>();
|
||||||
const cantrips: string[] = [];
|
const cantrips: SpellReference[] = [];
|
||||||
|
|
||||||
for (const spell of linkedSpells) {
|
for (const spell of linkedSpells) {
|
||||||
const { isCantrip, rank, label } = classifySpell(spell);
|
const ref = normalizeSpell(spell);
|
||||||
|
const isCantrip =
|
||||||
|
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||||
|
"cantrip",
|
||||||
|
) ?? false;
|
||||||
if (isCantrip) {
|
if (isCantrip) {
|
||||||
cantrips.push(spell.name);
|
cantrips.push(ref);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const rank = ref.rank ?? 0;
|
||||||
const existing = byRank.get(rank) ?? [];
|
const existing = byRank.get(rank) ?? [];
|
||||||
existing.push(label);
|
existing.push(ref);
|
||||||
byRank.set(rank, existing);
|
byRank.set(rank, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daily = [...byRank.entries()]
|
const daily = [...byRank.entries()]
|
||||||
.sort(([a], [b]) => b - a)
|
.sort(([a], [b]) => b - a)
|
||||||
.map(([rank, spellNames]) => ({
|
.map(([rank, spells]) => ({
|
||||||
uses: rank,
|
uses: rank,
|
||||||
each: true,
|
each: true,
|
||||||
spells: spellNames,
|
spells,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
@@ -22,6 +25,12 @@ 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 GOBLIN_WARRIOR: Pf2eCreature = {
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
system: "pf2e",
|
system: "pf2e",
|
||||||
@@ -90,9 +99,13 @@ const NAUNET: Pf2eCreature = {
|
|||||||
name: "Divine Innate Spells",
|
name: "Divine Innate Spells",
|
||||||
headerText: "DC 25, attack +17",
|
headerText: "DC 25, attack +17",
|
||||||
daily: [
|
daily: [
|
||||||
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
atWill: ["Detect Magic"],
|
atWill: [{ name: "Detect Magic" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -147,6 +160,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);
|
||||||
@@ -277,4 +337,87 @@ describe("Pf2eStatBlock", () => {
|
|||||||
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("clickable spells", () => {
|
||||||
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
|
...NAUNET,
|
||||||
|
id: creatureId("test:spellcaster"),
|
||||||
|
name: "Spellcaster",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 30, attack +20",
|
||||||
|
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [
|
||||||
|
{
|
||||||
|
name: "Heal",
|
||||||
|
description: "You channel positive energy to heal.",
|
||||||
|
rank: 4,
|
||||||
|
usesPerDay: 3,
|
||||||
|
},
|
||||||
|
{ name: "Restoration", rank: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell with a description as a clickable button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell without description as plain text (not a button)", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Restoration" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the spell popover when a spell button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popover when Escape is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||||
const adapters = createTestAdapters();
|
const adapters = createTestAdapters();
|
||||||
// Wire getCachedSources to return the provided sources initially,
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
// then empty after clear operations
|
// then empty after clear operations
|
||||||
@@ -57,14 +57,14 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
|||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
void renderWithSources([]);
|
renderWithSources([]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -88,7 +88,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("Clear All button removes all sources", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -110,7 +110,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("individual source delete button removes that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
|
|||||||
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal file
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { SpellReference } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SpellDetailPopover } from "../spell-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const FIREBALL: SpellReference = {
|
||||||
|
name: "Fireball",
|
||||||
|
slug: "fireball",
|
||||||
|
rank: 3,
|
||||||
|
description: "A spark leaps from your fingertip to the target.",
|
||||||
|
traits: ["fire", "manipulate"],
|
||||||
|
traditions: ["arcane", "primal"],
|
||||||
|
range: "500 feet",
|
||||||
|
area: "20-foot burst",
|
||||||
|
defense: "basic Reflex",
|
||||||
|
actionCost: "2",
|
||||||
|
heightening: "Heightened (+1) The damage increases by 2d6.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
|
||||||
|
const SPARK_LEAPS_REGEX = /spark leaps/;
|
||||||
|
const HEIGHTENED_REGEX = /Heightened.*2d6/;
|
||||||
|
const RANGE_REGEX = /500 feet/;
|
||||||
|
const AREA_REGEX = /20-foot burst/;
|
||||||
|
const DEFENSE_REGEX = /basic Reflex/;
|
||||||
|
const NO_DESCRIPTION_REGEX = /No description available/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Spell details: Fireball/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Force desktop variant in jsdom
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SpellDetailPopover", () => {
|
||||||
|
it("renders spell name, rank, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Fireball")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fire")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("manipulate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heightening rules when present", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders range, area, and defense", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AREA_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows placeholder when description is missing", () => {
|
||||||
|
const spell: SpellReference = { name: "Mystery", rank: 1 };
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action cost as an icon when it is a numeric action count", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Action cost "2" renders as an SVG ActivityIcon (portaled to body)
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders non-numeric action cost as text", () => {
|
||||||
|
const spell: SpellReference = {
|
||||||
|
...FIREBALL,
|
||||||
|
actionCost: "1 minute",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1 minute")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the spell name as label", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,9 +111,15 @@ const DRAGON: Creature = {
|
|||||||
{
|
{
|
||||||
name: "Innate Spellcasting",
|
name: "Innate Spellcasting",
|
||||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
atWill: ["detect magic", "suggestion"],
|
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
daily: [
|
||||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
{
|
||||||
|
uses: 3,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<div className="pl-2">
|
||||||
<span className="font-semibold">At Will:</span>{" "}
|
<span className="font-semibold">At Will:</span>{" "}
|
||||||
{sc.atWill.join(", ")}
|
{sc.atWill.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
@@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/day
|
{d.uses}/day
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sc.restLong?.map((d) => (
|
{sc.restLong?.map((d) => (
|
||||||
@@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/long rest
|
{d.uses}/long rest
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
||||||
import { formatInitiativeModifier } from "@initiative/domain";
|
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
SectionDivider,
|
SectionDivider,
|
||||||
@@ -34,7 +36,85 @@ function formatMod(mod: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpellLinkProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) {
|
||||||
|
if (count === undefined || count <= 1) return null;
|
||||||
|
return <span> (×{count})</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellLink({ spell, onOpen }: Readonly<SpellLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!spell.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(spell, rect);
|
||||||
|
}, [spell, onOpen]);
|
||||||
|
|
||||||
|
if (!spell.description) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{spell.name}
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{spell.name}
|
||||||
|
</button>
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellListLineProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly spells: readonly SpellReference[];
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellListLine({
|
||||||
|
label,
|
||||||
|
spells,
|
||||||
|
onOpen,
|
||||||
|
}: Readonly<SpellListLineProps>) {
|
||||||
|
return (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">{label}:</span>{" "}
|
||||||
|
{spells.map((spell, i) => (
|
||||||
|
<span key={spell.slug ?? spell.name}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<SpellLink spell={spell} onOpen={onOpen} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
|
spell: SpellReference;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenSpell = useCallback(
|
||||||
|
(spell: SpellReference, rect: DOMRect) => setOpenSpell({ spell, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
||||||
|
|
||||||
|
const rk = recallKnowledge(creature.level, creature.traits);
|
||||||
|
|
||||||
const abilityEntries = [
|
const abilityEntries = [
|
||||||
{ label: "Str", mod: creature.abilityMods.str },
|
{ label: "Str", mod: creature.abilityMods.str },
|
||||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||||
@@ -69,6 +149,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 />
|
||||||
@@ -152,23 +238,31 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{sc.headerText}
|
{sc.headerText}
|
||||||
</div>
|
</div>
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
<div key={d.uses} className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">
|
key={d.uses}
|
||||||
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||||
</span>{" "}
|
spells={d.spells}
|
||||||
{d.spells.join(", ")}
|
onOpen={handleOpenSpell}
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">Cantrips:</span>{" "}
|
label="Cantrips"
|
||||||
{sc.atWill.join(", ")}
|
spells={sc.atWill}
|
||||||
</div>
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{openSpell ? (
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={openSpell.spell}
|
||||||
|
anchorRect={openSpell.rect}
|
||||||
|
onClose={handleCloseSpell}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
296
apps/web/src/components/spell-detail-popover.tsx
Normal file
296
apps/web/src/components/spell-detail-popover.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface SpellDetailPopoverProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_LABELS = [
|
||||||
|
"Cantrip",
|
||||||
|
"1st",
|
||||||
|
"2nd",
|
||||||
|
"3rd",
|
||||||
|
"4th",
|
||||||
|
"5th",
|
||||||
|
"6th",
|
||||||
|
"7th",
|
||||||
|
"8th",
|
||||||
|
"9th",
|
||||||
|
"10th",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRank(rank: number | undefined): string {
|
||||||
|
if (rank === undefined) return "";
|
||||||
|
return RANK_LABELS[rank] ?? `Rank ${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActionCost(cost: string): ActivityCost | null {
|
||||||
|
if (cost === "free") return { number: 1, unit: "free" };
|
||||||
|
if (cost === "reaction") return { number: 1, unit: "reaction" };
|
||||||
|
const n = Number(cost);
|
||||||
|
if (n >= 1 && n <= 3) return { number: n, unit: "action" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) {
|
||||||
|
if (!cost) return null;
|
||||||
|
const activity = parseActionCost(cost);
|
||||||
|
if (activity) {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-lg">
|
||||||
|
<ActivityIcon activity={activity} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="shrink-0 text-muted-foreground text-xs">{cost}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{spell.name}</h3>
|
||||||
|
<SpellActionCost cost={spell.actionCost} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) {
|
||||||
|
if (traits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LabeledValue({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{ label: string; value: string }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const items: { label: string; value: string }[] = [];
|
||||||
|
if (spell.range) items.push({ label: "Range", value: spell.range });
|
||||||
|
if (spell.target) items.push({ label: "Target", value: spell.target });
|
||||||
|
if (spell.area) items.push({ label: "Area", value: spell.area });
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<span key={item.label}>
|
||||||
|
{i > 0 ? "; " : ""}
|
||||||
|
<LabeledValue label={item.label} value={item.value} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const hasTraditions =
|
||||||
|
spell.traditions !== undefined && spell.traditions.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
{spell.rank === undefined ? null : (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{formatRank(spell.rank)}</span>
|
||||||
|
{hasTraditions ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({spell.traditions?.join(", ")})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SpellRangeLine spell={spell} />
|
||||||
|
{spell.duration ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Duration" value={spell.duration} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spell.defense ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Defense" value={spell.defense} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_OUTCOME_REGEX =
|
||||||
|
/(Critical Success|Critical Failure|Success|Failure)/g;
|
||||||
|
|
||||||
|
function SpellDescription({ text }: Readonly<{ text: string }>) {
|
||||||
|
const parts = text.split(SAVE_OUTCOME_REGEX);
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (SAVE_OUTCOME_REGEX.test(part)) {
|
||||||
|
elements.push(<strong key={`b-${offset}`}>{part}</strong>);
|
||||||
|
} else if (part) {
|
||||||
|
elements.push(<span key={`t-${offset}`}>{part}</span>);
|
||||||
|
}
|
||||||
|
offset += part.length;
|
||||||
|
}
|
||||||
|
return <p className="whitespace-pre-line text-foreground">{elements}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<SpellHeader spell={spell} />
|
||||||
|
<SpellTraits traits={spell.traits ?? []} />
|
||||||
|
<SpellMeta spell={spell} />
|
||||||
|
{spell.description ? (
|
||||||
|
<SpellDescription text={spell.description} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{spell.heightening ? (
|
||||||
|
<p className="whitespace-pre-line text-foreground text-xs">
|
||||||
|
{spell.heightening}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
|||||||
const REACTION_ARROW =
|
const REACTION_ARROW =
|
||||||
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
||||||
|
|
||||||
function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
export function ActivityIcon({
|
||||||
|
activity,
|
||||||
|
}: Readonly<{ activity: ActivityCost }>) {
|
||||||
const cls = "inline-block h-[1em] align-[-0.1em]";
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||||
if (activity.unit === "free") {
|
if (activity.unit === "free") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -421,7 +421,10 @@ function dispatchEncounterAction(
|
|||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
initializeState(
|
||||||
|
() => encounterPersistence.load(),
|
||||||
|
() => undoRedoPersistence.load(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
|
|||||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
|||||||
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical (down-only) variant for dismissing bottom sheets via swipe-down.
|
||||||
|
* Mirrors `useSwipeToDismiss` but locks to vertical direction and tracks
|
||||||
|
* the sheet height instead of width.
|
||||||
|
*/
|
||||||
|
export function useSwipeToDismissDown(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const sheetHeight = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
sheetHeight.current = el.getBoundingClientRect().height;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dy) > Math.abs(dx) ? "vertical" : "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "horizontal") return;
|
||||||
|
|
||||||
|
const clampedY = Math.max(0, dy);
|
||||||
|
// `offsetX` is reused as the vertical offset to keep SwipeState shared.
|
||||||
|
setSwipe({ offsetX: clampedY, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "vertical") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / sheetHeight.current;
|
||||||
|
const ratio =
|
||||||
|
sheetHeight.current > 0 ? swipe.offsetX / sheetHeight.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetY: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
animation: slide-in-right 200ms ease-out;
|
animation: slide-in-right 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-in-bottom {
|
||||||
|
animation: slide-in-bottom 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes confirm-pulse {
|
@keyframes confirm-pulse {
|
||||||
0% {
|
0% {
|
||||||
scale: 1;
|
scale: 1;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.mjs",
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ describe("getConditionDescription", () => {
|
|||||||
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
||||||
);
|
);
|
||||||
for (const def of sharedDndConditions) {
|
for (const def of sharedDndConditions) {
|
||||||
expect(def.description, `${def.id} missing description`).toBeTruthy();
|
expect(def.description).toBeTruthy();
|
||||||
expect(def.description5e, `${def.id} missing description5e`).toBeTruthy();
|
expect(def.description5e).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,28 @@
|
|||||||
|
const DIGITS_ONLY = /^\d+$/;
|
||||||
|
|
||||||
|
function scanExisting(
|
||||||
|
baseName: string,
|
||||||
|
existingNames: readonly string[],
|
||||||
|
): { exactMatches: number[]; maxNumber: number } {
|
||||||
|
const exactMatches: number[] = [];
|
||||||
|
let maxNumber = 0;
|
||||||
|
const prefix = `${baseName} `;
|
||||||
|
|
||||||
|
for (let i = 0; i < existingNames.length; i++) {
|
||||||
|
const name = existingNames[i];
|
||||||
|
if (name === baseName) {
|
||||||
|
exactMatches.push(i);
|
||||||
|
} else if (name.startsWith(prefix)) {
|
||||||
|
const suffix = name.slice(prefix.length);
|
||||||
|
if (DIGITS_ONLY.test(suffix)) {
|
||||||
|
const num = Number.parseInt(suffix, 10);
|
||||||
|
if (num > maxNumber) maxNumber = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { exactMatches, maxNumber };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a creature name against existing combatant names,
|
* Resolves a creature name against existing combatant names,
|
||||||
* handling auto-numbering for duplicates.
|
* handling auto-numbering for duplicates.
|
||||||
@@ -14,25 +39,7 @@ export function resolveCreatureName(
|
|||||||
newName: string;
|
newName: string;
|
||||||
renames: ReadonlyArray<{ from: string; to: string }>;
|
renames: ReadonlyArray<{ from: string; to: string }>;
|
||||||
} {
|
} {
|
||||||
// Find exact matches and numbered matches (e.g., "Goblin 1", "Goblin 2")
|
const { exactMatches, maxNumber } = scanExisting(baseName, existingNames);
|
||||||
const exactMatches: number[] = [];
|
|
||||||
let maxNumber = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < existingNames.length; i++) {
|
|
||||||
const name = existingNames[i];
|
|
||||||
if (name === baseName) {
|
|
||||||
exactMatches.push(i);
|
|
||||||
} else {
|
|
||||||
const match = new RegExp(
|
|
||||||
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
|
|
||||||
).exec(name);
|
|
||||||
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
|
|
||||||
if (match) {
|
|
||||||
const num = Number.parseInt(match[1], 10);
|
|
||||||
if (num > maxNumber) maxNumber = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No conflict at all
|
// No conflict at all
|
||||||
if (exactMatches.length === 0 && maxNumber === 0) {
|
if (exactMatches.length === 0 && maxNumber === 0) {
|
||||||
@@ -51,7 +58,3 @@ export function resolveCreatureName(
|
|||||||
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
||||||
return { newName: `${baseName} ${nextNumber}`, renames: [] };
|
return { newName: `${baseName} ${nextNumber}`, renames: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
|
||||||
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,16 +31,71 @@ export interface LegendaryBlock {
|
|||||||
readonly entries: readonly TraitBlock[];
|
readonly entries: readonly TraitBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single spell entry within a creature's spellcasting block.
|
||||||
|
*
|
||||||
|
* `name` is always populated. All other fields are optional and are only
|
||||||
|
* populated for PF2e creatures (sourced from embedded Foundry VTT spell items).
|
||||||
|
* D&D 5e creatures populate only `name`.
|
||||||
|
*/
|
||||||
|
export interface SpellReference {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** Stable slug from Foundry VTT (e.g. "magic-missile"). PF2e only. */
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
|
/** Plain-text description with Foundry enrichment tags stripped. */
|
||||||
|
readonly description?: string;
|
||||||
|
|
||||||
|
/** Spell rank/level (0 = cantrip). */
|
||||||
|
readonly rank?: number;
|
||||||
|
|
||||||
|
/** Trait slugs (e.g. ["concentrate", "manipulate", "force"]). */
|
||||||
|
readonly traits?: readonly string[];
|
||||||
|
|
||||||
|
/** Tradition labels (e.g. ["arcane", "occult"]). */
|
||||||
|
readonly traditions?: readonly string[];
|
||||||
|
|
||||||
|
/** Range (e.g. "30 feet", "touch"). */
|
||||||
|
readonly range?: string;
|
||||||
|
|
||||||
|
/** Target (e.g. "1 creature"). */
|
||||||
|
readonly target?: string;
|
||||||
|
|
||||||
|
/** Area (e.g. "20-foot burst"). */
|
||||||
|
readonly area?: string;
|
||||||
|
|
||||||
|
/** Duration (e.g. "1 minute", "sustained up to 1 minute"). */
|
||||||
|
readonly duration?: string;
|
||||||
|
|
||||||
|
/** Defense / save (e.g. "basic Reflex", "Will"). */
|
||||||
|
readonly defense?: string;
|
||||||
|
|
||||||
|
/** Action cost. PF2e: number = action count, "reaction", "free", or
|
||||||
|
* "1 minute" / "10 minutes" for cast time. */
|
||||||
|
readonly actionCost?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heightening rules text. May come from `system.heightening` (fixed
|
||||||
|
* intervals) or `system.overlays` (variant casts). Plain text after
|
||||||
|
* tag stripping.
|
||||||
|
*/
|
||||||
|
readonly heightening?: string;
|
||||||
|
|
||||||
|
/** Uses per day for "(×N)" rendering, when > 1. PF2e only. */
|
||||||
|
readonly usesPerDay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailySpells {
|
export interface DailySpells {
|
||||||
readonly uses: number;
|
readonly uses: number;
|
||||||
readonly each: boolean;
|
readonly each: boolean;
|
||||||
readonly spells: readonly string[];
|
readonly spells: readonly SpellReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpellcastingBlock {
|
export interface SpellcastingBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly headerText: string;
|
readonly headerText: string;
|
||||||
readonly atWill?: readonly string[];
|
readonly atWill?: readonly SpellReference[];
|
||||||
readonly daily?: readonly DailySpells[];
|
readonly daily?: readonly DailySpells[];
|
||||||
readonly restLong?: readonly DailySpells[];
|
readonly restLong?: readonly DailySpells[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export {
|
|||||||
type Pf2eCreature,
|
type Pf2eCreature,
|
||||||
proficiencyBonus,
|
proficiencyBonus,
|
||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
|
type SpellReference,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
type TraitListItem,
|
type TraitListItem,
|
||||||
type TraitSegment,
|
type TraitSegment,
|
||||||
@@ -107,6 +108,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 };
|
||||||
|
}
|
||||||
@@ -1,29 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Backpressure check for biome-ignore comments.
|
* Zero-tolerance check for biome-ignore comments.
|
||||||
*
|
*
|
||||||
* 1. Ratcheting cap — source and test files have separate max counts.
|
* Any `biome-ignore` in tracked .ts/.tsx files fails the build.
|
||||||
* Lower these numbers as you fix ignores; they can never go up silently.
|
* Fix the underlying issue instead of suppressing the rule.
|
||||||
* 2. Banned rules — ignoring certain rule categories is never allowed.
|
|
||||||
* 3. Justification — every ignore must have a non-empty explanation after
|
|
||||||
* the rule name.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
// ── Configuration ──────────────────────────────────────────────────────
|
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)/;
|
||||||
const MAX_SOURCE_IGNORES = 2;
|
|
||||||
const MAX_TEST_IGNORES = 3;
|
|
||||||
|
|
||||||
/** Rule prefixes that must never be suppressed. */
|
|
||||||
const BANNED_PREFIXES = [
|
|
||||||
"lint/security/",
|
|
||||||
"lint/correctness/noGlobalObjectCalls",
|
|
||||||
"lint/correctness/noUnsafeFinally",
|
|
||||||
];
|
|
||||||
// ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)(?::\s*(.*))?/;
|
|
||||||
|
|
||||||
function findFiles() {
|
function findFiles() {
|
||||||
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
|
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
|
||||||
@@ -32,17 +17,7 @@ function findFiles() {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTestFile(path) {
|
let count = 0;
|
||||||
return (
|
|
||||||
path.includes("__tests__/") ||
|
|
||||||
path.endsWith(".test.ts") ||
|
|
||||||
path.endsWith(".test.tsx")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let errors = 0;
|
|
||||||
let sourceCount = 0;
|
|
||||||
let testCount = 0;
|
|
||||||
|
|
||||||
for (const file of findFiles()) {
|
for (const file of findFiles()) {
|
||||||
const lines = readFileSync(file, "utf-8").split("\n");
|
const lines = readFileSync(file, "utf-8").split("\n");
|
||||||
@@ -51,58 +26,16 @@ for (const file of findFiles()) {
|
|||||||
const match = lines[i].match(IGNORE_PATTERN);
|
const match = lines[i].match(IGNORE_PATTERN);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
const rule = match[1];
|
count++;
|
||||||
const justification = (match[2] ?? "").trim();
|
console.error(`FORBIDDEN: ${file}:${i + 1} — biome-ignore ${match[1]}`);
|
||||||
const loc = `${file}:${i + 1}`;
|
|
||||||
|
|
||||||
// Count by category
|
|
||||||
if (isTestFile(file)) {
|
|
||||||
testCount++;
|
|
||||||
} else {
|
|
||||||
sourceCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Banned rules
|
|
||||||
for (const prefix of BANNED_PREFIXES) {
|
|
||||||
if (rule.startsWith(prefix)) {
|
|
||||||
console.error(`BANNED: ${loc} — ${rule} must not be suppressed`);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Justification required
|
|
||||||
if (!justification) {
|
|
||||||
console.error(
|
|
||||||
`MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`,
|
|
||||||
);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ratcheting caps
|
if (count > 0) {
|
||||||
if (sourceCount > MAX_SOURCE_IGNORES) {
|
|
||||||
console.error(
|
console.error(
|
||||||
`SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`,
|
`\n${count} biome-ignore comment(s) found. Fix the issue or restructure the code.`,
|
||||||
);
|
);
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testCount > MAX_TEST_IGNORES) {
|
|
||||||
console.error(
|
|
||||||
`TEST CAP EXCEEDED: ${testCount} biome-ignore comments in tests (max ${MAX_TEST_IGNORES}). Fix issues and lower the cap.`,
|
|
||||||
);
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log(
|
|
||||||
`biome-ignore: ${sourceCount} source (max ${MAX_SOURCE_IGNORES}), ${testCount} test (max ${MAX_TEST_IGNORES})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (errors > 0) {
|
|
||||||
console.error(`\n${errors} problem(s) found.`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
console.log("All checks passed.");
|
console.log("biome-ignore: 0 — all clear.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ A view button in the search bar (repurposed from the current search icon) opens
|
|||||||
**US-D3 — Responsive Layout (P4)**
|
**US-D3 — Responsive Layout (P4)**
|
||||||
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
|
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
|
||||||
|
|
||||||
|
**US-D4 — View Spell Descriptions Inline (P2)**
|
||||||
|
As a DM running a PF2e encounter, I want to click a spell name in a creature's stat block to see the spell's full description without leaving the stat block, so I can quickly resolve what a spell does mid-combat without consulting external tools.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -116,6 +126,13 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
|
- **FR-067**: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
|
||||||
- **FR-068**: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
|
- **FR-068**: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
|
||||||
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
- **FR-062**: Bestiary-linked combatant rows MUST display a small book icon (Lucide `BookOpen`) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and an `aria-label="View stat block"` for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon.
|
||||||
|
- **FR-077**: PF2e stat blocks MUST render each spell name in the spellcasting section as an interactive element (clickable button), not as plain joined text.
|
||||||
|
- **FR-078**: Clicking a spell name MUST open a popover (desktop) or bottom sheet (mobile) displaying the spell's description, level, traits, range, time/actions, target/area, duration, defense/save, and heightening rules.
|
||||||
|
- **FR-079**: The spell description popover/sheet MUST render content from the spell data already embedded in the cached creature JSON — no additional network fetch is required.
|
||||||
|
- **FR-080**: The spell description popover/sheet MUST be dismissible by clicking outside, pressing Escape, or (on mobile) swiping the sheet down.
|
||||||
|
- **FR-081**: Spell descriptions MUST be processed through the existing Foundry tag-stripping utility before display (consistent with FR-068).
|
||||||
|
- **FR-082**: When a spell name has a parenthetical modifier (e.g., "Heal (×3)", "Unfettered Movement (Constant)"), only the spell name portion MUST be the click target; the modifier MUST remain as adjacent plain text.
|
||||||
|
- **FR-083**: The spell description display MUST handle both representations of heightening present in Foundry VTT data: `system.heightening` and `system.overlays`.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
@@ -131,12 +148,25 @@ As a DM using the app on different devices, I want the layout to adapt between s
|
|||||||
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
10. **Given** a bestiary-linked combatant row is visible, **When** the user clicks the combatant's name, **Then** inline rename mode is entered — the stat block does NOT open.
|
||||||
11. **Given** a PF2e creature is selected, **When** the stat block opens, **Then** it displays: name, level, traits as tags, Perception, senses, languages, skills, ability modifiers, AC, Fort/Ref/Will saves, HP, speed, attacks, abilities (top/mid/bot sections), and spellcasting (if applicable). No CR, no ability scores, no legendary actions.
|
11. **Given** a PF2e creature is selected, **When** the stat block opens, **Then** it displays: name, level, traits as tags, Perception, senses, languages, skills, ability modifiers, AC, Fort/Ref/Will saves, HP, speed, attacks, abilities (top/mid/bot sections), and spellcasting (if applicable). No CR, no ability scores, no legendary actions.
|
||||||
12. **Given** a PF2e creature with conditional AC (e.g., "with shield raised"), **When** viewing the stat block, **Then** both the standard AC and conditional AC are shown.
|
12. **Given** a PF2e creature with conditional AC (e.g., "with shield raised"), **When** viewing the stat block, **Then** both the standard AC and conditional AC are shown.
|
||||||
|
13. **Given** a PF2e creature with spellcasting is displayed in the stat block panel, **When** the DM clicks a spell name in the spellcasting section, **Then** a popover (desktop) or bottom sheet (mobile) opens showing the spell's description, level, traits, range, action cost, and any heightening rules.
|
||||||
|
14. **Given** the spell description popover is open, **When** the DM clicks outside it or presses Escape, **Then** the popover 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.
|
||||||
|
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
|
||||||
|
|
||||||
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
|
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
|
||||||
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
|
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
|
||||||
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,6 +227,12 @@ A DM wants to see which sources are cached, find a specific source, clear a spec
|
|||||||
- **FR-074**: The PF2e index MUST exclude legacy/pre-remaster creatures based on the `publication.remaster` field — only remaster-era content is included by default.
|
- **FR-074**: The PF2e index MUST exclude legacy/pre-remaster creatures based on the `publication.remaster` field — only remaster-era content is included by default.
|
||||||
- **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-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
|
||||||
|
|
||||||
@@ -298,7 +334,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **Search Index (D&D)** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block.
|
- **Search Index (D&D)** (`BestiaryIndex`): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block.
|
||||||
- **Search Index (PF2e)** (`Pf2eBestiaryIndex`): Pre-shipped lightweight dataset for PF2e creatures, containing name, source code, AC, HP, level, Perception modifier, size, and creature type. Parallel to the D&D search index but with PF2e-specific fields (level instead of CR, Perception instead of DEX/proficiency).
|
- **Search Index (PF2e)** (`Pf2eBestiaryIndex`): Pre-shipped lightweight dataset for PF2e creatures, containing name, source code, AC, HP, level, Perception modifier, size, and creature type. Parallel to the D&D search index but with PF2e-specific fields (level instead of CR, Perception instead of DEX/proficiency).
|
||||||
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
||||||
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`.
|
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches.
|
||||||
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
||||||
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation.
|
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation.
|
||||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||||
@@ -331,3 +367,5 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
- **SC-019**: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
|
||||||
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
- **SC-020**: Switching game system immediately changes which creatures appear in search — no page reload required.
|
||||||
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
- **SC-021**: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
|
||||||
|
- **SC-022**: Clicking any spell in a PF2e creature's stat block opens its description display within 100ms — no network I/O is performed.
|
||||||
|
- **SC-023**: PF2e spell descriptions are available offline once the bestiary source containing the creature has been cached.
|
||||||
|
|||||||
Reference in New Issue
Block a user