import type { CreatureId, EquipmentItem, Pf2eCreature, SpellcastingBlock, SpellReference, TraitBlock, } from "@initiative/domain"; import { creatureId } from "@initiative/domain"; import { stripFoundryTags } from "./strip-foundry-tags.js"; // -- Raw Foundry VTT types (minimal, for parsing) -- interface RawFoundryCreature { _id: string; name: string; type: string; system: { abilities: Record; attributes: { ac: { value: number; details?: string }; hp: { max: number; details?: string }; speed: { value: number; otherSpeeds?: { type: string; value: number }[]; details?: string; }; immunities?: { type: string; exceptions?: string[] }[]; resistances?: { type: string; value: number; exceptions?: string[] }[]; weaknesses?: { type: string; value: number }[]; allSaves?: { value: string }; }; details: { level: { value: number }; languages: { value?: string[]; details?: string }; publication: { license: string; remaster: boolean; title: string }; }; perception: { mod: number; details?: string; senses?: { type: string; acuity?: string; range?: number }[]; }; saves: { fortitude: { value: number; saveDetail?: string }; reflex: { value: number; saveDetail?: string }; will: { value: number; saveDetail?: string }; }; skills: Record; traits: { rarity: string; size: { value: string }; value: string[] }; }; items: RawFoundryItem[]; } interface RawFoundryItem { _id: string; name: string; type: string; system: Record; sort?: number; } interface MeleeSystem { bonus?: { value: number }; damageRolls?: Record; traits?: { value: string[] }; attackEffects?: { value: string[] }; } interface ActionSystem { category?: string; actionType?: { value: string }; actions?: { value: number | null }; traits?: { value: string[] }; description?: { value: string }; frequency?: { max: number; per: string }; } interface SpellcastingEntrySystem { tradition?: { value: string }; prepared?: { value: string }; spelldc?: { dc: number; value?: number }; } interface SpellSystem { slug?: string; location?: { value: string; heightenedLevel?: number; uses?: { max: number; value: number }; }; level?: { value: number }; 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; } | { type: "interval"; interval: number; damage?: { value: string }; } | undefined; overlays?: Record< string, { name?: string; system?: { description?: { value: string } } } >; } 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 = { tiny: "tiny", sm: "small", med: "medium", lg: "large", huge: "huge", grg: "gargantuan", }; // -- Helpers -- function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } function makeCreatureId(source: string, name: string): CreatureId { const slug = name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); return creatureId(`${source}:${slug}`); } const NUMERIC_SLUG = /^(.+)-(\d+)$/; const LETTER_SLUG = /^(.+)-([a-z])$/; /** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */ const NUMERIC_TRAIT_FORMATS: Record string> = { reach: (n) => `reach ${n} feet`, range: (n) => `range ${n} feet`, "range-increment": (n) => `range increment ${n} feet`, versatile: (n) => `versatile ${n}`, deadly: (n) => `deadly d${n}`, fatal: (n) => `fatal d${n}`, "fatal-aim": (n) => `fatal aim d${n}`, reload: (n) => `reload ${n}`, }; /** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */ const LETTER_TRAIT_FORMATS: Record string> = { versatile: (l) => `versatile ${l.toUpperCase()}`, deadly: (l) => `deadly d${l}`, }; /** Expand slugified trait names: "reach-10" → "reach 10 feet" */ function formatTrait(slug: string): string { const numMatch = NUMERIC_SLUG.exec(slug); if (numMatch) { const [, base, num] = numMatch; const fmt = NUMERIC_TRAIT_FORMATS[base]; return fmt ? fmt(num) : `${base} ${num}`; } const letterMatch = LETTER_SLUG.exec(slug); if (letterMatch) { const [, base, letter] = letterMatch; const fmt = LETTER_TRAIT_FORMATS[base]; if (fmt) return fmt(letter); } return slug.replaceAll("-", " "); } // -- Formatting -- function formatSenses( senses: { type: string; acuity?: string; range?: number }[] | undefined, ): string | undefined { if (!senses || senses.length === 0) return undefined; return senses .map((s) => { const parts = [capitalize(s.type.replaceAll("-", " "))]; if (s.acuity && s.acuity !== "precise") { parts.push(`(${s.acuity})`); } if (s.range != null) parts.push(`${s.range} feet`); return parts.join(" "); }) .join(", "); } function formatLanguages( languages: { value?: string[]; details?: string } | undefined, ): string | undefined { if (!languages?.value || languages.value.length === 0) return undefined; const list = languages.value.map(capitalize).join(", "); return languages.details ? `${list} (${languages.details})` : list; } function formatSkills( skills: Record | undefined, ): string | undefined { if (!skills) return undefined; const entries = Object.entries(skills); if (entries.length === 0) return undefined; return entries .map(([name, val]) => { const label = capitalize(name.replaceAll("-", " ")); return `${label} +${val.base}`; }) .sort() .join(", "); } function formatImmunities( immunities: { type: string; exceptions?: string[] }[] | undefined, ): string | undefined { if (!immunities || immunities.length === 0) return undefined; return immunities .map((i) => { const base = capitalize(i.type.replaceAll("-", " ")); if (i.exceptions && i.exceptions.length > 0) { return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`; } return base; }) .join(", "); } function formatResistances( resistances: | { type: string; value: number; exceptions?: string[] }[] | undefined, ): string | undefined { if (!resistances || resistances.length === 0) return undefined; return resistances .map((r) => { const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`; if (r.exceptions && r.exceptions.length > 0) { return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`; } return base; }) .join(", "); } function formatWeaknesses( weaknesses: { type: string; value: number }[] | undefined, ): string | undefined { if (!weaknesses || weaknesses.length === 0) return undefined; return weaknesses .map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`) .join(", "); } function formatSpeed(speed: { value: number; otherSpeeds?: { type: string; value: number }[]; details?: string; }): string { const parts = [`${speed.value} feet`]; if (speed.otherSpeeds) { for (const s of speed.otherSpeeds) { parts.push(`${capitalize(s.type)} ${s.value} feet`); } } const base = parts.join(", "); return speed.details ? `${base} (${speed.details})` : base; } // -- Attack normalization -- /** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */ function formatAttackEffect(slug: string, creatureName: string): string { const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`; const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug; return stripped.split("-").map(capitalize).join(" "); } function normalizeAttack( item: RawFoundryItem, creatureName: string, ): TraitBlock { const sys = item.system as unknown as MeleeSystem; const bonus = sys.bonus?.value ?? 0; const traits = sys.traits?.value ?? []; const damageEntries = Object.values(sys.damageRolls ?? {}); const damage = damageEntries .map((d) => `${d.damage} ${d.damageType}`) .join(" plus "); const traitStr = traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : ""; const effects = sys.attackEffects?.value ?? []; const effectStr = effects.length > 0 ? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}` : ""; return { name: capitalize(item.name), activity: { number: 1, unit: "action" }, segments: [ { type: "text", value: `+${bonus}${traitStr}, ${damage}${effectStr}`, }, ], }; } function parseActivity( actionType: string | undefined, actionCount: number | null | undefined, ): { number: number; unit: "action" | "free" | "reaction" } | undefined { if (actionType === "action") { return { number: actionCount ?? 1, unit: "action" }; } if (actionType === "reaction") { return { number: 1, unit: "reaction" }; } if (actionType === "free") { return { number: 1, unit: "free" }; } return undefined; } // -- Ability normalization -- const FREQUENCY_LINE = /()?Frequency(<\/strong>)?\s+[^\n]+\n*/i; /** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */ function stripFrequencyLine(text: string): string { return text.replace(FREQUENCY_LINE, "").trimStart(); } function normalizeAbility(item: RawFoundryItem): TraitBlock { const sys = item.system as unknown as ActionSystem; const actionType = sys.actionType?.value; const actionCount = sys.actions?.value; let description = stripFoundryTags(sys.description?.value ?? ""); const traits = sys.traits?.value ?? []; const activity = parseActivity(actionType, actionCount); const frequency = sys.frequency?.max != null && sys.frequency.per ? `${sys.frequency.max}/${sys.frequency.per}` : undefined; if (frequency) { description = stripFrequencyLine(description); } const traitStr = traits.length > 0 ? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) ` : ""; const text = traitStr ? `${traitStr}${description}` : description; const segments: { type: "text"; value: string }[] = text ? [{ type: "text", value: text }] : []; return { name: item.name, activity, frequency, segments }; } // -- Spellcasting normalization -- 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, creatureLevel: number, ): SpellReference { const sys = item.system as unknown as SpellSystem; const usesMax = sys.location?.uses?.max; 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(); } 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( entry: RawFoundryItem, allSpells: readonly RawFoundryItem[], creatureLevel: number, ): SpellcastingBlock { const sys = entry.system as unknown as SpellcastingEntrySystem; const tradition = capitalize(sys.tradition?.value ?? ""); const prepared = sys.prepared?.value ?? ""; const dc = sys.spelldc?.dc ?? 0; const attack = sys.spelldc?.value ?? 0; const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`; const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`; const linkedSpells = allSpells.filter( (s) => (s.system as unknown as SpellSystem).location?.value === entry._id, ); const byRank = new Map(); const cantrips: SpellReference[] = []; for (const spell of linkedSpells) { const ref = normalizeSpell(spell, creatureLevel); const isCantrip = (spell.system as unknown as SpellSystem).traits?.value?.includes( "cantrip", ) ?? false; if (isCantrip) { cantrips.push(ref); continue; } const rank = ref.rank ?? 0; const existing = byRank.get(rank) ?? []; existing.push(ref); byRank.set(rank, existing); } const daily = [...byRank.entries()] .sort(([a], [b]) => b - a) .map(([rank, spells]) => ({ uses: rank, each: true, spells, })); return { name, headerText, atWill: orUndefined(cantrips), daily: orUndefined(daily), }; } 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, creatureLevel), ); } // -- Main normalization -- function orUndefined(arr: T[]): T[] | undefined { return arr.length > 0 ? arr : undefined; } /** Build display traits: [rarity (if not common), size, ...type traits] */ function buildTraits(traits: { rarity: string; size: { value: string }; value: string[]; }): string[] { const result: string[] = []; if (traits.rarity && traits.rarity !== "common") { result.push(traits.rarity); } const size = SIZE_MAP[traits.size.value] ?? "medium"; result.push(size); result.push(...traits.value); return result; } const HEALING_GLOSSARY = /^

