# Data Model: Combatant HP Tracking **Feature**: 009-combatant-hp | **Date**: 2026-03-05 ## Entities ### Combatant (extended) | Field | Type | Required | Constraints | |-------|------|----------|-------------| | id | CombatantId (branded string) | Yes | Unique within encounter | | name | string | Yes | Non-empty | | initiative | number or undefined | No | Integer when set | | **maxHp** | **number or undefined** | **No** | **Positive integer (>= 1) when set** | | **currentHp** | **number or undefined** | **No** | **Integer in [0, maxHp] when set; requires maxHp** | **Invariants**: - If `maxHp` is undefined, `currentHp` must also be undefined (no current HP without a max). - If `maxHp` is defined, `currentHp` must be defined and satisfy `0 <= currentHp <= maxHp`. - When `maxHp` is first set, `currentHp` defaults to `maxHp` (full health). - When `maxHp` changes and `currentHp === maxHp` (full health), `currentHp` stays synced to the new `maxHp`. - When `maxHp` changes and `currentHp < maxHp` (not full health), `currentHp` is unchanged unless it exceeds the new `maxHp`, in which case it is clamped. - When `maxHp` is cleared (set to undefined), `currentHp` is also cleared. ### Encounter (unchanged structure) No structural changes. The encounter continues to hold `readonly combatants: readonly Combatant[]`, `activeIndex`, and `roundNumber`. HP state lives on individual combatants. ## Domain Events (new) ### MaxHpSet Emitted when a combatant's max HP is set, changed, or cleared. | Field | Type | Description | |-------|------|-------------| | type | "MaxHpSet" | Event discriminant | | combatantId | CombatantId | Target combatant | | previousMaxHp | number or undefined | Max HP before change | | newMaxHp | number or undefined | Max HP after change | | previousCurrentHp | number or undefined | Current HP before change | | newCurrentHp | number or undefined | Current HP after change (may differ due to clamping) | ### CurrentHpAdjusted Emitted when a combatant's current HP is adjusted via +/- or direct entry. | Field | Type | Description | |-------|------|-------------| | type | "CurrentHpAdjusted" | Event discriminant | | combatantId | CombatantId | Target combatant | | previousHp | number | Current HP before adjustment | | newHp | number | Current HP after adjustment (clamped) | | delta | number | Requested change amount (positive = heal, negative = damage) | ## State Transitions ### setHp(encounter, combatantId, maxHp) ``` Input: Encounter, CombatantId, number | undefined Output: { encounter: Encounter, events: [MaxHpSet] } | DomainError - combatant not found → DomainError("combatant-not-found") - maxHp <= 0 or non-integer → DomainError("invalid-max-hp") - maxHp = undefined → clear both maxHp and currentHp - maxHp = N (new) → set maxHp=N, currentHp=N - maxHp = N (changed, at full health: currentHp=prevMaxHp) → set maxHp=N, currentHp=N - maxHp = N (changed, not full health) → set maxHp=N, currentHp=min(currentHp, N) ``` ### adjustHp(encounter, combatantId, delta) ``` Input: Encounter, CombatantId, number Output: { encounter: Encounter, events: [CurrentHpAdjusted] } | DomainError - combatant not found → DomainError("combatant-not-found") - combatant has no maxHp set → DomainError("no-hp-tracking") - delta = 0 → DomainError("zero-delta") - delta non-integer → DomainError("invalid-delta") - result → currentHp = clamp(currentHp + delta, 0, maxHp) ```