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,