3.8 KiB
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,setHpare 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.undefinedis more idiomatic TypeScript for "not applicable".