Implement the 029-on-demand-bestiary feature that replaces the bundled XMM bestiary JSON with a compact search index (~350KB) and on-demand source loading, where users explicitly provide a URL or upload a JSON file to fetch full stat block data per source, which is then normalized and cached in IndexedDB (with in-memory fallback) so creature stat blocks load instantly on subsequent visits while keeping the app bundle small and never auto-fetching copyrighted content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 22:46:13 +01:00
parent 99d1ba1bcd
commit 91120d7c82
31 changed files with 38321 additions and 63422 deletions

View File

@@ -0,0 +1,133 @@
# Data Model: On-Demand Bestiary with Pre-Indexed Search
**Feature**: 029-on-demand-bestiary
**Date**: 2026-03-10
## Domain Types
### BestiaryIndexEntry (NEW)
A lightweight creature record from the pre-shipped search index. Contains only mechanical facts — no copyrightable prose.
| Field | Type | Description |
|-------|------|-------------|
| name | string | Creature name (e.g., "Goblin Warrior") |
| source | string | Source code (e.g., "XMM") |
| ac | number | Armor class |
| hp | number | Average hit points |
| dex | number | Dexterity ability score |
| cr | string | Challenge rating (e.g., "1/4", "10") |
| initiativeProficiency | number | Initiative proficiency multiplier (0, 1, or 2) |
| size | string | Size code (e.g., "M", "L", "T") |
| type | string | Creature type (e.g., "humanoid", "fiend") |
**Uniqueness**: name + source (same creature name may appear in different sources)
**Derivable fields** (not stored, calculated at use):
- CreatureId: `{source.toLowerCase()}:{slugify(name)}`
- Source display name: resolved from BestiaryIndex.sources map
- Proficiency bonus: derived from CR via existing `proficiencyBonus(cr)` function
- Initiative modifier: derived from DEX + proficiency calculation
### BestiaryIndex (NEW)
The complete pre-shipped index loaded from `data/bestiary/index.json`.
| Field | Type | Description |
|-------|------|-------------|
| sources | Record<string, string> | Map of source code → display name (e.g., "XMM" → "Monster Manual (2025)") |
| creatures | BestiaryIndexEntry[] | All indexed creatures (3,312 entries) |
### Creature (EXISTING, unchanged)
Full stat block data — available only after source data is fetched and cached. See `packages/domain/src/creature-types.ts` for complete definition. Key fields: id, name, source, sourceDisplayName, size, type, alignment, ac, acSource, hp (average + formula), speed, abilities, cr, initiativeProficiency, proficiencyBonus, passive, traits, actions, bonusActions, reactions, legendaryActions, spellcasting.
### Combatant (EXISTING, unchanged)
Encounter participant. Links to creature via `creatureId?: CreatureId`. All combatant data (name, HP, AC, initiative) is stored independently from the creature — clearing the cache does not affect in-encounter combatants.
## Adapter Types
### CachedSourceRecord (NEW, adapter-layer only)
Stored in IndexedDB. One record per imported source.
| Field | Type | Description |
|-------|------|-------------|
| sourceCode | string | Primary key (e.g., "XMM") |
| displayName | string | Human-readable source name |
| creatures | Creature[] | Full normalized creature array |
| cachedAt | number | Unix timestamp of when source was cached |
| creatureCount | number | Number of creatures in this source (for management UI) |
### SourceFetchState (NEW, adapter-layer only)
UI state for the source fetch/upload prompt.
| Field | Type | Description |
|-------|------|-------------|
| sourceCode | string | Source being fetched |
| displayName | string | Display name for the prompt |
| defaultUrl | string | Pre-filled URL for this source |
| status | "idle" \| "fetching" \| "error" \| "success" | Current fetch state |
| error | string \| undefined | Error message if fetch failed |
## State Transitions
### Source Cache Lifecycle
```
UNCACHED → FETCHING → CACHED
↘ ERROR → (retry) → FETCHING
↘ (change URL) → FETCHING
↘ (upload file) → CACHED
CACHED → CLEARED → UNCACHED
```
### Stat Block View Flow
```
1. User clicks creature → stat block panel opens
2. Check: creatureId exists on combatant?
NO → show "No stat block available" (custom combatant)
YES → continue
3. Check: source cached in IndexedDB?
YES → lookup creature by ID → render stat block
NO → show SourceFetchPrompt for this source
4. After successful fetch → creature available → render stat block
```
## Storage Layout
### IndexedDB Database: "initiative-bestiary"
**Object Store: "sources"**
- Key path: `sourceCode`
- Records: `CachedSourceRecord`
- Expected size: 1-3 MB per source, ~150 MB if all 102 sources cached (unlikely)
### localStorage (unchanged)
- Key: `"initiative:encounter"` — encounter state with combatant creatureId links
### Shipped Static Asset
- `data/bestiary/index.json` — imported at build time, included in JS bundle (~52 KB gzipped)
## Relationships
```
BestiaryIndex (shipped, static)
├── sources: {sourceCode → displayName}
└── creatures: BestiaryIndexEntry[]
├──[search]──→ BestiarySearch UI (displays name + source)
├──[add]──→ Combatant (name, HP, AC, creatureId)
└──[view stat block]──→ CachedSourceRecord?
├── YES → Creature (full stat block)
└── NO → SourceFetchPrompt
├── fetch URL → normalizeBestiary() → CachedSourceRecord
└── upload file → normalizeBestiary() → CachedSourceRecord
```