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