Add PF2e spell description popovers in stat blocks
Clicking a spell name in a PF2e creature's stat block now opens a popover (desktop) or bottom sheet (mobile) showing full spell details: description, traits, rank, range, target, area, duration, defense, action cost icons, and heightening rules. All data is sourced from the embedded Foundry VTT spell items already in the bestiary cache. - Add SpellReference type replacing bare string spell arrays - Extract full spell data in pf2e-bestiary-adapter (description, traits, traditions, range, target, area, duration, defense, action cost, heightening, overlays) - Strip inline heightening text from descriptions to avoid duplication - Bold save outcome labels (Critical Success/Failure) in descriptions - Bump DB_VERSION to 6 for cache invalidation - Add useSwipeToDismissDown hook for mobile bottom sheet - Portal popover to document.body to escape transformed ancestors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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([
|
||||
{
|
||||
uses: 1,
|
||||
each: true,
|
||||
spells: ["Sure Strike (\u00d73)"],
|
||||
},
|
||||
]);
|
||||
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: [
|
||||
{
|
||||
_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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user