import type { Creature, CreatureId, DailySpells, LegendaryBlock, SpellcastingBlock, TraitBlock, } from "@initiative/domain"; import { creatureId, proficiencyBonus } from "@initiative/domain"; import { stripTags } from "./strip-tags.js"; const LEADING_DIGITS_REGEX = /^(\d+)/; // --- Raw 5etools types (minimal, for parsing) --- interface RawMonster { name: string; source: string; size: string[]; type: string | { type: string; tags?: string[]; swarmSize?: string }; alignment?: string[]; ac: (number | { ac: number; from?: string[] } | { special: string })[]; hp: { average?: number; formula?: string; special?: string }; speed: Record< string, number | { number: number; condition?: string } | boolean >; str: number; dex: number; con: number; int: number; wis: number; cha: number; save?: Record; skill?: Record; senses?: string[]; passive: number; resist?: (string | { special: string })[]; immune?: (string | { special: string })[]; vulnerable?: (string | { special: string })[]; conditionImmune?: string[]; languages?: string[]; cr?: string | { cr: string }; trait?: RawEntry[]; action?: RawEntry[]; bonus?: RawEntry[]; reaction?: RawEntry[]; legendary?: RawEntry[]; legendaryActions?: number; legendaryActionsLair?: number; legendaryHeader?: string[]; spellcasting?: RawSpellcasting[]; initiative?: { proficiency?: number }; _copy?: unknown; } interface RawEntry { name: string; entries: (string | RawEntryObject)[]; } interface RawEntryObject { type: string; items?: ( | string | { type: string; name?: string; entries?: (string | RawEntryObject)[] } )[]; style?: string; name?: string; entries?: (string | RawEntryObject)[]; } interface RawSpellcasting { name: string; headerEntries: string[]; will?: string[]; daily?: Record; rest?: Record; hidden?: string[]; ability?: string; displayAs?: string; legendary?: Record; } // --- Source mapping --- let sourceDisplayNames: Record = {}; export function setSourceDisplayNames(names: Record): void { sourceDisplayNames = names; } // --- Size mapping --- const SIZE_MAP: Record = { T: "Tiny", S: "Small", M: "Medium", L: "Large", H: "Huge", G: "Gargantuan", }; // --- Alignment mapping --- const ALIGNMENT_MAP: Record = { L: "Lawful", N: "Neutral", C: "Chaotic", G: "Good", E: "Evil", U: "Unaligned", }; function formatAlignment(codes?: string[]): string { if (!codes || codes.length === 0) return "Unaligned"; if (codes.length === 1 && codes[0] === "U") return "Unaligned"; if (codes.length === 1 && codes[0] === "N") return "Neutral"; return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" "); } // --- Helpers --- function formatSize(sizes: string[]): string { return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or "); } function formatType( type: | string | { type: string | { choose: string[] }; tags?: string[]; swarmSize?: string; }, ): string { if (typeof type === "string") return capitalize(type); const baseType = typeof type.type === "string" ? capitalize(type.type) : type.type.choose.map(capitalize).join(" or "); let result = baseType; if (type.tags && type.tags.length > 0) { const tagStrs = type.tags .filter((t): t is string => typeof t === "string") .map(capitalize); if (tagStrs.length > 0) { result += ` (${tagStrs.join(", ")})`; } } if (type.swarmSize) { const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize; result = `Swarm of ${swarmSizeLabel} ${result}s`; } return result; } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } function extractAc(ac: RawMonster["ac"]): { value: number; source?: string; } { const first = ac[0]; if (typeof first === "number") { return { value: first }; } if ("special" in first) { // Variable AC (e.g. spell summons) — parse leading number if possible const match = LEADING_DIGITS_REGEX.exec(first.special); return { value: match ? Number(match[1]) : 0, source: first.special, }; } return { value: first.ac, source: first.from ? stripTags(first.from.join(", ")) : undefined, }; } function formatSpeed(speed: RawMonster["speed"]): string { const parts: string[] = []; for (const [mode, value] of Object.entries(speed)) { if (mode === "canHover") continue; if (typeof value === "boolean") continue; let numStr: string; let condition = ""; if (typeof value === "number") { numStr = `${value} ft.`; } else { numStr = `${value.number} ft.`; if (value.condition) { condition = ` ${value.condition}`; } } if (mode === "walk") { parts.push(`${numStr}${condition}`); } else { parts.push(`${mode} ${numStr}${condition}`); } } return parts.join(", "); } function formatSaves(save?: Record): string | undefined { if (!save) return undefined; return Object.entries(save) .map(([key, val]) => `${key.toUpperCase()} ${val}`) .join(", "); } function formatSkills(skill?: Record): string | undefined { if (!skill) return undefined; return Object.entries(skill) .map(([key, val]) => `${capitalize(key)} ${val}`) .join(", "); } function formatDamageList( items?: (string | Record)[], ): string | undefined { if (!items || items.length === 0) return undefined; return items .map((item) => { if (typeof item === "string") return capitalize(stripTags(item)); if (typeof item.special === "string") return stripTags(item.special); // Handle conditional entries like { vulnerable: [...], note: "..." } const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ?? []) as string[]; const note = typeof item.note === "string" ? ` ${item.note}` : ""; return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note; }) .join(", "); } function formatConditionImmunities( items?: (string | { conditionImmune?: string[]; note?: string })[], ): string | undefined { if (!items || items.length === 0) return undefined; return items .flatMap((c) => { if (typeof c === "string") return [capitalize(stripTags(c))]; if (c.conditionImmune) { const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci))); const note = c.note ? ` ${c.note}` : ""; return conds.map((ci) => `${ci}${note}`); } return []; }) .join(", "); } function renderListItem(item: string | RawEntryObject): string | undefined { if (typeof item === "string") { return `• ${stripTags(item)}`; } if (item.name && item.entries) { return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`; } return undefined; } function renderEntryObject(entry: RawEntryObject, parts: string[]): void { if (entry.type === "list") { for (const item of entry.items ?? []) { const rendered = renderListItem(item); if (rendered) parts.push(rendered); } } else if (entry.type === "item" && entry.name && entry.entries) { parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`); } else if (entry.entries) { parts.push(renderEntries(entry.entries)); } } function renderEntries(entries: (string | RawEntryObject)[]): string { const parts: string[] = []; for (const entry of entries) { if (typeof entry === "string") { parts.push(stripTags(entry)); } else { renderEntryObject(entry, parts); } } return parts.join(" "); } function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined { if (!raw || raw.length === 0) return undefined; return raw.map((t) => ({ name: stripTags(t.name), text: renderEntries(t.entries), })); } function normalizeSpellcasting( raw?: RawSpellcasting[], ): SpellcastingBlock[] | undefined { if (!raw || raw.length === 0) return undefined; return raw.map((sc) => { const block: { name: string; headerText: string; atWill?: string[]; daily?: DailySpells[]; restLong?: DailySpells[]; } = { name: stripTags(sc.name), headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "), }; const hidden = new Set(sc.hidden ?? []); if (sc.will && !hidden.has("will")) { block.atWill = sc.will.map((s) => stripTags(s)); } if (sc.daily) { block.daily = parseDailyMap(sc.daily); } if (sc.rest) { block.restLong = parseDailyMap(sc.rest); } return block; }); } function parseDailyMap(map: Record): DailySpells[] { return Object.entries(map).map(([key, spells]) => { const each = key.endsWith("e"); const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10); return { uses, each, spells: spells.map((s) => stripTags(s)), }; }); } function normalizeLegendary( raw?: RawEntry[], monster?: RawMonster, ): LegendaryBlock | undefined { if (!raw || raw.length === 0) return undefined; const name = monster?.name ?? "creature"; const count = monster?.legendaryActions ?? 3; const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`; return { preamble, entries: raw.map((e) => ({ name: stripTags(e.name), text: renderEntries(e.entries), })), }; } function extractCr(cr: string | { cr: string } | undefined): string { if (cr === undefined) return "—"; return typeof cr === "string" ? cr : cr.cr; } function makeCreatureId(source: string, name: string): CreatureId { const slug = name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); return creatureId(`${source.toLowerCase()}:${slug}`); } /** * Normalizes raw 5etools bestiary JSON into domain Creature[]. */ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] { // Filter out _copy entries (reference another source's monster) and // monsters missing required fields (ac, hp, size, type) const monsters = raw.monster.filter((m) => { if (m._copy) return false; return ( Array.isArray(m.ac) && m.ac.length > 0 && m.hp !== undefined && Array.isArray(m.size) && m.size.length > 0 && m.type !== undefined ); }); const creatures: Creature[] = []; for (const m of monsters) { try { creatures.push(normalizeMonster(m)); } catch { // Skip monsters with unexpected data shapes } } return creatures; } function normalizeMonster(m: RawMonster): Creature { const crStr = extractCr(m.cr); const ac = extractAc(m.ac); return { id: makeCreatureId(m.source, m.name), name: m.name, source: m.source, sourceDisplayName: sourceDisplayNames[m.source] ?? m.source, size: formatSize(m.size), type: formatType(m.type), alignment: formatAlignment(m.alignment), ac: ac.value, acSource: ac.source, hp: { average: m.hp.average ?? 0, formula: m.hp.formula ?? m.hp.special ?? "", }, speed: formatSpeed(m.speed), abilities: { str: m.str, dex: m.dex, con: m.con, int: m.int, wis: m.wis, cha: m.cha, }, cr: crStr, initiativeProficiency: m.initiative?.proficiency ?? 0, proficiencyBonus: proficiencyBonus(crStr), passive: m.passive, savingThrows: formatSaves(m.save), skills: formatSkills(m.skill), resist: formatDamageList(m.resist), immune: formatDamageList(m.immune), vulnerable: formatDamageList(m.vulnerable), conditionImmune: formatConditionImmunities(m.conditionImmune), senses: m.senses && m.senses.length > 0 ? m.senses.map((s) => stripTags(s)).join(", ") : undefined, languages: m.languages && m.languages.length > 0 ? m.languages.join(", ") : undefined, traits: normalizeTraits(m.trait), actions: normalizeTraits(m.action), bonusActions: normalizeTraits(m.bonus), reactions: normalizeTraits(m.reaction), legendaryActions: normalizeLegendary(m.legendary, m), spellcasting: normalizeSpellcasting(m.spellcasting), }; }