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
+ {item.label != null && (
+ {item.label}.
+ )}
+ {item.text}
+