Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-09 11:01:07 +01:00
parent 04a4f18f98
commit fa078be2f9
30 changed files with 66221 additions and 56 deletions

View File

@@ -0,0 +1,326 @@
import { describe, expect, it } from "vitest";
import { normalizeBestiary } from "../bestiary-adapter.js";
describe("normalizeBestiary", () => {
it("normalizes a simple creature", () => {
const raw = {
monster: [
{
name: "Goblin Warrior",
source: "XMM",
size: ["S"],
type: { type: "fey", tags: ["goblinoid"] },
alignment: ["C", "N"],
ac: [15],
hp: { average: 10, formula: "3d6" },
speed: { walk: 30 },
str: 8,
dex: 15,
con: 10,
int: 10,
wis: 8,
cha: 8,
skill: { stealth: "+6" },
senses: ["Darkvision 60 ft."],
passive: 9,
languages: ["Common", "Goblin"],
cr: "1/4",
action: [
{
name: "Scimitar",
entries: [
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
],
},
],
bonus: [
{
name: "Nimble Escape",
entries: [
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures).toHaveLength(1);
const c = creatures[0];
expect(c.id).toBe("xmm:goblin-warrior");
expect(c.name).toBe("Goblin Warrior");
expect(c.source).toBe("XMM");
expect(c.sourceDisplayName).toBe("MM 2024");
expect(c.size).toBe("Small");
expect(c.type).toBe("Fey (Goblinoid)");
expect(c.alignment).toBe("Chaotic Neutral");
expect(c.ac).toBe(15);
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
expect(c.speed).toBe("30 ft.");
expect(c.abilities.dex).toBe(15);
expect(c.cr).toBe("1/4");
expect(c.proficiencyBonus).toBe(2);
expect(c.passive).toBe(9);
expect(c.skills).toBe("Stealth +6");
expect(c.senses).toBe("Darkvision 60 ft.");
expect(c.languages).toBe("Common, Goblin");
expect(c.actions).toHaveLength(1);
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
expect(c.actions?.[0].text).not.toContain("{@");
expect(c.bonusActions).toHaveLength(1);
expect(c.bonusActions?.[0].text).toContain("Disengage");
expect(c.bonusActions?.[0].text).not.toContain("{@");
});
it("normalizes a creature with legendary actions", () => {
const raw = {
monster: [
{
name: "Aboleth",
source: "XMM",
size: ["L"],
type: "aberration",
alignment: ["L", "E"],
ac: [17],
hp: { average: 135, formula: "18d10 + 36" },
speed: { walk: 10, swim: 40 },
str: 21,
dex: 9,
con: 15,
int: 18,
wis: 15,
cha: 18,
save: { con: "+6", int: "+8", wis: "+6" },
senses: ["Darkvision 120 ft."],
passive: 12,
languages: ["Deep Speech", "Telepathy 120 ft."],
cr: "10",
legendary: [
{
name: "Lash",
entries: ["The aboleth makes one Tentacle attack."],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.legendaryActions).toBeDefined();
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
expect(c.legendaryActions?.entries).toHaveLength(1);
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
});
it("normalizes a creature with spellcasting", () => {
const raw = {
monster: [
{
name: "Test Caster",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [12],
hp: { average: 40, formula: "9d8" },
speed: { walk: 30 },
str: 10,
dex: 14,
con: 10,
int: 17,
wis: 12,
cha: 11,
passive: 11,
cr: "6",
spellcasting: [
{
name: "Spellcasting",
headerEntries: [
"The caster casts spells using Intelligence (spell save {@dc 15}):",
],
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
daily: {
"2e": ["{@spell Fireball|XPHB}"],
"1": ["{@spell Dimension Door|XPHB}"],
},
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.spellcasting).toHaveLength(1);
const sc = c.spellcasting?.[0];
expect(sc).toBeDefined();
expect(sc?.name).toBe("Spellcasting");
expect(sc?.headerText).toContain("DC 15");
expect(sc?.headerText).not.toContain("{@");
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
expect(sc?.daily).toHaveLength(2);
expect(sc?.daily).toContainEqual({
uses: 2,
each: true,
spells: ["Fireball"],
});
expect(sc?.daily).toContainEqual({
uses: 1,
each: false,
spells: ["Dimension Door"],
});
});
it("normalizes a creature with object-type type field", () => {
const raw = {
monster: [
{
name: "Swarm of Bats",
source: "XMM",
size: ["L"],
type: { type: "beast", swarmSize: "T" },
ac: [12],
hp: { average: 11, formula: "2d10" },
speed: { walk: 5, fly: 30 },
str: 5,
dex: 15,
con: 10,
int: 2,
wis: 12,
cha: 4,
passive: 11,
resist: ["bludgeoning", "piercing", "slashing"],
conditionImmune: ["charmed", "frightened"],
cr: "1/4",
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.type).toBe("Swarm of Tiny Beasts");
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
expect(c.conditionImmune).toBe("Charmed, Frightened");
});
it("normalizes a creature with conditional resistances", () => {
const raw = {
monster: [
{
name: "Half-Dragon",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [18],
hp: { average: 65, formula: "10d8 + 20" },
speed: { walk: 30 },
str: 16,
dex: 13,
con: 14,
int: 10,
wis: 11,
cha: 10,
passive: 10,
cr: "5",
resist: [
{
special: "Damage type chosen for the Draconic Origin trait",
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
});
it("normalizes a creature with multiple sizes", () => {
const raw = {
monster: [
{
name: "Aberrant Cultist",
source: "XMM",
size: ["S", "M"],
type: "humanoid",
ac: [13],
hp: { average: 22, formula: "4d8 + 4" },
speed: { walk: 30 },
str: 11,
dex: 14,
con: 12,
int: 10,
wis: 13,
cha: 8,
passive: 11,
cr: "1/2",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].size).toBe("Small or Medium");
});
it("normalizes a creature with CR as object", () => {
const raw = {
monster: [
{
name: "Dragon",
source: "XMM",
size: ["H"],
type: "dragon",
ac: [19],
hp: { average: 256, formula: "19d12 + 133" },
speed: { walk: 40 },
str: 27,
dex: 10,
con: 25,
int: 16,
wis: 13,
cha: 23,
passive: 23,
cr: { cr: "17", xpLair: 20000 },
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].cr).toBe("17");
expect(creatures[0].proficiencyBonus).toBe(6);
});
it("handles fly speed with hover condition", () => {
const raw = {
monster: [
{
name: "Air Elemental",
source: "XMM",
size: ["L"],
type: "elemental",
ac: [15],
hp: { average: 90, formula: "12d10 + 24" },
speed: {
walk: 10,
fly: { number: 90, condition: "(hover)" },
canHover: true,
},
str: 14,
dex: 20,
con: 14,
int: 6,
wis: 10,
cha: 6,
passive: 10,
cr: "5",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
});
});

View File

@@ -0,0 +1,16 @@
import { expect, it } from "vitest";
import rawData from "../../../../../data/bestiary/xmm.json";
import { normalizeBestiary } from "../bestiary-adapter.js";
it("normalizes all 503 monsters without error", () => {
const creatures = normalizeBestiary(
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
);
expect(creatures.length).toBe(503);
for (const c of creatures) {
expect(c.name).toBeTruthy();
expect(c.id).toBeTruthy();
expect(c.ac).toBeGreaterThanOrEqual(0);
expect(c.hp.average).toBeGreaterThan(0);
}
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { stripTags } from "../strip-tags.js";
describe("stripTags", () => {
it("returns text unchanged when no tags present", () => {
expect(stripTags("Hello world")).toBe("Hello world");
});
it("strips {@spell Name|Source} to Name", () => {
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
});
it("strips {@condition Name|Source} to Name", () => {
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
});
it("strips {@damage dice} to dice", () => {
expect(stripTags("{@damage 2d10}")).toBe("2d10");
});
it("strips {@dice value} to value", () => {
expect(stripTags("{@dice 5d10}")).toBe("5d10");
});
it("strips {@dc N} to DC N", () => {
expect(stripTags("{@dc 15}")).toBe("DC 15");
});
it("strips {@hit N} to +N", () => {
expect(stripTags("{@hit 5}")).toBe("+5");
});
it("strips {@h} to Hit: ", () => {
expect(stripTags("{@h}")).toBe("Hit: ");
});
it("strips {@hom} to Hit or Miss: ", () => {
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
});
it("strips {@atkr m} to Melee Attack Roll:", () => {
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
});
it("strips {@atkr r} to Ranged Attack Roll:", () => {
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
});
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
});
it("strips {@recharge 5} to (Recharge 5-6)", () => {
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
});
it("strips {@recharge} to (Recharge 6)", () => {
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
});
it("strips {@actSave wis} to Wisdom saving throw", () => {
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
});
it("strips {@actSaveFail} to Failure:", () => {
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
});
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
});
it("strips {@actSaveSuccess} to Success:", () => {
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
});
it("strips {@actTrigger} to Trigger:", () => {
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
});
it("strips {@actResponse} to Response:", () => {
expect(stripTags("{@actResponse}")).toBe("Response:");
});
it("strips {@variantrule Name|Source|Display} to Display", () => {
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
"Cone",
);
});
it("strips {@action Name|Source|Display} to Display", () => {
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
});
it("strips {@skill Name|Source} to Name", () => {
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
});
it("strips {@creature Name|Source} to Name", () => {
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
});
it("strips {@hazard Name|Source} to Name", () => {
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
});
it("strips {@status Name|Source} to Name", () => {
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
});
it("handles unknown tags by extracting first segment", () => {
expect(stripTags("{@unknown Something|else}")).toBe("Something");
});
it("handles multiple tags in the same string", () => {
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
);
});
it("handles nested tags gracefully", () => {
expect(
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
).toBe("The spell Fireball deals 8d6.");
});
it("handles text with no tags", () => {
expect(stripTags("Just plain text.")).toBe("Just plain text.");
});
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
});
it("strips {@action Name|Source} to Name when no display text", () => {
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
});
});

View File

@@ -0,0 +1,404 @@
import type {
Creature,
CreatureId,
DailySpells,
LegendaryBlock,
SpellcastingBlock,
TraitBlock,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
// --- 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[] })[];
hp: { average: number; formula: 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[];
}
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 ---
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
XMM: "MM 2024",
};
// --- 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) {
result += ` (${type.tags.map(capitalize).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 };
}
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 renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else if (entry.type === "list") {
for (const item of entry.items ?? []) {
if (typeof item === "string") {
parts.push(`${stripTags(item)}`);
} else if (item.name && item.entries) {
parts.push(
`${stripTags(item.name)}: ${renderEntries(item.entries)}`,
);
}
}
} 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));
}
}
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 }): string {
return typeof cr === "string" ? cr : cr.cr;
}
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}
/**
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
return raw.monster.map((m) => {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: SOURCE_DISPLAY_NAMES[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, formula: m.hp.formula },
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,
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),
};
});
}

View File

@@ -0,0 +1,99 @@
const ABILITY_MAP: Record<string, string> = {
str: "Strength",
dex: "Dexterity",
con: "Constitution",
int: "Intelligence",
wis: "Wisdom",
cha: "Charisma",
};
const ATKR_MAP: Record<string, string> = {
m: "Melee Attack Roll:",
r: "Ranged Attack Roll:",
"m,r": "Melee or Ranged Attack Roll:",
"r,m": "Melee or Ranged Attack Roll:",
};
/**
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
*
* Handles 15+ tag types per research.md R-002 tag resolution rules.
*/
export function stripTags(text: string): string {
// Process special tags with specific output formats first
let result = text;
// {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: ");
// {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
// {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
// {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:");
// {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
// {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
});
// {@actSave ability} → "Ability saving throw"
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags
result = result.replace(
/\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment
const segments = content.split("|");
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
return segments[2];
}
return segments[0];
},
);
return result;
}