Files
initiative/specs/013-hp-status-indicators/research.md

61 lines
3.8 KiB
Markdown

# Research: HP Status Indicators
**Feature**: 013-hp-status-indicators
**Date**: 2026-03-05
## R1: HP Status Derivation Approach
**Decision**: Implement as a pure domain function `deriveHpStatus(currentHp, maxHp)` returning a discriminated union type `HpStatus = "healthy" | "bloodied" | "unconscious"`.
**Rationale**:
- The status is a derived value, not stored state. Computing it on demand avoids synchronization issues and keeps the data model unchanged.
- A standalone pure function in the domain layer is consistent with the project's architecture (e.g., `adjustHp`, `setHp` are each in their own module).
- Returning a simple string union (not an object or class) is the lightest representation and easy to pattern-match in the UI.
**Alternatives considered**:
- Storing status as a field on `Combatant`: Rejected -- introduces redundant state that must stay in sync with HP changes. Violates principle of derived data.
- Computing in the UI layer only: Rejected -- violates constitution principle I (domain logic must be pure domain functions) and makes the logic untestable without a UI.
## R2: Bloodied Threshold
**Decision**: Bloodied when `0 < currentHp < maxHp / 2` (strict less-than for both bounds, floating-point division).
**Rationale**:
- Follows D&D 4e/5e convention where "bloodied" means below half HP.
- Using strict less-than means at exactly half HP the combatant is still "healthy" -- this is the most common TTRPG interpretation.
- Floating-point division handles odd maxHp values correctly (e.g., maxHp=21: bloodied at 10 since 10 < 10.5).
**Alternatives considered**:
- `currentHp <= maxHp / 2`: Would make combatants at exactly half HP appear bloodied. Less standard.
- `Math.floor(maxHp / 2)`: Would make maxHp=21 bloodied at 10 (same result) but maxHp=20 bloodied at 10 (also same). No practical difference but adds unnecessary complexity.
## R3: Visual Treatment Approach
**Decision**: Color the HP text and optionally mute the entire row for unconscious combatants:
- **Healthy**: Default foreground color (no change)
- **Bloodied**: Amber/orange HP text (`text-amber-400`) -- warm danger signal
- **Unconscious**: Red HP text (`text-red-400`) + reduced row opacity (`opacity-50`) -- clearly out of action
**Rationale**:
- Coloring the HP number is the most targeted indicator -- it draws the eye to the relevant data point.
- Row opacity reduction for unconscious combatants provides a strong "out of action" signal without removing the row (GM may still need to reference it).
- Amber and red are universally understood warning/danger colors and already used in the project (red for damage mode in quick-hp-input, `--color-destructive: #ef4444`).
- No new icons needed -- color alone is sufficient for two states and avoids visual clutter.
**Alternatives considered**:
- Full row background color: Too visually heavy, would conflict with the active-turn indicator (blue left border + bg-accent/10).
- Strikethrough on name for unconscious: Considered but opacity reduction is more modern and less ambiguous (strikethrough could imply "removed").
- Badge/pill indicator: Adds UI elements and visual weight. Color on existing text is simpler.
## R4: Function Signature & Return for Undefined HP
**Decision**: Return `undefined` when maxHp is not set (status is not applicable). Function signature: `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`.
**Rationale**:
- When maxHp is undefined, HP tracking is disabled for that combatant. No status can be derived.
- Returning `undefined` (not a fourth status value) keeps the type clean and matches the "no HP tracking" semantic.
- The UI simply skips styling when the result is `undefined`.
**Alternatives considered**:
- Separate `"none"` status: Adds a value that is semantically different from the three health states. `undefined` is more idiomatic TypeScript for "not applicable".