Files
initiative/specs/029-on-demand-bestiary/research.md

8.9 KiB

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.