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

@@ -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.