Files
initiative/specs/009-combatant-hp/data-model.md

84 lines
3.4 KiB
Markdown

# 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)
```