Files
initiative/specs/009-combatant-hp/research.md

74 lines
6.3 KiB
Markdown

# Research: Combatant HP Tracking
**Feature**: 009-combatant-hp | **Date**: 2026-03-05
## Decision 1: Domain Function Granularity
**Decision**: Two separate domain functions — `setHp` (sets/updates max HP, initializes current HP, syncs full-health combatants) and `adjustHp` (applies a delta to current HP with clamping).
**Rationale**: The existing codebase follows a one-file-per-operation pattern (`edit-combatant.ts`, `set-initiative.ts`). Separating "set max HP" from "adjust current HP" keeps each function focused and testable. The `adjustHp` function accepting a delta (rather than an absolute value) is extensible: a future damage/heal dialog would call `adjustHp(encounter, id, -15)` for 15 damage, while the current +/- buttons call `adjustHp(encounter, id, -1)` or `adjustHp(encounter, id, +1)`.
**Alternatives considered**:
- Single `updateHp` function handling both max and current → rejected because it conflates two distinct user intents (configuring a combatant vs. tracking combat damage).
- Separate `damageHp` and `healHp` functions → rejected as premature; `adjustHp` with positive/negative delta covers both directions and is simpler for MVP.
## Decision 2: HP Field Placement on Combatant
**Decision**: Add optional `maxHp?: number` and `currentHp?: number` directly to the `Combatant` interface. Both are `undefined` when HP tracking is not active for a combatant.
**Rationale**: The `Combatant` type is small (id, name, initiative). Adding two optional fields keeps the type flat and simple. The existing persistence layer serializes the entire `Encounter` including all `Combatant` fields, so HP values persist automatically once added to the type.
**Alternatives considered**:
- Separate `HitPoints` value object → rejected as over-engineering for two fields. Can refactor later if more HP-related fields emerge (temp HP, resistances, etc.).
- HP stored in a separate map keyed by CombatantId → rejected because it breaks the colocation of combatant data and complicates persistence/serialization.
## Decision 3: Direct HP Entry Implementation
**Decision**: Direct entry sets `currentHp` to an absolute value (clamped to 0..maxHp). This is handled by `adjustHp` or a simple `setCurrentHp` path within the same function by computing the needed delta internally, or as a separate thin wrapper. Simplest: `adjustHp` accepts a delta, and the UI computes `newValue - currentHp` as the delta.
**Rationale**: Keeps the domain function consistent (always delta-based, always clamped). The UI adapter is responsible for translating "user typed 35" into a delta. This avoids a third domain function and keeps the domain API small.
**Alternatives considered**:
- Domain function that accepts absolute current HP → would duplicate clamping logic already in adjustHp. Not chosen.
## Decision 4: Event Design
**Decision**: Two event types — `MaxHpSet` (combatantId, previousMaxHp, newMaxHp, previousCurrentHp, newCurrentHp) and `CurrentHpAdjusted` (combatantId, previousHp, newHp, delta).
**Rationale**: Matches the existing event-per-operation pattern. `MaxHpSet` includes current HP in case it was clamped (the user needs to see that side effect). `CurrentHpAdjusted` includes the delta for future extensibility (a combat log might show "took 5 damage" vs. "healed 3").
**Alternatives considered**:
- Single `HpChanged` event → rejected because setting max HP and adjusting current HP are semantically different operations with different triggers.
## Decision 5: Full-Health Sync on Max HP Change
**Decision**: When `maxHp` changes and the combatant is at full health (`currentHp === previousMaxHp`), `currentHp` is updated to match the new `maxHp`. When not at full health, `currentHp` is only clamped downward (never increased).
**Rationale**: A combatant at full health should stay at full health when their max HP increases. This is a clean domain-level rule independent of UI concerns.
**Alternatives considered**:
- Always sync currentHp to maxHp on change → rejected because it would overwrite intentional HP adjustments (e.g., combatant at 5/20 HP would jump to 25 when max changes to 25).
## Decision 6: Always-Visible HP Fields + Blur-Commit for Max HP
**Decision**: Both Current HP and Max HP input fields are always visible in each combatant row. Current HP is disabled when Max HP is not set. The +/- buttons only appear when HP tracking is active (maxHp defined). The Max HP input uses local draft state and commits to the domain only on blur or Enter — not on every keystroke.
**Rationale**: Two related problems drove this decision:
1. **Focus loss**: Conditionally rendering the Max HP input caused focus loss. Typing the first digit triggered `setHp`, switching DOM branches and destroying the input element.
2. **Premature clearing**: Committing on every keystroke meant clearing the field to retype a value would call `setHp(undefined)`, wiping currentHp. Retyping then triggered the "first set" path, resetting currentHp to the new maxHp — losing the user's previous HP adjustment.
The blur-commit approach solves both: the input keeps a local draft string, so intermediate states (empty field, partial values) never reach the domain. The domain only sees the final intended value.
**Alternatives considered**:
- Conditional render with "Set HP" placeholder → rejected because it causes focus loss when the branch switches.
- Per-keystroke commit (matching the initiative input pattern) → rejected because it causes the premature-clearing bug. The initiative input doesn't have this problem since it has no dependent field like currentHp.
## Decision 7: Persistence Validation
**Decision**: Extend `loadEncounter()` validation (originally Decision 5) to check `maxHp` and `currentHp` fields on each combatant. If `maxHp` is present, validate it is a positive integer. If `currentHp` is present, validate it is an integer in [0, maxHp]. Missing fields are acceptable (optional). Invalid values cause the field to be stripped (combatant loads without HP rather than failing the entire encounter load).
**Rationale**: Follows the existing defensive validation pattern in `encounter-storage.ts`. Graceful degradation per-combatant is better than losing the entire encounter.
**Alternatives considered**:
- Reject entire encounter on HP validation failure → too aggressive; existing combatant data should survive.