import type { CreatureId, Pf2eCreature, TraitBlock, TraitSegment, } from "@initiative/domain"; import { creatureId } from "@initiative/domain"; import { stripTags } from "./strip-tags.js"; // -- Raw Pf2eTools types (minimal, for parsing) -- interface RawPf2eCreature { name: string; source: string; level?: number; traits?: string[]; perception?: { std?: number }; senses?: { name?: string; type?: string; range?: number }[]; languages?: { languages?: string[] }; skills?: Record; abilityMods?: Record; items?: string[]; defenses?: RawDefenses; speed?: Record; attacks?: RawAttack[]; abilities?: { top?: RawAbility[]; mid?: RawAbility[]; bot?: RawAbility[]; }; _copy?: unknown; } interface RawDefenses { ac?: Record; savingThrows?: { fort?: { std?: number }; ref?: { std?: number }; will?: { std?: number }; }; hp?: { hp?: number }[]; immunities?: (string | { name: string })[]; resistances?: { amount?: number; name: string; note?: string }[]; weaknesses?: { amount?: number; name: string; note?: string }[]; } interface RawAbility { name?: string; activity?: { number?: number; unit?: string }; traits?: string[]; entries?: RawEntry[]; } interface RawAttack { range?: string; name: string; attack?: number; traits?: string[]; damage?: string; } type RawEntry = string | RawEntryObject; interface RawEntryObject { type?: string; items?: (string | { name?: string; entry?: string })[]; entries?: RawEntry[]; } // -- Module state -- let sourceDisplayNames: Record = {}; export function setPf2eSourceDisplayNames(names: Record): void { sourceDisplayNames = names; } // -- Helpers -- function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } function formatActivityIcon( activity: { number?: number; unit?: string } | undefined, ): string { if (!activity) return ""; switch (activity.unit) { case "free": return "\u25C7 "; case "reaction": return "\u21BA "; case "action": return "\u25C6".repeat(activity.number ?? 1) + " "; default: return ""; } } function stripAngleBrackets(s: string): string { return s.replaceAll(/<([^>]+)>/g, "$1"); } function makeCreatureId(source: string, name: string): CreatureId { const slug = name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); return creatureId(`${source.toLowerCase()}:${slug}`); } function formatSpeed( speed: Record | undefined, ): string { if (!speed) return ""; const parts: string[] = []; for (const [mode, value] of Object.entries(speed)) { if (typeof value === "number") { parts.push( mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`, ); } else if (typeof value === "object" && "number" in value) { parts.push( mode === "walk" ? `${value.number} feet` : `${capitalize(mode)} ${value.number} feet`, ); } } return parts.join(", "); } function formatSkills( skills: Record | undefined, ): string | undefined { if (!skills) return undefined; const parts = Object.entries(skills) .map(([name, val]) => `${capitalize(name)} +${val.std ?? 0}`) .sort(); return parts.length > 0 ? parts.join(", ") : undefined; } function formatSenses( senses: | readonly { name?: string; type?: string; range?: number }[] | undefined, ): string | undefined { if (!senses || senses.length === 0) return undefined; return senses .map((s) => { const label = stripTags(s.name ?? s.type ?? ""); if (!label) return ""; const parts = [capitalize(label)]; if (s.type && s.name) parts.push(`(${s.type})`); if (s.range != null) parts.push(`${s.range} feet`); return parts.join(" "); }) .filter(Boolean) .join(", "); } function formatLanguages( languages: { languages?: string[] } | undefined, ): string | undefined { if (!languages?.languages || languages.languages.length === 0) return undefined; return languages.languages.map(capitalize).join(", "); } function formatImmunities( immunities: readonly (string | { name: string })[] | undefined, ): string | undefined { if (!immunities || immunities.length === 0) return undefined; return immunities .map((i) => capitalize(typeof i === "string" ? i : i.name)) .join(", "); } function formatResistances( resistances: | readonly { amount?: number; name: string; note?: string }[] | undefined, ): string | undefined { if (!resistances || resistances.length === 0) return undefined; return resistances .map((r) => { const base = r.amount == null ? capitalize(r.name) : `${capitalize(r.name)} ${r.amount}`; return r.note ? `${base} (${r.note})` : base; }) .join(", "); } function formatWeaknesses( weaknesses: | readonly { amount?: number; name: string; note?: string }[] | undefined, ): string | undefined { if (!weaknesses || weaknesses.length === 0) return undefined; return weaknesses .map((w) => { const base = w.amount == null ? capitalize(w.name) : `${capitalize(w.name)} ${w.amount}`; return w.note ? `${base} (${w.note})` : base; }) .join(", "); } // -- Entry parsing -- function segmentizeEntries(entries: unknown): TraitSegment[] { if (!Array.isArray(entries)) return []; const segments: TraitSegment[] = []; for (const entry of entries) { if (typeof entry === "string") { segments.push({ type: "text", value: stripTags(entry) }); } else if (typeof entry === "object" && entry !== null) { const obj = entry as RawEntryObject; if (obj.type === "list" && Array.isArray(obj.items)) { segments.push({ type: "list", items: obj.items.map((item) => { if (typeof item === "string") { return { text: stripTags(item) }; } return { label: item.name, text: stripTags(item.entry ?? "") }; }), }); } else if (Array.isArray(obj.entries)) { segments.push(...segmentizeEntries(obj.entries)); } } } return segments; } function formatAffliction(a: Record): TraitSegment[] { const parts: string[] = []; if (a.note) parts.push(stripTags(String(a.note))); if (a.DC) parts.push(`DC ${a.DC}`); if (a.savingThrow) parts.push(String(a.savingThrow)); const stages = a.stages as | { stage: number; entry: string; duration: string }[] | undefined; if (stages) { for (const s of stages) { parts.push(`Stage ${s.stage}: ${stripTags(s.entry)} (${s.duration})`); } } return parts.length > 0 ? [{ type: "text", value: parts.join("; ") }] : []; } function normalizeAbilities( abilities: readonly RawAbility[] | undefined, ): TraitBlock[] | undefined { if (!abilities || abilities.length === 0) return undefined; return abilities .filter((a) => a.name) .map((a) => { const raw = a as Record; const icon = formatActivityIcon(a.activity); const traits = a.traits && a.traits.length > 0 ? `(${a.traits.map((t) => capitalize(stripAngleBrackets(stripTags(t)))).join(", ")}) ` : ""; const body = Array.isArray(a.entries) ? segmentizeEntries(a.entries) : formatAffliction(raw); const name = icon + stripTags(a.name as string); if (traits && body.length > 0 && body[0].type === "text") { return { name, segments: [ { type: "text" as const, value: traits + body[0].value }, ...body.slice(1), ], }; } return { name, segments: traits ? [{ type: "text" as const, value: traits }, ...body] : body, }; }); } function normalizeAttacks( attacks: readonly RawAttack[] | undefined, ): TraitBlock[] | undefined { if (!attacks || attacks.length === 0) return undefined; return attacks.map((a) => { const parts: string[] = []; if (a.range) parts.push(a.range); const attackMod = a.attack == null ? "" : ` +${a.attack}`; const traits = a.traits && a.traits.length > 0 ? ` (${a.traits.map((t) => stripAngleBrackets(stripTags(t))).join(", ")})` : ""; const damage = a.damage ? `, ${stripAngleBrackets(stripTags(a.damage))}` : ""; return { name: capitalize(stripTags(a.name)), segments: [ { type: "text" as const, value: `${parts.join(" ")}${attackMod}${traits}${damage}`, }, ], }; }); } // -- Defenses extraction -- function extractDefenses(defenses: RawDefenses | undefined) { const acRecord = defenses?.ac ?? {}; const acStd = (acRecord.std as number | undefined) ?? 0; const acEntries = Object.entries(acRecord).filter(([k]) => k !== "std"); return { ac: acStd, acConditional: acEntries.length > 0 ? acEntries.map(([k, v]) => `${v} ${k}`).join(", ") : undefined, saveFort: defenses?.savingThrows?.fort?.std ?? 0, saveRef: defenses?.savingThrows?.ref?.std ?? 0, saveWill: defenses?.savingThrows?.will?.std ?? 0, hp: defenses?.hp?.[0]?.hp ?? 0, immunities: formatImmunities(defenses?.immunities), resistances: formatResistances(defenses?.resistances), weaknesses: formatWeaknesses(defenses?.weaknesses), }; } // -- Main normalization -- function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature { const source = raw.source ?? ""; const defenses = extractDefenses(raw.defenses); const mods = raw.abilityMods ?? {}; return { system: "pf2e", id: makeCreatureId(source, raw.name), name: raw.name, source, sourceDisplayName: sourceDisplayNames[source] ?? source, level: raw.level ?? 0, traits: raw.traits ?? [], perception: raw.perception?.std ?? 0, senses: formatSenses(raw.senses), languages: formatLanguages(raw.languages), skills: formatSkills(raw.skills), abilityMods: { str: mods.str ?? 0, dex: mods.dex ?? 0, con: mods.con ?? 0, int: mods.int ?? 0, wis: mods.wis ?? 0, cha: mods.cha ?? 0, }, ...defenses, speed: formatSpeed(raw.speed), attacks: normalizeAttacks(raw.attacks), abilitiesTop: normalizeAbilities(raw.abilities?.top), abilitiesMid: normalizeAbilities(raw.abilities?.mid), abilitiesBot: normalizeAbilities(raw.abilities?.bot), }; } export function normalizePf2eBestiary(raw: { creature: unknown[]; }): Pf2eCreature[] { return (raw.creature ?? []) .filter((c: unknown) => { const obj = c as { _copy?: unknown }; return !obj._copy; }) .map((c) => normalizeCreature(c as RawPf2eCreature)); }