Add min/max hit point variants for D&D creatures

A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle
below the Hit Points line, deriving both ends from the Hit Dice formula
(3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP
edits and damage already taken survive it. Unlike the PF2e weak/elite
adjustment this is a convenience tool, not a rules mechanic: nothing but
HP changes and the combatant is not renamed. The toggle is hidden when
the HP field carries prose instead of a dice pool.

Also extracts a SegmentedControl primitive. Game system, theme and the
PF2e weak/elite toggle were three hand-rolled copies of the same markup,
which is why the D&D toggle first came out chunkier and in the wrong
accent color. All four now share one definition, and segments expose
aria-pressed instead of signalling the active state by color alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-08-05 17:06:01 +02:00
co-authored by Claude Opus 5
parent 7b599e9ad9
commit be3778a0c9
22 changed files with 942 additions and 88 deletions
+22 -1
View File
@@ -118,6 +118,11 @@ As a DM running a PF2e encounter, I want to toggle a weak or elite adjustment on
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-113FR-116), and its name gains a prefix (see `specs/001-combatant-management/spec.md`, FR-041FR-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.
**US-D8 — Set a D&D Combatant to Minimum or Maximum Hit Points (P2)**
As a DM running a D&D encounter, I want to switch a bestiary-linked combatant between the minimum, average and maximum result of its Hit Dice so I can make a single monster tougher or flimsier without hand-editing HP.
When viewing a D&D creature's stat block for a combatant, a Min/Avg/Max toggle appears under the Hit Points line. The stat block shows the printed average by default; selecting "Min" or "Max" shows the value the Hit Dice would produce if every die rolled its lowest or highest face (e.g., `3d6 + 6` → 9 or 24) and updates the combatant's stored max HP by the same delta. This is a convenience tool, not a rules mechanic — no other stat changes and the combatant is not renamed.
### Requirements
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
@@ -151,6 +156,12 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
- **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-041FR-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 `creatureAdjustment` field and persist across page reloads.
- **FR-109**: D&D stat blocks MUST include a Min/Avg/Max hit point toggle below the Hit Points line, defaulting to "Avg".
- **FR-110**: The Min/Avg/Max toggle MUST only be shown for a bestiary-linked combatant whose HP formula is a plain Hit Dice pool (`NdM`, optionally `± K`) with a spread — it MUST NOT be shown while browsing a creature without a combatant, for PF2e creatures, or when the HP field carries prose instead of dice.
- **FR-111**: "Min" MUST use the result of every Hit Die rolling 1 (dice count + modifier); "Max" MUST use the result of every Hit Die rolling its highest face (dice count × faces + modifier). Both MUST be at least 1. "Avg" MUST use the printed average.
- **FR-112**: Selecting a variant MUST shift the combatant's stored maxHp by the delta between the previously selected and newly selected value, preserving manual HP edits. The combatant's currentHp MUST shift by the same delta, clamped to [0, new maxHp].
- **FR-113**: Selecting a variant MUST NOT change the combatant's name or any stat other than HP.
- **FR-114**: The selected variant MUST be stored on the combatant as an `hpVariant` field (`"min" | "max"`, absent for average) and persist across page reloads and JSON export/import.
### Acceptance Scenarios
@@ -194,6 +205,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
38. **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).
39. **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.
40. **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.
41. **Given** a D&D combatant's stat block is open, **When** the DM views the Hit Points line, **Then** a Min/Avg/Max toggle is visible, set to "Avg".
42. **Given** a D&D combatant with `3d6 + 6` hit points (average 16) is at full health, **When** the DM selects "Max", **Then** the stat block shows 24 hit points and the combatant's HP becomes 24/24.
43. **Given** the same combatant, **When** the DM selects "Min", **Then** the stat block shows 9 hit points and the combatant's HP becomes 9/9.
44. **Given** a D&D combatant set to "Max" has taken 5 damage (19/24), **When** the DM selects "Avg", **Then** the combatant's HP becomes 11/16 — the damage taken is preserved.
45. **Given** a D&D combatant was set to "Max" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Max" and the maximum hit points are displayed.
### Edge Cases
@@ -216,6 +232,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
- 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 `per` value variations (e.g., "day", "round", "turn"): the value is rendered as-is in the "(N/per)" format.
- HP formula that is not a dice pool (empty, prose such as "equal to the summoner's", or a flat number): the Min/Avg/Max toggle is omitted and the printed average is shown unchanged.
- HP formula written with an en dash or em dash instead of a hyphen (present in bundled source data, e.g. `2d6 2`): parsed as a negative modifier.
- Hit Dice modifier larger than the dice pool (e.g. `1d4 - 10`): the resulting hit points are floored at 1.
- Toggling from Min to Max: applies the full swing in a single operation.
- Combatant whose max HP was edited by hand before toggling: the edit is preserved as an offset, since the toggle applies the delta between variants rather than an absolute value.
---
@@ -396,7 +417,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
- **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 branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches. PF2e creatures also carry an `equipment` list of carried items (weapons, consumables) extracted from `items[type=weapon]` and `items[type=consumable]` entries, each with name, level, traits, description, and (for scrolls) embedded spell data. PF2e attack entries carry an optional `attackEffects` list of on-hit effect names. PF2e ability entries carry an optional `frequency` with `max` and `per` fields. PF2e creature perception carries an optional `details` string (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 `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads.
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads. D&D bestiary-linked combatants may carry an `hpVariant` (`"min" | "max"`) indicating that their max HP was set to the lowest or highest possible Hit Dice result instead of the printed average, likewise 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.