Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-09 11:01:07 +01:00
parent 04a4f18f98
commit fa078be2f9
30 changed files with 66221 additions and 56 deletions

View File

@@ -71,6 +71,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions) - TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter) - N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity) - TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons) (020-fix-zero-hp-opacity)
- Browser localStorage (existing adapter, extended for creatureId) (021-bestiary-statblock)
## Recent Changes ## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite

View File

@@ -1,6 +1,10 @@
import type { Creature } from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
import { StatBlockPanel } from "./components/stat-block-panel";
import { TurnNavigation } from "./components/turn-navigation"; import { TurnNavigation } from "./components/turn-navigation";
import { useBestiary } from "./hooks/use-bestiary";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
export function App() { export function App() {
@@ -17,51 +21,125 @@ export function App() {
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
addFromBestiary,
} = useEncounter(); } = useEncounter();
const { search, getCreature, isLoaded } = useBestiary();
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
null,
);
const [suggestions, setSuggestions] = useState<Creature[]>([]);
const handleAddFromBestiary = useCallback(
(creature: Creature) => {
addFromBestiary(creature);
setSelectedCreature(creature);
},
[addFromBestiary],
);
const handleShowStatBlock = useCallback((creature: Creature) => {
setSelectedCreature(creature);
}, []);
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
const creature = getCreature(
creatureId as import("@initiative/domain").CreatureId,
);
if (creature) setSelectedCreature(creature);
},
[getCreature],
);
const handleSearchChange = useCallback(
(query: string) => {
if (!isLoaded || query.length < 2) {
setSuggestions([]);
return;
}
setSuggestions(search(query));
},
[isLoaded, search],
);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker
useEffect(() => {
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(
active.creatureId as import("@initiative/domain").CreatureId,
);
if (creature) setSelectedCreature(creature);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
return ( return (
<div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8"> <div className="h-screen overflow-y-auto">
{/* Header */} <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-4 py-8">
<header className="space-y-1"> {/* Header */}
<h1 className="text-2xl font-bold tracking-tight"> <header className="space-y-1">
Initiative Tracker <h1 className="text-2xl font-bold tracking-tight">
</h1> Initiative Tracker
</header> </h1>
</header>
{/* Turn Navigation */} {/* Turn Navigation */}
<TurnNavigation <TurnNavigation
encounter={encounter} encounter={encounter}
onAdvanceTurn={advanceTurn} onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn} onRetreatTurn={retreatTurn}
/> />
{/* Combatant List */} {/* Combatant List */}
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"> <p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started No combatants yet add one to get started
</p> </p>
) : ( ) : (
encounter.combatants.map((c, i) => ( encounter.combatants.map((c, i) => (
<CombatantRow <CombatantRow
key={c.id} key={c.id}
combatant={c} combatant={c}
isActive={i === encounter.activeIndex} isActive={i === encounter.activeIndex}
onRename={editCombatant} onRename={editCombatant}
onSetInitiative={setInitiative} onSetInitiative={setInitiative}
onRemove={removeCombatant} onRemove={removeCombatant}
onSetHp={setHp} onSetHp={setHp}
onAdjustHp={adjustHp} onAdjustHp={adjustHp}
onSetAc={setAc} onSetAc={setAc}
onToggleCondition={toggleCondition} onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration} onToggleConcentration={toggleConcentration}
/> onShowStatBlock={
)) c.creatureId
)} ? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
/>
))
)}
</div>
{/* Action Bar */}
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
</div> </div>
{/* Action Bar */} {/* Stat Block Panel */}
<ActionBar onAddCombatant={addCombatant} /> <StatBlockPanel
creature={selectedCreature}
onClose={() => setSelectedCreature(null)}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,326 @@
import { describe, expect, it } from "vitest";
import { normalizeBestiary } from "../bestiary-adapter.js";
describe("normalizeBestiary", () => {
it("normalizes a simple creature", () => {
const raw = {
monster: [
{
name: "Goblin Warrior",
source: "XMM",
size: ["S"],
type: { type: "fey", tags: ["goblinoid"] },
alignment: ["C", "N"],
ac: [15],
hp: { average: 10, formula: "3d6" },
speed: { walk: 30 },
str: 8,
dex: 15,
con: 10,
int: 10,
wis: 8,
cha: 8,
skill: { stealth: "+6" },
senses: ["Darkvision 60 ft."],
passive: 9,
languages: ["Common", "Goblin"],
cr: "1/4",
action: [
{
name: "Scimitar",
entries: [
"{@atkr m} {@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2}) Slashing damage.",
],
},
],
bonus: [
{
name: "Nimble Escape",
entries: [
"The goblin takes the {@action Disengage|XPHB} or {@action Hide|XPHB} action.",
],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures).toHaveLength(1);
const c = creatures[0];
expect(c.id).toBe("xmm:goblin-warrior");
expect(c.name).toBe("Goblin Warrior");
expect(c.source).toBe("XMM");
expect(c.sourceDisplayName).toBe("MM 2024");
expect(c.size).toBe("Small");
expect(c.type).toBe("Fey (Goblinoid)");
expect(c.alignment).toBe("Chaotic Neutral");
expect(c.ac).toBe(15);
expect(c.hp).toEqual({ average: 10, formula: "3d6" });
expect(c.speed).toBe("30 ft.");
expect(c.abilities.dex).toBe(15);
expect(c.cr).toBe("1/4");
expect(c.proficiencyBonus).toBe(2);
expect(c.passive).toBe(9);
expect(c.skills).toBe("Stealth +6");
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(c.bonusActions).toHaveLength(1);
expect(c.bonusActions?.[0].text).toContain("Disengage");
expect(c.bonusActions?.[0].text).not.toContain("{@");
});
it("normalizes a creature with legendary actions", () => {
const raw = {
monster: [
{
name: "Aboleth",
source: "XMM",
size: ["L"],
type: "aberration",
alignment: ["L", "E"],
ac: [17],
hp: { average: 135, formula: "18d10 + 36" },
speed: { walk: 10, swim: 40 },
str: 21,
dex: 9,
con: 15,
int: 18,
wis: 15,
cha: 18,
save: { con: "+6", int: "+8", wis: "+6" },
senses: ["Darkvision 120 ft."],
passive: 12,
languages: ["Deep Speech", "Telepathy 120 ft."],
cr: "10",
legendary: [
{
name: "Lash",
entries: ["The aboleth makes one Tentacle attack."],
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.legendaryActions).toBeDefined();
expect(c.legendaryActions?.preamble).toContain("3 Legendary Actions");
expect(c.legendaryActions?.entries).toHaveLength(1);
expect(c.legendaryActions?.entries[0].name).toBe("Lash");
});
it("normalizes a creature with spellcasting", () => {
const raw = {
monster: [
{
name: "Test Caster",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [12],
hp: { average: 40, formula: "9d8" },
speed: { walk: 30 },
str: 10,
dex: 14,
con: 10,
int: 17,
wis: 12,
cha: 11,
passive: 11,
cr: "6",
spellcasting: [
{
name: "Spellcasting",
headerEntries: [
"The caster casts spells using Intelligence (spell save {@dc 15}):",
],
will: ["{@spell Detect Magic|XPHB}", "{@spell Mage Hand|XPHB}"],
daily: {
"2e": ["{@spell Fireball|XPHB}"],
"1": ["{@spell Dimension Door|XPHB}"],
},
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.spellcasting).toHaveLength(1);
const sc = c.spellcasting?.[0];
expect(sc).toBeDefined();
expect(sc?.name).toBe("Spellcasting");
expect(sc?.headerText).toContain("DC 15");
expect(sc?.headerText).not.toContain("{@");
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
expect(sc?.daily).toHaveLength(2);
expect(sc?.daily).toContainEqual({
uses: 2,
each: true,
spells: ["Fireball"],
});
expect(sc?.daily).toContainEqual({
uses: 1,
each: false,
spells: ["Dimension Door"],
});
});
it("normalizes a creature with object-type type field", () => {
const raw = {
monster: [
{
name: "Swarm of Bats",
source: "XMM",
size: ["L"],
type: { type: "beast", swarmSize: "T" },
ac: [12],
hp: { average: 11, formula: "2d10" },
speed: { walk: 5, fly: 30 },
str: 5,
dex: 15,
con: 10,
int: 2,
wis: 12,
cha: 4,
passive: 11,
resist: ["bludgeoning", "piercing", "slashing"],
conditionImmune: ["charmed", "frightened"],
cr: "1/4",
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.type).toBe("Swarm of Tiny Beasts");
expect(c.resist).toBe("Bludgeoning, Piercing, Slashing");
expect(c.conditionImmune).toBe("Charmed, Frightened");
});
it("normalizes a creature with conditional resistances", () => {
const raw = {
monster: [
{
name: "Half-Dragon",
source: "XMM",
size: ["M"],
type: "humanoid",
ac: [18],
hp: { average: 65, formula: "10d8 + 20" },
speed: { walk: 30 },
str: 16,
dex: 13,
con: 14,
int: 10,
wis: 11,
cha: 10,
passive: 10,
cr: "5",
resist: [
{
special: "Damage type chosen for the Draconic Origin trait",
},
],
},
],
};
const creatures = normalizeBestiary(raw);
const c = creatures[0];
expect(c.resist).toBe("Damage type chosen for the Draconic Origin trait");
});
it("normalizes a creature with multiple sizes", () => {
const raw = {
monster: [
{
name: "Aberrant Cultist",
source: "XMM",
size: ["S", "M"],
type: "humanoid",
ac: [13],
hp: { average: 22, formula: "4d8 + 4" },
speed: { walk: 30 },
str: 11,
dex: 14,
con: 12,
int: 10,
wis: 13,
cha: 8,
passive: 11,
cr: "1/2",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].size).toBe("Small or Medium");
});
it("normalizes a creature with CR as object", () => {
const raw = {
monster: [
{
name: "Dragon",
source: "XMM",
size: ["H"],
type: "dragon",
ac: [19],
hp: { average: 256, formula: "19d12 + 133" },
speed: { walk: 40 },
str: 27,
dex: 10,
con: 25,
int: 16,
wis: 13,
cha: 23,
passive: 23,
cr: { cr: "17", xpLair: 20000 },
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].cr).toBe("17");
expect(creatures[0].proficiencyBonus).toBe(6);
});
it("handles fly speed with hover condition", () => {
const raw = {
monster: [
{
name: "Air Elemental",
source: "XMM",
size: ["L"],
type: "elemental",
ac: [15],
hp: { average: 90, formula: "12d10 + 24" },
speed: {
walk: 10,
fly: { number: 90, condition: "(hover)" },
canHover: true,
},
str: 14,
dex: 20,
con: 14,
int: 6,
wis: 10,
cha: 6,
passive: 10,
cr: "5",
},
],
};
const creatures = normalizeBestiary(raw);
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
});
});

View File

@@ -0,0 +1,16 @@
import { expect, it } from "vitest";
import rawData from "../../../../../data/bestiary/xmm.json";
import { normalizeBestiary } from "../bestiary-adapter.js";
it("normalizes all 503 monsters without error", () => {
const creatures = normalizeBestiary(
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
);
expect(creatures.length).toBe(503);
for (const c of creatures) {
expect(c.name).toBeTruthy();
expect(c.id).toBeTruthy();
expect(c.ac).toBeGreaterThanOrEqual(0);
expect(c.hp.average).toBeGreaterThan(0);
}
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { stripTags } from "../strip-tags.js";
describe("stripTags", () => {
it("returns text unchanged when no tags present", () => {
expect(stripTags("Hello world")).toBe("Hello world");
});
it("strips {@spell Name|Source} to Name", () => {
expect(stripTags("{@spell Fireball|XPHB}")).toBe("Fireball");
});
it("strips {@condition Name|Source} to Name", () => {
expect(stripTags("{@condition Frightened|XPHB}")).toBe("Frightened");
});
it("strips {@damage dice} to dice", () => {
expect(stripTags("{@damage 2d10}")).toBe("2d10");
});
it("strips {@dice value} to value", () => {
expect(stripTags("{@dice 5d10}")).toBe("5d10");
});
it("strips {@dc N} to DC N", () => {
expect(stripTags("{@dc 15}")).toBe("DC 15");
});
it("strips {@hit N} to +N", () => {
expect(stripTags("{@hit 5}")).toBe("+5");
});
it("strips {@h} to Hit: ", () => {
expect(stripTags("{@h}")).toBe("Hit: ");
});
it("strips {@hom} to Hit or Miss: ", () => {
expect(stripTags("{@hom}")).toBe("Hit or Miss: ");
});
it("strips {@atkr m} to Melee Attack Roll:", () => {
expect(stripTags("{@atkr m}")).toBe("Melee Attack Roll:");
});
it("strips {@atkr r} to Ranged Attack Roll:", () => {
expect(stripTags("{@atkr r}")).toBe("Ranged Attack Roll:");
});
it("strips {@atkr m,r} to Melee or Ranged Attack Roll:", () => {
expect(stripTags("{@atkr m,r}")).toBe("Melee or Ranged Attack Roll:");
});
it("strips {@recharge 5} to (Recharge 5-6)", () => {
expect(stripTags("{@recharge 5}")).toBe("(Recharge 5-6)");
});
it("strips {@recharge} to (Recharge 6)", () => {
expect(stripTags("{@recharge}")).toBe("(Recharge 6)");
});
it("strips {@actSave wis} to Wisdom saving throw", () => {
expect(stripTags("{@actSave wis}")).toBe("Wisdom saving throw");
});
it("strips {@actSaveFail} to Failure:", () => {
expect(stripTags("{@actSaveFail}")).toBe("Failure:");
});
it("strips {@actSaveFail 2} to Failure by 2 or More:", () => {
expect(stripTags("{@actSaveFail 2}")).toBe("Failure by 2 or More:");
});
it("strips {@actSaveSuccess} to Success:", () => {
expect(stripTags("{@actSaveSuccess}")).toBe("Success:");
});
it("strips {@actTrigger} to Trigger:", () => {
expect(stripTags("{@actTrigger}")).toBe("Trigger:");
});
it("strips {@actResponse} to Response:", () => {
expect(stripTags("{@actResponse}")).toBe("Response:");
});
it("strips {@variantrule Name|Source|Display} to Display", () => {
expect(stripTags("{@variantrule Cone [Area of Effect]|XPHB|Cone}")).toBe(
"Cone",
);
});
it("strips {@action Name|Source|Display} to Display", () => {
expect(stripTags("{@action Disengage|XPHB|Disengage}")).toBe("Disengage");
});
it("strips {@skill Name|Source} to Name", () => {
expect(stripTags("{@skill Perception|XPHB}")).toBe("Perception");
});
it("strips {@creature Name|Source} to Name", () => {
expect(stripTags("{@creature Goblin|XPHB}")).toBe("Goblin");
});
it("strips {@hazard Name|Source} to Name", () => {
expect(stripTags("{@hazard Lava|XPHB}")).toBe("Lava");
});
it("strips {@status Name|Source} to Name", () => {
expect(stripTags("{@status Bloodied|XPHB}")).toBe("Bloodied");
});
it("handles unknown tags by extracting first segment", () => {
expect(stripTags("{@unknown Something|else}")).toBe("Something");
});
it("handles multiple tags in the same string", () => {
expect(stripTags("{@hit 4}, reach 5 ft. {@h}5 ({@damage 1d6 + 2})")).toBe(
"+4, reach 5 ft. Hit: 5 (1d6 + 2)",
);
});
it("handles nested tags gracefully", () => {
expect(
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
).toBe("The spell Fireball deals 8d6.");
});
it("handles text with no tags", () => {
expect(stripTags("Just plain text.")).toBe("Just plain text.");
});
it("strips {@actSaveSuccessOrFail} to Success or Failure:", () => {
expect(stripTags("{@actSaveSuccessOrFail}")).toBe("Success or Failure:");
});
it("strips {@action Name|Source} to Name when no display text", () => {
expect(stripTags("{@action Disengage|XPHB}")).toBe("Disengage");
});
});

View File

@@ -0,0 +1,404 @@
import type {
Creature,
CreatureId,
DailySpells,
LegendaryBlock,
SpellcastingBlock,
TraitBlock,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
name: string;
source: string;
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] })[];
hp: { average: number; formula: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
>;
str: number;
dex: number;
con: number;
int: number;
wis: number;
cha: number;
save?: Record<string, string>;
skill?: Record<string, string>;
senses?: string[];
passive: number;
resist?: (string | { special: string })[];
immune?: (string | { special: string })[];
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
reaction?: RawEntry[];
legendary?: RawEntry[];
legendaryActions?: number;
legendaryActionsLair?: number;
legendaryHeader?: string[];
spellcasting?: RawSpellcasting[];
}
interface RawEntry {
name: string;
entries: (string | RawEntryObject)[];
}
interface RawEntryObject {
type: string;
items?: (
| string
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
)[];
style?: string;
name?: string;
entries?: (string | RawEntryObject)[];
}
interface RawSpellcasting {
name: string;
headerEntries: string[];
will?: string[];
daily?: Record<string, string[]>;
rest?: Record<string, string[]>;
hidden?: string[];
ability?: string;
displayAs?: string;
legendary?: Record<string, string[]>;
}
// --- Source mapping ---
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
XMM: "MM 2024",
};
// --- Size mapping ---
const SIZE_MAP: Record<string, string> = {
T: "Tiny",
S: "Small",
M: "Medium",
L: "Large",
H: "Huge",
G: "Gargantuan",
};
// --- Alignment mapping ---
const ALIGNMENT_MAP: Record<string, string> = {
L: "Lawful",
N: "Neutral",
C: "Chaotic",
G: "Good",
E: "Evil",
U: "Unaligned",
};
function formatAlignment(codes?: string[]): string {
if (!codes || codes.length === 0) return "Unaligned";
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
if (codes.length === 1 && codes[0] === "N") return "Neutral";
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
}
// --- Helpers ---
function formatSize(sizes: string[]): string {
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
}
function formatType(
type:
| string
| {
type: string | { choose: string[] };
tags?: string[];
swarmSize?: string;
},
): string {
if (typeof type === "string") return capitalize(type);
const baseType =
typeof type.type === "string"
? capitalize(type.type)
: type.type.choose.map(capitalize).join(" or ");
let result = baseType;
if (type.tags && type.tags.length > 0) {
result += ` (${type.tags.map(capitalize).join(", ")})`;
}
if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
result = `Swarm of ${swarmSizeLabel} ${result}s`;
}
return result;
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function extractAc(ac: RawMonster["ac"]): {
value: number;
source?: string;
} {
const first = ac[0];
if (typeof first === "number") {
return { value: first };
}
return {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
};
}
function formatSpeed(speed: RawMonster["speed"]): string {
const parts: string[] = [];
for (const [mode, value] of Object.entries(speed)) {
if (mode === "canHover") continue;
if (typeof value === "boolean") continue;
let numStr: string;
let condition = "";
if (typeof value === "number") {
numStr = `${value} ft.`;
} else {
numStr = `${value.number} ft.`;
if (value.condition) {
condition = ` ${value.condition}`;
}
}
if (mode === "walk") {
parts.push(`${numStr}${condition}`);
} else {
parts.push(`${mode} ${numStr}${condition}`);
}
}
return parts.join(", ");
}
function formatSaves(save?: Record<string, string>): string | undefined {
if (!save) return undefined;
return Object.entries(save)
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
.join(", ");
}
function formatSkills(skill?: Record<string, string>): string | undefined {
if (!skill) return undefined;
return Object.entries(skill)
.map(([key, val]) => `${capitalize(key)} ${val}`)
.join(", ");
}
function formatDamageList(
items?: (string | Record<string, unknown>)[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.map((item) => {
if (typeof item === "string") return capitalize(stripTags(item));
if (typeof item.special === "string") return stripTags(item.special);
// Handle conditional entries like { vulnerable: [...], note: "..." }
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
[]) as string[];
const note = typeof item.note === "string" ? ` ${item.note}` : "";
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
})
.join(", ");
}
function formatConditionImmunities(
items?: (string | { conditionImmune?: string[]; note?: string })[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.flatMap((c) => {
if (typeof c === "string") return [capitalize(stripTags(c))];
if (c.conditionImmune) {
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
const note = c.note ? ` ${c.note}` : "";
return conds.map((ci) => `${ci}${note}`);
}
return [];
})
.join(", ");
}
function renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else if (entry.type === "list") {
for (const item of entry.items ?? []) {
if (typeof item === "string") {
parts.push(`${stripTags(item)}`);
} else if (item.name && item.entries) {
parts.push(
`${stripTags(item.name)}: ${renderEntries(item.entries)}`,
);
}
}
} else 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));
}
}
return parts.join(" ");
}
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),
}));
}
function normalizeSpellcasting(
raw?: RawSpellcasting[],
): SpellcastingBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((sc) => {
const block: {
name: string;
headerText: string;
atWill?: string[];
daily?: DailySpells[];
restLong?: DailySpells[];
} = {
name: stripTags(sc.name),
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
};
const hidden = new Set(sc.hidden ?? []);
if (sc.will && !hidden.has("will")) {
block.atWill = sc.will.map((s) => stripTags(s));
}
if (sc.daily) {
block.daily = parseDailyMap(sc.daily);
}
if (sc.rest) {
block.restLong = parseDailyMap(sc.rest);
}
return block;
});
}
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
return Object.entries(map).map(([key, spells]) => {
const each = key.endsWith("e");
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
return {
uses,
each,
spells: spells.map((s) => stripTags(s)),
};
});
}
function normalizeLegendary(
raw?: RawEntry[],
monster?: RawMonster,
): LegendaryBlock | undefined {
if (!raw || raw.length === 0) return undefined;
const name = monster?.name ?? "creature";
const count = monster?.legendaryActions ?? 3;
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
return {
preamble,
entries: raw.map((e) => ({
name: stripTags(e.name),
text: renderEntries(e.entries),
})),
};
}
function extractCr(cr: string | { cr: string }): string {
return typeof cr === "string" ? cr : cr.cr;
}
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}
/**
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
return raw.monster.map((m) => {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: { average: m.hp.average, formula: m.hp.formula },
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
});
}

View File

@@ -0,0 +1,99 @@
const ABILITY_MAP: Record<string, string> = {
str: "Strength",
dex: "Dexterity",
con: "Constitution",
int: "Intelligence",
wis: "Wisdom",
cha: "Charisma",
};
const ATKR_MAP: Record<string, string> = {
m: "Melee Attack Roll:",
r: "Ranged Attack Roll:",
"m,r": "Melee or Ranged Attack Roll:",
"r,m": "Melee or Ranged Attack Roll:",
};
/**
* Strips 5etools {@tag ...} markup from text, converting to plain readable text.
*
* Handles 15+ tag types per research.md R-002 tag resolution rules.
*/
export function stripTags(text: string): string {
// Process special tags with specific output formats first
let result = text;
// {@h} → "Hit: "
result = result.replace(/\{@h\}/g, "Hit: ");
// {@hom} → "Hit or Miss: "
result = result.replace(/\{@hom\}/g, "Hit or Miss: ");
// {@actTrigger} → "Trigger:"
result = result.replace(/\{@actTrigger\}/g, "Trigger:");
// {@actResponse} → "Response:"
result = result.replace(/\{@actResponse\}/g, "Response:");
// {@actSaveSuccess} → "Success:"
result = result.replace(/\{@actSaveSuccess\}/g, "Success:");
// {@actSaveSuccessOrFail} → handled below as parameterized
// {@recharge 5} → "(Recharge 5-6)", {@recharge} → "(Recharge 6)"
result = result.replace(/\{@recharge\s+(\d)\}/g, "(Recharge $1-6)");
result = result.replace(/\{@recharge\}/g, "(Recharge 6)");
// {@dc N} → "DC N"
result = result.replace(/\{@dc\s+(\d+)\}/g, "DC $1");
// {@hit N} → "+N"
result = result.replace(/\{@hit\s+(\d+)\}/g, "+$1");
// {@atkr type} → mapped attack roll text
result = result.replace(/\{@atkr\s+([^}]+)\}/g, (_, type: string) => {
return ATKR_MAP[type.trim()] ?? `Attack Roll:`;
});
// {@actSave ability} → "Ability saving throw"
result = result.replace(/\{@actSave\s+([^}]+)\}/g, (_, ability: string) => {
const name = ABILITY_MAP[ability.trim().toLowerCase()];
return name ? `${name} saving throw` : `${ability} saving throw`;
});
// {@actSaveFail} → "Failure:" or {@actSaveFail N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFail\s+(\d+)\}/g,
"Failure by $1 or More:",
);
result = result.replace(/\{@actSaveFail\}/g, "Failure:");
// {@actSaveSuccessOrFail} → keep as-is label
result = result.replace(/\{@actSaveSuccessOrFail\}/g, "Success or Failure:");
// {@actSaveFailBy N} → "Failure by N or More:"
result = result.replace(
/\{@actSaveFailBy\s+(\d+)\}/g,
"Failure by $1 or More:",
);
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
// Covers: spell, condition, damage, dice, variantrule, action, skill,
// creature, hazard, status, plus any unknown tags
result = result.replace(
/\{@(\w+)\s+([^}]+)\}/g,
(_, tag: string, content: string) => {
// For tags with Display|Source format, extract first segment
const segments = content.split("|");
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
return segments[2];
}
return segments[0];
},
);
return result;
}

