Add PF2e equipment display with detail popovers in stat blocks
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 17s

Extract shared DetailPopover shell from spell popovers. Normalize
weapon/consumable/equipment/armor items from Foundry data into
mundane (Items line) and detailed (Equipment section with clickable
popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-10 20:21:11 +02:00
parent e2e8297c95
commit 1eaeecad32
16 changed files with 943 additions and 158 deletions

View File

@@ -1,5 +1,6 @@
import type {
CreatureId,
EquipmentItem,
Pf2eCreature,
SpellcastingBlock,
SpellReference,
@@ -114,6 +115,73 @@ interface SpellSystem {
>;
}
interface ConsumableSystem {
level?: { value: number };
traits?: { value: string[] };
description?: { value: string };
category?: string;
spell?: {
name: string;
system?: { level?: { value: number } };
} | null;
}
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
/** Items shown in the Equipment section with popovers. */
function isDetailedEquipment(item: RawFoundryItem): boolean {
if (!EQUIPMENT_TYPES.has(item.type)) return false;
const sys = item.system;
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
if (item.type === "consumable") return true;
// Magical/invested items
if (traits.includes("magical") || traits.includes("invested")) return true;
// Special material armor/equipment
const material = sys.material as { type: string | null } | undefined;
if (material?.type) return true;
// Higher-level items
if (level > 0) return true;
return false;
}
/** Items shown on the "Items" line as plain names. */
function isMundaneItem(item: RawFoundryItem): boolean {
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
}
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
const sys = item.system;
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
const traits = (sys.traits as { value: string[] } | undefined)?.value;
const rawDesc = (sys.description as { value: string } | undefined)?.value;
const description = rawDesc
? stripFoundryTags(rawDesc) || undefined
: undefined;
const category = sys.category as string | undefined;
let spellName: string | undefined;
let spellRank: number | undefined;
if (item.type === "consumable") {
const spell = (sys as unknown as ConsumableSystem).spell;
if (spell) {
spellName = spell.name;
spellRank = spell.system?.level?.value;
}
}
return {
name: item.name,
level,
category: category || undefined,
traits: traits && traits.length > 0 ? traits : undefined,
description,
spellName,
spellRank,
};
}
const SIZE_MAP: Record<string, string> = {
tiny: "tiny",
sm: "small",
@@ -402,16 +470,25 @@ function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
*/
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
function normalizeSpell(item: RawFoundryItem): SpellReference {
function normalizeSpell(
item: RawFoundryItem,
creatureLevel: number,
): 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 isCantrip = sys.traits?.value?.includes("cantrip") ?? false;
const rank =
sys.location?.heightenedLevel ??
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
const heightening =
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
let description: string | undefined;
if (sys.description?.value) {
let text = stripFoundryTags(sys.description.value);
// Resolve Foundry Roll formula references to the spell's actual rank.
// The parenthesized form (e.g., "(@item.level)d4") is most common.
text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank));
if (heightening) {
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
}
@@ -439,6 +516,7 @@ function normalizeSpell(item: RawFoundryItem): SpellReference {
function normalizeSpellcastingEntry(
entry: RawFoundryItem,
allSpells: readonly RawFoundryItem[],
creatureLevel: number,
): SpellcastingBlock {
const sys = entry.system as unknown as SpellcastingEntrySystem;
const tradition = capitalize(sys.tradition?.value ?? "");
@@ -457,7 +535,7 @@ function normalizeSpellcastingEntry(
const cantrips: SpellReference[] = [];
for (const spell of linkedSpells) {
const ref = normalizeSpell(spell);
const ref = normalizeSpell(spell, creatureLevel);
const isCantrip =
(spell.system as unknown as SpellSystem).traits?.value?.includes(
"cantrip",
@@ -490,10 +568,13 @@ function normalizeSpellcastingEntry(
function normalizeSpellcasting(
items: readonly RawFoundryItem[],
creatureLevel: number,
): SpellcastingBlock[] {
const entries = items.filter((i) => i.type === "spellcastingEntry");
const spells = items.filter((i) => i.type === "spell");
return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
return entries.map((entry) =>
normalizeSpellcastingEntry(entry, spells, creatureLevel),
);
}
// -- Main normalization --
@@ -632,7 +713,17 @@ export function normalizeFoundryCreature(
),
),
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
spellcasting: orUndefined(normalizeSpellcasting(items)),
spellcasting: orUndefined(
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
),
items:
items
.filter(isMundaneItem)
.map((i) => i.name)
.join(", ") || undefined,
equipment: orUndefined(
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
),
};
}