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 }[]; 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; 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 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 }[] | undefined, ): string | undefined { if (!senses || senses.length === 0) return undefined; return senses .map((s) => { const label = s.name ?? s.type ?? ""; return label ? capitalize(label) : ""; }) .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) => r.note ? `${capitalize(r.name)} ${r.amount} (${r.note})` : `${capitalize(r.name)} ${r.amount}`, ) .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) => w.note ? `${capitalize(w.name)} ${w.amount} (${w.note})` : `${capitalize(w.name)} ${w.amount}`, ) .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; return { name: stripTags(a.name as string), segments: Array.isArray(a.entries) ? segmentizeEntries(a.entries) : formatAffliction(raw), }; }); } 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) => stripTags(t)).join(", ")})` : ""; const damage = a.damage ? `, ${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)); }