@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/; /** Glossary-only abilities that duplicate structured data shown elsewhere. */ const REDUNDANT_GLOSSARY = /^

@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/; const STRIP_GLOSSARY_AND_P = /

@Localize\[[^\]]+\]<\/p>|<\/?p>/g; /** True when the description has no user-visible content beyond glossary tags. */ function isGlossaryOnly(desc: string | undefined): boolean { if (!desc) return true; return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === ""; } function isRedundantAbility( item: RawFoundryItem, excludeName: string | undefined, hpDetails: string | undefined, ): boolean { const sys = item.system as unknown as ActionSystem; const desc = sys.description?.value; // Ability duplicates the allSaves line — suppress only if glossary-only if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) { return isGlossaryOnly(desc); } if (!desc) return false; // Healing/regen glossary when hp.details already shows the info if (hpDetails && HEALING_GLOSSARY.test(desc)) return true; // Spell mechanic glossary reminders shown in the spellcasting section if (REDUNDANT_GLOSSARY.test(desc)) return true; return false; } function actionsByCategory( items: readonly RawFoundryItem[], category: string, excludeName?: string, hpDetails?: string, ): TraitBlock[] { return items .filter( (a) => a.type === "action" && (a.system as unknown as ActionSystem).category === category && !isRedundantAbility(a, excludeName, hpDetails), ) .map(normalizeAbility); } function extractAbilityMods( mods: Record, ): Pf2eCreature["abilityMods"] { return { str: mods.str?.mod ?? 0, dex: mods.dex?.mod ?? 0, con: mods.con?.mod ?? 0, int: mods.int?.mod ?? 0, wis: mods.wis?.mod ?? 0, cha: mods.cha?.mod ?? 0, }; } export function normalizeFoundryCreature( raw: unknown, sourceCode?: string, sourceDisplayName?: string, ): Pf2eCreature { const r = raw as RawFoundryCreature; const sys = r.system; const publication = sys.details?.publication; const source = sourceCode ?? publication?.title ?? ""; const items = r.items ?? []; const allSavesText = sys.attributes.allSaves?.value ?? ""; return { system: "pf2e", id: makeCreatureId(source, r.name), name: r.name, source, sourceDisplayName: sourceDisplayName ?? publication?.title ?? "", level: sys.details?.level?.value ?? 0, traits: buildTraits(sys.traits), perception: sys.perception?.mod ?? 0, perceptionDetails: sys.perception?.details || undefined, senses: formatSenses(sys.perception?.senses), languages: formatLanguages(sys.details?.languages), skills: formatSkills(sys.skills), abilityMods: extractAbilityMods(sys.abilities ?? {}), ac: sys.attributes.ac.value, acConditional: sys.attributes.ac.details || undefined, saveFort: sys.saves.fortitude.value, saveRef: sys.saves.reflex.value, saveWill: sys.saves.will.value, saveConditional: allSavesText || undefined, hp: sys.attributes.hp.max, hpDetails: sys.attributes.hp.details || undefined, immunities: formatImmunities(sys.attributes.immunities), resistances: formatResistances(sys.attributes.resistances), weaknesses: formatWeaknesses(sys.attributes.weaknesses), speed: formatSpeed(sys.attributes.speed), attacks: orUndefined( items .filter((i) => i.type === "melee") .map((i) => normalizeAttack(i, r.name)), ), abilitiesTop: orUndefined(actionsByCategory(items, "interaction")), abilitiesMid: orUndefined( actionsByCategory( items, "defensive", allSavesText || undefined, sys.attributes.hp.details || undefined, ), ), abilitiesBot: orUndefined(actionsByCategory(items, "offensive")), 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), ), }; } export function normalizeFoundryCreatures( rawCreatures: unknown[], sourceCode?: string, sourceDisplayName?: string, ): Pf2eCreature[] { return rawCreatures.map((raw) => normalizeFoundryCreature(raw, sourceCode, sourceDisplayName), ); }