Add PF2e equipment display with detail popovers in stat blocks
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:
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user