Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e8297c95 | ||
|
|
e161645228 | ||
|
|
9b0cb38897 | ||
|
|
5cb5721a6f |
@@ -33,8 +33,7 @@ async function addCombatant(
|
||||
opts?: { maxHp?: string },
|
||||
) {
|
||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||
const input = inputs.at(-1)!;
|
||||
const input = inputs.at(-1) ?? inputs[0];
|
||||
await user.type(input, name);
|
||||
|
||||
if (opts?.maxHp) {
|
||||
|
||||
@@ -198,21 +198,23 @@ describe("ConfirmButton", () => {
|
||||
|
||||
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||
const parentHandler = vi.fn();
|
||||
render(
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||
<div onKeyDown={parentHandler}>
|
||||
function Wrapper() {
|
||||
return (
|
||||
<button type="button" onKeyDown={parentHandler}>
|
||||
<ConfirmButton
|
||||
icon={<XIcon />}
|
||||
label="Remove combatant"
|
||||
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(button, { key: " " });
|
||||
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||
fireEvent.keyDown(confirmButton, { key: " " });
|
||||
|
||||
expect(parentHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -181,17 +181,20 @@ describe("normalizeBestiary", () => {
|
||||
expect(sc?.name).toBe("Spellcasting");
|
||||
expect(sc?.headerText).toContain("DC 15");
|
||||
expect(sc?.headerText).not.toContain("{@");
|
||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
||||
expect(sc?.atWill).toEqual([
|
||||
{ name: "Detect Magic" },
|
||||
{ name: "Mage Hand" },
|
||||
]);
|
||||
expect(sc?.daily).toHaveLength(2);
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 2,
|
||||
each: true,
|
||||
spells: ["Fireball"],
|
||||
spells: [{ name: "Fireball" }],
|
||||
});
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 1,
|
||||
each: false,
|
||||
spells: ["Dimension Door"],
|
||||
spells: [{ name: "Dimension Door" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -593,11 +593,11 @@ describe("normalizeFoundryCreature", () => {
|
||||
const sc = creature.spellcasting?.[0];
|
||||
expect(sc?.name).toBe("Primal Prepared Spells");
|
||||
expect(sc?.headerText).toBe("DC 30, attack +22");
|
||||
expect(sc?.daily).toEqual([
|
||||
{ uses: 6, each: true, spells: ["Earthquake"] },
|
||||
{ uses: 3, each: true, spells: ["Heal"] },
|
||||
]);
|
||||
expect(sc?.atWill).toEqual(["Detect Magic"]);
|
||||
expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]);
|
||||
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
||||
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
||||
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
||||
expect(sc?.atWill?.[0]?.rank).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes innate spells with uses", () => {
|
||||
@@ -633,13 +633,334 @@ describe("normalizeFoundryCreature", () => {
|
||||
);
|
||||
const sc = creature.spellcasting?.[0];
|
||||
expect(sc?.headerText).toBe("DC 32");
|
||||
expect(sc?.daily).toEqual([
|
||||
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,
|
||||
each: true,
|
||||
spells: ["Sure Strike (\u00d73)"],
|
||||
_id: "entry1",
|
||||
name: "Divine Innate Spells",
|
||||
type: "spellcastingEntry",
|
||||
system: {
|
||||
tradition: { value: "divine" },
|
||||
prepared: { value: "innate" },
|
||||
spelldc: { dc: 35, value: 27 },
|
||||
},
|
||||
]);
|
||||
},
|
||||
{
|
||||
_id: "s1",
|
||||
name: "Heal",
|
||||
type: "spell",
|
||||
system: {
|
||||
slug: "heal",
|
||||
location: { value: "entry1" },
|
||||
level: { value: 6 },
|
||||
traits: {
|
||||
rarity: "common",
|
||||
value: ["healing", "vitality"],
|
||||
traditions: ["divine", "primal"],
|
||||
},
|
||||
description: {
|
||||
value:
|
||||
"<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,
|
||||
LegendaryBlock,
|
||||
SpellcastingBlock,
|
||||
SpellReference,
|
||||
TraitBlock,
|
||||
TraitListItem,
|
||||
TraitSegment,
|
||||
@@ -385,7 +386,7 @@ function normalizeSpellcasting(
|
||||
const block: {
|
||||
name: string;
|
||||
headerText: string;
|
||||
atWill?: string[];
|
||||
atWill?: SpellReference[];
|
||||
daily?: DailySpells[];
|
||||
restLong?: DailySpells[];
|
||||
} = {
|
||||
@@ -396,7 +397,7 @@ function normalizeSpellcasting(
|
||||
const hidden = new Set(sc.hidden ?? []);
|
||||
|
||||
if (sc.will && !hidden.has("will")) {
|
||||
block.atWill = sc.will.map((s) => stripTags(s));
|
||||
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||
}
|
||||
|
||||
if (sc.daily) {
|
||||
@@ -418,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||
return {
|
||||
uses,
|
||||
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 STORE_NAME = "sources";
|
||||
const DB_VERSION = 5;
|
||||
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
|
||||
const DB_VERSION = 6;
|
||||
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CreatureId,
|
||||
Pf2eCreature,
|
||||
SpellcastingBlock,
|
||||
SpellReference,
|
||||
TraitBlock,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
@@ -78,13 +79,39 @@ interface SpellcastingEntrySystem {
|
||||
}
|
||||
|
||||
interface SpellSystem {
|
||||
slug?: string;
|
||||
location?: {
|
||||
value: string;
|
||||
heightenedLevel?: number;
|
||||
uses?: { max: number; value: number };
|
||||
};
|
||||
level?: { value: number };
|
||||
traits?: { value: string[] };
|
||||
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
||||
description?: { value: string };
|
||||
range?: { value: string };
|
||||
target?: { value: string };
|
||||
area?: { type?: string; value?: number; details?: string };
|
||||
duration?: { value: string; sustained?: boolean };
|
||||
time?: { value: string };
|
||||
defense?: {
|
||||
save?: { statistic: string; basic?: boolean };
|
||||
passive?: { statistic: string };
|
||||
};
|
||||
heightening?:
|
||||
| {
|
||||
type: "fixed";
|
||||
levels: Record<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> = {
|
||||
@@ -311,18 +338,102 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
|
||||
// -- Spellcasting normalization --
|
||||
|
||||
function classifySpell(spell: RawFoundryItem): {
|
||||
isCantrip: boolean;
|
||||
rank: number;
|
||||
label: string;
|
||||
} {
|
||||
const sys = spell.system as unknown as SpellSystem;
|
||||
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
|
||||
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||
if (!range?.value) return undefined;
|
||||
return range.value;
|
||||
}
|
||||
|
||||
function formatArea(
|
||||
area: { type?: string; value?: number; details?: string } | undefined,
|
||||
): string | undefined {
|
||||
if (!area) return undefined;
|
||||
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||
return area.details ?? undefined;
|
||||
}
|
||||
|
||||
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
||||
if (!defense) return undefined;
|
||||
if (defense.save) {
|
||||
const stat = capitalize(defense.save.statistic);
|
||||
return defense.save.basic ? `basic ${stat}` : stat;
|
||||
}
|
||||
if (defense.passive) return capitalize(defense.passive.statistic);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatHeightening(
|
||||
heightening: SpellSystem["heightening"],
|
||||
): string | undefined {
|
||||
if (!heightening) return undefined;
|
||||
if (heightening.type === "fixed") {
|
||||
const parts = Object.entries(heightening.levels)
|
||||
.filter(([, lvl]) => lvl.text)
|
||||
.map(
|
||||
([rank, lvl]) =>
|
||||
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
||||
);
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
if (heightening.type === "interval") {
|
||||
const dmg = heightening.damage?.value
|
||||
? ` damage increases by ${heightening.damage.value}`
|
||||
: "";
|
||||
return `Heightened (+${heightening.interval})${dmg}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||
if (!overlays) return undefined;
|
||||
const parts: string[] = [];
|
||||
for (const overlay of Object.values(overlays)) {
|
||||
const desc = overlay.system?.description?.value;
|
||||
if (!desc) continue;
|
||||
const label = overlay.name ? `${overlay.name}: ` : "";
|
||||
parts.push(`${label}${stripFoundryTags(desc)}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foundry descriptions often include heightening rules inline at the end.
|
||||
* When we extract heightening into a structured field, strip that trailing
|
||||
* text to avoid duplication.
|
||||
*/
|
||||
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||
|
||||
function normalizeSpell(item: RawFoundryItem): SpellReference {
|
||||
const sys = item.system as unknown as SpellSystem;
|
||||
const usesMax = sys.location?.uses?.max;
|
||||
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
||||
const uses = sys.location?.uses;
|
||||
const label =
|
||||
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
|
||||
return { isCantrip, rank, label };
|
||||
const heightening =
|
||||
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||
|
||||
let description: string | undefined;
|
||||
if (sys.description?.value) {
|
||||
let text = stripFoundryTags(sys.description.value);
|
||||
if (heightening) {
|
||||
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
||||
}
|
||||
description = text || undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
slug: sys.slug,
|
||||
rank,
|
||||
description,
|
||||
traits: sys.traits?.value,
|
||||
traditions: sys.traits?.traditions,
|
||||
range: formatRange(sys.range),
|
||||
target: sys.target?.value || undefined,
|
||||
area: formatArea(sys.area),
|
||||
duration: sys.duration?.value || undefined,
|
||||
defense: formatDefense(sys.defense),
|
||||
actionCost: sys.time?.value || undefined,
|
||||
heightening,
|
||||
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpellcastingEntry(
|
||||
@@ -342,26 +453,31 @@ function normalizeSpellcastingEntry(
|
||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||
);
|
||||
|
||||
const byRank = new Map<number, string[]>();
|
||||
const cantrips: string[] = [];
|
||||
const byRank = new Map<number, SpellReference[]>();
|
||||
const cantrips: SpellReference[] = [];
|
||||
|
||||
for (const spell of linkedSpells) {
|
||||
const { isCantrip, rank, label } = classifySpell(spell);
|
||||
const ref = normalizeSpell(spell);
|
||||
const isCantrip =
|
||||
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||
"cantrip",
|
||||
) ?? false;
|
||||
if (isCantrip) {
|
||||
cantrips.push(spell.name);
|
||||
cantrips.push(ref);
|
||||
continue;
|
||||
}
|
||||
const rank = ref.rank ?? 0;
|
||||
const existing = byRank.get(rank) ?? [];
|
||||
existing.push(label);
|
||||
existing.push(ref);
|
||||
byRank.set(rank, existing);
|
||||
}
|
||||
|
||||
const daily = [...byRank.entries()]
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rank, spellNames]) => ({
|
||||
.map(([rank, spells]) => ({
|
||||
uses: rank,
|
||||
each: true,
|
||||
spells: spellNames,
|
||||
spells,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const USES_PER_DAY_REGEX = /×3/;
|
||||
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||
@@ -22,6 +25,12 @@ const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||
const CANTRIPS_REGEX = /Cantrips:/;
|
||||
const AC_REGEX = /16/;
|
||||
const RK_DC_13_REGEX = /DC 13/;
|
||||
const RK_DC_15_REGEX = /DC 15/;
|
||||
const RK_DC_25_REGEX = /DC 25/;
|
||||
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||
|
||||
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||
system: "pf2e",
|
||||
@@ -90,9 +99,13 @@ const NAUNET: Pf2eCreature = {
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 25, attack +17",
|
||||
daily: [
|
||||
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||
},
|
||||
],
|
||||
atWill: ["Detect Magic"],
|
||||
atWill: [{ name: "Detect Magic" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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", () => {
|
||||
it("renders perception modifier and senses", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
@@ -277,4 +337,87 @@ describe("Pf2eStatBlock", () => {
|
||||
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickable spells", () => {
|
||||
const SPELLCASTER: Pf2eCreature = {
|
||||
...NAUNET,
|
||||
id: creatureId("test:spellcaster"),
|
||||
name: "Spellcaster",
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 30, attack +20",
|
||||
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||
daily: [
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [
|
||||
{
|
||||
name: "Heal",
|
||||
description: "You channel positive energy to heal.",
|
||||
rank: 4,
|
||||
usesPerDay: 3,
|
||||
},
|
||||
{ name: "Restoration", rank: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: "(min-width: 1024px)",
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a spell with a description as a clickable button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a spell without description as plain text (not a button)", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Restoration" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the spell popover when a spell button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the popover when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(
|
||||
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeAll(() => {
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||
const adapters = createTestAdapters();
|
||||
// Wire getCachedSources to return the provided sources initially,
|
||||
// then empty after clear operations
|
||||
@@ -57,14 +57,14 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
void renderWithSources([]);
|
||||
renderWithSources([]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -88,7 +88,7 @@ describe("SourceManager", () => {
|
||||
|
||||
it("Clear All button removes all sources", async () => {
|
||||
const user = userEvent.setup();
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -110,7 +110,7 @@ describe("SourceManager", () => {
|
||||
|
||||
it("individual source delete button removes that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
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",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||
daily: [
|
||||
{
|
||||
uses: 3,
|
||||
each: true,
|
||||
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||
},
|
||||
],
|
||||
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">At Will:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
{sc.atWill.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sc.daily?.map((d) => (
|
||||
@@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{d.uses}/day
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
{sc.restLong?.map((d) => (
|
||||
@@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
{d.uses}/long rest
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import { formatInitiativeModifier } from "@initiative/domain";
|
||||
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
||||
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
@@ -34,7 +36,85 @@ function formatMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
interface SpellLinkProps {
|
||||
readonly spell: SpellReference;
|
||||
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) {
|
||||
if (count === undefined || count <= 1) return null;
|
||||
return <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>) {
|
||||
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 = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
{ 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">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
{rk && (
|
||||
<p className="mt-1 text-sm">
|
||||
<span className="font-semibold">Recall Knowledge</span> DC {rk.dc}{" "}
|
||||
• {capitalize(rk.type)} ({rk.skills.join("/")})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
@@ -152,23 +238,31 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{sc.headerText}
|
||||
</div>
|
||||
{sc.daily?.map((d) => (
|
||||
<div key={d.uses} className="pl-2">
|
||||
<span className="font-semibold">
|
||||
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
</div>
|
||||
<SpellListLine
|
||||
key={d.uses}
|
||||
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||
spells={d.spells}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
))}
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">Cantrips:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
</div>
|
||||
<SpellListLine
|
||||
label="Cantrips"
|
||||
spells={sc.atWill}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{openSpell ? (
|
||||
<SpellDetailPopover
|
||||
spell={openSpell.spell}
|
||||
anchorRect={openSpell.rect}
|
||||
onClose={handleCloseSpell}
|
||||
/>
|
||||
) : null}
|
||||
</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);
|
||||
}
|
||||
@@ -61,15 +61,19 @@ function TraitSegments({
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 28 L78 50 L48 72 Z";
|
||||
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 27 L71 50 L48 73 Z";
|
||||
const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z";
|
||||
const ACTION_DIAMOND_OUTLINE =
|
||||
"M90 2 L136 50 L90 98 L44 50 Z M90 29 L111 50 L90 71 L69 50 Z";
|
||||
const FREE_ACTION_DIAMOND =
|
||||
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
||||
const FREE_ACTION_CHEVRON = "M48 28 L78 50 L48 72 Z";
|
||||
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
||||
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";
|
||||
|
||||
function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
||||
export function ActivityIcon({
|
||||
activity,
|
||||
}: Readonly<{ activity: ActivityCost }>) {
|
||||
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||
if (activity.unit === "free") {
|
||||
return (
|
||||
@@ -101,7 +105,7 @@ function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||
<path
|
||||
d="M90 2 L136 50 L90 98 L44 50 Z M88 28 L118 50 L88 72 Z"
|
||||
d={ACTION_DIAMOND_OUTLINE}
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
@@ -113,7 +117,7 @@ function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
||||
<path
|
||||
d="M130 2 L176 50 L130 98 L84 50 Z M128 28 L158 50 L128 72 Z"
|
||||
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
|
||||
@@ -421,7 +421,10 @@ function dispatchEncounterAction(
|
||||
export function useEncounter() {
|
||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||
initializeState(
|
||||
() => encounterPersistence.load(),
|
||||
() => undoRedoPersistence.load(),
|
||||
),
|
||||
);
|
||||
const { encounter, undoRedoState, events } = state;
|
||||
|
||||
|
||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
||||
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;
|
||||
}
|
||||
|
||||
@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 {
|
||||
0% {
|
||||
scale: 1;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"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:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("getConditionDescription", () => {
|
||||
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
||||
);
|
||||
for (const def of sharedDndConditions) {
|
||||
expect(def.description, `${def.id} missing description`).toBeTruthy();
|
||||
expect(def.description5e, `${def.id} missing description5e`).toBeTruthy();
|
||||
expect(def.description).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,
|
||||
* handling auto-numbering for duplicates.
|
||||
@@ -14,25 +39,7 @@ export function resolveCreatureName(
|
||||
newName: string;
|
||||
renames: ReadonlyArray<{ from: string; to: string }>;
|
||||
} {
|
||||
// Find exact matches and numbered matches (e.g., "Goblin 1", "Goblin 2")
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { exactMatches, maxNumber } = scanExisting(baseName, existingNames);
|
||||
|
||||
// No conflict at all
|
||||
if (exactMatches.length === 0 && maxNumber === 0) {
|
||||
@@ -51,7 +58,3 @@ export function resolveCreatureName(
|
||||
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
readonly uses: number;
|
||||
readonly each: boolean;
|
||||
readonly spells: readonly string[];
|
||||
readonly spells: readonly SpellReference[];
|
||||
}
|
||||
|
||||
export interface SpellcastingBlock {
|
||||
readonly name: string;
|
||||
readonly headerText: string;
|
||||
readonly atWill?: readonly string[];
|
||||
readonly atWill?: readonly SpellReference[];
|
||||
readonly daily?: readonly DailySpells[];
|
||||
readonly restLong?: readonly DailySpells[];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
type Pf2eCreature,
|
||||
proficiencyBonus,
|
||||
type SpellcastingBlock,
|
||||
type SpellReference,
|
||||
type TraitBlock,
|
||||
type TraitListItem,
|
||||
type TraitSegment,
|
||||
@@ -107,6 +108,10 @@ export {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RecallKnowledge,
|
||||
recallKnowledge,
|
||||
} from "./recall-knowledge.js";
|
||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||
export {
|
||||
|
||||
118
packages/domain/src/recall-knowledge.ts
Normal file
118
packages/domain/src/recall-knowledge.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PF2e Recall Knowledge DC calculation and type-to-skill mapping.
|
||||
*
|
||||
* DC is derived from creature level using the standard DC-by-level table
|
||||
* (Player Core / GM Core), adjusted for rarity.
|
||||
*/
|
||||
|
||||
/** Standard DC-by-level table from PF2e GM Core. Index = level + 1 (level -1 → index 0). */
|
||||
const DC_BY_LEVEL: readonly number[] = [
|
||||
13, // level -1
|
||||
14, // level 0
|
||||
15, // level 1
|
||||
16, // level 2
|
||||
18, // level 3
|
||||
19, // level 4
|
||||
20, // level 5
|
||||
22, // level 6
|
||||
23, // level 7
|
||||
24, // level 8
|
||||
26, // level 9
|
||||
27, // level 10
|
||||
28, // level 11
|
||||
30, // level 12
|
||||
31, // level 13
|
||||
32, // level 14
|
||||
34, // level 15
|
||||
35, // level 16
|
||||
36, // level 17
|
||||
38, // level 18
|
||||
39, // level 19
|
||||
40, // level 20
|
||||
42, // level 21
|
||||
44, // level 22
|
||||
46, // level 23
|
||||
48, // level 24
|
||||
50, // level 25
|
||||
];
|
||||
|
||||
const RARITY_ADJUSTMENT: Readonly<Record<string, number>> = {
|
||||
uncommon: 2,
|
||||
rare: 5,
|
||||
unique: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping from PF2e creature type traits to the skill(s) used for
|
||||
* Recall Knowledge. Types that map to multiple skills list all of them.
|
||||
*/
|
||||
const TYPE_TO_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
||||
aberration: ["Occultism"],
|
||||
animal: ["Nature"],
|
||||
astral: ["Occultism"],
|
||||
beast: ["Arcana", "Nature"],
|
||||
celestial: ["Religion"],
|
||||
construct: ["Arcana", "Crafting"],
|
||||
dragon: ["Arcana"],
|
||||
dream: ["Occultism"],
|
||||
elemental: ["Arcana", "Nature"],
|
||||
ethereal: ["Occultism"],
|
||||
fey: ["Nature"],
|
||||
fiend: ["Religion"],
|
||||
fungus: ["Nature"],
|
||||
giant: ["Society"],
|
||||
humanoid: ["Society"],
|
||||
monitor: ["Religion"],
|
||||
ooze: ["Occultism"],
|
||||
plant: ["Nature"],
|
||||
undead: ["Religion"],
|
||||
};
|
||||
|
||||
export interface RecallKnowledge {
|
||||
readonly dc: number;
|
||||
readonly type: string;
|
||||
readonly skills: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Recall Knowledge DC, type, and skill(s) for a PF2e creature.
|
||||
*
|
||||
* Returns `null` when no recognized type trait is found in the creature's
|
||||
* traits array, indicating the Recall Knowledge line should be omitted.
|
||||
*/
|
||||
export function recallKnowledge(
|
||||
level: number,
|
||||
traits: readonly string[],
|
||||
): RecallKnowledge | null {
|
||||
// Find the first type trait that maps to a skill
|
||||
let matchedType: string | undefined;
|
||||
let skills: readonly string[] | undefined;
|
||||
|
||||
for (const trait of traits) {
|
||||
const lower = trait.toLowerCase();
|
||||
const mapped = TYPE_TO_SKILLS[lower];
|
||||
if (mapped) {
|
||||
matchedType = trait;
|
||||
skills = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedType || !skills) return null;
|
||||
|
||||
// Calculate DC from level
|
||||
const clampedIndex = Math.max(0, Math.min(level + 1, DC_BY_LEVEL.length - 1));
|
||||
let dc = DC_BY_LEVEL[clampedIndex];
|
||||
|
||||
// Apply rarity adjustment (rarity traits are included in the traits array
|
||||
// for non-common creatures by the normalization pipeline)
|
||||
for (const trait of traits) {
|
||||
const adjustment = RARITY_ADJUSTMENT[trait.toLowerCase()];
|
||||
if (adjustment) {
|
||||
dc += adjustment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dc, type: matchedType, skills };
|
||||
}
|
||||
@@ -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.
|
||||
* Lower these numbers as you fix ignores; they can never go up silently.
|
||||
* 2. Banned rules — ignoring certain rule categories is never allowed.
|
||||
* 3. Justification — every ignore must have a non-empty explanation after
|
||||
* the rule name.
|
||||
* Any `biome-ignore` in tracked .ts/.tsx files fails the build.
|
||||
* Fix the underlying issue instead of suppressing the rule.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────
|
||||
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*(.*))?/;
|
||||
const IGNORE_PATTERN = /biome-ignore\s+([\w/]+)/;
|
||||
|
||||
function findFiles() {
|
||||
return execSync("git ls-files -- '*.ts' '*.tsx'", { encoding: "utf-8" })
|
||||
@@ -32,17 +17,7 @@ function findFiles() {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isTestFile(path) {
|
||||
return (
|
||||
path.includes("__tests__/") ||
|
||||
path.endsWith(".test.ts") ||
|
||||
path.endsWith(".test.tsx")
|
||||
);
|
||||
}
|
||||
|
||||
let errors = 0;
|
||||
let sourceCount = 0;
|
||||
let testCount = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const file of findFiles()) {
|
||||
const lines = readFileSync(file, "utf-8").split("\n");
|
||||
@@ -51,58 +26,16 @@ for (const file of findFiles()) {
|
||||
const match = lines[i].match(IGNORE_PATTERN);
|
||||
if (!match) continue;
|
||||
|
||||
const rule = match[1];
|
||||
const justification = (match[2] ?? "").trim();
|
||||
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++;
|
||||
count++;
|
||||
console.error(`FORBIDDEN: ${file}:${i + 1} — biome-ignore ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Justification required
|
||||
if (!justification) {
|
||||
if (count > 0) {
|
||||
console.error(
|
||||
`MISSING JUSTIFICATION: ${loc} — biome-ignore ${rule} needs an explanation after the colon`,
|
||||
`\n${count} biome-ignore comment(s) found. Fix the issue or restructure the code.`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ratcheting caps
|
||||
if (sourceCount > MAX_SOURCE_IGNORES) {
|
||||
console.error(
|
||||
`SOURCE CAP EXCEEDED: ${sourceCount} biome-ignore comments in source (max ${MAX_SOURCE_IGNORES}). Fix issues and lower the cap.`,
|
||||
);
|
||||
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);
|
||||
} 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)**
|
||||
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
|
||||
|
||||
- **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-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-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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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-075**: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
|
||||
- **FR-076**: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
|
||||
- **FR-084**: The PF2e normalization pipeline MUST preserve per-spell data (slug, level, traits, range, time, target, area, duration, defense, description, heightening/overlays) from embedded `items[type=spell]` entries on NPCs, in addition to the spell name. This data MUST be stored in the cached source data and persisted across browser sessions.
|
||||
- **FR-085**: PF2e stat blocks MUST display a "Recall Knowledge" line below the trait tags showing the DC and the associated skill (e.g., "Recall Knowledge DC 18 • Undead (Religion)").
|
||||
- **FR-086**: The Recall Knowledge DC MUST be calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity: uncommon +2, rare +5, unique +10.
|
||||
- **FR-087**: The Recall Knowledge skill MUST be derived from the creature's type trait using the standard PF2e mapping (e.g., Aberration → Occultism, Animal → Nature, Astral → Occultism, Beast → Arcana/Nature, Celestial → Religion, Construct → Arcana/Crafting, Dragon → Arcana, Dream → Occultism, Elemental → Arcana/Nature, Ethereal → Occultism, Fey → Nature, Fiend → Religion, Fungus → Nature, Giant → Society, Humanoid → Society, Monitor → Religion, Ooze → Occultism, Plant → Nature, Undead → Religion).
|
||||
- **FR-088**: Creatures with no recognized type trait MUST omit the Recall Knowledge line entirely rather than showing incorrect data.
|
||||
- **FR-089**: The Recall Knowledge line MUST NOT be shown for D&D creatures.
|
||||
|
||||
### Acceptance Scenarios
|
||||
|
||||
@@ -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 (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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -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-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-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