Render structured list and table entries in stat block traits
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 17s

Stat block traits containing 5etools list (e.g. Confusing Burble d4
effects) or table entries were silently dropped. The adapter now
produces structured TraitSegment[] instead of flat text, preserving
lists and tables as first-class data. The stat block component renders
labeled list items inline (bold label + flowing text) matching the
5etools layout. Also fixes support for the singular "entry" field on
list items and bumps the bestiary cache version to force re-normalize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-04 22:09:11 +02:00
parent 817cfddabc
commit 8f6eebc43b
7 changed files with 335 additions and 37 deletions

View File

@@ -1,9 +1,23 @@
import type { TraitBlock } from "@initiative/domain";
import { beforeAll, describe, expect, it } from "vitest";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../bestiary-adapter.js";
/** Flatten segments to a single string for simple text assertions. */
function flatText(trait: TraitBlock): string {
return trait.segments
.map((s) =>
s.type === "text"
? s.value
: s.items
.map((i) => (i.label ? `${i.label}. ${i.text}` : i.text))
.join(" "),
)
.join(" ");
}
beforeAll(() => {
setSourceDisplayNames({ XMM: "MM 2024" });
});
@@ -74,11 +88,11 @@ describe("normalizeBestiary", () => {
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(flatText(c.actions![0])).toContain("Melee Attack Roll:");
expect(flatText(c.actions![0])).not.toContain("{@");
expect(c.bonusActions).toHaveLength(1);
expect(c.bonusActions?.[0].text).toContain("Disengage");
expect(c.bonusActions?.[0].text).not.toContain("{@");
expect(flatText(c.bonusActions![0])).toContain("Disengage");
expect(flatText(c.bonusActions![0])).not.toContain("{@");
});
it("normalizes a creature with legendary actions", () => {
@@ -333,9 +347,9 @@ describe("normalizeBestiary", () => {
const creatures = normalizeBestiary(raw);
const bite = creatures[0].actions?.[0];
expect(bite?.text).toContain("Melee Weapon Attack:");
expect(bite?.text).not.toContain("mw");
expect(bite?.text).not.toContain("{@");
expect(flatText(bite!)).toContain("Melee Weapon Attack:");
expect(flatText(bite!)).not.toContain("mw");
expect(flatText(bite!)).not.toContain("{@");
});
it("handles fly speed with hover condition", () => {
@@ -368,4 +382,129 @@ describe("normalizeBestiary", () => {
const creatures = normalizeBestiary(raw);
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
});
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
const raw = {
monster: [
{
name: "Jabberwock",
source: "WBtW",
size: ["H"],
type: "dragon",
ac: [18],
hp: { average: 115, formula: "10d12 + 50" },
speed: { walk: 30 },
str: 22,
dex: 15,
con: 20,
int: 8,
wis: 14,
cha: 16,
passive: 12,
cr: "13",
trait: [
{
name: "Confusing Burble",
entries: [
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
{
type: "list",
style: "list-hang-notitle",
items: [
{
type: "item",
name: "1-2",
entry: "The creature does nothing.",
},
{
type: "item",
name: "3",
entry:
"The creature uses all its movement to move in a random direction.",
},
{
type: "item",
name: "4",
entry:
"The creature makes one melee attack against a random creature.",
},
],
},
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const trait = creatures[0].traits![0];
expect(trait.name).toBe("Confusing Burble");
expect(trait.segments).toHaveLength(2);
expect(trait.segments[0]).toEqual({
type: "text",
value: expect.stringContaining("d4"),
});
expect(trait.segments[1]).toEqual({
type: "list",
items: [
{ label: "1-2", text: "The creature does nothing." },
{
label: "3",
text: expect.stringContaining("random direction"),
},
{ label: "4", text: expect.stringContaining("melee attack") },
],
});
});
it("renders table entries as structured list segments", () => {
const raw = {
monster: [
{
name: "Test Creature",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [12],
hp: { average: 40, formula: "9d8" },
speed: { walk: 30 },
str: 10,
dex: 10,
con: 10,
int: 10,
wis: 10,
cha: 10,
passive: 10,
cr: "1",
trait: [
{
name: "Random Effect",
entries: [
"Roll on the table:",
{
type: "table",
colLabels: ["d4", "Effect"],
rows: [
["1", "Nothing happens."],
["2", "Something happens."],
],
},
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const trait = creatures[0].traits![0];
expect(trait.segments[1]).toEqual({
type: "list",
items: [
{ label: "1", text: "Nothing happens." },
{ label: "2", text: "Something happens." },
],
});
});
});