View File

@@ -1,35 +1,143 @@
import type { Creature } from "@initiative/domain";
import { Search } from "lucide-react";
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { Button } from "./ui/button"; import { BestiarySearch } from "./bestiary-search.js";
import { Input } from "./ui/input"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface ActionBarProps { interface ActionBarProps {
onAddCombatant: (name: string) => void; onAddCombatant: (name: string) => void;
onAddFromBestiary: (creature: Creature) => void;
bestiarySearch: (query: string) => Creature[];
bestiaryLoaded: boolean;
suggestions: Creature[];
onSearchChange: (query: string) => void;
onShowStatBlock?: (creature: Creature) => void;
} }
export function ActionBar({ onAddCombatant }: ActionBarProps) { export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
suggestions,
onSearchChange,
onShowStatBlock,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const handleAdd = (e: FormEvent) => { const handleAdd = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (nameInput.trim() === "") return; if (nameInput.trim() === "") return;
onAddCombatant(nameInput); onAddCombatant(nameInput);
setNameInput(""); setNameInput("");
onSearchChange("");
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
onSearchChange(value);
};
const handleSelectCreature = (creature: Creature) => {
onAddFromBestiary(creature);
setSearchOpen(false);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
};
const handleSelectSuggestion = (creature: Creature) => {
onAddFromBestiary(creature);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]);
} else if (e.key === "Escape") {
setSuggestionIndex(-1);
onSearchChange("");
}
}; };
return ( return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"> <div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<form onSubmit={handleAdd} className="flex flex-1 items-center gap-2"> {searchOpen ? (
<Input <BestiarySearch
type="text" onSelectCreature={handleSelectCreature}
value={nameInput} onClose={() => setSearchOpen(false)}
onChange={(e) => setNameInput(e.target.value)} searchFn={bestiarySearch}
placeholder="Combatant name"
className="max-w-xs"
/> />
<Button type="submit" size="sm"> ) : (
Add <form
</Button> onSubmit={handleAdd}
</form> className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Combatant name"
className="max-w-xs"
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-accent/10"
}`}
onClick={() => handleSelectSuggestion(creature)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{creature.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setSearchOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
)}
</form>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,129 @@
import type { Creature } from "@initiative/domain";
import { Search, X } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Input } from "./ui/input.js";
interface BestiarySearchProps {
onSelectCreature: (creature: Creature) => void;
onClose: () => void;
searchFn: (query: string) => Creature[];
}
export function BestiarySearch({
onSelectCreature,
onClose,
searchFn,
}: BestiarySearchProps) {
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const results = query.length >= 2 ? searchFn(query) : [];
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
setHighlightIndex(-1);
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
return;
}
if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
onSelectCreature(results[highlightIndex]);
}
},
[results, highlightIndex, onClose, onSelectCreature],
);
return (
<div ref={containerRef} className="relative w-full max-w-sm">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search bestiary..."
className="flex-1"
/>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{query.length >= 2 && (
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
No creatures found
</div>
) : (
<ul className="max-h-60 overflow-y-auto py-1">
{results.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === highlightIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-accent/10"
}`}
onClick={() => onSelectCreature(creature)}
onMouseEnter={() => setHighlightIndex(i)}
>
<span>{creature.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import {
type ConditionId, type ConditionId,
deriveHpStatus, deriveHpStatus,
} from "@initiative/domain"; } from "@initiative/domain";
import { Brain, Shield, X } from "lucide-react"; import { BookOpen, Brain, Shield, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker"; import { ConditionPicker } from "./condition-picker";
@@ -34,6 +34,7 @@ interface CombatantRowProps {
onSetAc: (id: CombatantId, value: number | undefined) => void; onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void; onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void; onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
} }
function EditableName({ function EditableName({
@@ -276,6 +277,7 @@ export function CombatantRow({
onSetAc, onSetAc,
onToggleCondition, onToggleCondition,
onToggleConcentration, onToggleConcentration,
onShowStatBlock,
}: CombatantRowProps) { }: CombatantRowProps) {
const { id, name, initiative, maxHp, currentHp } = combatant; const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
@@ -364,8 +366,19 @@ export function CombatantRow({
/> />
{/* Name */} {/* Name */}
<div className={cn(dimmed && "opacity-50")}> <div className={cn("flex items-center gap-1", dimmed && "opacity-50")}>
<EditableName name={name} combatantId={id} onRename={onRename} /> <EditableName name={name} combatantId={id} onRename={onRename} />
{onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
className="text-muted-foreground hover:text-amber-400 transition-colors"
title="View stat block"
aria-label="View stat block"
>
<BookOpen size={14} />
</button>
)}
</div> </div>
{/* AC */} {/* AC */}

View File

@@ -0,0 +1,77 @@
import type { Creature } from "@initiative/domain";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { StatBlock } from "./stat-block.js";
interface StatBlockPanelProps {
creature: Creature | null;
onClose: () => void;
}
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
const [isDesktop, setIsDesktop] = useState(
() => window.matchMedia("(min-width: 1024px)").matches,
);
useEffect(() => {
const mq = window.matchMedia("(min-width: 1024px)");
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
if (!creature) return null;
if (isDesktop) {
return (
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
Stat Block
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
);
}
// Mobile drawer
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<button
type="button"
className="absolute inset-0 bg-black/50 animate-in fade-in"
onClick={onClose}
aria-label="Close stat block"
/>
{/* Drawer */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-sm font-semibold text-muted-foreground">
Stat Block
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
<StatBlock creature={creature} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import type { Creature } from "@initiative/domain";
interface StatBlockProps {
creature: Creature;
}
function abilityMod(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
function PropertyLine({
label,
value,
}: {
label: string;
value: string | undefined;
}) {
if (!value) return null;
return (
<div className="text-sm">
<span className="font-semibold">{label}</span> {value}
</div>
);
}
function SectionDivider() {
return (
<div className="my-2 h-px bg-gradient-to-r from-amber-800/60 via-amber-700/40 to-transparent" />
);
}
export function StatBlock({ creature }: StatBlockProps) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },
{ label: "CON", score: creature.abilities.con },
{ label: "INT", score: creature.abilities.int },
{ label: "WIS", score: creature.abilities.wis },
{ label: "CHA", score: creature.abilities.cha },
];
return (
<div className="space-y-1 text-foreground">
{/* Header */}
<div>
<h2 className="text-xl font-bold text-amber-400">{creature.name}</h2>
<p className="text-sm italic text-muted-foreground">
{creature.size} {creature.type}, {creature.alignment}
</p>
<p className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</p>
</div>
<SectionDivider />
{/* Stats bar */}
<div className="space-y-0.5 text-sm">
<div>
<span className="font-semibold">Armor Class</span> {creature.ac}
{creature.acSource && (
<span className="text-muted-foreground">
{" "}
({creature.acSource})
</span>
)}
</div>
<div>
<span className="font-semibold">Hit Points</span>{" "}
{creature.hp.average}{" "}
<span className="text-muted-foreground">({creature.hp.formula})</span>
</div>
<div>
<span className="font-semibold">Speed</span> {creature.speed}
</div>
</div>
<SectionDivider />
{/* Ability scores */}
<div className="grid grid-cols-6 gap-1 text-center text-sm">
{abilities.map(({ label, score }) => (
<div key={label}>
<div className="font-semibold">{label}</div>
<div>
{score}{" "}
<span className="text-muted-foreground">
({abilityMod(score)})
</span>
</div>
</div>
))}
</div>
<SectionDivider />
{/* Properties */}
<div className="space-y-0.5">
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
<PropertyLine label="Skills" value={creature.skills} />
<PropertyLine
label="Damage Vulnerabilities"
value={creature.vulnerable}
/>
<PropertyLine label="Damage Resistances" value={creature.resist} />
<PropertyLine label="Damage Immunities" value={creature.immune} />
<PropertyLine
label="Condition Immunities"
value={creature.conditionImmune}
/>
<PropertyLine label="Senses" value={creature.senses} />
<PropertyLine label="Languages" value={creature.languages} />
<div className="text-sm">
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
<span className="text-muted-foreground">
(Proficiency Bonus +{creature.proficiencyBonus})
</span>
</div>
</div>
{/* Traits */}
{creature.traits && creature.traits.length > 0 && (
<>
<SectionDivider />
<div className="space-y-2">
{creature.traits.map((t) => (
<div key={t.name} className="text-sm">
<span className="font-semibold italic">{t.name}.</span> {t.text}
</div>
))}
</div>
</>
)}
{/* Spellcasting */}
{creature.spellcasting && creature.spellcasting.length > 0 && (
<>
<SectionDivider />
{creature.spellcasting.map((sc) => (
<div key={sc.name} className="space-y-1 text-sm">
<div>
<span className="font-semibold italic">{sc.name}.</span>{" "}
{sc.headerText}
</div>
{sc.atWill && sc.atWill.length > 0 && (
<div className="pl-2">
<span className="font-semibold">At Will:</span>{" "}
{sc.atWill.join(", ")}
</div>
)}
{sc.daily?.map((d) => (
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
<span className="font-semibold">
{d.uses}/day
{d.each ? " each" : ""}:
</span>{" "}
{d.spells.join(", ")}
</div>
))}
{sc.restLong?.map((d) => (
<div
key={`rest-${d.uses}${d.each ? "e" : ""}`}
className="pl-2"
>
<span className="font-semibold">
{d.uses}/long rest
{d.each ? " each" : ""}:
</span>{" "}
{d.spells.join(", ")}
</div>
))}
</div>
))}
</>
)}
{/* Actions */}
{creature.actions && creature.actions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Actions</h3>
<div className="space-y-2">
{creature.actions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Bonus Actions */}
{creature.bonusActions && creature.bonusActions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Bonus Actions</h3>
<div className="space-y-2">
{creature.bonusActions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Reactions */}
{creature.reactions && creature.reactions.length > 0 && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">Reactions</h3>
<div className="space-y-2">
{creature.reactions.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
{/* Legendary Actions */}
{creature.legendaryActions && (
<>
<SectionDivider />
<h3 className="text-base font-bold text-amber-400">
Legendary Actions
</h3>
<p className="text-sm italic text-muted-foreground">
{creature.legendaryActions.preamble}
</p>
<div className="space-y-2">
{creature.legendaryActions.entries.map((a) => (
<div key={a.name} className="text-sm">
<span className="font-semibold italic">{a.name}.</span> {a.text}
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import type { Creature, CreatureId } from "@initiative/domain";
import { useEffect, useMemo, useRef, useState } from "react";
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
interface BestiaryHook {
search: (query: string) => Creature[];
getCreature: (id: CreatureId) => Creature | undefined;
allCreatures: Creature[];
isLoaded: boolean;
}
export function useBestiary(): BestiaryHook {
const [creatures, setCreatures] = useState<Creature[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
const loadAttempted = useRef(false);
useEffect(() => {
if (loadAttempted.current) return;
loadAttempted.current = true;
import("../../../../data/bestiary/xmm.json")
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
.then((mod: any) => {
const raw = mod.default ?? mod;
try {
const normalized = normalizeBestiary(raw);
const map = new Map<string, Creature>();
for (const c of normalized) {
map.set(c.id, c);
}
creatureMapRef.current = map;
setCreatures(normalized);
setIsLoaded(true);
} catch {
// Normalization failed — bestiary unavailable
}
})
.catch(() => {
// Import failed — bestiary unavailable
});
}, []);
const search = useMemo(() => {
return (query: string): Creature[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
return creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10);
};
}, [creatures]);
const getCreature = useMemo(() => {
return (id: CreatureId): Creature | undefined => {
return creatureMapRef.current.get(id);
};
}, []);
return { search, getCreature, allCreatures: creatures, isLoaded };
}

View File

@@ -15,6 +15,7 @@ import {
import type { import type {
CombatantId, CombatantId,
ConditionId, ConditionId,
Creature,
DomainEvent, DomainEvent,
Encounter, Encounter,
} from "@initiative/domain"; } from "@initiative/domain";
@@ -22,6 +23,7 @@ import {
combatantId, combatantId,
createEncounter, createEncounter,
isDomainError, isDomainError,
resolveCreatureName,
} from "@initiative/domain"; } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
@@ -74,7 +76,10 @@ export function useEncounter() {
const makeStore = useCallback((): EncounterStore => { const makeStore = useCallback((): EncounterStore => {
return { return {
get: () => encounterRef.current, get: () => encounterRef.current,
save: (e) => setEncounter(e), save: (e) => {
encounterRef.current = e;
setEncounter(e);
},
}; };
}, []); }, []);
@@ -218,6 +223,57 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const addFromBestiary = useCallback(
(creature: Creature) => {
const store = makeStore();
const existingNames = store.get().combatants.map((c) => c.name);
const { newName, renames } = resolveCreatureName(
creature.name,
existingNames,
);
// Apply renames (e.g., "Goblin" → "Goblin 1")
for (const { from, to } of renames) {
const target = store.get().combatants.find((c) => c.name === from);
if (target) {
editCombatantUseCase(makeStore(), target.id, to);
}
}
// Add combatant with resolved name
const id = combatantId(`c-${++nextId.current}`);
const addResult = addCombatantUseCase(makeStore(), id, newName);
if (isDomainError(addResult)) return;
// Set HP
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
if (!isDomainError(hpResult)) {
setEvents((prev) => [...prev, ...hpResult]);
}
// Set AC
if (creature.ac > 0) {
const acResult = setAcUseCase(makeStore(), id, creature.ac);
if (!isDomainError(acResult)) {
setEvents((prev) => [...prev, ...acResult]);
}
}
// Set creatureId on the combatant
const currentEncounter = store.get();
const updated = {
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: creature.id } : c,
),
};
setEncounter(updated);
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
);
return { return {
encounter, encounter,
events, events,
@@ -232,5 +288,6 @@ export function useEncounter() {
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
addFromBestiary,
} as const; } as const;
} }

View File

@@ -49,6 +49,19 @@
} }
} }
@keyframes slide-in-right {
from {
translate: 100%;
}
to {
translate: 0;
}
}
@utility animate-slide-in-right {
animation: slide-in-right 200ms ease-out;
}
@utility animate-concentration-pulse { @utility animate-concentration-pulse {
animation: animation:
concentration-shake 450ms ease-out, concentration-shake 450ms ease-out,

View File

@@ -2,6 +2,7 @@ import {
type ConditionId, type ConditionId,
combatantId, combatantId,
createEncounter, createEncounter,
creatureId,
type Encounter, type Encounter,
isDomainError, isDomainError,
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
@@ -75,6 +76,13 @@ export function loadEncounter(): Encounter | null {
// Validate isConcentrating field // Validate isConcentrating field
const isConcentrating = entry.isConcentrating === true ? true : undefined; const isConcentrating = entry.isConcentrating === true ? true : undefined;
// Validate creatureId field
const rawCreatureId = entry.creatureId;
const validCreatureId =
typeof rawCreatureId === "string" && rawCreatureId.length > 0
? creatureId(rawCreatureId)
: undefined;
// Validate and attach HP fields if valid // Validate and attach HP fields if valid
const maxHp = entry.maxHp; const maxHp = entry.maxHp;
const currentHp = entry.currentHp; const currentHp = entry.currentHp;
@@ -89,12 +97,19 @@ export function loadEncounter(): Encounter | null {
ac: validAc, ac: validAc,
conditions, conditions,
isConcentrating, isConcentrating,
creatureId: validCreatureId,
maxHp, maxHp,
currentHp: validCurrentHp ? currentHp : maxHp, currentHp: validCurrentHp ? currentHp : maxHp,
}; };
} }
return { ...base, ac: validAc, conditions, isConcentrating }; return {
...base,
ac: validAc,
conditions,
isConcentrating,
creatureId: validCreatureId,
};
}); });
const result = createEncounter( const result = createEncounter(

63266
data/bestiary/xmm.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { resolveCreatureName } from "../auto-number.js";
describe("resolveCreatureName", () => {
it("returns name as-is when no conflict exists", () => {
const result = resolveCreatureName("Goblin", ["Orc", "Dragon"]);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
it("returns name as-is when existing list is empty", () => {
const result = resolveCreatureName("Goblin", []);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
it("renames existing to 'Name 1' and new to 'Name 2' on first conflict", () => {
const result = resolveCreatureName("Goblin", ["Orc", "Goblin", "Dragon"]);
expect(result).toEqual({
newName: "Goblin 2",
renames: [{ from: "Goblin", to: "Goblin 1" }],
});
});
it("appends next number when numbered variants already exist", () => {
const result = resolveCreatureName("Goblin", ["Goblin 1", "Goblin 2"]);
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
});
it("handles mixed exact and numbered matches", () => {
const result = resolveCreatureName("Goblin", [
"Goblin",
"Goblin 1",
"Goblin 2",
]);
expect(result).toEqual({ newName: "Goblin 3", renames: [] });
});
it("handles names with special regex characters", () => {
const result = resolveCreatureName("Goblin (Boss)", ["Goblin (Boss)"]);
expect(result).toEqual({
newName: "Goblin (Boss) 2",
renames: [{ from: "Goblin (Boss)", to: "Goblin (Boss) 1" }],
});
});
it("does not match partial name overlaps", () => {
const result = resolveCreatureName("Goblin", ["Goblin Boss"]);
expect(result).toEqual({ newName: "Goblin", renames: [] });
});
});

View File

@@ -0,0 +1,54 @@
/**
* Resolves a creature name against existing combatant names,
* handling auto-numbering for duplicates.
*
* - No conflict: returns name as-is, no renames.
* - First conflict (one existing match): renames existing to "Name 1",
* new becomes "Name 2".
* - Subsequent conflicts: new gets next number suffix.
*/
export function resolveCreatureName(
baseName: string,
existingNames: readonly string[],
): {
newName: string;
renames: ReadonlyArray<{ from: string; to: string }>;
} {
// Find exact matches and numbered matches (e.g., "Goblin 1", "Goblin 2")
const exactMatches: number[] = [];
let maxNumber = 0;
for (let i = 0; i < existingNames.length; i++) {
const name = existingNames[i];
if (name === baseName) {
exactMatches.push(i);
} else {
const match = new RegExp(`^${escapeRegExp(baseName)} (\\d+)$`).exec(name);
if (match) {
const num = Number.parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num;
}
}
}
// No conflict at all
if (exactMatches.length === 0 && maxNumber === 0) {
return { newName: baseName, renames: [] };
}
// First conflict: one exact match, no numbered ones yet
if (exactMatches.length === 1 && maxNumber === 0) {
return {
newName: `${baseName} 2`,
renames: [{ from: baseName, to: `${baseName} 1` }],
};
}
// Subsequent conflicts: append next number
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
return { newName: `${baseName} ${nextNumber}`, renames: [] };
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,91 @@
/** Branded string type for creature identity. */
export type CreatureId = string & { readonly __brand: "CreatureId" };
export function creatureId(id: string): CreatureId {
return id as CreatureId;
}
export interface TraitBlock {
readonly name: string;
readonly text: string;
}
export interface LegendaryBlock {
readonly preamble: string;
readonly entries: readonly TraitBlock[];
}
export interface DailySpells {
readonly uses: number;
readonly each: boolean;
readonly spells: readonly string[];
}
export interface SpellcastingBlock {
readonly name: string;
readonly headerText: string;
readonly atWill?: readonly string[];
readonly daily?: readonly DailySpells[];
readonly restLong?: readonly DailySpells[];
}
export interface BestiarySource {
readonly id: string;
readonly displayName: string;
readonly tag?: string;
}
export interface Creature {
readonly id: CreatureId;
readonly name: string;
readonly source: string;
readonly sourceDisplayName: string;
readonly size: string;
readonly type: string;
readonly alignment: string;
readonly ac: number;
readonly acSource?: string;
readonly hp: { readonly average: number; readonly formula: string };
readonly speed: string;
readonly abilities: {
readonly str: number;
readonly dex: number;
readonly con: number;
readonly int: number;
readonly wis: number;
readonly cha: number;
};
readonly cr: string;
readonly proficiencyBonus: number;
readonly passive: number;
readonly savingThrows?: string;
readonly skills?: string;
readonly resist?: string;
readonly immune?: string;
readonly vulnerable?: string;
readonly conditionImmune?: string;
readonly senses?: string;
readonly languages?: string;
readonly traits?: readonly TraitBlock[];
readonly actions?: readonly TraitBlock[];
readonly bonusActions?: readonly TraitBlock[];
readonly reactions?: readonly TraitBlock[];
readonly legendaryActions?: LegendaryBlock;
readonly spellcasting?: readonly SpellcastingBlock[];
}
/** Maps a CR string to the corresponding proficiency bonus. */
export function proficiencyBonus(cr: string): number {
const numericCr = cr.includes("/")
? Number(cr.split("/")[0]) / Number(cr.split("/")[1])
: Number(cr);
if (numericCr <= 4) return 2;
if (numericCr <= 8) return 3;
if (numericCr <= 12) return 4;
if (numericCr <= 16) return 5;
if (numericCr <= 20) return 6;
if (numericCr <= 24) return 7;
if (numericCr <= 28) return 8;
return 9;
}

View File

@@ -1,12 +1,24 @@
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js"; export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js"; export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
export { advanceTurn } from "./advance-turn.js"; export { advanceTurn } from "./advance-turn.js";
export { resolveCreatureName } from "./auto-number.js";
export { export {
CONDITION_DEFINITIONS, CONDITION_DEFINITIONS,
type ConditionDefinition, type ConditionDefinition,
type ConditionId, type ConditionId,
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
} from "./conditions.js"; } from "./conditions.js";
export {
type BestiarySource,
type Creature,
type CreatureId,
creatureId,
type DailySpells,
type LegendaryBlock,
proficiencyBonus,
type SpellcastingBlock,
type TraitBlock,
} from "./creature-types.js";
export { export {
type EditCombatantSuccess, type EditCombatantSuccess,
editCombatant, editCombatant,

View File

@@ -6,6 +6,7 @@ export function combatantId(id: string): CombatantId {
} }
import type { ConditionId } from "./conditions.js"; import type { ConditionId } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
export interface Combatant { export interface Combatant {
readonly id: CombatantId; readonly id: CombatantId;
@@ -16,6 +17,7 @@ export interface Combatant {
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
} }
export interface Encounter { export interface Encounter {

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Bestiary Search & Stat Block Display
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation.
- Assumptions made: auto-numbering renames first instance to "Goblin 1" when a second is added (documented in edge cases). Minimum 2 characters before suggestions appear (documented in US3). Source tag disambiguation for future multi-source support (documented in edge cases).

View File

@@ -0,0 +1,140 @@
# UI Contracts: Bestiary Search & Stat Block Display
**Branch**: `021-bestiary-statblock` | **Date**: 2026-03-06
## Component Contracts
### BestiarySearch
**Purpose**: Search input with autocomplete dropdown for creature selection.
**Props**:
- `onSelectCreature: (creature: Creature) => void` — Called when user selects a creature from results
- `onClose: () => void` — Called when search is dismissed (Escape, click outside)
**Behavior**:
- Opens focused on the search input
- Filters creatures by case-insensitive substring match on name
- Minimum 2 characters before showing results
- Maximum 10 results shown at a time
- Keyboard navigation: ArrowUp/ArrowDown to navigate, Enter to select, Escape to close
- Shows "No creatures found" for zero-match queries
- Shows source tag next to creature name (e.g., "Goblin (MM 2024)")
### StatBlock
**Purpose**: Renders a complete creature stat block.
**Props**:
- `creature: Creature` — The creature data to display
**Sections rendered** (in order, omitted if data absent):
1. Header: name, size, type, alignment
2. Stats bar: AC, HP (average + formula), Speed
3. Ability scores: STR/DEX/CON/INT/WIS/CHA with modifiers
4. Properties: Saving Throws, Skills, Damage Vulnerabilities, Damage Resistances, Damage Immunities, Condition Immunities, Senses, Languages, CR + Proficiency Bonus
5. Traits
6. Spellcasting (rendered as a separate section after traits)
7. Actions
8. Bonus Actions
9. Reactions
10. Legendary Actions (with preamble text)
### StatBlockPanel
**Purpose**: Responsive wrapper — side panel on desktop, drawer on mobile.
**Props**:
- `creature: Creature | null` — Currently displayed creature (null = closed)
- `onClose: () => void` — Close the panel/drawer
**Behavior**:
- Desktop (>= 1024px): Renders as a right-side panel, tracker shifts left
- Mobile (< 1024px): Renders as a slide-over drawer from right with backdrop
- Close button always visible
- Panel scrolls independently of the main content
### ActionBar (modified)
**Extended Props**:
- `onAddCombatant: (name: string) => void` — Existing: add plain combatant
- `onAddFromBestiary: (creature: Creature) => void` — New: add creature with stat pre-fill
- `suggestions: Creature[]` — Matching creatures for current name input
- `onSearchChange: (query: string) => void` — Notify parent of input changes for suggestion filtering
**New Elements**:
- Magnifying glass icon button next to input → opens dedicated BestiarySearch
- Autocomplete suggestion list below input when typing (>= 2 chars and matches exist)
## Hook Contracts
### useBestiary
**Purpose**: Provides creature search and lookup from the bundled bestiary.
**Returns**:
- `search: (query: string) => Creature[]` — Filter creatures by name substring
- `getCreature: (id: CreatureId) => Creature | undefined` — Look up creature by ID
- `allCreatures: Creature[]` — Full list (for potential future use)
**Behavior**:
- Loads and normalizes bestiary data once on initialization (lazy, memoized)
- Search is synchronous (in-memory filter)
- Returns results sorted alphabetically by name
### useEncounter (extended)
**Extended return**:
- `addFromBestiary: (creature: Creature) => void` — New: adds combatant with auto-numbered name, pre-filled HP/AC, and creatureId link
**Auto-numbering behavior**:
- Checks existing combatant names for conflicts with the creature's base name
- If no conflict: uses creature name as-is
- If one existing match: renames existing to "Name 1", new one becomes "Name 2"
- If N existing matches: new one becomes "Name N+1"
- Names remain editable after auto-numbering
## Layout Contract
### App (modified)
**Desktop layout (>= 1024px, stat block open)**:
```
+--------------------------------------------------+
| Header |
+-------------------------+------------------------+
| Tracker (flex-1) | Stat Block (~400px) |
| - Turn Navigation | - Scrollable |
| - Combatant List | - Close button |
| - Action Bar | |
+-------------------------+------------------------+
```
**Desktop layout (stat block closed)**:
```
+--------------------------------------------------+
| Header |
| Tracker (max-w-2xl, centered) |
| - Turn Navigation |
| - Combatant List |
| - Action Bar |
+--------------------------------------------------+
```
**Mobile layout (< 1024px, stat block open)**:
```
+--------------------------------------------------+
| Header |
| Tracker (full width) |
| - Turn Navigation |
| - Combatant List |
| - Action Bar |
| |
| +----------------------------------------------+ |
| | Drawer (slide from right, 85% width) | |
| | - Close button | |
| | - Stat Block (scrollable) | |
| +----------------------------------------------+ |
| | Backdrop (click to close) | |
+--------------------------------------------------+
```

View File

@@ -0,0 +1,146 @@
# Data Model: Bestiary Search & Stat Block Display
**Branch**: `021-bestiary-statblock` | **Date**: 2026-03-06
## Domain Entities
### CreatureId (branded type)
A branded string type for creature identity, following the same pattern as `CombatantId`.
```
CreatureId = string & { __brand: "CreatureId" }
```
Format: `{source}:{name-slug}` (e.g., `xmm:goblin`, `xmm:adult-red-dragon`)
### Creature
Represents a normalized bestiary entry. All fields are readonly.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | CreatureId | yes | Unique identifier |
| name | string | yes | Display name |
| source | string | yes | Source identifier (e.g., "XMM") |
| sourceDisplayName | string | yes | Human-readable source (e.g., "MM 2024") |
| size | string | yes | Size label(s) (e.g., "Medium", "Small or Medium") |
| type | string | yes | Creature type (e.g., "Dragon (Chromatic)", "Humanoid") |
| alignment | string | yes | Alignment text (e.g., "Chaotic Evil", "Unaligned") |
| ac | number | yes | Armor class (numeric value) |
| acSource | string or undefined | no | Armor source description (e.g., "natural armor", "plate armor") |
| hp | object | yes | `{ average: number; formula: string }` |
| speed | string | yes | Formatted speed string (e.g., "30 ft., fly 60 ft. (hover)") |
| abilities | object | yes | `{ str, dex, con, int, wis, cha: number }` |
| cr | string | yes | Challenge rating (e.g., "1/4", "17") |
| proficiencyBonus | number | yes | Derived from CR |
| passive | number | yes | Passive perception |
| savingThrows | string or undefined | no | Formatted saves (e.g., "DEX +5, WIS +5") |
| skills | string or undefined | no | Formatted skills (e.g., "Perception +7, Stealth +5") |
| resist | string or undefined | no | Damage resistances |
| immune | string or undefined | no | Damage immunities |
| vulnerable | string or undefined | no | Damage vulnerabilities |
| conditionImmune | string or undefined | no | Condition immunities |
| senses | string or undefined | no | Senses (e.g., "Darkvision 60 ft.") |
| languages | string or undefined | no | Languages |
| traits | TraitBlock[] or undefined | no | Creature traits |
| actions | TraitBlock[] or undefined | no | Actions |
| bonusActions | TraitBlock[] or undefined | no | Bonus actions |
| reactions | TraitBlock[] or undefined | no | Reactions |
| legendaryActions | LegendaryBlock or undefined | no | Legendary actions with preamble |
| spellcasting | SpellcastingBlock[] or undefined | no | Spellcasting entries |
### TraitBlock
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | yes | Trait/action name |
| text | string | yes | Pre-rendered plain text (tags stripped) |
### LegendaryBlock
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| preamble | string | yes | Introductory text about legendary actions |
| entries | TraitBlock[] | yes | Individual legendary actions |
### SpellcastingBlock
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | yes | "Spellcasting" or variant name |
| headerText | string | yes | Pre-rendered header description |
| atWill | string[] or undefined | no | At-will spells |
| daily | DailySpells[] or undefined | no | Daily-use spells with uses count |
| restLong | DailySpells[] or undefined | no | Per-long-rest spells |
### DailySpells
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| uses | number | yes | Number of uses |
| each | boolean | yes | Whether "each" applies |
| spells | string[] | yes | Spell names (plain text) |
### BestiarySource
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | string | yes | Source code (e.g., "XMM") |
| displayName | string | yes | Human-readable name (e.g., "Monster Manual (2024)") |
| tag | string or undefined | no | Optional tag (e.g., "legacy" for 2014 books) |
## Extended Existing Entities
### Combatant (extended)
Add one optional field:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| creatureId | CreatureId or undefined | no | Reference to bestiary creature for stat block lookup |
This field is:
- Set when adding a combatant from the bestiary
- Undefined when adding a plain-named combatant
- Persisted to localStorage
- Used by the web layer to look up creature data for stat block display
## State Transitions
### Adding a creature from bestiary
1. User searches → selects creature
2. System reads creature's name, hp.average, ac
3. System checks existing combatants for name conflicts
4. If conflicts: auto-number (rename first to "X 1" if needed, new one gets next number)
5. Domain `addCombatant` called with resolved name
6. `creatureId` stored on the new combatant
### Viewing a stat block
1. User clicks search result or bestiary-linked combatant
2. Web layer resolves `creatureId` to `Creature` from in-memory bestiary
3. Stat block panel renders the `Creature` data
4. No domain state change
## Validation Rules
- `CreatureId` must be non-empty branded string
- `Creature.ac` must be a non-negative integer
- `Creature.hp.average` must be a positive integer
- `Creature.abilities` values must be positive integers (1-30 range)
- `Creature.cr` must be a valid CR string
- Auto-numbering suffix must be a positive integer
- `Combatant.creatureId` when present must reference a valid creature in the loaded bestiary (graceful degradation if not found — stat block unavailable but combatant still functions)
## Proficiency Bonus Derivation
| CR | Proficiency Bonus |
|----|-------------------|
| 0 - 4 | +2 |
| 5 - 8 | +3 |
| 9 - 12 | +4 |
| 13 - 16 | +5 |
| 17 - 20 | +6 |
| 21 - 24 | +7 |
| 25 - 28 | +8 |
| 29 - 30 | +9 |

View File

@@ -0,0 +1,96 @@
# Implementation Plan: Bestiary Search & Stat Block Display
**Branch**: `021-bestiary-statblock` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/021-bestiary-statblock/spec.md`
## Summary
Add a searchable creature library (2024 Monster Manual, 503 creatures) to the initiative tracker. Users search by name, view full stat blocks in a responsive side panel, and add creatures as combatants with HP/AC/name pre-filled. Data is bundled as static JSON, normalized at load time via an adapter. Auto-numbering handles duplicate creature names. Layout adapts between side-by-side (desktop) and drawer (mobile).
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
**Storage**: Browser localStorage (existing adapter, extended for creatureId)
**Testing**: Vitest (unit tests for domain functions, adapter normalization, tag stripping)
**Target Platform**: Modern browsers (desktop + mobile)
**Project Type**: Web application (local-first, single-user)
**Performance Goals**: Search results in <200ms for 500 creatures (trivially met with in-memory filter)
**Constraints**: Offline-capable, no backend, bundle size increase ~300-500KB gzipped
**Scale/Scope**: 503 creatures (2024 MM), extensible to multiple bestiary sources
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | Creature types and auto-numbering are pure. No I/O in domain. Normalization adapter lives in web layer. |
| II. Layered Architecture | PASS | Domain: types + auto-numbering. Application: N/A (no new use case needed — addCombatant reused). Web: adapter, hooks, components. |
| III. Agent Boundary | N/A | No agent layer involvement. |
| IV. Clarification-First | PASS | All ambiguities resolved in spec discussion and research phase. |
| V. Escalation Gates | PASS | Feature fully specified before planning. |
| VI. MVP Baseline Language | PASS | Custom creature editor, fuzzy search, multiple bestiary sources described as future iterations. |
| VII. No Gameplay Rules | PASS | Stat block display is data rendering, not gameplay mechanics. |
**Post-Phase 1 Re-check**: All gates still pass. The `Creature` type in domain contains no I/O. The bestiary adapter in the web layer handles all data transformation. The `creatureId` extension to `Combatant` is a minimal, optional field.
## Project Structure
### Documentation (this feature)
```text
specs/021-bestiary-statblock/
├── spec.md
├── plan.md # This file
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── ui-contracts.md
├── checklists/
│ └── requirements.md
└── tasks.md # Generated by /speckit.tasks
```
### Source Code (repository root)
```text
data/
└── bestiary/
└── xmm.json # Raw 5etools bestiary (2024 MM)
packages/domain/src/
├── types.ts # MODIFIED: add creatureId to Combatant
├── creature-types.ts # NEW: Creature, CreatureId, TraitBlock, etc.
├── auto-number.ts # NEW: resolveCreatureName()
├── index.ts # MODIFIED: export new types
└── __tests__/
├── auto-number.test.ts # NEW
└── layer-boundaries.test.ts # EXISTING (unchanged)
packages/application/src/
└── (no changes — reuses existing addCombatant use case)
apps/web/src/
├── App.tsx # MODIFIED: two-column layout, stat block state
├── adapters/
│ ├── bestiary-adapter.ts # NEW: raw JSON → Creature[]
│ ├── strip-tags.ts # NEW: {@tag} → plain text
│ └── __tests__/
│ ├── bestiary-adapter.test.ts # NEW
│ └── strip-tags.test.ts # NEW
├── hooks/
│ ├── use-encounter.ts # MODIFIED: addFromBestiary, auto-numbering
│ └── use-bestiary.ts # NEW: search + lookup
├── components/
│ ├── action-bar.tsx # MODIFIED: search icon, suggestions
│ ├── bestiary-search.tsx # NEW: search input + dropdown
│ ├── stat-block.tsx # NEW: creature stat block renderer
│ └── stat-block-panel.tsx # NEW: responsive panel/drawer wrapper
├── persistence/
│ └── encounter-storage.ts # MODIFIED: persist/rehydrate creatureId
└── index.css # MODIFIED: stat block styling
```
**Structure Decision**: Follows existing monorepo layout. New domain types in `packages/domain`. Bestiary adapter and UI components in `apps/web`. Raw data in top-level `data/` directory (not inside any package, as it's a static asset consumed by the web app's build).

View File

@@ -0,0 +1,74 @@
# Quickstart: Bestiary Search & Stat Block Display
**Branch**: `021-bestiary-statblock` | **Date**: 2026-03-06
## What This Feature Does
Adds a searchable creature library (D&D 2024 Monster Manual) to the initiative tracker. Users can search for creatures by name, view their full stat block in a side panel, and add them as combatants with stats pre-filled (name, HP, AC). Multiple instances of the same creature are auto-numbered.
## Key Files to Touch
### New Files (Domain)
- `packages/domain/src/creature-types.ts` — Creature, CreatureId, TraitBlock, etc.
- `packages/domain/src/auto-number.ts` — Auto-numbering logic for duplicate creature names
### New Files (Web)
- `data/bestiary/xmm.json` — Raw 5etools bestiary data (bundled at build time)
- `apps/web/src/adapters/bestiary-adapter.ts` — Normalizes raw JSON to Creature[]
- `apps/web/src/adapters/strip-tags.ts` — Strips {@tag} markup to plain text
- `apps/web/src/hooks/use-bestiary.ts` — Provides search + creature lookup
- `apps/web/src/components/bestiary-search.tsx` — Search input with autocomplete dropdown
- `apps/web/src/components/stat-block.tsx` — Full creature stat block display
- `apps/web/src/components/stat-block-panel.tsx` — Side panel / drawer wrapper
### Modified Files
- `packages/domain/src/types.ts` — Add optional `creatureId` to Combatant
- `packages/domain/src/index.ts` — Export new creature types
- `apps/web/src/App.tsx` — Two-column layout + stat block panel state
- `apps/web/src/components/action-bar.tsx` — Add search icon + bestiary search integration
- `apps/web/src/hooks/use-encounter.ts` — Handle creatureId on add, auto-numbering
- `apps/web/src/persistence/encounter-storage.ts` — Persist/rehydrate creatureId
## Architecture Fit
```
data/bestiary/xmm.json (static asset)
|
v
bestiary-adapter.ts (web adapter) --normalizes--> Creature[] (domain types)
|
v
use-bestiary.ts (hook) --provides--> search + lookup
|
v
bestiary-search.tsx --selects creature--> use-encounter.ts --calls--> addCombatant (domain)
| |
v v
stat-block-panel.tsx <--reads creature data-- creatureId on Combatant
```
The domain layer gets the `Creature` type definitions and auto-numbering logic (pure functions). The web adapter handles the raw-to-domain transformation and tag stripping. The hook layer orchestrates search and state.
## Running & Testing
```bash
# Dev server
pnpm --filter web dev
# Run all tests
pnpm test
# Typecheck
pnpm typecheck
# Full merge gate
pnpm check
```
## Key Design Decisions
1. **Bundled JSON** — No runtime fetching, no database. JSON imported at build time.
2. **Pre-rendered text** — Tags stripped during normalization, not at render time.
3. **Creature type in domain** — Type definitions live in domain, normalization adapter lives in web.
4. **creatureId on Combatant** — Lightweight reference, not embedded creature data.
5. **Auto-numbering in domain** — Pure function that takes existing names and base name, returns resolved name.

View File

@@ -0,0 +1,91 @@
# Research: Bestiary Search & Stat Block Display
**Branch**: `021-bestiary-statblock` | **Date**: 2026-03-06
## R-001: Bestiary Data Format & Normalization
**Decision**: Use the 5etools `bestiary-xmm.json` (2024 Monster Manual, 503 creatures) as the initial data source. Copy the JSON file into the repo at `data/bestiary/xmm.json` and normalize at build time via a Vite static import and adapter function.
**Rationale**: The 5etools format is well-structured but has many variations (e.g., `type` can be string or object, `cr` can be string or object, `ac` is an int array, `size` is a string array). A normalization adapter converts these into a consistent domain `Creature` type, isolating the raw format from the rest of the application. Bundling at build time ensures offline capability and zero runtime dependencies.
**Alternatives considered**:
- Fetch at runtime from GitHub: rejected (offline-first, unreliable external dependency)
- Transform to a custom JSON format in a build script: rejected (adds build complexity, raw format is fine as source of truth)
- Use a database (IndexedDB): rejected (overkill for ~500 entries, in-memory search is instant)
## R-002: Markup Tag Stripping
**Decision**: Implement a `stripTags(text: string): string` utility that converts 5etools `{@tag ...}` markup to plain text using regex replacement.
**Rationale**: The bestiary text contains 15+ tag types (`{@spell}`, `{@dice}`, `{@damage}`, `{@dc}`, `{@condition}`, `{@hit}`, `{@atkr}`, `{@h}`, `{@hom}`, `{@recharge}`, `{@variantrule}`, `{@action}`, `{@skill}`, `{@creature}`, `{@hazard}`, `{@status}`, `{@actSave}`, `{@actSaveFail}`, `{@actSaveSuccess}`, `{@actSaveSuccessOrFail}`, `{@actSaveFailBy}`, `{@actTrigger}`, `{@actResponse}`). Most follow the pattern `{@tag DisplayName|Source}` or `{@tag value}`. A general regex can handle the common case, with specific handlers for special tags.
**Tag resolution rules**:
- `{@spell Name|Source}` → "Name" (drop source)
- `{@condition Name|Source}` → "Name"
- `{@damage 2d10}` → "2d10"
- `{@dice 5d10}` → "5d10"
- `{@dc 15}` → "DC 15"
- `{@hit 5}` → "+5"
- `{@h}` → "Hit: "
- `{@hom}` → "Hit or Miss: "
- `{@atkr m}` → "Melee Attack Roll:"
- `{@atkr r}` → "Ranged Attack Roll:"
- `{@atkr m,r}` → "Melee or Ranged Attack Roll:"
- `{@recharge 5}` → "(Recharge 5-6)"
- `{@recharge}` → "(Recharge 6)"
- `{@actSave wis}` → "Wisdom saving throw"
- `{@actSaveFail}` / `{@actSaveFail 2}` → "Failure:" / "Failure by 2 or More:"
- `{@actSaveSuccess}` → "Success:"
- `{@actTrigger}` → "Trigger:"
- `{@actResponse}` → "Response:"
- `{@variantrule Name|Source|Display}` → "Display" or "Name"
- `{@action Name|Source|Display}` → "Display" or "Name"
- `{@skill Name|Source}` → "Name"
- `{@creature Name|Source}` → "Name"
- `{@hazard Name|Source}` → "Name"
- `{@status Name|Source}` → "Name"
- Any remaining `{@tag X}` → "X" (first segment before `|`)
## R-003: Search Strategy
**Decision**: In-memory substring search with case-insensitive matching. No external search library for MVP.
**Rationale**: 503 creatures is trivial to filter in-memory. `Array.filter()` with `name.toLowerCase().includes(query)` runs in sub-millisecond time. Fuse.js or similar fuzzy search is unnecessary complexity for MVP. If future bestiary expansion reaches thousands of entries, a prefix trie or Fuse.js could be added without architectural changes.
**Alternatives considered**:
- Fuse.js: rejected for MVP (adds dependency, fuzzy matching not needed for exact-ish creature name search)
- Web Worker search: rejected (overkill for 500 entries)
## R-004: Auto-Numbering Strategy
**Decision**: When adding a creature from the bestiary, check existing combatants for name conflicts. If "Goblin" exists, the new one becomes "Goblin 2". If adding the second instance, rename the first to "Goblin 1". Track the base creature name on the combatant to enable this.
**Rationale**: DMs commonly add multiple instances of the same creature. Auto-numbering reduces manual work. Renaming the first instance ensures consistent numbering (no "Goblin" alongside "Goblin 2"). Names remain editable after auto-numbering.
## R-005: Layout Strategy
**Decision**: Use a CSS-based responsive layout. On viewports >= 1024px (lg breakpoint), display side-by-side with the tracker taking ~60% width and stat block ~40%. On narrower viewports, the stat block renders as a fixed-position drawer from the right edge with a backdrop overlay.
**Rationale**: Tailwind CSS v4's responsive utilities handle the breakpoint transition. No additional layout library needed. The drawer pattern is standard for mobile detail views. The tracker's current `max-w-2xl` constraint gets replaced with a flexible layout when the stat block is open.
**Alternatives considered**:
- Dialog/modal for stat block: rejected (blocks interaction with tracker)
- Bottom sheet on mobile: rejected (stat block is too tall for bottom sheet UX)
## R-006: Combatant-to-Creature Linking
**Decision**: Add an optional `creatureId` field to the `Combatant` type in the domain layer. This is a branded string type (`CreatureId`). The web layer uses this to look up the creature data for stat block display.
**Rationale**: A simple ID reference keeps the domain layer pure (no creature data in the combatant). The web adapter resolves the ID to the full creature data. This supports re-opening stat blocks for existing combatants and persists across localStorage save/load cycles.
## R-007: Bundle Size
**Decision**: Import the bestiary JSON as a static Vite import. Vite will include it in the JS bundle, and gzip compression will reduce the ~2-3MB raw JSON to ~300-500KB transferred.
**Rationale**: For a local-first tool with no CDN requirements, this is acceptable. If bundle size becomes an issue with multiple bestiary files, lazy loading via dynamic `import()` per source file is a straightforward optimization.
## R-008: Entry Rendering — Nested Objects in `entries`
**Decision**: Handle both string entries and structured object entries (`{ type: "list", items: [...] }`). For MVP, render list objects as indented items and recursively process nested entry text. Ignore unknown object types gracefully.
**Rationale**: The 5etools format uses nested list objects for sub-items (e.g., legendary action cost descriptions, multi-part abilities). A recursive renderer that handles `type: "list"` and `type: "item"` covers the majority of cases.

View File

@@ -0,0 +1,131 @@
# Feature Specification: Bestiary Search & Stat Block Display
**Feature Branch**: `021-bestiary-statblock`
**Created**: 2026-03-06
**Status**: Draft
**Input**: User description: "Bestiary creature library with search, stat block display, and combatant prefill from bundled D&D 5e monster data."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Search and Add a Creature from the Bestiary (Priority: P1)
As a DM running an encounter, I want to search for a creature by name in the bestiary so that I can quickly add it as a combatant with its stats pre-filled (name, HP, AC), saving me from manually entering data.
The action bar gains a search icon (magnifying glass). Clicking it opens a search input with autocomplete suggestions filtered from the bundled bestiary. Selecting a creature adds it as a combatant with name, max HP, current HP, and AC pre-filled from the creature's stat block data.
**Why this priority**: This is the core value proposition — reducing manual data entry when setting up encounters. Without this, the bestiary data has no way to reach the encounter tracker.
**Independent Test**: Can be fully tested by searching for "Goblin", selecting it, and verifying a combatant appears with the correct name, HP, and AC values from the 2024 Monster Manual.
**Acceptance Scenarios**:
1. **Given** the action bar is visible, **When** the user clicks the search icon, **Then** a search input appears with focus.
2. **Given** the search input is open, **When** the user types "gob", **Then** a dropdown shows matching creatures (e.g., "Goblin", "Goblin Boss", "Goblin Shaman") filtered from the bestiary.
3. **Given** search results are showing, **When** the user selects "Goblin", **Then** a new combatant is added with name "Goblin", max HP, current HP, and AC matching the Goblin's bestiary entry.
4. **Given** a combatant named "Goblin" already exists, **When** the user adds another Goblin from the bestiary, **Then** the existing combatant is renamed to "Goblin 1" and the new combatant is named "Goblin 2" (auto-numbered).
5. **Given** an auto-numbered combatant "Goblin 2" exists, **When** the user edits its name, **Then** the name updates as usual (renaming is not blocked by auto-numbering).
6. **Given** the search input is open, **When** the user types a query with no matches (e.g., "zzzzz"), **Then** the dropdown shows a "No creatures found" message.
7. **Given** the search input is open, **When** the user presses Escape or clicks outside, **Then** the search closes without adding a combatant.
---
### User Story 2 - View Creature Stat Block (Priority: P2)
As a DM, I want to see the full stat block of a creature displayed in a side panel so that I can reference its abilities, actions, and traits during combat without switching to another tool.
When a creature is selected from the bestiary search or when clicking a bestiary-linked combatant in the tracker, a stat block panel appears beside the encounter tracker showing the creature's full information in the classic D&D stat block layout.
**Why this priority**: The stat block display is the primary reference tool during combat. It transforms the app from just an initiative tracker into a combat management tool.
**Independent Test**: Can be tested by selecting any creature and verifying all stat block sections render correctly with accurate data from the bestiary.
**Acceptance Scenarios**:
1. **Given** a creature is selected from the bestiary search, **When** the stat block panel opens, **Then** it displays: name, size, type, alignment, AC, HP (average and formula), speed, ability scores with modifiers, saving throws, skills, damage resistances/immunities, condition immunities, senses, languages, challenge rating, traits, actions, and legendary actions (if applicable).
2. **Given** the stat block panel is open on desktop (wide viewport), **Then** the layout is side-by-side: encounter tracker on the left, stat block panel on the right.
3. **Given** the stat block panel is open on mobile (narrow viewport), **Then** the stat block appears as a slide-over drawer that can be dismissed.
4. **Given** a stat block is displayed, **When** the user clicks a different bestiary-linked combatant in the tracker, **Then** the stat block panel updates to show that creature's data.
5. **Given** a stat block is displayed, **When** the user closes the panel (via close button or gesture), **Then** the layout returns to the single-column tracker view.
6. **Given** a creature entry contains markup tags (e.g., spell references, dice notation), **Then** they render as plain text (e.g., "{@spell fireball|XPHB}" renders as "fireball", "{@dice 3d6}" renders as "3d6").
---
### User Story 3 - Quick-Add by Name with Bestiary Suggestions (Priority: P3)
As a DM, I want to see bestiary suggestions while typing a combatant name in the regular add flow, so that I can seamlessly switch between adding a custom-named combatant and pulling stats from the bestiary.
When typing in the existing combatant name input, matching bestiary creatures appear as suggestions. The user can either ignore them and add a plain-named combatant (current behavior) or select a suggestion to get the stat pre-fill.
**Why this priority**: This enhances the existing add flow without disrupting it. It's a convenience layer on top of P1 and P2.
**Independent Test**: Can be tested by typing "Dragon" in the name input and verifying suggestions appear, then either selecting one (stats pre-filled) or pressing Enter to add a plain "Dragon" combatant.
**Acceptance Scenarios**:
1. **Given** the user is typing in the combatant name input, **When** the text matches one or more bestiary creature names, **Then** matching suggestions appear below the input.
2. **Given** suggestions are visible, **When** the user selects a suggestion, **Then** the combatant is added with name, HP, and AC pre-filled from the bestiary.
3. **Given** suggestions are visible, **When** the user presses Enter without selecting a suggestion, **Then** a plain combatant is added with just the typed name (current behavior preserved).
4. **Given** the user types fewer than 2 characters, **Then** no suggestions are shown (to avoid overwhelming results).
---
### User Story 4 - Responsive Layout Transition (Priority: P4)
As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) views so that the stat block is usable regardless of screen size.
**Why this priority**: The core functionality works on desktop with the side-by-side layout. Mobile adaptation is important for table use but is an enhancement over the desktop experience.
**Independent Test**: Can be tested by resizing the browser window and verifying the stat block transitions between panel and drawer modes.
**Acceptance Scenarios**:
1. **Given** a wide viewport (desktop), **When** a stat block is open, **Then** the tracker and stat block display side-by-side.
2. **Given** a narrow viewport (mobile/tablet), **When** a stat block is open, **Then** it appears as a dismissible drawer overlay.
3. **Given** the viewport is resized from wide to narrow while a stat block is open, **Then** the layout transitions smoothly from panel to drawer.
---
### Edge Cases
- What happens when two creatures from different future bestiary sources share the same name? The source tag is shown alongside the name in search results (e.g., "Goblin (MM 2024)").
- What happens when adding multiple creatures of the same type? Auto-numbering appends an incrementing suffix: "Goblin 1", "Goblin 2", etc. The first instance is renamed to "Goblin 1" when a second is added.
- What happens if the bestiary data fails to load? The app continues to function as a plain initiative tracker; the search icon is hidden or disabled with appropriate feedback.
- How does the stat block handle creatures with no traits or legendary actions? Those sections are simply omitted from the stat block display.
- How does the stat block handle very long content (e.g., a Lich with extensive spellcasting)? The stat block panel scrolls independently of the encounter tracker.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST bundle the 2024 Monster Manual bestiary data as a static asset shipped with the application.
- **FR-002**: System MUST normalize raw bestiary data into a consistent internal creature format at load time via an adapter.
- **FR-003**: System MUST provide a search interface (magnifying glass icon) in the action bar that filters creatures by name substring matching.
- **FR-004**: Search results MUST appear as the user types, with results updating on each keystroke (or with minimal debounce for performance).
- **FR-005**: Selecting a creature from search results MUST add a combatant with name, max HP, current HP, and AC pre-filled from the creature's data.
- **FR-006**: System MUST auto-number duplicate creature names (e.g., "Goblin 1", "Goblin 2") when multiple combatants share the same bestiary creature name.
- **FR-007**: Auto-numbered names MUST remain editable via the existing rename functionality.
- **FR-008**: System MUST display a stat block panel showing the full creature information when a creature is selected.
- **FR-009**: The stat block MUST include: name, size, type, alignment, AC (with armor source if applicable), HP (average + formula), speed, ability scores with modifiers, saving throws, skills, damage vulnerabilities, damage resistances, damage immunities, condition immunities, senses, languages, challenge rating, proficiency bonus, traits, actions, bonus actions, reactions, and legendary actions.
- **FR-010**: System MUST strip bestiary markup tags (spell references, dice notation, attack tags) and render them as plain readable text.
- **FR-011**: On wide viewports, the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
- **FR-012**: On narrow viewports, the stat block MUST appear as a dismissible drawer or slide-over.
- **FR-013**: The existing action bar name input SHOULD show bestiary suggestions as the user types, allowing selection for stat pre-fill or plain entry on Enter.
- **FR-014**: System MUST support adding more bestiary source files in the future without structural changes, each identified by a source tag.
- **FR-015**: Combatants added from the bestiary MUST retain a link to their creature data so the stat block can be re-opened from the tracker.
### Key Entities
- **Creature**: A bestiary entry representing a monster or NPC. Key attributes: name, source, size, type, alignment, armor class, hit points (average and formula), speed, ability scores (STR/DEX/CON/INT/WIS/CHA), saving throw bonuses, skill bonuses, damage resistances/immunities, condition immunities, senses, languages, challenge rating, proficiency bonus, traits, actions, legendary actions.
- **Bestiary Source**: A collection of creatures from a specific publication (e.g., "MM 2024"). Key attributes: source identifier, display name, tag label (e.g., "legacy" for 2014 books).
- **Combatant** (extended): Existing combatant entity gains an optional reference to a Creature, enabling stat block lookup and stat pre-fill on creation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can search for and add a creature from the bestiary as a combatant in under 5 seconds (type query, select result, combatant appears with stats).
- **SC-002**: Search results appear within 200ms of typing for the full 2024 Monster Manual dataset (400+ creatures).
- **SC-003**: All stat block sections render correctly for 100% of creatures in the bundled bestiary (no missing data, no raw markup tags visible).
- **SC-004**: The stat block panel is readable and fully functional on viewports from 375px (mobile) to 2560px (ultrawide) without horizontal scrolling or content clipping.
- **SC-005**: Adding a creature from the bestiary pre-fills name, HP, and AC with 100% accuracy relative to the source data.
- **SC-006**: The existing "quick add by name" flow continues to work identically for users who do not interact with bestiary features (zero regression).

View File

@@ -0,0 +1,198 @@
# Tasks: Bestiary Search & Stat Block Display
**Input**: Design documents from `/specs/021-bestiary-statblock/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ui-contracts.md, quickstart.md
**Tests**: Included for domain logic, adapter normalization, and tag stripping (core correctness). UI components tested via manual verification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup
**Purpose**: Bestiary data and domain type foundations
- [x] T001 Copy raw 5etools 2024 Monster Manual JSON into `data/bestiary/xmm.json` (download from 5etools-mirror-3 repo, commit as static asset)
- [x] T002 [P] Define `CreatureId` branded type and all creature-related types (`Creature`, `TraitBlock`, `LegendaryBlock`, `SpellcastingBlock`, `DailySpells`, `BestiarySource`) in `packages/domain/src/creature-types.ts` per data-model.md
- [x] T003 [P] Define `proficiencyBonus(cr: string): number` pure function in `packages/domain/src/creature-types.ts` using the CR-to-bonus table from data-model.md
- [x] T004 Export all new creature types from `packages/domain/src/index.ts`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Adapter layer that normalizes raw bestiary data — required before any search or display can work
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T005 Implement `stripTags(text: string): string` in `apps/web/src/adapters/strip-tags.ts` — converts all `{@tag ...}` markup patterns to plain text per research.md R-002 tag resolution rules (15+ tag types: spell, dice, damage, dc, hit, h, hom, atkr, recharge, actSave, actSaveFail, actSaveSuccess, actTrigger, actResponse, variantrule, action, skill, creature, hazard, status, condition, actSaveSuccessOrFail, actSaveFailBy)
- [x] T006 Write tests for `stripTags` in `apps/web/src/adapters/__tests__/strip-tags.test.ts` — cover each tag type with at least one example, edge cases (nested tags, unknown tags, no tags)
- [x] T007 Implement `normalizeBestiary(raw: { monster: RawMonster[] }): Creature[]` adapter in `apps/web/src/adapters/bestiary-adapter.ts` — normalizes all 5etools format variations: `type` as string/object (with tags, swarmSize), `size` as string array, `ac` as int array (extract numeric value and optional `acSource` string from armor description), `cr` as string/object, `alignment` codes to text, `speed` object to formatted string, ability score modifiers, saving throws, skills, vulnerabilities/resistances/immunities (strings and conditional objects), condition immunities, senses, languages, traits/actions/legendary/bonus/reactions with recursive entry rendering, spellcasting blocks (will/daily/restLong). Uses `stripTags` for all text content. Generates `CreatureId` from source + name slug.
- [x] T008 Write tests for `normalizeBestiary` in `apps/web/src/adapters/__tests__/bestiary-adapter.test.ts` — test with representative raw entries: simple creature, creature with legendary actions, creature with spellcasting, creature with conditional resistances, creature with object-type `type` field, creature with multiple sizes
- [x] T009 Add optional `creatureId?: CreatureId` field to `Combatant` interface in `packages/domain/src/types.ts`
- [x] T010 Update `loadEncounter` in `apps/web/src/persistence/encounter-storage.ts` to rehydrate the `creatureId` field (validate as string, brand with `creatureId()` constructor, gracefully ignore if absent)
**Checkpoint**: Bestiary adapter tested and working. Combatant type extended. Ready for user story implementation.
---
## Phase 3: User Story 1 — Search and Add a Creature from the Bestiary (Priority: P1) MVP
**Goal**: Users can search the bundled bestiary by creature name and add a matching creature as a combatant with name, HP, and AC pre-filled.
**Independent Test**: Search for "Goblin", select it, verify combatant appears with correct name/HP/AC. Add another Goblin, verify auto-numbering ("Goblin 1", "Goblin 2").
### Implementation for User Story 1
- [x] T011 [P] [US1] Implement `resolveCreatureName(baseName: string, existingNames: readonly string[]): { newName: string; renames: ReadonlyArray<{ from: string; to: string }> }` pure function in `packages/domain/src/auto-number.ts` — handles auto-numbering logic: no conflict returns name as-is; first conflict renames existing to "Name 1" and new to "Name 2"; subsequent conflicts append next number. Include unit tests in `packages/domain/src/__tests__/auto-number.test.ts`
- [x] T012 [P] [US1] Export `resolveCreatureName` from `packages/domain/src/index.ts`
- [x] T013 [US1] Implement `useBestiary()` hook in `apps/web/src/hooks/use-bestiary.ts` — lazy-loads and normalizes `xmm.json` via `normalizeBestiary`, memoizes result. Returns `search(query: string): Creature[]` (case-insensitive substring filter, sorted alphabetically, max 10 results), `getCreature(id: CreatureId): Creature | undefined`, `allCreatures: Creature[]`, and `isLoaded: boolean`. If loading fails, `isLoaded` remains false, search returns empty results, and the bestiary search icon in the action bar is hidden.
- [x] T014 [US1] Create `BestiarySearch` component in `apps/web/src/components/bestiary-search.tsx` — search input with autocomplete dropdown: minimum 2 chars to trigger, keyboard navigation (ArrowUp/ArrowDown/Enter/Escape), shows creature name with source tag (e.g., "Goblin (MM 2024)"), "No creatures found" empty state. Calls `onSelectCreature(creature)` on selection, `onClose()` on dismiss.
- [x] T015 [US1] Add `addFromBestiary(creature: Creature): void` to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — calls `resolveCreatureName` to get auto-numbered name and any renames, applies renames via `editCombatant`, then calls `addCombatantUseCase` with resolved name. Sets `creatureId`, `maxHp`, `currentHp` (= hp.average), and `ac` on the new combatant. (Note: setting HP and AC requires calling `setHpUseCase` and `setAcUseCase` after add.)
- [x] T016 [US1] Modify `ActionBar` in `apps/web/src/components/action-bar.tsx` — add magnifying glass icon button (Lucide `Search` icon) next to the existing input. Clicking it opens `BestiarySearch` overlay. Wire `onSelectCreature` to `addFromBestiary`. Keep existing name input and Add button for plain combatant adding.
**Checkpoint**: User Story 1 fully functional — search, select, auto-number, pre-fill. Testable independently.
---
## Phase 4: User Story 2 — View Creature Stat Block (Priority: P2)
**Goal**: Display a full creature stat block in a side panel when a creature is selected from search or when clicking a bestiary-linked combatant.
**Independent Test**: Select any creature from search, verify all stat block sections render with correct data and no raw markup tags visible.
### Implementation for User Story 2
- [x] T017 [P] [US2] Create `StatBlock` component in `apps/web/src/components/stat-block.tsx` — renders the full creature stat block per contracts/ui-contracts.md section order: (1) header (name, size/type/alignment), (2) stats bar (AC, HP with formula, Speed), (3) ability scores with modifiers in 6-column grid, (4) properties block (saves, skills, vulnerabilities, resistances, immunities, condition immunities, senses, languages, CR + proficiency bonus), (5) traits, (6) spellcasting, (7) actions, (8) bonus actions, (9) reactions, (10) legendary actions with preamble. Sections omitted when data is absent. Style with Tailwind to match classic stat block aesthetic (section dividers, bold labels, italic names).
- [x] T018 [P] [US2] Create `StatBlockPanel` responsive wrapper in `apps/web/src/components/stat-block-panel.tsx` — desktop (>= 1024px): right-side panel with close button, independently scrollable. Mobile (< 1024px): slide-over drawer from right (85% width) with backdrop overlay, click-backdrop-to-close. Animate open/close transitions.
- [x] T019 [US2] Add stat block state management to `App.tsx` in `apps/web/src/App.tsx` — track `selectedCreature: Creature | null` state. When set, render `StatBlockPanel`. Modify layout: when panel open on desktop, switch from centered `max-w-2xl` to side-by-side flex layout (tracker flex-1, panel ~400px). When closed, revert to centered layout.
- [x] T020 [US2] Wire creature selection to stat block in `apps/web/src/App.tsx` — when `addFromBestiary` is called, also set `selectedCreature`. Add `onShowStatBlock` prop to `CombatantRow` — when clicking a combatant that has `creatureId`, resolve to `Creature` via `useBestiary().getCreature()` and set `selectedCreature`.
- [x] T021 [US2] Add stat block-related CSS in `apps/web/src/index.css` — stat block section dividers (gradient line), drawer slide animation (`@keyframes slide-in-right`), backdrop fade, responsive transitions.
**Checkpoint**: User Stories 1 AND 2 both work — search adds creatures AND stat block displays.
---
## Phase 5: User Story 3 — Quick-Add with Bestiary Suggestions (Priority: P3)
**Goal**: Show bestiary suggestions while typing in the existing combatant name input, allowing seamless switching between plain-name add and bestiary-assisted add.
**Independent Test**: Type "Dragon" in name input, see suggestions appear, select one (stats pre-filled) or press Enter to add plain combatant.
### Implementation for User Story 3
- [x] T022 [US3] Create inline suggestion dropdown for `ActionBar` in `apps/web/src/components/action-bar.tsx` — when text in name input >= 2 chars and matches bestiary creatures, show a suggestion list below the input (reuse search filtering from `useBestiary`). Reuse the dropdown rendering and keyboard navigation logic from `BestiarySearch` (T014) via a shared component or by extracting the dropdown portion. Selecting a suggestion calls `addFromBestiary`. Pressing Enter without selecting adds a plain combatant (current behavior). Suggestions show creature name + source tag.
**Checkpoint**: All three user stories independently functional.
---
## Phase 6: User Story 4 — Responsive Layout Transition (Priority: P4)
**Goal**: Layout adapts smoothly between side-by-side (desktop) and drawer (mobile) when viewport changes.
**Independent Test**: Open stat block on desktop, resize window below 1024px, verify transitions to drawer. Resize back, verify returns to side-by-side.
### Implementation for User Story 4
- [x] T023 [US4] Implement responsive transition in `apps/web/src/components/stat-block-panel.tsx` — use CSS media query or `matchMedia` listener to detect viewport width changes while panel is open. Ensure smooth transition between panel and drawer modes without losing scroll position or closing the panel. Test with browser resize and device rotation.
**Checkpoint**: All four user stories work across viewport sizes.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Final validation and cleanup
- [x] T024 Verify layer boundaries pass — run `pnpm test` and confirm `layer-boundaries.test.ts` passes with new creature types in domain and adapter in web
- [x] T025 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues — ensure no unused exports from creature-types.ts, no lint errors in new components, typecheck passes with creatureId extension
- [ ] T026 Manually test full flow (requires human verification) end-to-end: search creature, add with pre-fill, verify auto-numbering, view stat block, close panel, click combatant to re-open stat block, test on narrow viewport drawer mode, verify localStorage persistence of creatureId across page reload
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (T001 for raw data, T002-T004 for types)
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
- **User Story 2 (Phase 4)**: Depends on Phase 2 completion. Can run in parallel with US1, but better sequentially since US2 wires into US1's `addFromBestiary`
- **User Story 3 (Phase 5)**: Depends on US1 (reuses `addFromBestiary` and `useBestiary`)
- **User Story 4 (Phase 6)**: Depends on US2 (enhances `StatBlockPanel`)
- **Polish (Phase 7)**: Depends on all user stories
### User Story Dependencies
- **US1 (P1)**: Independent after Phase 2 — core MVP
- **US2 (P2)**: Best after US1 (wires into addFromBestiary flow), but StatBlock component itself is independent
- **US3 (P3)**: Depends on US1 (uses addFromBestiary and useBestiary)
- **US4 (P4)**: Depends on US2 (enhances StatBlockPanel)
### Parallel Opportunities
**Within Phase 1**: T002 and T003 can run in parallel (different concerns in same file, but logically separable)
**Within Phase 2**: T005+T006 (strip-tags) and T009+T010 (combatant extension) can run in parallel
**Within Phase 3**: T011 (auto-number) and T012 (export) can run in parallel with T013 (hook)
**Within Phase 4**: T017 (StatBlock) and T018 (StatBlockPanel) can run in parallel
---
## Parallel Example: User Story 1
```bash
# These can run in parallel (different files, no dependencies):
Task T011: "Implement resolveCreatureName in packages/domain/src/auto-number.ts"
Task T013: "Implement useBestiary hook in apps/web/src/hooks/use-bestiary.ts"
# Then sequentially:
Task T015: "Add addFromBestiary to useEncounter (depends on T011 + T013)"
Task T014: "Create BestiarySearch component (depends on T013 for search)"
Task T016: "Modify ActionBar to integrate BestiarySearch (depends on T014 + T015)"
```
## Parallel Example: User Story 2
```bash
# These can run in parallel (different files):
Task T017: "Create StatBlock component in apps/web/src/components/stat-block.tsx"
Task T018: "Create StatBlockPanel wrapper in apps/web/src/components/stat-block-panel.tsx"
# Then sequentially:
Task T019: "Add stat block state to App.tsx (depends on T017 + T018)"
Task T020: "Wire creature selection (depends on T019)"
Task T021: "Add stat block CSS (depends on T017)"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001-T004)
2. Complete Phase 2: Foundational (T005-T010)
3. Complete Phase 3: User Story 1 (T011-T016)
4. **STOP and VALIDATE**: Search for creatures, add with pre-fill, verify auto-numbering
5. This alone delivers significant value — bestiary-assisted combatant creation
### Incremental Delivery
1. Setup + Foundational -> Foundation ready
2. Add User Story 1 -> Test independently (MVP: search + add + pre-fill)
3. Add User Story 2 -> Test independently (stat block display)
4. Add User Story 3 -> Test independently (inline suggestions)
5. Add User Story 4 -> Test independently (responsive layout)
6. Polish -> Full merge gate validation
### Recommended Order
Sequential implementation in priority order (P1 -> P2 -> P3 -> P4) is recommended since each story builds on the previous one's infrastructure.