The _copy field is a real property in the raw bestiary JSON — adding it to the interface is more accurate than casting through any. Ratchet source ignore threshold from 3 to 2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
463 lines
12 KiB
TypeScript
463 lines
12 KiB
TypeScript
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<string, string>;
|
|
skill?: Record<string, string>;
|
|
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<string, string[]>;
|
|
rest?: Record<string, string[]>;
|
|
hidden?: string[];
|
|
ability?: string;
|
|
displayAs?: string;
|
|
legendary?: Record<string, string[]>;
|
|
}
|
|
|
|
// --- Source mapping ---
|
|
|
|
let sourceDisplayNames: Record<string, string> = {};
|
|
|
|
export function setSourceDisplayNames(names: Record<string, string>): void {
|
|
sourceDisplayNames = names;
|
|
}
|
|
|
|
// --- Size mapping ---
|
|
|
|
const SIZE_MAP: Record<string, string> = {
|
|
T: "Tiny",
|
|
S: "Small",
|
|
M: "Medium",
|
|
L: "Large",
|
|
H: "Huge",
|
|
G: "Gargantuan",
|
|
};
|
|
|
|
// --- Alignment mapping ---
|
|
|
|
const ALIGNMENT_MAP: Record<string, string> = {
|
|
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, string>): string | undefined {
|
|
if (!save) return undefined;
|
|
return Object.entries(save)
|
|
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
|
|
.join(", ");
|
|
}
|
|
|
|
function formatSkills(skill?: Record<string, string>): string | undefined {
|
|
if (!skill) return undefined;
|
|
return Object.entries(skill)
|
|
.map(([key, val]) => `${capitalize(key)} ${val}`)
|
|
.join(", ");
|
|
}
|
|
|
|
function formatDamageList(
|
|
items?: (string | Record<string, unknown>)[],
|
|
): 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<string, string[]>): 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),
|
|
};
|
|
}
|