From 8f6eebc43b9a28c11baa66d7f6fb2cbf52705e4a Mon Sep 17 00:00:00 2001
From: Lukas
Date: Sat, 4 Apr 2026 22:09:11 +0200
Subject: [PATCH] Render structured list and table entries in stat block traits
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)
---
.../__tests__/bestiary-adapter.test.ts | 153 +++++++++++++++++-
apps/web/src/adapters/bestiary-adapter.ts | 100 ++++++++++--
apps/web/src/adapters/bestiary-cache.ts | 9 +-
.../components/__tests__/stat-block.test.tsx | 40 ++++-
apps/web/src/components/stat-block.tsx | 57 ++++++-
packages/domain/src/creature-types.ts | 11 +-
packages/domain/src/index.ts | 2 +
7 files changed, 335 insertions(+), 37 deletions(-)
diff --git a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
index 549faa4..517c622 100644
--- a/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
+++ b/apps/web/src/adapters/__tests__/bestiary-adapter.test.ts
@@ -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." },
+ ],
+ });
+ });
});
diff --git a/apps/web/src/adapters/bestiary-adapter.ts b/apps/web/src/adapters/bestiary-adapter.ts
index a8b4391..4f709dd 100644
--- a/apps/web/src/adapters/bestiary-adapter.ts
+++ b/apps/web/src/adapters/bestiary-adapter.ts
@@ -5,6 +5,8 @@ import type {
LegendaryBlock,
SpellcastingBlock,
TraitBlock,
+ TraitListItem,
+ TraitSegment,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
@@ -63,11 +65,18 @@ interface RawEntryObject {
type: string;
items?: (
| string
- | { type: string; name?: string; entries?: (string | RawEntryObject)[] }
+ | {
+ type: string;
+ name?: string;
+ entry?: string;
+ entries?: (string | RawEntryObject)[];
+ }
)[];
style?: string;
name?: string;
entries?: (string | RawEntryObject)[];
+ colLabels?: string[];
+ rows?: (string | RawEntryObject)[][];
}
interface RawSpellcasting {
@@ -257,23 +266,34 @@ function formatConditionImmunities(
.join(", ");
}
-function renderListItem(item: string | RawEntryObject): string | undefined {
+function toListItem(
+ item:
+ | string
+ | {
+ type: string;
+ name?: string;
+ entry?: string;
+ entries?: (string | RawEntryObject)[];
+ },
+): TraitListItem | undefined {
if (typeof item === "string") {
- return `• ${stripTags(item)}`;
+ return { text: stripTags(item) };
}
if (item.name && item.entries) {
- return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
+ return { label: stripTags(item.name), text: renderEntries(item.entries) };
+ }
+ if (item.name && item.entry) {
+ return { label: stripTags(item.name), text: stripTags(item.entry) };
}
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) {
+ if (entry.type === "list" || entry.type === "table") {
+ // Handled structurally in segmentizeEntries
+ return;
+ }
+ 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));
@@ -292,11 +312,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
return parts.join(" ");
}
+function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
+ return {
+ label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
+ text: row
+ .slice(1)
+ .map((cell) =>
+ typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
+ )
+ .join(" "),
+ };
+}
+
+function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
+ if (entry.type === "list") {
+ const items = (entry.items ?? [])
+ .map(toListItem)
+ .filter((i): i is TraitListItem => i !== undefined);
+ return items.length > 0 ? { type: "list", items } : undefined;
+ }
+ if (entry.type === "table" && entry.rows) {
+ const items = entry.rows.map(tableRowToListItem);
+ return items.length > 0 ? { type: "list", items } : undefined;
+ }
+ return undefined;
+}
+
+function segmentizeEntries(
+ entries: (string | RawEntryObject)[],
+): TraitSegment[] {
+ const segments: TraitSegment[] = [];
+ const textParts: string[] = [];
+
+ const flushText = () => {
+ if (textParts.length > 0) {
+ segments.push({ type: "text", value: textParts.join(" ") });
+ textParts.length = 0;
+ }
+ };
+
+ for (const entry of entries) {
+ if (typeof entry === "string") {
+ textParts.push(stripTags(entry));
+ continue;
+ }
+ const listSeg = entryToListSegment(entry);
+ if (listSeg) {
+ flushText();
+ segments.push(listSeg);
+ } else {
+ renderEntryObject(entry, textParts);
+ }
+ }
+ flushText();
+ return segments;
+}
+
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),
+ segments: segmentizeEntries(t.entries),
}));
}
@@ -361,7 +437,7 @@ function normalizeLegendary(
preamble,
entries: raw.map((e) => ({
name: stripTags(e.name),
- text: renderEntries(e.entries),
+ segments: segmentizeEntries(e.entries),
})),
};
}
diff --git a/apps/web/src/adapters/bestiary-cache.ts b/apps/web/src/adapters/bestiary-cache.ts
index 5a4312a..5e9856d 100644
--- a/apps/web/src/adapters/bestiary-cache.ts
+++ b/apps/web/src/adapters/bestiary-cache.ts
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources";
-const DB_VERSION = 2;
+const DB_VERSION = 3;
interface CachedSourceInfo {
readonly sourceCode: string;
@@ -38,8 +38,11 @@ async function getDb(): Promise {
keyPath: "sourceCode",
});
}
- if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
- // Clear cached creatures to pick up improved tag processing
+ if (
+ oldVersion < DB_VERSION &&
+ database.objectStoreNames.contains(STORE_NAME)
+ ) {
+ // Clear cached creatures so they get re-normalized with latest rendering
void transaction.objectStore(STORE_NAME).clear();
}
},
diff --git a/apps/web/src/components/__tests__/stat-block.test.tsx b/apps/web/src/components/__tests__/stat-block.test.tsx
index d9d369d..1365160 100644
--- a/apps/web/src/components/__tests__/stat-block.test.tsx
+++ b/apps/web/src/components/__tests__/stat-block.test.tsx
@@ -46,10 +46,30 @@ const GOBLIN: Creature = {
skills: "Stealth +6",
senses: "darkvision 60 ft., passive Perception 9",
languages: "Common, Goblin",
- traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
- actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
- bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
- reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
+ traits: [
+ {
+ name: "Nimble Escape",
+ segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
+ },
+ ],
+ actions: [
+ {
+ name: "Scimitar",
+ segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
+ },
+ ],
+ bonusActions: [
+ {
+ name: "Nimble",
+ segments: [{ type: "text", value: "Disengage or Hide." }],
+ },
+ ],
+ reactions: [
+ {
+ name: "Redirect",
+ segments: [{ type: "text", value: "Redirect attack to ally." }],
+ },
+ ],
};
const DRAGON: Creature = {
@@ -75,8 +95,16 @@ const DRAGON: Creature = {
legendaryActions: {
preamble: "The dragon can take 3 legendary actions.",
entries: [
- { name: "Detect", text: "Wisdom (Perception) check." },
- { name: "Tail Attack", text: "Tail attack." },
+ {
+ name: "Detect",
+ segments: [
+ { type: "text" as const, value: "Wisdom (Perception) check." },
+ ],
+ },
+ {
+ name: "Tail Attack",
+ segments: [{ type: "text" as const, value: "Tail attack." }],
+ },
],
},
spellcasting: [
diff --git a/apps/web/src/components/stat-block.tsx b/apps/web/src/components/stat-block.tsx
index 071cb40..25d9ed8 100644
--- a/apps/web/src/components/stat-block.tsx
+++ b/apps/web/src/components/stat-block.tsx
@@ -1,5 +1,5 @@
+import type { Creature, TraitBlock, TraitSegment } from "@initiative/domain";
import {
- type Creature,
calculateInitiative,
formatInitiativeModifier,
} from "@initiative/domain";
@@ -34,11 +34,56 @@ function SectionDivider() {
);
}
+function segmentKey(seg: TraitSegment): string {
+ return seg.type === "text"
+ ? seg.value.slice(0, 40)
+ : seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
+}
+
+function TraitSegments({
+ segments,
+}: Readonly<{ segments: readonly TraitSegment[] }>) {
+ return (
+ <>
+ {segments.map((seg, i) => {
+ if (seg.type === "text") {
+ return (
+
+ {i === 0 ? ` ${seg.value}` : seg.value}
+
+ );
+ }
+ return (
+
+ {seg.items.map((item) => (
+
+ {item.label != null && (
+ {item.label}.
+ )}
+ {item.text}
+
+ ))}
+
+ );
+ })}
+ >
+ );
+}
+
+function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
+ return (
+
+ {trait.name}.
+
+
+ );
+}
+
function TraitSection({
entries,
heading,
}: Readonly<{
- entries: readonly { name: string; text: string }[] | undefined;
+ entries: readonly TraitBlock[] | undefined;
heading?: string;
}>) {
if (!entries || entries.length === 0) return null;
@@ -50,9 +95,7 @@ function TraitSection({
) : null}
{entries.map((e) => (
-
- {e.name}. {e.text}
-
+
))}
>
@@ -219,9 +262,7 @@ export function StatBlock({ creature }: Readonly) {
{creature.legendaryActions.entries.map((a) => (
-
- {a.name}. {a.text}
-
+
))}
>
diff --git a/packages/domain/src/creature-types.ts b/packages/domain/src/creature-types.ts
index 5a625b3..14a9bf8 100644
--- a/packages/domain/src/creature-types.ts
+++ b/packages/domain/src/creature-types.ts
@@ -5,9 +5,18 @@ export function creatureId(id: string): CreatureId {
return id as CreatureId;
}
+export type TraitSegment =
+ | { readonly type: "text"; readonly value: string }
+ | { readonly type: "list"; readonly items: readonly TraitListItem[] };
+
+export interface TraitListItem {
+ readonly label?: string;
+ readonly text: string;
+}
+
export interface TraitBlock {
readonly name: string;
- readonly text: string;
+ readonly segments: readonly TraitSegment[];
}
export interface LegendaryBlock {
diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts
index b82988d..0f41db3 100644
--- a/packages/domain/src/index.ts
+++ b/packages/domain/src/index.ts
@@ -34,6 +34,8 @@ export {
proficiencyBonus,
type SpellcastingBlock,
type TraitBlock,
+ type TraitListItem,
+ type TraitSegment,
} from "./creature-types.js";
export {
type DeletePlayerCharacterSuccess,