Weak/Normal/Elite toggle in PF2e stat block header applies standard adjustments (level, AC, HP, saves, Perception, attacks, damage) to individual combatants. Adjusted stats are highlighted blue (elite) or red (weak). Persisted via creatureAdjustment field on Combatant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
58 KiB
Feature Specification: Bestiary
Feature Branch: 004-bestiary
Created: 2026-03-06
Status: Implemented
Overview
The Bestiary feature provides creature search across pre-indexed creature libraries: 3,312+ D&D creatures from 102+ sources and 2,500+ Pathfinder 2e creatures from the Foundry VTT PF2e system (remaster-era content: Monster Core, Monster Core 2, and post-remaster books). The active game system setting (see specs/003-combatant-state/spec.md, FR-096) determines which index the search operates against. Stat block display, source management, and creature pre-fill all adapt to the active game system.
The feature also includes full creature reference via stat block display during combat, source data management via on-demand fetch or file upload, and a dual-panel UX with collapse/expand and pin capabilities. Creatures can be added individually or in batch from a search dropdown, with stats pre-filled from the index.
The architecture uses a two-tier design: lightweight search indexes shipped with the app (one per game system) containing mechanical facts for all creatures, and full stat block data loaded on-demand at the source level, normalized, and cached in IndexedDB.
Structure: This spec is organized by topic area. Each topic section contains its own user scenarios, requirements, and edge cases.
Search & Discovery
User Stories
US-S1 — Search and Add a Creature (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.
A search field in the bottom bar accepts typed queries. Matching creatures from the pre-shipped index appear in a dropdown, each labeled with its source display name (e.g., "Goblin (Monster Manual (2025))"). Selecting a creature adds it as a combatant — name, HP, AC, and initiative modifier are populated directly from the index without any network fetch. The search field displays action-oriented placeholder text (e.g., "Search creatures to add...").
US-S2 — Batch Add Multiple Copies of a Creature (P1) As a DM, I want to quickly add multiple copies of the same creature from the bestiary so I can set up encounters with groups of identical monsters without repetitive searching and clicking.
Clicking a dropdown entry once shows a count badge (starting at 1) and a confirm button on that row. Clicking the same entry again increments the count. Confirming adds N copies of that creature to combat and resets the queue. Only one creature type may be queued at a time.
US-S3 — Add a Custom Creature with Optional Stats (P2) As a DM, I want to type a custom creature name that doesn't match the bestiary and optionally provide initiative, AC, and max HP values so I can add homebrew or improvised creatures with pre-filled stats.
When the search input has no bestiary matches (or fewer than 2 characters typed), optional input fields for initiative, AC, and max HP appear. The creature is addable with or without these fields filled in.
Requirements
- FR-001: The app MUST ship pre-generated search indexes for each supported game system. The D&D index (
data/bestiary/index.json) MUST contain creature name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size code, and creature type. The PF2e index (data/bestiary/pf2e-index.json) MUST contain creature name, source code, AC, HP, level, Perception modifier, size, and creature type. - FR-002: The app MUST include a source display name map translating source codes to human-readable names (e.g., "XMM" -> "Monster Manual (2025)").
- FR-003: Search MUST operate against the shipped index corresponding to the active game system — 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. For D&D creatures, initiative data is the DEX-based modifier. For PF2e creatures, initiative data is the Perception modifier.
- FR-006: The system MUST auto-number duplicate creature names (e.g., "Goblin 1", "Goblin 2") when multiple combatants share the same bestiary creature name. When a second copy is added, the existing combatant is renamed to include the suffix.
- FR-007: Auto-numbered names MUST remain editable via the existing rename functionality.
- FR-008: Combatants added from the bestiary MUST retain a link (
creatureId) to their creature data so the stat block can be re-opened from the tracker. - FR-009: The search field placeholder MUST display action-oriented hint text (e.g., "Search creatures to add...").
- FR-010: Clicking a bestiary dropdown entry MUST show a count badge (starting at 1) and a confirm button on that row.
- FR-011: Clicking the same dropdown entry again MUST increment the count by 1.
- FR-012: Only one creature type MAY be queued at a time; selecting a different creature MUST replace the current queue.
- FR-013: Confirming the queue (via confirm button or Enter key) MUST add N copies of the selected creature to combat and reset the queue state.
- FR-014: When no bestiary match exists for the typed name, the system MUST show optional input fields for initiative, AC, and max HP, each with a visible label.
- FR-015: Custom creatures MUST be addable with or without the optional fields filled in; invalid numeric input MUST be treated as empty.
Acceptance Scenarios
- 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.
- 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.
- Given the app is loaded, When the DM types a single character, Then no results appear (minimum 2 characters required).
- Given search results are showing, When the user types a query with no matches (e.g., "zzzzz"), Then the dropdown shows a "No creatures found" message.
- 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".
- 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).
- Given the dropdown is showing results, When the user clicks on a creature entry, Then a count badge showing "1" and a confirm button appear on that row.
- Given a creature entry shows a count of N, When the user clicks that same entry again, Then the count increments to N+1.
- Given a creature is queued with count N, When the user clicks the confirm button or presses Enter, Then N copies of that creature are added to combat and the queue resets.
- Given the user types a name with no bestiary match, When the dropdown shows no results, Then optional input fields for initiative, AC, and max HP appear with visible labels.
- Given the optional fields are visible, When the user leaves all optional fields empty and submits, Then the creature is added with only the name (no stats pre-filled).
- Given the search input is open, When the user presses Escape, Then the search closes without adding a combatant.
- Given the game system is Pathfinder 2e, When the DM types "abo" in the search field, Then results show PF2e creatures (e.g., "Aboleth (Bestiary 1)") from the PF2e index, not D&D creatures.
- Given the game system is Pathfinder 2e, When the DM selects a PF2e creature, Then a combatant is added with name, HP, AC, and Perception as the initiative modifier.
Edge Cases
- Two creatures from different sources sharing the same name: the source tag is shown alongside the name in search results.
- Queued creature removed from results when search query changes: the queue resets when the queued creature is no longer visible in the results.
- User presses Escape with a queued creature: the queue resets and the dropdown closes.
- Non-numeric input in optional custom creature fields: treated as empty (ignored).
Stat Block Display
User Stories
US-D1 — View Full Stat Block in Side Panel (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 search results or when clicking the book icon on a bestiary-linked combatant row, a stat block panel appears showing the creature's full information in the classic D&D stat block layout. Clicking the book icon on a different combatant updates the panel to that creature's data. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant's name always enters inline rename mode (see specs/001-combatant-management/spec.md, FR-024).
US-D2 — Preview Stat Block from Search Dropdown (P3) As a DM, I want to preview a creature's stat block directly from the search dropdown so I can review creature details before deciding to add them to the encounter.
A view button in the search bar (repurposed from the current search icon) opens the stat block panel for the currently focused/highlighted creature in the dropdown without committing to adding it.
US-D3 — Responsive Layout (P4) As a DM using the app on different devices, I want the layout to adapt between side-by-side (desktop) and drawer (mobile) so that the stat block is usable regardless of screen size.
US-D4 — View Spell Descriptions Inline (P2) As a DM running a PF2e encounter, I want to click a spell name in a creature's stat block to see the spell's full description without leaving the stat block, so I can quickly resolve what a spell does mid-combat without consulting external tools.
A click on any spell name in the spellcasting section opens a popover (desktop) or bottom sheet (mobile) showing the spell's description, level, traits, range, action cost, target/area, duration, defense/save, and heightening rules. The data is read directly from the cached creature data (already embedded in NPC JSON from Foundry VTT) — no additional network fetch is required, and the feature works offline once the source has been loaded. Dismiss with click-outside, Escape, or (on mobile) swipe-down.
US-D5 — View Recall Knowledge DC and Skill (P2) As a DM running a PF2e encounter, I want to see the Recall Knowledge DC and associated skill on a creature's stat block so I can quickly tell players the DC and which skill to roll without looking it up in external tools.
The Recall Knowledge line appears below the trait tags, showing the DC (calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity) and the skill determined by the creature's type trait. The line is omitted for creatures with no recognized type trait and never shown for D&D creatures.
US-D6 — View NPC Equipment and Consumables (P2) As a DM running a PF2e encounter, I want to see a creature's carried equipment — magic weapons, potions, scrolls, wands, and other items — displayed on its stat block so I can use these tactical options in combat without consulting external tools.
An "Equipment" section appears on the stat block listing each carried item with its name and relevant details (level, traits, activation description). Scrolls additionally show the embedded spell name and rank (e.g., "Scroll of Teleport (Rank 6)"). The section is omitted entirely for creatures that carry no equipment. Equipment data is extracted from the existing cached creature JSON — no additional fetch is required.
US-D7 — Toggle Weak/Elite Adjustment on PF2e Stat Block (P2) As a DM running a PF2e encounter, I want to toggle a weak or elite adjustment on a bestiary-linked combatant's stat block so that the standard PF2e stat modifications are applied to that specific combatant and reflected in both the stat block and the tracker.
When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in the header. Selecting "Elite" or "Weak" applies the standard PF2e adjustments: ±2 to AC, saves, Perception, attack rolls, and strike damage; HP adjusted by the standard level bracket table; level shifted. The combatant's stored HP and AC update accordingly (see specs/003-combatant-state/spec.md, FR-113–FR-116), and its name gains a prefix (see specs/001-combatant-management/spec.md, FR-041–FR-042). The toggle defaults to "Normal" and is not shown for D&D creatures. A visual indicator (the same icon used in the toggle) appears next to the creature name in the header.
Requirements
- FR-016: The system MUST display a stat block panel with full creature information when a creature is selected.
- FR-017: For D&D creatures, 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, passive perception, traits, actions, bonus actions, reactions, spellcasting, and legendary actions. For PF2e creatures, the stat block MUST include: name, level, traits (as tags), Perception (with details text such as "smoke vision" alongside senses), languages, skills, ability modifiers (Str/Dex/Con/Int/Wis/Cha as modifiers, not scores), items, AC, saving throws (Fort/Ref/Will), HP (with optional immunities/resistances/weaknesses), speed, attacks (with inline on-hit effects), abilities with frequency limits where applicable, top abilities, mid abilities (reactions/auras), bot abilities (active), spellcasting, and equipment (weapons, consumables, and other carried items).
- FR-018: Optional stat block sections (traits, legendary actions, bonus actions, reactions, etc.) MUST be omitted entirely when the creature has none.
- FR-019: The system MUST strip bestiary markup tags (spell references, dice notation, attack tags) and render them as plain readable text (e.g.,
{@spell fireball|XPHB}-> "fireball",{@dice 3d6}-> "3d6"). - FR-020: On wide viewports (desktop), the layout MUST be side-by-side with the encounter tracker on the left and stat block on the right.
- FR-021: On narrow viewports (mobile), the stat block MUST appear as a dismissible drawer or slide-over.
- FR-022: The stat block panel MUST scroll independently of the encounter tracker.
- FR-023: When the user clicks the book icon on a different bestiary-linked combatant row, the stat block panel MUST update to show that creature's data.
- FR-024: The existing search/view button MUST be repurposed to open the stat block for the currently focused/selected creature in the dropdown.
- FR-063: The stat block renderer MUST select the appropriate layout (D&D or PF2e) based on the creature's game system. The creature's game system is determined by the index it was added from.
- FR-064: PF2e stat blocks MUST display level in place of challenge rating. Level MUST appear in the stat block header.
- FR-065: PF2e stat blocks MUST display three saving throws (Fortitude, Reflex, Will) instead of D&D's six ability-based saves.
- FR-066: PF2e stat blocks MUST display ability modifiers directly (e.g., "Str +4") rather than ability scores with derived modifiers.
- FR-067: PF2e stat blocks MUST organize abilities into three sections: top (above defenses), mid (reactions and auras), and bot (active abilities), matching the Foundry VTT PF2e item categorization.
- FR-068: PF2e stat blocks MUST strip HTML tags from Foundry VTT ability descriptions and render them as plain readable text. The HTML-to-text conversion serves the same role as the D&D tag-stripping approach (FR-019).
- FR-062: Bestiary-linked combatant rows MUST display a small book icon (Lucide
BookOpen) as the dedicated stat block trigger. The icon MUST have a tooltip ("View stat block") and anaria-label="View stat block"for accessibility. The book icon is the only way to manually open a stat block from the tracker — clicking the combatant name always enters inline rename mode. Non-bestiary combatant rows MUST NOT display the book icon. - FR-077: PF2e stat blocks MUST render each spell name in the spellcasting section as an interactive element (clickable button), not as plain joined text.
- FR-078: Clicking a spell name MUST open a popover (desktop) or bottom sheet (mobile) displaying the spell's description, level, traits, range, time/actions, target/area, duration, defense/save, and heightening rules.
- FR-079: The spell description popover/sheet MUST render content from the spell data already embedded in the cached creature JSON — no additional network fetch is required.
- FR-080: The spell description popover/sheet MUST be dismissible by clicking outside, pressing Escape, or (on mobile) swiping the sheet down.
- FR-081: Spell descriptions MUST be processed through the existing Foundry tag-stripping utility before display (consistent with FR-068).
- FR-082: When a spell name has a parenthetical modifier (e.g., "Heal (×3)", "Unfettered Movement (Constant)"), only the spell name portion MUST be the click target; the modifier MUST remain as adjacent plain text.
- FR-083: The spell description display MUST handle both representations of heightening present in Foundry VTT data:
system.heighteningandsystem.overlays. - FR-101: PF2e stat blocks MUST include a Weak/Normal/Elite toggle in the header, defaulting to "Normal".
- FR-102: The Weak/Normal/Elite toggle MUST NOT be shown for D&D creatures or non-bestiary combatants.
- FR-103: Selecting "Elite" MUST display the stat block with the standard PF2e elite adjustment applied: +2 to AC, saving throws, Perception, and attack rolls; +2 to strike damage; HP increase by level bracket (per the standard PF2e table); level +1 (or +2 if base level ≤ 0).
- FR-104: Selecting "Weak" MUST display the stat block with the standard PF2e weak adjustment applied: −2 to AC, saving throws, Perception, and attack rolls; −2 to strike damage; HP decrease by level bracket (per the standard PF2e table); level −1 (or −2 if base level is 1).
- FR-105: Toggling the adjustment MUST update the combatant's stored maxHp and ac to the adjusted values (see
specs/003-combatant-state/spec.md, FR-113–FR-116). The combatant's currentHp MUST shift by the same delta as maxHp, clamped to [0, new maxHp]. - FR-106: Toggling the adjustment MUST update the combatant's name with the appropriate prefix — "Weak" or "Elite" — or remove the prefix when returning to "Normal" (see
specs/001-combatant-management/spec.md, FR-041–FR-042). - FR-107: The stat block header MUST display a visual indicator (the same icon used in the toggle) next to the creature name when the creature has a weak or elite adjustment.
- FR-108: The adjustment MUST be stored on the combatant as a
creatureAdjustmentfield and persist across page reloads.
Acceptance Scenarios
- 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).
- 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.
- 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.
- Given a stat block is displayed, When the user clicks the book icon on a different bestiary-linked combatant row, Then the stat block panel updates to show that creature's data.
- Given a creature entry contains markup tags (e.g., spell references, dice notation), Then they render as plain text.
- Given the dropdown is showing bestiary results, When the user clicks the stat block view button, Then the stat block panel opens for the currently focused/highlighted creature in the dropdown.
- Given no creature is focused in the dropdown, When the user clicks the stat block view button, Then nothing happens (button is disabled or no-op).
- Given a bestiary-linked combatant row is visible, When the user looks at the row, Then a small book icon is visible as the stat block trigger with a tooltip "View stat block".
- Given a custom (non-bestiary) combatant row is visible, When the user looks at the row, Then no book icon is displayed.
- Given a bestiary-linked combatant row is visible, When the user clicks the combatant's name, Then inline rename mode is entered — the stat block does NOT open.
- Given a PF2e creature is selected, When the stat block opens, Then it displays: name, level, traits as tags, Perception, senses, languages, skills, ability modifiers, AC, Fort/Ref/Will saves, HP, speed, attacks, abilities (top/mid/bot sections), and spellcasting (if applicable). No CR, no ability scores, no legendary actions.
- Given a PF2e creature with conditional AC (e.g., "with shield raised"), When viewing the stat block, Then both the standard AC and conditional AC are shown.
- Given a PF2e creature with spellcasting is displayed in the stat block panel, When the DM clicks a spell name in the spellcasting section, Then a popover (desktop) or bottom sheet (mobile) opens showing the spell's description, level, traits, range, action cost, and any heightening rules.
- Given the spell description popover is open, When the DM clicks outside it or presses Escape, Then the popover dismisses.
- Given the spell description bottom sheet is open on mobile, When the DM swipes the sheet down, Then the sheet dismisses.
- Given a creature from a legacy (non-remastered) PF2e source has spells with pre-remaster names (e.g., "Magic Missile", "True Strike"), When the DM clicks one of those spell names, Then the spell description still displays correctly using the embedded data.
- Given a spell name appears as "Heal (×3)" in the stat block, When the DM looks at the rendered output, Then "Heal" is the clickable element and "(×3)" appears as plain text next to it.
- Given a PF2e creature with level 5 and common rarity is displayed, When the DM views the stat block, Then a "Recall Knowledge" line appears below the trait tags showing the DC calculated from level 5 (DC 20) and the skill derived from the creature's type trait.
- Given a PF2e creature with rare rarity is displayed, When the DM views the stat block, Then the Recall Knowledge DC is the standard DC for its level +5.
- Given a PF2e creature with the "Undead" type trait is displayed, When the DM views the stat block, Then the Recall Knowledge line shows "Religion" as the associated skill.
- Given a D&D creature is displayed, When the DM views the stat block, Then no Recall Knowledge line is shown.
- Given a PF2e creature carrying a Staff of Fire and an Invisibility Potion is displayed, When the DM views the stat block, Then an "Equipment" section appears listing both items with their names and relevant details.
- Given a PF2e creature carrying a Scroll of Teleport Rank 6 is displayed, When the DM views the stat block, Then the Equipment section shows the scroll with the embedded spell name and rank (e.g., "Scroll of Teleport (Rank 6)").
- Given a PF2e creature with no equipment items is displayed, When the DM views the stat block, Then no Equipment section is shown.
- Given a PF2e creature with equipment is displayed, When the DM views the stat block, Then equipment item descriptions have HTML tags stripped and render as plain readable text.
- Given a D&D creature is displayed, When the DM views the stat block, Then no Equipment section is shown (equipment display is PF2e-only).
- Given a PF2e creature with a melee attack that has
attackEffects: ["grab"], When the DM views the stat block, Then the attack line shows the damage followed by "plus Grab". - Given a PF2e creature with a melee attack that has no attack effects, When the DM views the stat block, Then the attack line shows only the damage with no "plus" suffix.
- Given a PF2e creature with an ability that has
frequency: {max: 1, per: "day"}, When the DM views the stat block, Then the ability name is followed by "(1/day)". - Given a PF2e creature with an ability that has no frequency limit, When the DM views the stat block, Then the ability name renders without any frequency annotation.
- Given a PF2e creature with
perception.details: "smoke vision", When the DM views the stat block, Then the perception line shows "smoke vision" alongside the senses. - Given a PF2e creature with no perception details, When the DM views the stat block, Then the perception line shows only the modifier and senses as before.
- Given a PF2e creature's stat block is open, When the DM views the header, Then a Weak/Normal/Elite toggle is visible, set to "Normal" by default.
- Given a D&D creature's stat block is open, When the DM views the header, Then no Weak/Normal/Elite toggle is shown.
- Given a PF2e creature (level 5, AC 22, HP 75) stat block is open, When the DM selects "Elite", Then the stat block shows AC 24, HP 95 (75+20 for level 5 bracket), level 6, and all saves/Perception/attacks are adjusted by +2.
- Given a PF2e creature (level 5, AC 22, HP 75) stat block is open, When the DM selects "Weak", Then the stat block shows AC 20, HP 55 (75−20 for level 5 bracket), level 4, and all saves/Perception/attacks are adjusted by −2.
- Given a PF2e creature with level 0 stat block is open, When the DM selects "Elite", Then the level increases by 2 (not 1).
- Given a PF2e creature with level 1 stat block is open, When the DM selects "Weak", Then the level decreases by 2 (to −1, not 0).
- Given a PF2e combatant was set to "Elite" and the page is reloaded, When the DM opens the stat block, Then the toggle shows "Elite" and the stat block displays adjusted stats.
- Given a PF2e combatant was set to "Elite", When the DM toggles back to "Normal", Then the stat block reverts to base stats, the combatant's HP/AC revert, and the name prefix is removed.
Edge Cases
- Creatures with no traits or legendary actions: those sections are omitted from the stat block display.
- Very long content (e.g., a Lich with extensive spellcasting): the stat block panel scrolls independently of the encounter tracker.
- Viewport resized from wide to narrow while stat block is open: the layout transitions from panel to drawer.
- Embedded spell item missing description text: the popover/sheet shows the available metadata (level, traits, range, etc.) and a placeholder note for the missing description.
- Scroll item with missing or empty
system.spelldata: the scroll is displayed by name only, without spell name or rank. - Equipment item with empty description: the item is displayed with its name and metadata (level, traits) but no description text.
- Cached source data from before the spell description feature was added: existing cached entries lack the new per-spell data fields. The IndexedDB schema version MUST be bumped to invalidate old caches and trigger re-fetch (re-normalization from raw Foundry data is not possible because the original raw JSON is not retained).
- Creature with no recognized type trait (e.g., a creature whose only traits are not in the type-to-skill mapping): the Recall Knowledge line is omitted entirely.
- Weak adjustment on a level 1 creature: level becomes −1 (special case, −2 instead of −1).
- Elite adjustment on a level ≤ 0 creature: level increases by 2 instead of 1.
- HP bracket table: HP adjustments follow the standard PF2e weak/elite HP adjustment table keyed by creature level (1 or lower: ±10, 2–4: ±15, 5–19: ±20, 20+: ±30).
- Toggling from Elite to Weak: applies the full swing (reverts elite, then applies weak) in a single operation.
- Combatant has taken damage before toggle: currentHp shifts by the maxHp delta, clamped to [0, new maxHp]. E.g., 65/75 HP → Elite → 85/95 HP.
- Source data not yet cached when toggling: toggle is disabled until source data is loaded (adjustment requires full creature data to compute).
- Recall Knowledge DC updates based on adjusted level.
- Creature with a type trait that maps to multiple skills (e.g., Beast → Arcana/Nature): both skills are shown.
- Attack with multiple on-hit effects (e.g.,
["grab", "knockdown"]): all effects shown, joined with "and" (e.g., "plus Grab and Knockdown"). - Attack effect slug with creature-name prefix (e.g.,
"lich-siphon-life"on a Lich): the creature-name prefix is stripped, rendering as "Siphon Life". - Frequency
pervalue variations (e.g., "day", "round", "turn"): the value is rendered as-is in the "(N/per)" format.
Source Management
User Stories
US-M1 — View Full Stat Block via On-Demand Source Fetch (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 in IndexedDB. For any subsequent creature from the same source, the stat block appears instantly without prompting.
US-M2 — Manual File Upload as Fetch Alternative (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.
US-M3 — Bulk Load All Sources (P1) The user wants to pre-load all bestiary sources at once so that every creature's stat block is instantly available without per-source fetch prompts during gameplay. An import button in the top bar opens the stat block side panel with a bulk import prompt, showing the dynamic source count, an editable pre-filled base URL, and a "Load All" button. All source files are fetched concurrently; already-cached sources are skipped.
US-M4 — Progress Feedback During Bulk Import (P1) While the bulk import is in progress, the user sees a text counter ("Loading sources... 34/102") and a progress bar in the side panel, giving them confidence the operation is proceeding.
US-M5 — Toast Notification on Panel Close During Import (P2) If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar.
US-M6 — Manage Cached Sources (P4) A DM wants to see which sources are cached, find a specific source, clear a specific source's cache, or clear all cached data. A management UI provides this visibility and control, including a filter input to quickly locate sources by name when many are cached.
Requirements
- FR-025: 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-026: The source fetch prompt MUST include an editable URL field pre-filled with the default URL for that source's raw data file.
- FR-027: The source fetch prompt MUST appear once per source, not once per creature. After fetching a source, all its creatures' stat blocks become available.
- FR-028: On confirmation, the app MUST fetch the JSON, normalize it through the existing normalization pipeline, and cache all creatures from that source in IndexedDB.
- FR-029: Cached source data MUST persist across browser sessions using IndexedDB.
- FR-030: The app MUST provide a file upload option as an alternative to URL fetching, processing the uploaded file identically to a fetch.
- FR-031: 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 (HP, AC, etc.) MUST remain intact in the encounter.
- FR-032: If the fetched JSON does not match the expected format, an error is shown to the user.
- FR-033: If persistent client-side storage is unavailable (private browsing, storage full), the app MUST fall back to in-memory caching for the current session and warn the user that data will not persist.
- FR-034: An import button (Lucide Import icon) in the top bar MUST open the stat block side panel with the bulk import prompt.
- FR-035: The bulk import prompt MUST show a descriptive text explaining the operation, including approximate data volume (~12.5 MB) and the dynamic number of sources derived from the bestiary index at runtime.
- FR-036: The system MUST pre-fill a base URL that the user can edit.
- FR-037: The system MUST construct individual fetch URLs by appending the appropriate filename pattern to the base URL:
bestiary-{sourceCode}.jsonfor D&D sources, and the Foundry VTT PF2e per-creature file pattern for PF2e sources. - FR-038: All fetch requests during bulk import MUST fire concurrently (browser handles connection pooling).
- FR-039: Already-cached sources MUST be skipped during bulk import.
- FR-040: The system MUST show a text counter ("Loading sources... N/T") and progress bar during bulk import.
- FR-041: When the user closes the side panel during an active bulk import, a toast notification MUST appear at the bottom-center of the screen showing the progress counter and progress bar.
- FR-042: On full success, the toast MUST auto-dismiss after a few seconds. On partial failure, the toast MUST remain visible until manually dismissed.
- FR-043: The toast system MUST be a lightweight custom component — no third-party toast library.
- FR-044: The bulk import MUST run asynchronously and not block the rest of the app.
- FR-045: The user MUST explicitly provide/confirm the URL before any fetches occur — the app never auto-fetches content.
- FR-046: The "Load All" button MUST be disabled when the URL field is empty or while a bulk import is already in progress.
- FR-047: The app MUST provide a management UI showing cached sources with a filter input for searching by display name and options to clear individual sources or all cached data.
- FR-048: The normalization adapter and tag-stripping utility MUST remain the canonical pipeline for all fetched and uploaded data.
- FR-049: The distributed app bundle MUST contain zero copyrighted prose content — only mechanical facts and creature names in the search index.
- FR-069: The system MUST use a separate normalization pipeline for PF2e source data, mapping the Foundry VTT PF2e JSON structure (
system.*fields anditems[]array) to the PF2e creature type. Both pipelines (D&D and PF2e) MUST use the canonical tag-stripping utility (HTML-to-text for PF2e, markup-to-text for D&D). - FR-070: The source cache MUST be scoped by game system. D&D and PF2e sources MUST NOT collide in IndexedDB (e.g., both may have a source code "B1" but they are different sources).
- FR-071: The bulk import prompt MUST adapt to the active game system: showing the correct source count, base URL (Foundry VTT PF2e repo for PF2e, 5etools for D&D), and approximate data volume for the active system.
- FR-072: The source management UI MUST show only sources for the active game system.
- FR-073: The PF2e index generation script MUST read Foundry VTT PF2e one-file-per-creature JSON from the
packs/pf2e/directory structure. - FR-074: The PF2e index MUST exclude legacy/pre-remaster creatures based on the
publication.remasterfield — only remaster-era content is included by default. - FR-075: PF2e creature abilities MUST have complete descriptive text in stat blocks. Stubs, generic feat references, and unresolved copy entries are not acceptable.
- FR-076: The PF2e index SHOULD carry per-creature license tagging (ORC/OGL) derived from the Foundry VTT source data.
- FR-084: The PF2e normalization pipeline MUST preserve per-spell data (slug, level, traits, range, time, target, area, duration, defense, description, heightening/overlays) from embedded
items[type=spell]entries on NPCs, in addition to the spell name. This data MUST be stored in the cached source data and persisted across browser sessions. - FR-085: PF2e stat blocks MUST display a "Recall Knowledge" line below the trait tags showing the DC and the associated skill (e.g., "Recall Knowledge DC 18 • Undead (Religion)").
- FR-086: The Recall Knowledge DC MUST be calculated from the creature's level using the PF2e standard DC-by-level table, adjusted for rarity: uncommon +2, rare +5, unique +10.
- FR-087: The Recall Knowledge skill MUST be derived from the creature's type trait using the standard PF2e mapping (e.g., Aberration → Occultism, Animal → Nature, Astral → Occultism, Beast → Arcana/Nature, Celestial → Religion, Construct → Arcana/Crafting, Dragon → Arcana, Dream → Occultism, Elemental → Arcana/Nature, Ethereal → Occultism, Fey → Nature, Fiend → Religion, Fungus → Nature, Giant → Society, Humanoid → Society, Monitor → Religion, Ooze → Occultism, Plant → Nature, Undead → Religion).
- FR-088: Creatures with no recognized type trait MUST omit the Recall Knowledge line entirely rather than showing incorrect data.
- FR-089: The Recall Knowledge line MUST NOT be shown for D&D creatures.
- FR-090: The PF2e normalization pipeline MUST extract
weaponandconsumableitem types from the Foundry VTTitems[]array, in addition to the existingmelee,action,spell, andspellcastingEntrytypes. Each extracted equipment item MUST include name, level, traits, and description text. - FR-091: PF2e stat blocks MUST display an "Equipment" section listing all extracted equipment items. Each item MUST show its name and relevant details (e.g., level, traits, activation description).
- FR-092: For scroll items, the stat block MUST display the embedded spell name and rank derived from the
system.spelldata on the item (e.g., "Scroll of Teleport (Rank 6)"). - FR-093: The Equipment section MUST be omitted entirely when the creature has no equipment items, consistent with FR-018 (optional sections omitted when empty).
- FR-094: Equipment item descriptions MUST be processed through the existing Foundry tag-stripping utility before display, consistent with FR-068 and FR-081.
- FR-095: The PF2e normalization pipeline MUST extract
system.attackEffects.value(an array of slug strings, e.g.,["grab"],["lich-siphon-life"]) from melee items and include them in the normalized attack data. - FR-096: PF2e attack lines MUST display inline on-hit effects after the damage text (e.g., "2d12+7 piercing plus Grab"). Effect slugs MUST be converted to title case with hyphens replaced by spaces; creature-name prefixes (e.g., "lich-" in "lich-siphon-life") MUST be stripped. Multiple effects MUST be joined with "plus" (e.g., "plus Grab and Knockdown"). Attacks without on-hit effects MUST render unchanged.
- FR-097: The PF2e normalization pipeline MUST extract
system.frequency(withmaxandperfields, e.g.,{max: 1, per: "day"}) from action items and include it in the normalized ability data. - FR-098: PF2e abilities with a frequency limit MUST display it alongside the ability name as "(N/per)" (e.g., "(1/day)", "(1/round)"). Abilities without a frequency limit MUST render unchanged.
- FR-099: The PF2e normalization pipeline MUST extract
system.perception.details(a string, e.g., "smoke vision") and include it in the normalized creature perception data. - FR-100: PF2e stat blocks MUST display perception details text on the perception line alongside senses (e.g., "Perception +12; darkvision, smoke vision"). When no perception details are present, the perception line MUST render unchanged.
Acceptance Scenarios
- 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.
- 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.
- Given source data for a source 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.
- 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.
- 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 fetch outcome.
- Given the source fetch prompt is visible, When the DM chooses "Upload file" and selects a valid bestiary JSON, Then the app normalizes and caches the data, and stat blocks become available.
- 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.
- Given no sources are cached, When the user clicks the import button in the top bar, Then the stat block side panel opens showing a descriptive explanation, an editable pre-filled base URL, and a "Load All" button.
- Given the bulk import prompt is visible, When the user clicks "Load All", Then the app fires fetch requests for all sources concurrently, normalizes each response, and caches results in IndexedDB.
- Given some sources are already cached, When the user initiates a bulk import, Then already-cached sources are skipped and only uncached sources are fetched.
- Given a bulk import is in progress, When the user views the side panel, Then they see a text counter (e.g., "Loading sources... 34/102") and a visual progress bar.
- Given each source finishes loading, Then the counter and progress bar update immediately.
- Given a bulk import is in progress, When the user closes the side panel, Then a toast notification appears at the bottom-center showing the progress counter and progress bar.
- Given the toast is visible and all sources finish loading successfully, Then the toast shows "All sources loaded" and auto-dismisses after a few seconds.
- Given the toast is visible and some sources fail, Then the toast shows "Loaded N/T sources (F failed)" and remains visible until the user dismisses it.
- Given two sources have been cached, When the DM opens the source management UI, Then both sources are listed with their display names.
- Given the source management UI is open, When the DM clears a single source, Then that source's data is removed; stat blocks for its creatures require re-fetching, while other cached sources remain.
- 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.
- Given many sources are cached, When the DM types a partial name in the filter input, Then only sources whose display name matches (case-insensitive) are shown.
- Given the game system is Pathfinder 2e, When the user clicks the import button, Then the bulk import prompt shows the PF2e source count, a Foundry VTT PF2e-based URL, and a PF2e-appropriate data volume estimate.
- Given the game system is Pathfinder 2e and a PF2e source is cached, When the user opens a PF2e creature's stat block from that source, Then the PF2e stat block renders correctly from cached data.
Edge Cases
- 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.
- Fetched JSON does not match expected format: normalization failure shows an error to the user.
- User 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.
- Storage unavailable (private browsing, storage full): fall back to in-memory caching for the current session with a warning.
- 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.
- "Load All" clicked while a bulk import is already in progress: button is disabled during an active import.
- All sources already cached before bulk import: the operation completes immediately and reports "All sources loaded".
- Network completely unavailable during bulk import: all fetches fail; result shows "Loaded 0/T sources (T failed)".
- User navigates away or refreshes during import: partially completed caches persist; the user can re-run to pick up remaining sources.
- Base URL is empty or invalid: the "Load All" button is disabled.
Panel UX (Collapse, Pin, Second Panel)
User Stories
US-P1 — Collapse and Expand Stat Block Panel (P1) As a DM running an encounter, I want to collapse the stat block panel to a slim tab so I can temporarily reclaim screen space without losing my place, then quickly expand it again to reference creature stats.
The close button is replaced with a collapse/expand toggle. Collapsing slides the panel out to the right edge, leaving a slim vertical tab displaying the creature's name. Clicking the tab expands the panel, showing the same creature that was displayed before collapsing. No "Stat Block" heading text is shown in the panel header.
US-P2 — Pin Creature to Second Panel (P2) As a DM comparing creatures or referencing one creature while browsing others, I want to pin the current creature to a secondary panel on the left side of the screen so I can keep it visible while browsing different creatures in the right panel.
Clicking the pin button copies the current creature to a new left panel. The right panel remains active for browsing different creatures independently. The left panel has an unpin button that removes it.
US-P3 — Collapse Behavior with Pinned Panel (P3) As a DM with a creature pinned, I want to collapse the right (browse) panel independently so I can focus on just the pinned creature, or collapse both panels to see the full encounter list.
Requirements
- FR-050: The system MUST replace the close button on the stat block panel with a collapse/expand toggle control.
- FR-051: The system MUST remove the "Stat Block" heading text from the panel header.
- FR-052: When collapsed, the panel MUST reduce to a slim vertical tab anchored to the right edge of the screen displaying the creature's name.
- FR-053: Collapsing and expanding MUST use a smooth CSS slide animation (~200ms ease-out).
- FR-054: The collapse/expand toggle MUST preserve the currently displayed creature — expanding shows the same creature that was visible when collapsed.
- FR-055: The panel MUST include a pin button that copies the current creature to a new panel on the left side of the screen.
- FR-056: After pinning, the right panel MUST remain active for browsing different creatures independently.
- FR-057: The pinned (left) panel MUST include an unpin button that removes it when clicked.
- FR-058: The pin button MUST be hidden on viewports where dual panels would not fit (small screens / mobile).
- FR-059: The pin button MUST be hidden when the panel is showing a source fetch prompt (no creature data displayed yet).
- FR-060: On mobile viewports, the existing drawer/backdrop behavior MUST be preserved — collapse/expand replaces only the close button behavior for the desktop layout; the backdrop click still dismisses the panel.
- FR-061: Both the browse (right) and pinned (left) panels MUST have independent collapsed states.
Acceptance Scenarios
- Given the stat block panel is open showing a creature, When the user clicks the collapse button, Then the panel slides out to the right edge and a slim vertical tab appears showing the creature's name.
- Given the stat block panel is collapsed to a tab, When the user clicks the tab, Then the panel slides back in showing the same creature that was displayed before collapsing.
- Given the stat block panel is open, When the user looks for a close button, Then no close button is present — only a collapse toggle.
- Given the stat block panel is open, When the user looks at the panel header, Then no "Stat Block" heading text is visible.
- Given the panel is collapsing or expanding, When the animation plays, Then it completes with a smooth slide transition (~200ms ease-out).
- Given the stat block panel is showing a creature on a wide screen, When the user clicks the pin button, Then the current creature appears in a new panel on the left side of the screen.
- Given a creature is pinned to the left panel, When the user clicks the book icon on a different bestiary combatant, Then the right panel updates to show the new creature while the left panel continues showing the pinned creature.
- Given a creature is pinned to the left panel, When the user clicks the unpin button on the left panel, Then the left panel is removed and only the right panel remains.
- Given the user is on a small screen or mobile viewport, When the stat block panel is displayed, Then the pin button is not visible.
- Given both pinned (left) and browse (right) panels are open, When the user collapses the right panel, Then the left pinned panel remains visible and the right panel reduces to a tab.
- Given the right panel is collapsed and the left panel is pinned, When the user expands the right panel, Then it slides back showing the last browsed creature.
Edge Cases
- User pins a creature and then that creature is removed from the encounter: the pinned panel continues displaying the creature's stat block (data is already loaded).
- Active combatant changes while panel is open: if the new active combatant has a creature, the panel auto-updates to show that creature's stat block. If the new active combatant has no creature, the panel remains on the previous creature. If the panel is collapsed, it stays collapsed. If the panel is closed, it stays closed.
- Viewport resized from wide to narrow while a creature is pinned: the pinned (left) panel is hidden and the pin button disappears; resizing back to wide restores the pinned panel.
- User is in bulk import mode and tries to collapse: the collapse/expand behavior applies to the bulk import panel identically.
- Panel showing a source fetch prompt: the pin button is hidden.
Key Entities
- Search Index (D&D) (
BestiaryIndex): Pre-shipped lightweight dataset keyed by name + source, containing mechanical facts (name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size, type) for all creatures. Sufficient for adding combatants; insufficient for rendering a full stat block. - Search Index (PF2e) (
Pf2eBestiaryIndex): Pre-shipped lightweight dataset for PF2e creatures, containing name, source code, AC, HP, level, Perception modifier, size, and creature type. Parallel to the D&D search index but with PF2e-specific fields (level instead of CR, Perception instead of DEX/proficiency). - Source (
BestiarySource): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level. - Creature (Full) (
Creature): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a brandedCreatureId. For PF2e creatures, each spell entry insidespellcastingcarries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embeddeditems[type=spell]data on the source NPC, enabling inline spell description display without additional fetches. PF2e creatures also carry anequipmentlist of carried items (weapons, consumables) extracted fromitems[type=weapon]anditems[type=consumable]entries, each with name, level, traits, description, and (for scrolls) embedded spell data. PF2e attack entries carry an optionalattackEffectslist of on-hit effect names. PF2e ability entries carry an optionalfrequencywithmaxandperfields. PF2e creature perception carries an optionaldetailsstring (e.g., "smoke vision"). - Cached Source Data: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
- Combatant (extended): Gains an optional
creatureIdreference to aCreature, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry acreatureAdjustment("weak" | "elite") indicating the active PF2e weak/elite adjustment, persisted across reloads. - Queued Creature: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
- Bulk Import Operation: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
- Toast Notification: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
- Panel State: Represents whether a stat block panel is expanded, collapsed, or absent. The browse (right) and pinned (left) panels each have independent state.
Success Criteria (mandatory)
- SC-001: All indexed creatures for the active game system (3,312+ D&D or 2,500+ PF2e) 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 distributed app bundle contains zero copyrighted prose content — only mechanical facts and creature names in the search 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 app bundle size is smaller than a bundled-full-bestiary approach, shipping only the lightweight index.
- SC-008: A DM can add 4 identical creatures to combat in 3 steps: type search query, click creature entry 4 times to set count, confirm — down from 4 separate search-and-add cycles.
- SC-009: All stat block sections render correctly for all creatures (no missing data, no raw markup tags visible).
- SC-010: The stat block panel is readable and fully functional on viewports from 375px (mobile) to 2560px (ultrawide) without horizontal scrolling or content clipping.
- SC-011: Users can load all bestiary sources with a single confirmation action; real-time progress is visible during the operation.
- SC-012: Already-cached sources are skipped during bulk import, reducing redundant data transfer on repeat imports.
- SC-013: The rest of the app remains fully interactive during the bulk import operation.
- SC-014: Users can collapse the stat block panel in a single click and expand it in a single click, with the transition completing in under 300ms.
- SC-015: Users can pin a creature and browse a different creature's stat block simultaneously, without any additional navigation steps.
- SC-016: The pinned panel is never visible on screens narrower than the dual-panel breakpoint, ensuring mobile usability is not degraded.
- SC-017: All collapse/expand and pin/unpin interactions are discoverable through visible button controls without requiring documentation or tooltips.
- SC-018: All 2,500+ PF2e indexed creatures (remaster-era content from Foundry VTT PF2e) are searchable when PF2e is the active game system, with search results appearing within 100ms of typing.
- SC-019: PF2e stat blocks render the correct layout (level, three saves, ability modifiers, ability sections) for all PF2e creatures — no D&D-specific fields (CR, ability scores, legendary actions) are shown.
- SC-020: Switching game system immediately changes which creatures appear in search — no page reload required.
- SC-021: Both D&D and PF2e search indexes ship bundled with the app; no network fetch is required to search creatures in either system.
- SC-022: Clicking any spell in a PF2e creature's stat block opens its description display within 100ms — no network I/O is performed.
- SC-023: PF2e spell descriptions are available offline once the bestiary source containing the creature has been cached.