Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6eebc43b |
@@ -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