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