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

6.3 KiB

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.