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

3.8 KiB

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".