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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,23 @@
|
|||||||
|
import type { TraitBlock } from "@initiative/domain";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../bestiary-adapter.js";
|
} 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(() => {
|
beforeAll(() => {
|
||||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||||
});
|
});
|
||||||
@@ -74,11 +88,11 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||||
expect(c.languages).toBe("Common, Goblin");
|
expect(c.languages).toBe("Common, Goblin");
|
||||||
expect(c.actions).toHaveLength(1);
|
expect(c.actions).toHaveLength(1);
|
||||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
expect(flatText(c.actions![0])).toContain("Melee Attack Roll:");
|
||||||
expect(c.actions?.[0].text).not.toContain("{@");
|
expect(flatText(c.actions![0])).not.toContain("{@");
|
||||||
expect(c.bonusActions).toHaveLength(1);
|
expect(c.bonusActions).toHaveLength(1);
|
||||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
expect(flatText(c.bonusActions![0])).toContain("Disengage");
|
||||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
expect(flatText(c.bonusActions![0])).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes a creature with legendary actions", () => {
|
it("normalizes a creature with legendary actions", () => {
|
||||||
@@ -333,9 +347,9 @@ describe("normalizeBestiary", () => {
|
|||||||
|
|
||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
const bite = creatures[0].actions?.[0];
|
const bite = creatures[0].actions?.[0];
|
||||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
expect(flatText(bite!)).toContain("Melee Weapon Attack:");
|
||||||
expect(bite?.text).not.toContain("mw");
|
expect(flatText(bite!)).not.toContain("mw");
|
||||||
expect(bite?.text).not.toContain("{@");
|
expect(flatText(bite!)).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
@@ -368,4 +382,129 @@ describe("normalizeBestiary", () => {
|
|||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
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." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
|
TraitListItem,
|
||||||
|
TraitSegment,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
@@ -63,11 +65,18 @@ interface RawEntryObject {
|
|||||||
type: string;
|
type: string;
|
||||||
items?: (
|
items?: (
|
||||||
| string
|
| string
|
||||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
)[];
|
)[];
|
||||||
style?: string;
|
style?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
entries?: (string | RawEntryObject)[];
|
entries?: (string | RawEntryObject)[];
|
||||||
|
colLabels?: string[];
|
||||||
|
rows?: (string | RawEntryObject)[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawSpellcasting {
|
interface RawSpellcasting {
|
||||||
@@ -257,23 +266,34 @@ function formatConditionImmunities(
|
|||||||
.join(", ");
|
.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") {
|
if (typeof item === "string") {
|
||||||
return `• ${stripTags(item)}`;
|
return { text: stripTags(item) };
|
||||||
}
|
}
|
||||||
if (item.name && item.entries) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||||
if (entry.type === "list") {
|
if (entry.type === "list" || entry.type === "table") {
|
||||||
for (const item of entry.items ?? []) {
|
// Handled structurally in segmentizeEntries
|
||||||
const rendered = renderListItem(item);
|
return;
|
||||||
if (rendered) parts.push(rendered);
|
}
|
||||||
}
|
if (entry.type === "item" && entry.name && entry.entries) {
|
||||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
|
||||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||||
} else if (entry.entries) {
|
} else if (entry.entries) {
|
||||||
parts.push(renderEntries(entry.entries));
|
parts.push(renderEntries(entry.entries));
|
||||||
@@ -292,11 +312,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
|
|||||||
return parts.join(" ");
|
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 {
|
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||||
if (!raw || raw.length === 0) return undefined;
|
if (!raw || raw.length === 0) return undefined;
|
||||||
return raw.map((t) => ({
|
return raw.map((t) => ({
|
||||||
name: stripTags(t.name),
|
name: stripTags(t.name),
|
||||||
text: renderEntries(t.entries),
|
segments: segmentizeEntries(t.entries),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +437,7 @@ function normalizeLegendary(
|
|||||||
preamble,
|
preamble,
|
||||||
entries: raw.map((e) => ({
|
entries: raw.map((e) => ({
|
||||||
name: stripTags(e.name),
|
name: stripTags(e.name),
|
||||||
text: renderEntries(e.entries),
|
segments: segmentizeEntries(e.entries),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 2;
|
const DB_VERSION = 3;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
@@ -38,8 +38,11 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
if (
|
||||||
// Clear cached creatures to pick up improved tag processing
|
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();
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,10 +46,30 @@ const GOBLIN: Creature = {
|
|||||||
skills: "Stealth +6",
|
skills: "Stealth +6",
|
||||||
senses: "darkvision 60 ft., passive Perception 9",
|
senses: "darkvision 60 ft., passive Perception 9",
|
||||||
languages: "Common, Goblin",
|
languages: "Common, Goblin",
|
||||||
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
traits: [
|
||||||
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
{
|
||||||
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
name: "Nimble Escape",
|
||||||
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
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 = {
|
const DRAGON: Creature = {
|
||||||
@@ -75,8 +95,16 @@ const DRAGON: Creature = {
|
|||||||
legendaryActions: {
|
legendaryActions: {
|
||||||
preamble: "The dragon can take 3 legendary actions.",
|
preamble: "The dragon can take 3 legendary actions.",
|
||||||
entries: [
|
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: [
|
spellcasting: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { Creature, TraitBlock, TraitSegment } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
type Creature,
|
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
} from "@initiative/domain";
|
} 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 (
|
||||||
|
<span key={segmentKey(seg)}>
|
||||||
|
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||||
|
{seg.items.map((item) => (
|
||||||
|
<p key={item.label ?? item.text}>
|
||||||
|
{item.label != null && (
|
||||||
|
<span className="font-semibold">{item.label}. </span>
|
||||||
|
)}
|
||||||
|
{item.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold italic">{trait.name}.</span>
|
||||||
|
<TraitSegments segments={trait.segments} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TraitSection({
|
function TraitSection({
|
||||||
entries,
|
entries,
|
||||||
heading,
|
heading,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
entries: readonly { name: string; text: string }[] | undefined;
|
entries: readonly TraitBlock[] | undefined;
|
||||||
heading?: string;
|
heading?: string;
|
||||||
}>) {
|
}>) {
|
||||||
if (!entries || entries.length === 0) return null;
|
if (!entries || entries.length === 0) return null;
|
||||||
@@ -50,9 +95,7 @@ function TraitSection({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<div key={e.name} className="text-sm">
|
<TraitEntry key={e.name} trait={e} />
|
||||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -219,9 +262,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.legendaryActions.entries.map((a) => (
|
{creature.legendaryActions.entries.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<TraitEntry key={a.name} trait={a} />
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ export function creatureId(id: string): CreatureId {
|
|||||||
return id as 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 {
|
export interface TraitBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly text: string;
|
readonly segments: readonly TraitSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegendaryBlock {
|
export interface LegendaryBlock {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export {
|
|||||||
proficiencyBonus,
|
proficiencyBonus,
|
||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
|
type TraitListItem,
|
||||||
|
type TraitSegment,
|
||||||
} from "./creature-types.js";
|
} from "./creature-types.js";
|
||||||
export {
|
export {
|
||||||
type DeletePlayerCharacterSuccess,
|
type DeletePlayerCharacterSuccess,
|
||||||
|
|||||||
Reference in New Issue
Block a user