# Data Model: Turn Navigation ## Existing Entities (unchanged) ### Encounter - `combatants`: readonly array of Combatant - `activeIndex`: number (0-based index into combatants) - `roundNumber`: positive integer (>= 1) ### Combatant - `id`: CombatantId (branded string) - `name`: string - `initiative?`: number - `maxHp?`: number - `currentHp?`: number ## New Domain Events ### TurnRetreated Emitted on every successful RetreatTurn operation. | Field | Type | Description | |-------|------|-------------| | type | `"TurnRetreated"` (literal) | Discriminant for event union | | previousCombatantId | CombatantId | The combatant whose turn was active before retreat | | newCombatantId | CombatantId | The combatant who is now active after retreat | | roundNumber | number | The round number after the retreat | ### RoundRetreated Emitted when RetreatTurn crosses a round boundary (activeIndex wraps from 0 to last combatant). | Field | Type | Description | |-------|------|-------------| | type | `"RoundRetreated"` (literal) | Discriminant for event union | | newRoundNumber | number | The round number after decrementing | ## State Transitions ### RetreatTurn **Input**: Encounter **Output**: `{ encounter: Encounter, events: DomainEvent[] }` | `DomainError` **Rules**: 1. If `combatants.length === 0` -> DomainError("invalid-encounter") 2. If `roundNumber === 1 && activeIndex === 0` -> DomainError("no-previous-turn") 3. If `activeIndex > 0`: newIndex = activeIndex - 1, roundNumber unchanged 4. If `activeIndex === 0`: newIndex = combatants.length - 1, roundNumber - 1 **Events emitted**: - Always: TurnRetreated - On round boundary crossing (rule 4): TurnRetreated then RoundRetreated (order matters) ## Validation Rules - RetreatTurn MUST NOT produce roundNumber < 1 - RetreatTurn MUST NOT produce activeIndex < 0 or >= combatants.length - RetreatTurn is a pure function: identical input produces identical output