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:
36
specs/029-on-demand-bestiary/checklists/requirements.md
Normal file
36
specs/029-on-demand-bestiary/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-10
|
||||
**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
- FR-009 originally mentioned "IndexedDB" explicitly; updated to technology-agnostic "client-side storage" language.
|
||||
- The spec covers 4 user stories with clear priority ordering: search (P1), stat block fetch (P2), file upload (P3), cache management (P4).
|
||||
66
specs/029-on-demand-bestiary/contracts/bestiary-port.md
Normal file
66
specs/029-on-demand-bestiary/contracts/bestiary-port.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Contract: BestiarySourceCache Port
|
||||
|
||||
**Feature**: 029-on-demand-bestiary
|
||||
**Layer**: Application (port interface, implemented by web adapter)
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the interface for caching and retrieving full bestiary source data. The application layer uses this port to look up full creature stat blocks. The web adapter implements it using IndexedDB.
|
||||
|
||||
## Interface: BestiarySourceCache
|
||||
|
||||
### getCreature(creatureId: CreatureId): Creature | undefined
|
||||
|
||||
Look up a full creature by its ID from the cache.
|
||||
|
||||
- **Input**: `creatureId` — branded string in format `{source}:{slug}`
|
||||
- **Output**: Full `Creature` object if the creature's source is cached, `undefined` otherwise
|
||||
- **Side effects**: None (read-only)
|
||||
|
||||
### isSourceCached(sourceCode: string): boolean
|
||||
|
||||
Check whether a source's data has been cached.
|
||||
|
||||
- **Input**: `sourceCode` — source identifier (e.g., "XMM")
|
||||
- **Output**: `true` if the source has been fetched and cached, `false` otherwise
|
||||
|
||||
### cacheSource(sourceCode: string, displayName: string, creatures: Creature[]): Promise\<void>
|
||||
|
||||
Store a full source's worth of normalized creature data.
|
||||
|
||||
- **Input**: source code, display name, array of normalized creatures
|
||||
- **Output**: Resolves when data is persisted
|
||||
- **Behavior**: Overwrites any existing cache for this source
|
||||
|
||||
### getCachedSources(): CachedSourceInfo[]
|
||||
|
||||
List all cached sources for the management UI.
|
||||
|
||||
- **Output**: Array of `{ sourceCode: string, displayName: string, creatureCount: number, cachedAt: number }`
|
||||
|
||||
### clearSource(sourceCode: string): Promise\<void>
|
||||
|
||||
Remove a single source's cached data.
|
||||
|
||||
- **Input**: source code to clear
|
||||
- **Output**: Resolves when data is removed
|
||||
|
||||
### clearAll(): Promise\<void>
|
||||
|
||||
Remove all cached source data.
|
||||
|
||||
- **Output**: Resolves when all data is removed
|
||||
|
||||
## Index Adapter (no port)
|
||||
|
||||
The index adapter (`bestiary-index-adapter.ts`) exposes plain exported functions consumed directly by the web adapter hooks. No application-layer port is needed because the index is a static build-time asset with no I/O variability. See `apps/web/src/adapters/bestiary-index-adapter.ts` for the implementation.
|
||||
|
||||
Exported functions: `loadBestiaryIndex()`, `getSourceDisplayName(sourceCode)`, `getDefaultFetchUrl(sourceCode)`.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. `getCreature` MUST return `undefined` for any creature whose source is not cached — never throw.
|
||||
2. `cacheSource` MUST be idempotent — calling it twice with the same data produces the same result.
|
||||
3. `clearSource` MUST NOT affect other cached sources.
|
||||
4. `search` MUST return an empty array for queries shorter than 2 characters.
|
||||
5. The index adapter MUST be available synchronously after app initialization — no async loading state for search.
|
||||
133
specs/029-on-demand-bestiary/data-model.md
Normal file
133
specs/029-on-demand-bestiary/data-model.md
Normal 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
|
||||
```
|
||||
94
specs/029-on-demand-bestiary/plan.md
Normal file
94
specs/029-on-demand-bestiary/plan.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Implementation Plan: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Branch**: `029-on-demand-bestiary` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/029-on-demand-bestiary/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the bundled `data/bestiary/xmm.json` (503 creatures, ~1.3 MB, one source) with a two-tier architecture: a pre-shipped lightweight search index (`data/bestiary/index.json`, 3,312 creatures, 102 sources, ~52 KB gzipped) for instant multi-source search and combatant creation, plus on-demand full stat block data fetched from user-provided URLs and cached in IndexedDB per source. This removes copyrighted prose from the distributed app, expands coverage from 1 source to 102, and preserves the existing normalization pipeline.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons), idb (IndexedDB wrapper)
|
||||
**Storage**: IndexedDB for cached source data (new); localStorage for encounter persistence (existing, unchanged)
|
||||
**Testing**: Vitest (existing)
|
||||
**Target Platform**: Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
**Project Type**: Web application (monorepo: domain, application, web adapter)
|
||||
**Performance Goals**: Search results <100ms, stat block display <200ms after cache, source fetch <5s on broadband
|
||||
**Constraints**: Zero copyrighted prose in shipped bundle; offline-capable for cached data; single-user local-first
|
||||
**Scale/Scope**: 3,312 creatures across 102 sources; index ~320 KB raw / ~52 KB gzipped
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | Index data is static. No I/O, randomness, or clocks in domain. Fetch/cache operations are purely adapter-layer. |
|
||||
| II. Layered Architecture | PASS | New `BestiaryIndex` type in domain (pure data). IndexedDB cache and fetch logic in adapter layer. Application layer orchestrates via port interfaces. |
|
||||
| III. Agent Boundary | N/A | No agent layer changes. |
|
||||
| IV. Clarification-First | PASS | Spec is comprehensive with zero NEEDS CLARIFICATION markers. All decisions documented in assumptions. |
|
||||
| V. Escalation Gates | PASS | Implementation follows spec → plan → tasks pipeline. |
|
||||
| VI. MVP Baseline Language | PASS | Cache management UI (P4) is included but scoped as MVP. No permanent bans. |
|
||||
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan — only data loading and caching architecture. |
|
||||
| Merge Gate | PASS | All changes must pass `pnpm check` before commit. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/029-on-demand-bestiary/
|
||||
├── spec.md
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── bestiary-port.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── creature-types.ts # Extended: BestiaryIndexEntry, BestiaryIndex types
|
||||
└── index.ts # Updated exports
|
||||
|
||||
packages/application/src/
|
||||
├── ports.ts # Extended: BestiarySourceCache port interface
|
||||
├── index.ts # Updated exports
|
||||
└── (no new use cases — orchestration stays in adapter hooks)
|
||||
|
||||
apps/web/src/
|
||||
├── adapters/
|
||||
│ ├── bestiary-adapter.ts # Unchanged (processes fetched data as before)
|
||||
│ ├── strip-tags.ts # Unchanged
|
||||
│ ├── bestiary-index-adapter.ts # NEW: loads/parses index.json, converts to domain types
|
||||
│ └── bestiary-cache.ts # NEW: IndexedDB cache adapter implementing BestiarySourceCache
|
||||
├── hooks/
|
||||
│ ├── use-bestiary.ts # REWRITTEN: search from index, getCreature from cache
|
||||
│ └── use-encounter.ts # MODIFIED: addFromBestiary uses index entry instead of full Creature
|
||||
├── components/
|
||||
│ ├── bestiary-search.tsx # MODIFIED: show source display name in results
|
||||
│ ├── stat-block.tsx # Unchanged
|
||||
│ ├── stat-block-panel.tsx # MODIFIED: trigger source fetch prompt when creature not cached
|
||||
│ ├── source-fetch-prompt.tsx # NEW: fetch/upload prompt dialog
|
||||
│ └── source-manager.tsx # NEW: cached source management UI
|
||||
└── persistence/
|
||||
└── encounter-storage.ts # Unchanged
|
||||
|
||||
data/bestiary/
|
||||
├── index.json # Existing (shipped with app)
|
||||
└── xmm.json # REMOVED from repo
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing monorepo layering. New adapter files for index loading and IndexedDB caching. New components for source fetch prompt and cache management. Domain extended with lightweight index types only — no I/O.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations to justify.
|
||||
76
specs/029-on-demand-bestiary/quickstart.md
Normal file
76
specs/029-on-demand-bestiary/quickstart.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Quickstart: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Feature**: 029-on-demand-bestiary
|
||||
**Date**: 2026-03-10
|
||||
|
||||
## What This Feature Does
|
||||
|
||||
Replaces the bundled full bestiary file (one source, ~1.3 MB of copyrighted content) with:
|
||||
1. A pre-shipped lightweight index (102 sources, 3,312 creatures, ~52 KB gzipped) for instant search and combatant creation
|
||||
2. On-demand fetching of full stat block data per source, cached in IndexedDB
|
||||
|
||||
## Key Changes
|
||||
|
||||
### Removed
|
||||
- `data/bestiary/xmm.json` — no longer shipped with the app
|
||||
|
||||
### New Files
|
||||
- `apps/web/src/adapters/bestiary-index-adapter.ts` — loads and parses the shipped index, converts compact format to domain types
|
||||
- `apps/web/src/adapters/bestiary-cache.ts` — IndexedDB cache adapter for fetched source data
|
||||
- `apps/web/src/components/source-fetch-prompt.tsx` — dialog prompting user to fetch/upload source data
|
||||
- `apps/web/src/components/source-manager.tsx` — UI for viewing and clearing cached sources
|
||||
|
||||
### Modified Files
|
||||
- `apps/web/src/hooks/use-bestiary.ts` — rewritten to search from index and look up creatures from cache
|
||||
- `apps/web/src/hooks/use-encounter.ts` — `addFromBestiary` accepts index entries (no fetch needed to add)
|
||||
- `apps/web/src/components/bestiary-search.tsx` — shows source display name in results
|
||||
- `apps/web/src/components/stat-block-panel.tsx` — triggers source fetch prompt when creature not cached
|
||||
- `packages/domain/src/creature-types.ts` — new `BestiaryIndexEntry` and `BestiaryIndex` types
|
||||
|
||||
### Unchanged
|
||||
- `apps/web/src/adapters/bestiary-adapter.ts` — normalization pipeline processes fetched data exactly as before
|
||||
- `apps/web/src/adapters/strip-tags.ts` — tag stripping unchanged
|
||||
- `apps/web/src/components/stat-block.tsx` — stat block rendering unchanged
|
||||
- `apps/web/src/persistence/encounter-storage.ts` — encounter persistence unchanged
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
index.json (shipped, static)
|
||||
↓ Vite JSON import
|
||||
bestiary-index-adapter.ts
|
||||
↓ BestiaryIndexEntry[]
|
||||
use-bestiary.ts (search, add)
|
||||
↓
|
||||
bestiary-search.tsx → use-encounter.ts (addFromBestiary)
|
||||
↓
|
||||
stat-block-panel.tsx
|
||||
↓ creatureId → source not cached?
|
||||
source-fetch-prompt.tsx
|
||||
↓ fetch URL or upload file
|
||||
bestiary-adapter.ts (normalizeBestiary — unchanged)
|
||||
↓ Creature[]
|
||||
bestiary-cache.ts (IndexedDB)
|
||||
↓
|
||||
stat-block.tsx (renders full stat block)
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
pnpm check # Must pass before every commit
|
||||
pnpm test # Run all tests
|
||||
pnpm typecheck # TypeScript type checking
|
||||
pnpm --filter web dev # Dev server at localhost:5173
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain tests**: Pure function tests for new `BestiaryIndexEntry` type and any utility functions
|
||||
- **Adapter tests**: Test index parsing (compact → readable format), test IndexedDB cache operations (mock IndexedDB via fake-indexeddb)
|
||||
- **Component tests**: Not in scope (existing pattern — components tested via manual verification)
|
||||
- **Integration**: Verify search returns multi-source results, verify add-from-index flow, verify fetch→cache→display flow
|
||||
|
||||
## New Dependency
|
||||
|
||||
- `idb` — Promise-based IndexedDB wrapper (~1.5 KB gzipped). Used only in `bestiary-cache.ts`.
|
||||
113
specs/029-on-demand-bestiary/research.md
Normal file
113
specs/029-on-demand-bestiary/research.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Research: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Feature**: 029-on-demand-bestiary
|
||||
**Date**: 2026-03-10
|
||||
|
||||
## R-001: IndexedDB for Source Data Caching
|
||||
|
||||
**Decision**: Use IndexedDB via the `idb` wrapper library for caching fetched/uploaded bestiary source data.
|
||||
|
||||
**Rationale**: IndexedDB is the only browser storage API with sufficient capacity (hundreds of MB) for full bestiary JSON files (~1-3 MB per source). localStorage is limited to ~5-10 MB total and already used for encounter persistence. The `idb` library provides a promise-based wrapper that simplifies IndexedDB usage without adding significant bundle size (~1.5 KB gzipped).
|
||||
|
||||
**Alternatives considered**:
|
||||
- **localStorage**: Insufficient capacity. A single source can be 1-3 MB; 102 sources would far exceed the ~5 MB limit.
|
||||
- **Cache API (Service Worker)**: More complex setup, designed for HTTP response caching rather than structured data. Overkill for this use case.
|
||||
- **Raw IndexedDB**: Viable but verbose callback-based API. The `idb` wrapper is minimal and well-maintained.
|
||||
- **OPFS (Origin Private File System)**: Newer API with good capacity but less browser support and more complex access patterns.
|
||||
|
||||
## R-002: Index Loading Strategy
|
||||
|
||||
**Decision**: Import `index.json` as a static asset via Vite's JSON import, parsed at build time and included in the JS bundle.
|
||||
|
||||
**Rationale**: The index is ~320 KB raw / ~52 KB gzipped — small enough to include in the bundle. This ensures instant availability on app load with zero additional network requests. Vite's JSON import treeshakes unused fields and benefits from standard bundling optimizations (code splitting, compression).
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Fetch at runtime**: Adds a network request and loading state. Unnecessary for a 52 KB file that every session needs.
|
||||
- **Web Worker**: Overhead of worker setup not justified for a single synchronous parse of 52 KB.
|
||||
- **Lazy import**: Could defer initial load, but search is the primary interaction — it must be available immediately.
|
||||
|
||||
## R-003: Index-to-Domain Type Mapping
|
||||
|
||||
**Decision**: Create a `BestiaryIndexEntry` domain type that maps the compact index fields (n, s, ac, hp, dx, cr, ip, sz, tp) to readable properties. The adapter converts index entries to this type on load.
|
||||
|
||||
**Rationale**: The index uses abbreviated keys for size optimization. The domain should work with readable, typed properties. The adapter boundary is the right place for this translation, consistent with how `normalizeBestiary()` translates raw 5etools format to `Creature`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Use index abbreviations in domain**: Violates readability conventions. Domain types should be self-documenting.
|
||||
- **Convert index entries to full `Creature` objects**: Would require fabricating missing fields (traits, actions, speed, etc.). Better to have a distinct lightweight type.
|
||||
|
||||
## R-004: Search Architecture with Multi-Source Index
|
||||
|
||||
**Decision**: Search operates on an in-memory array of `BestiaryIndexEntry` objects, loaded once from the shipped index. Results include the source display name resolved from the index's source map. The search algorithm remains unchanged (case-insensitive substring, min 2 chars, max 10 results, alphabetical sort).
|
||||
|
||||
**Rationale**: 3,312 entries is trivially searchable in-memory with no performance concern. The existing algorithm scales linearly and completes in <1ms for this dataset size. No indexing data structure (trie, inverted index) is needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Fuse.js / MiniSearch**: Fuzzy search libraries. Overhead not justified — exact substring matching is the specified behavior and works well for creature name lookup.
|
||||
- **Pre-sorted index**: Could avoid sort on each search, but 10-element sort is negligible. Simplicity wins.
|
||||
|
||||
## R-005: Source Fetch URL Pattern
|
||||
|
||||
**Decision**: Default fetch URLs follow the pattern `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-{source-code-lowercase}.json`. The URL is pre-filled but editable, allowing users to point to mirrors, forks, or local servers.
|
||||
|
||||
**Rationale**: This pattern matches the 5etools repository structure. Making the URL editable addresses mirror availability, corporate firewalls, and self-hosted scenarios. The app makes no guarantees about URL availability — this is explicitly a user responsibility per the spec.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Hardcoded URL per source**: Too rigid. Mirror URLs change, and some users need local hosting.
|
||||
- **No default URL**: Bad UX — most users will use the standard mirror. Pre-filling saves effort.
|
||||
- **Source-to-URL mapping file**: Over-engineering. The pattern is consistent across all sources; special cases can be handled by editing the URL.
|
||||
|
||||
## R-006: Fallback for Unavailable IndexedDB
|
||||
|
||||
**Decision**: If IndexedDB is unavailable (private browsing in some browsers, storage quota exceeded), fall back to an in-memory `Map<string, Creature[]>` for the current session. Show a non-blocking warning that cached data will not persist.
|
||||
|
||||
**Rationale**: The app should degrade gracefully. In-memory caching still provides the core stat block functionality for the session — only cross-session persistence is lost. This matches the existing pattern where localStorage failures are handled silently.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Block the feature entirely**: Too disruptive. Users in private browsing should still be able to use stat blocks.
|
||||
- **Fall back to localStorage**: Capacity is still limited and would compete with encounter persistence.
|
||||
|
||||
## R-007: Stat Block Lookup Flow Redesign
|
||||
|
||||
**Decision**: `useBestiary.getCreature(creatureId)` becomes an async operation that:
|
||||
1. Checks the in-memory cache (populated from IndexedDB on mount)
|
||||
2. If found, returns the `Creature` immediately
|
||||
3. If not found, returns `undefined` and the component shows the source fetch prompt
|
||||
|
||||
The `StatBlockPanel` component handles the transition: it renders a `SourceFetchPrompt` when `getCreature` returns `undefined` for a combatant with a `creatureId`. After successful fetch, the creature data is available in cache and the stat block renders.
|
||||
|
||||
**Rationale**: This keeps the lookup interface simple while adding the fetch-on-demand layer. The component tree already handles loading states. The prompt appears at the point of need (stat block view), not preemptively.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Auto-fetch without prompting**: Violates spec requirement (user must confirm). Also risks unwanted network requests.
|
||||
- **Pre-fetch all sources on app load**: Defeats the purpose of on-demand loading. Would download hundreds of MB.
|
||||
|
||||
## R-008: File Upload Processing
|
||||
|
||||
**Decision**: The source fetch prompt includes an "Upload file" button that opens a native file picker (`<input type="file" accept=".json">`). The uploaded file is read via `FileReader`, parsed as JSON, and processed through the same `normalizeBestiary()` pipeline as fetched data.
|
||||
|
||||
**Rationale**: Reuses the existing normalization pipeline. Native file picker is the simplest, most accessible approach. No drag-and-drop complexity needed for a secondary flow.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Drag-and-drop zone**: Additional UI complexity for a fallback feature. Can be added later if needed.
|
||||
- **Paste JSON**: Impractical for multi-MB files.
|
||||
|
||||
## R-009: Cache Keying Strategy
|
||||
|
||||
**Decision**: IndexedDB object store uses the source code (e.g., "XMM") as the key. Each record stores the full array of normalized `Creature` objects for that source. A separate metadata store tracks cached source codes and timestamps for the management UI.
|
||||
|
||||
**Rationale**: Source-level granularity matches the fetch-per-source model. One key per source keeps the cache simple to query and clear. Storing normalized `Creature` objects avoids re-normalization on every load.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Creature-level keys**: More granular but adds complexity. No use case requires per-creature cache operations.
|
||||
- **Store raw JSON**: Requires re-normalization on each load. Wastes CPU for no benefit.
|
||||
|
||||
## R-010: addFromBestiary Adaptation
|
||||
|
||||
**Decision**: `addFromBestiary` in `use-encounter.ts` will accept either a full `Creature` (for cached sources) or a `BestiaryIndexEntry` (for uncached sources). When given an index entry, it constructs a minimal combatant with name, HP, AC, and initiative data. The `creatureId` is set using the same `{source}:{slug}` pattern as before, derived from the index entry's source and name.
|
||||
|
||||
**Rationale**: The index contains all data needed for combatant creation (name, HP, AC, DEX, CR, initiative proficiency). No fetch is needed to add a creature. The `creatureId` enables later stat block lookup when the source is cached.
|
||||
|
||||
**Alternatives considered**:
|
||||
- **Always require full Creature**: Would force a source fetch before adding, contradicting the spec's "no fetch needed for adding" requirement.
|
||||
- **New use case in application layer**: The operation is adapter-level orchestration, not domain logic. Keeping it in the hook is consistent with the existing pattern.
|
||||
131
specs/029-on-demand-bestiary/spec.md
Normal file
131
specs/029-on-demand-bestiary/spec.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Feature Specification: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Feature Branch**: `029-on-demand-bestiary`
|
||||
**Created**: 2026-03-10
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Replace the bundled bestiary JSON with a two-tier architecture: a lightweight search index shipped with the app and on-demand full stat block data fetched at runtime and cached client-side."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Search All Creatures Instantly (Priority: P1)
|
||||
|
||||
A DM searches for a creature by name. The search operates against a pre-shipped index of 3,312 creatures across 102 sources. Results appear instantly with the creature name and source display name (e.g., "Goblin (Monster Manual (2025))"). The DM selects a creature to add it to the encounter. Name, HP, AC, and initiative data are prefilled directly from the index — no network fetch required.
|
||||
|
||||
**Why this priority**: This is the core interaction loop. Every session starts with adding creatures. Expanding from 1 source to 102 sources dramatically increases the app's usefulness, and doing it without any fetch latency preserves the current snappy UX.
|
||||
|
||||
**Independent Test**: Can be fully tested by typing a creature name, seeing multi-source results, and adding a creature to verify HP/AC/initiative are populated correctly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the app is loaded, **When** the DM types "gob" in the search field, **Then** results include goblins from multiple sources, each labeled with the source display name, sorted alphabetically, limited to 10 results.
|
||||
2. **Given** search results are visible, **When** the DM selects "Goblin (Monster Manual (2025))", **Then** a combatant is added with the correct name, HP, AC, and initiative modifier — no network request is made.
|
||||
3. **Given** the app is loaded, **When** the DM types a single character, **Then** no results appear (minimum 2 characters required).
|
||||
4. **Given** the app is loaded, **When** the DM searches for a creature that exists only in an obscure source (e.g., "Muk" from "Adventure with Muk"), **Then** the creature appears in results with its source name.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - View Full Stat Block via On-Demand Source Fetch (Priority: P2)
|
||||
|
||||
A DM clicks to view the stat block of a creature whose source data has not been loaded yet. The app displays a prompt: "Load [Source Display Name] bestiary data?" with a pre-filled URL pointing to the raw source file. The DM confirms, the app fetches the JSON, normalizes it, and caches all creatures from that source. The stat block then displays. For any subsequent creature from the same source, the stat block appears instantly without prompting.
|
||||
|
||||
**Why this priority**: Stat blocks are essential for running combat but are secondary to adding creatures. This story also addresses the legal motivation — no copyrighted prose is shipped with the app.
|
||||
|
||||
**Independent Test**: Can be tested by adding a creature, opening its stat block, confirming the fetch prompt, and verifying the stat block renders. Then opening another creature from the same source to verify no prompt appears.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a creature from an uncached source is in the encounter, **When** the DM opens its stat block, **Then** a prompt appears asking to load the source data with an editable URL field pre-filled with the correct raw file URL.
|
||||
2. **Given** the fetch prompt is visible, **When** the DM confirms the fetch, **Then** the app downloads the JSON, normalizes it, caches all creatures from that source, and displays the stat block.
|
||||
3. **Given** source data for "Monster Manual (2025)" has been cached, **When** the DM opens the stat block for any other creature from that source, **Then** the stat block displays instantly with no prompt.
|
||||
4. **Given** the fetch prompt is visible, **When** the DM edits the URL to point to a mirror or local server, **Then** the app fetches from the edited URL instead.
|
||||
5. **Given** a creature is in the encounter, **When** the DM opens its stat block and the source is not cached, **Then** the creature's index data (HP, AC, etc.) remains visible in the combatant row regardless of whether the fetch succeeds.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Manual File Upload as Fetch Alternative (Priority: P3)
|
||||
|
||||
A DM who cannot access the URL (corporate firewall, offline use) uses a file upload option to load bestiary data from a local JSON file. The file is processed identically to a fetched file — normalized and cached by source.
|
||||
|
||||
**Why this priority**: Important for accessibility and offline scenarios, but most users will use the URL fetch.
|
||||
|
||||
**Independent Test**: Can be tested by selecting a local JSON file in the upload dialog and verifying the stat blocks become available.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the source fetch prompt is visible, **When** the DM chooses "Upload file" and selects a valid bestiary JSON from their filesystem, **Then** the app normalizes and caches the data, and stat blocks become available.
|
||||
2. **Given** the DM uploads an invalid or malformed JSON file, **When** the upload completes, **Then** the app shows a user-friendly error message and allows retry.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Manage Cached Sources (Priority: P4)
|
||||
|
||||
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A settings/management UI provides this visibility and control.
|
||||
|
||||
**Why this priority**: Cache management is a housekeeping task, not part of the core combat flow. Important for long-term usability but not needed for initial sessions.
|
||||
|
||||
**Independent Test**: Can be tested by caching one or more sources, opening the management UI, verifying the list, and clearing individual or all caches.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
|
||||
2. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed and stat blocks for its creatures require re-fetching, while other cached sources remain.
|
||||
3. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the DM searches for a creature name that appears in multiple sources? Results show all matches, each labeled with the source display name, sorted alphabetically.
|
||||
- What happens when a network fetch fails mid-download? The app shows an error with the option to retry or change the URL. The creature remains in the encounter with its index data intact.
|
||||
- What happens when the DM adds a creature, caches its source, then clears the cache? The creature remains in the encounter with its index data. Opening the stat block triggers the fetch prompt again.
|
||||
- What happens when the fetched JSON does not match the expected format? The normalization adapter handles format variations as it does today. If normalization fails entirely, an error is shown.
|
||||
- What happens when persistent client-side storage is unavailable (private browsing, storage full)? The app falls back to in-memory caching for the current session and warns the user that data will not persist.
|
||||
- What happens when the browser is offline? The fetch prompt is shown but the fetch fails. The DM can use the file upload alternative. Previously cached sources remain available.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The app MUST ship a pre-generated search index containing creature name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size code, and creature type for all indexed creatures.
|
||||
- **FR-002**: The app MUST include a source display name map that translates source codes to human-readable names (e.g., "XMM" to "Monster Manual (2025)").
|
||||
- **FR-003**: Search MUST operate against the full shipped index — case-insensitive substring match on creature name, minimum 2 characters, maximum 10 results, sorted alphabetically.
|
||||
- **FR-004**: Search results MUST display the source display name alongside the creature name.
|
||||
- **FR-005**: Adding a creature from search MUST populate name, HP, AC, and initiative data directly from the index without any network fetch.
|
||||
- **FR-006**: When a user views a stat block for a creature whose source is not cached, the app MUST display a prompt to load the source data.
|
||||
- **FR-007**: The source fetch prompt MUST include an editable URL field pre-filled with the default URL for that source's raw data file.
|
||||
- **FR-008**: On confirmation, the app MUST fetch the JSON, normalize it through the existing normalization pipeline, and cache all creatures from that source.
|
||||
- **FR-009**: Cached source data MUST persist across browser sessions using client-side storage.
|
||||
- **FR-010**: The app MUST provide a file upload option as an alternative to URL fetching, processing the uploaded file identically.
|
||||
- **FR-011**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
|
||||
- **FR-012**: The bundled full bestiary data file MUST be removed from the distributed app.
|
||||
- **FR-013**: The existing normalization adapter and tag-stripping utility MUST remain unchanged — they process fetched data exactly as before.
|
||||
- **FR-014**: If a fetch or upload fails, the app MUST show a user-friendly error message with options to retry or change the URL. The creature's index data MUST remain intact in the encounter.
|
||||
- **FR-015**: The source fetch prompt MUST appear once per source, not once per creature. After fetching a source, all its creatures' stat blocks become available.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Search Index**: Pre-shipped lightweight dataset containing mechanical facts (name, source, AC, HP, DEX, CR, initiative proficiency, size, type) for all creatures. Keyed by name + source for uniqueness.
|
||||
- **Source**: A D&D publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Contains multiple creatures. Caching and fetching operate at the source level.
|
||||
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in persistent client-side storage. Contains complete creature stat blocks including traits, actions, and descriptions.
|
||||
- **Creature (Index Entry)**: A lightweight record from the search index — sufficient for adding a combatant but insufficient for rendering a full stat block.
|
||||
- **Creature (Full)**: A complete creature record with all stat block data (traits, actions, legendary actions, etc.), available only after source data is fetched/uploaded and cached.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: All 3,312 indexed creatures are searchable immediately on app load, with search results appearing within 100ms of typing.
|
||||
- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms.
|
||||
- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt.
|
||||
- **SC-004**: The app ships zero copyrighted prose content — only mechanical facts and creature names in the index.
|
||||
- **SC-005**: Source data import (fetch or upload) for a typical source completes and becomes usable within 5 seconds on a standard broadband connection.
|
||||
- **SC-006**: Cached data persists across browser sessions — closing and reopening the browser does not require re-fetching previously loaded sources.
|
||||
- **SC-007**: The shipped app bundle size decreases compared to the current approach (removing the bundled full bestiary data, replacing with the lightweight index).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The pre-generated search index (`data/bestiary/index.json`) is already available in the repository and maintained separately via the generation script.
|
||||
- The default fetch URLs follow a predictable pattern based on the source code, allowing the app to pre-fill the URL for each source.
|
||||
- Persistent client-side storage (e.g., IndexedDB) is available in all target browsers. Private browsing mode may limit persistence, handled as an edge case with in-memory fallback.
|
||||
- The existing normalization adapter can handle bestiary JSON from any of the 102 sources, not just the currently bundled one. If source-specific variations exist, adapter updates are implementation concerns.
|
||||
- Users are responsible for sourcing their own bestiary data files — the app provides the fetch mechanism but makes no guarantees about URL availability.
|
||||
198
specs/029-on-demand-bestiary/tasks.md
Normal file
198
specs/029-on-demand-bestiary/tasks.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Tasks: On-Demand Bestiary with Pre-Indexed Search
|
||||
|
||||
**Input**: Design documents from `/specs/029-on-demand-bestiary/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: No test tasks included — not explicitly requested in the feature specification.
|
||||
|
||||
**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 (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Install dependencies and add domain types that all stories depend on
|
||||
|
||||
- [x] T001 Install `idb` dependency in `apps/web/package.json` via `pnpm --filter web add idb`
|
||||
- [x] T002 Add `BestiaryIndexEntry` and `BestiaryIndex` readonly interfaces to `packages/domain/src/creature-types.ts` — `BestiaryIndexEntry` has fields: name (string), source (string), ac (number), hp (number), dex (number), cr (string), initiativeProficiency (number), size (string), type (string). `BestiaryIndex` has fields: sources (Record<string, string>), creatures (readonly BestiaryIndexEntry[])
|
||||
- [x] T003 Export `BestiaryIndexEntry` and `BestiaryIndex` types from `packages/domain/src/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core adapters and port interfaces that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [x] T004 [P] Create `apps/web/src/adapters/bestiary-index-adapter.ts` — import `data/bestiary/index.json` as a Vite static JSON import; export a `loadBestiaryIndex()` function that maps compact index fields (n→name, s→source, ac→ac, hp→hp, dx→dex, cr→cr, ip→initiativeProficiency, sz→size, tp→type) to `BestiaryIndex` domain type; export `getDefaultFetchUrl(sourceCode: string)` returning `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-{sourceCode-lowercase}.json`; export `getSourceDisplayName(sourceCode: string)` resolving from the sources map
|
||||
- [x] T005 [P] Create `apps/web/src/adapters/bestiary-cache.ts` — implement IndexedDB cache adapter using `idb` library; database name `"initiative-bestiary"`, object store `"sources"` with keyPath `"sourceCode"`; implement functions: `getCreature(creatureId)` extracts source from ID prefix, looks up source record, finds creature by ID; `isSourceCached(sourceCode)` checks store; `cacheSource(sourceCode, displayName, creatures)` stores `CachedSourceRecord` with cachedAt timestamp and creatureCount; `getCachedSources()` returns all records' metadata; `clearSource(sourceCode)` deletes one record; `clearAll()` clears store; `loadAllCachedCreatures()` returns a `Map<CreatureId, Creature>` from all cached sources for in-memory lookup; include in-memory `Map` fallback if IndexedDB open fails (private browsing)
|
||||
- [x] T006 [P] Add `BestiarySourceCache` port interface to `packages/application/src/ports.ts` — define interface with methods: `getCreature(creatureId: CreatureId): Creature | undefined`, `isSourceCached(sourceCode: string): boolean`; export from `packages/application/src/index.ts`
|
||||
|
||||
**Checkpoint**: Foundation ready — index adapter parses shipped data, cache adapter persists fetched data, port interface defined
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Search All Creatures Instantly (Priority: P1)
|
||||
|
||||
**Goal**: Replace the single-source bundled bestiary with multi-source index search. All 3,312 creatures searchable instantly. Adding a creature populates HP/AC/initiative from the index without any network fetch.
|
||||
|
||||
**Independent Test**: Type a creature name → see results from multiple sources with display names → select one → combatant added with correct HP, AC, initiative modifier. No network requests made.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T007 [US1] Rewrite `apps/web/src/hooks/use-bestiary.ts` — replace the `xmm.json` dynamic import with `loadBestiaryIndex()` from bestiary-index-adapter; store `BestiaryIndex` in state; rewrite `search(query)` to filter `index.creatures` by case-insensitive substring on name (min 2 chars, max 10 results, sorted alphabetically), returning results with `sourceDisplayName` resolved from `index.sources`; keep `getCreature(id)` for stat block lookup (initially returns undefined for all — cache integration comes in US2); export `searchIndex` function and `sourceDisplayName` resolver; update `BestiaryHook` interface to expose search results as index entries with source display names
|
||||
- [x] T008 [US1] Update `apps/web/src/components/bestiary-search.tsx` — modify search result rendering to show source display name alongside creature name (e.g., "Goblin (Monster Manual (2025))"); update the type of items from `Creature` to index-based search result type; ensure `onSelectCreature` callback receives the index entry data needed by addFromBestiary
|
||||
- [x] T009 [US1] Update `addFromBestiary` in `apps/web/src/hooks/use-encounter.ts` — accept a `BestiaryIndexEntry` (or compatible object with name, hp, ac, dex, cr, initiativeProficiency, source) instead of requiring a full `Creature`; derive `creatureId` from `{source.toLowerCase()}:{slugify(name)}` using existing slug logic; call `addCombatantUseCase`, `setHpUseCase(hp)`, `setAcUseCase(ac)`; set `creatureId` on the combatant for later stat block lookup
|
||||
- [x] T010 [US1] Remove `data/bestiary/xmm.json` from the repository (git rm); verify no remaining imports reference it; update any test files that imported xmm.json to use test fixtures or the index instead
|
||||
|
||||
**Checkpoint**: Search works across all 102 sources. Creatures can be added from any source with correct stats. No bundled copyrighted content. Stat blocks not yet available (US2).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — View Full Stat Block via On-Demand Source Fetch (Priority: P2)
|
||||
|
||||
**Goal**: When a stat block is opened for a creature whose source is not cached, prompt the user to fetch the source data from a URL. After fetching, normalize and cache all creatures from that source in IndexedDB. Subsequent lookups for any creature from that source are instant.
|
||||
|
||||
**Independent Test**: Add a creature → open its stat block → see fetch prompt with pre-filled URL → confirm → stat block renders. Open another creature from same source → stat block renders instantly with no prompt.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T011 [P] [US2] Create `apps/web/src/components/source-fetch-prompt.tsx` — dialog/card component that displays "Load [sourceDisplayName] bestiary data?" with an editable URL input pre-filled via `getDefaultFetchUrl(sourceCode)` from bestiary-index-adapter; "Load" button triggers fetch; show loading spinner during fetch; on success call `onSourceLoaded` callback; on error show error message with retry option and option to change URL; include error state for network failures and normalization failures
|
||||
- [x] T012 [US2] Integrate cache into `apps/web/src/hooks/use-bestiary.ts` — on mount, call `loadAllCachedCreatures()` from bestiary-cache to populate an in-memory `Map<CreatureId, Creature>`; update `getCreature(id)` to look up from this map; export `isSourceCached(sourceCode)` delegating to bestiary-cache; export `fetchAndCacheSource(sourceCode, url)` that fetches the URL, parses JSON, calls `normalizeBestiary()` from bestiary-adapter, calls `cacheSource()` from bestiary-cache, and updates the in-memory creature map; return cache loading state
|
||||
- [x] T013 [US2] Update `apps/web/src/components/stat-block-panel.tsx` — when `getCreature(creatureId)` returns `undefined` for a combatant that has a `creatureId`, extract the source code from the creatureId prefix; if `isSourceCached(source)` is false, render `SourceFetchPrompt` instead of the stat block; after `onSourceLoaded` callback fires, re-lookup the creature and render the stat block; if source is cached but creature not found (edge case), show appropriate message
|
||||
|
||||
**Checkpoint**: Full stat block flow works end-to-end. Source data fetched once per source, cached in IndexedDB, persists across sessions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Manual File Upload as Fetch Alternative (Priority: P3)
|
||||
|
||||
**Goal**: Add a file upload option to the source fetch prompt so users can load bestiary data from a local JSON file when the URL is inaccessible.
|
||||
|
||||
**Independent Test**: Open source fetch prompt → click "Upload file" → select a local bestiary JSON → stat blocks become available.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T014 [US3] Add file upload to `apps/web/src/components/source-fetch-prompt.tsx` — add an "Upload file" button/link below the URL fetch section; clicking opens a native file picker (`<input type="file" accept=".json">`); on file selection, read via FileReader, parse JSON, call `normalizeBestiary()`, call `cacheSource()`, and invoke `onSourceLoaded` callback; handle errors (invalid JSON, normalization failure) with user-friendly messages and retry option
|
||||
|
||||
**Checkpoint**: Both URL fetch and file upload paths work. Users in offline/restricted environments can still load stat blocks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Manage Cached Sources (Priority: P4)
|
||||
|
||||
**Goal**: Provide a UI for viewing which sources are cached and clearing individual or all cached data.
|
||||
|
||||
**Independent Test**: Cache one or more sources → open management UI → see cached sources listed → clear one → verify it requires re-fetch → clear all → verify all require re-fetch.
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [x] T015 [P] [US4] Create `apps/web/src/components/source-manager.tsx` — component showing a list of cached sources via `getCachedSources()` from bestiary-cache; each row shows source display name, creature count, and a "Clear" button calling `clearSource(sourceCode)`; include a "Clear All" button calling `clearAll()`; show empty state when no sources are cached; after clearing, update the in-memory creature map in use-bestiary
|
||||
- [x] T016 [US4] Wire source manager into the app UI — add a settings/gear icon button (Lucide `Settings` icon) in the top bar or an accessible location that opens the `SourceManager` component in a dialog or panel; ensure it integrates with the existing layout without disrupting the encounter flow
|
||||
|
||||
**Checkpoint**: Cache management fully functional. Users can inspect and clear cached data.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Edge cases, fallbacks, and merge gate validation
|
||||
|
||||
- [x] T017 Verify IndexedDB-unavailable fallback in `apps/web/src/adapters/bestiary-cache.ts` — ensure that when IndexedDB open fails (e.g., private browsing), the adapter silently falls back to in-memory storage; show a non-blocking warning via console or UI toast that cached data will not persist across sessions
|
||||
- [x] T018 Run `pnpm check` (knip + format + lint + typecheck + test) and fix all issues — ensure no unused exports from removed xmm.json references; verify layer boundary checks pass; fix any TypeScript errors from type changes; ensure Biome formatting is correct
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Phase 1 completion — BLOCKS all user stories
|
||||
- **US1 (Phase 3)**: Depends on Phase 2 — core search and add flow
|
||||
- **US2 (Phase 4)**: Depends on Phase 3 — stat block fetch needs working search/add
|
||||
- **US3 (Phase 5)**: Depends on Phase 4 — file upload extends the fetch prompt from US2
|
||||
- **US4 (Phase 6)**: Depends on US2 (Phase 4) — T015 needs the in-memory creature map from T012
|
||||
- **Polish (Phase 7)**: Depends on all user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Requires Foundational (Phase 2) — no other story dependencies
|
||||
- **US2 (P2)**: Requires US1 (uses rewritten use-bestiary hook and creatureId links)
|
||||
- **US3 (P3)**: Requires US2 (extends source-fetch-prompt.tsx created in US2)
|
||||
- **US4 (P4)**: Requires US2 (T012 establishes the in-memory creature map that T015 must update after clearing)
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Adapter/model tasks before hook integration tasks
|
||||
- Hook integration before component tasks
|
||||
- Core implementation before edge cases
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 2**: T004, T005, T006 can all run in parallel (different files, no dependencies)
|
||||
- **Phase 3**: T007 first, then T008/T009 can parallelize (different files), T010 after all
|
||||
- **Phase 4**: T011 can parallelize with T012 (different files), T013 after both
|
||||
- **Phase 6**: T015 depends on T012 (US2); T016 after T015
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 2 (Foundational)
|
||||
|
||||
```text
|
||||
# All three foundational tasks can run simultaneously:
|
||||
Task T004: "Create bestiary-index-adapter.ts" (apps/web/src/adapters/)
|
||||
Task T005: "Create bestiary-cache.ts" (apps/web/src/adapters/)
|
||||
Task T006: "Add BestiarySourceCache port" (packages/application/src/ports.ts)
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```text
|
||||
# After T007 (rewrite use-bestiary.ts):
|
||||
Task T008: "Update bestiary-search.tsx" (apps/web/src/components/)
|
||||
Task T009: "Update addFromBestiary" (apps/web/src/hooks/use-encounter.ts)
|
||||
# Then T010 after both complete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup (T001–T003)
|
||||
2. Complete Phase 2: Foundational (T004–T006)
|
||||
3. Complete Phase 3: User Story 1 (T007–T010)
|
||||
4. **STOP and VALIDATE**: Search works across 102 sources, creatures add with correct stats, no bundled copyrighted content
|
||||
5. Deploy/demo if ready — app is fully functional for adding creatures; stat blocks unavailable until US2
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → Foundation ready
|
||||
2. US1 → Multi-source search and add (MVP!)
|
||||
3. US2 → On-demand stat blocks via URL fetch
|
||||
4. US3 → File upload alternative
|
||||
5. US4 → Cache management
|
||||
6. Polish → Edge cases and merge gate
|
||||
7. Each story adds value without breaking previous stories
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- US4 (cache management) is independent of US2/US3 and can be built in parallel if desired
|
||||
- The existing `normalizeBestiary()` and `stripTags()` are unchanged — no tasks needed for them
|
||||
- The existing `stat-block.tsx` rendering component is unchanged
|
||||
- The existing `encounter-storage.ts` persistence is unchanged
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
Reference in New Issue
Block a user