61 lines
3.8 KiB
Markdown
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".
|