Prepares for 002-add-combatant by treating an empty combatant list as a valid aggregate state (activeIndex must be 0). AdvanceTurn behavior on non-empty encounters is unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
6.5 KiB
Markdown
173 lines
6.5 KiB
Markdown
# Feature Specification: Advance Turn
|
|
|
|
**Feature Branch**: `001-advance-turn`
|
|
**Created**: 2026-03-03
|
|
**Status**: Draft
|
|
**Input**: Walking-skeleton domain feature — deterministic turn advancement
|
|
|
|
## User Scenarios & Testing *(mandatory)*
|
|
|
|
### User Story 1 - Advance Turn (Priority: P1)
|
|
|
|
A game master running an encounter advances the turn to the next
|
|
combatant in initiative order. When the last combatant in the round
|
|
finishes, the round number increments and play wraps to the first
|
|
combatant.
|
|
|
|
**Why this priority**: This is the irreducible core of an initiative
|
|
tracker. Without turn advancement, no other feature has meaning.
|
|
|
|
**Independent Test**: Can be fully tested as a pure state transition
|
|
with no I/O, persistence, or UI. Given an Encounter value and an
|
|
AdvanceTurn action, assert the resulting Encounter value and emitted
|
|
domain events.
|
|
|
|
**Acceptance Scenarios**:
|
|
|
|
1. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn,
|
|
**Then** activeIndex is 1, roundNumber is 1,
|
|
and a TurnAdvanced event is emitted with
|
|
previousCombatantId A, newCombatantId B, roundNumber 1.
|
|
|
|
2. **Given** an encounter with combatants [A, B, C], activeIndex 1,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn,
|
|
**Then** activeIndex is 2, roundNumber is 1,
|
|
and a TurnAdvanced event is emitted with
|
|
previousCombatantId B, newCombatantId C, roundNumber 1.
|
|
|
|
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn,
|
|
**Then** activeIndex is 0, roundNumber is 2,
|
|
and events are emitted in order: TurnAdvanced
|
|
(previousCombatantId C, newCombatantId A, roundNumber 2) then
|
|
RoundAdvanced (newRoundNumber 2).
|
|
|
|
4. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
|
roundNumber 5,
|
|
**When** AdvanceTurn,
|
|
**Then** activeIndex is 0, roundNumber is 6,
|
|
and events are emitted in order: TurnAdvanced then RoundAdvanced
|
|
(verifies round increment is not hardcoded to 2).
|
|
|
|
5. **Given** an encounter with a single combatant [A], activeIndex 0,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn,
|
|
**Then** activeIndex is 0, roundNumber is 2,
|
|
and events are emitted in order: TurnAdvanced
|
|
(previousCombatantId A, newCombatantId A, roundNumber 2) then
|
|
RoundAdvanced (newRoundNumber 2).
|
|
|
|
6. **Given** an encounter with combatants [A, B], activeIndex 0,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn is applied twice in sequence,
|
|
**Then** after the first: activeIndex 1, roundNumber 1;
|
|
after the second: activeIndex 0, roundNumber 2.
|
|
|
|
7. **Given** an encounter with an empty combatant list,
|
|
**When** AdvanceTurn,
|
|
**Then** the operation MUST fail with an invalid-encounter error.
|
|
No events are emitted. State is unchanged.
|
|
|
|
8. **Given** an encounter with combatants [A, B, C], activeIndex 0,
|
|
roundNumber 1,
|
|
**When** AdvanceTurn is applied three times,
|
|
**Then** the encounter completes a full round cycle:
|
|
activeIndex returns to 0 and roundNumber is 2.
|
|
|
|
---
|
|
|
|
### Edge Cases
|
|
|
|
- Empty combatant list: valid aggregate state, but AdvanceTurn MUST
|
|
return a DomainError (no state change, no events).
|
|
- Single combatant: every advance wraps and increments the round.
|
|
- Large round numbers: no overflow or special-case behavior; round
|
|
increments uniformly.
|
|
|
|
## Clarifications
|
|
|
|
### Session 2026-03-03
|
|
|
|
- Q: Should an encounter with zero combatants be a valid aggregate state? → A: Yes. Empty encounter is valid; AdvanceTurn returns DomainError.
|
|
- Q: What is activeIndex when combatants list is empty? → A: activeIndex MUST be 0.
|
|
- Q: Does this change any non-empty encounter behavior? → A: No. All existing acceptance scenarios and event contracts remain unchanged.
|
|
|
|
## Domain Model *(mandatory)*
|
|
|
|
### Key Entities
|
|
|
|
- **Combatant**: An identified participant in the encounter. For this
|
|
feature, a combatant is an opaque identity (e.g., a name or id).
|
|
The MVP baseline does not include HP, conditions, or stats.
|
|
- **Encounter**: The aggregate root. Contains an ordered list of
|
|
combatants (pre-sorted by initiative), an activeIndex pointing to
|
|
the current combatant, and a roundNumber (positive integer,
|
|
starting at 1).
|
|
|
|
### Domain Events
|
|
|
|
- **TurnAdvanced**: Emitted on every successful AdvanceTurn.
|
|
Carries: previousCombatantId, newCombatantId, roundNumber.
|
|
- **RoundAdvanced**: Emitted when activeIndex wraps past the last
|
|
combatant. Carries: newRoundNumber.
|
|
|
|
When a round boundary is crossed, both TurnAdvanced and
|
|
RoundAdvanced MUST be emitted in that order (TurnAdvanced first).
|
|
This emission order is part of the observable domain contract and
|
|
MUST be verified by tests.
|
|
|
|
### Invariants
|
|
|
|
- **INV-1**: An encounter MAY have zero combatants (an empty
|
|
encounter is a valid aggregate state). AdvanceTurn on an empty
|
|
encounter MUST return a DomainError with no state change and no
|
|
events.
|
|
- **INV-2**: If combatants.length > 0, activeIndex MUST satisfy
|
|
0 <= activeIndex < combatants.length. If combatants.length == 0,
|
|
activeIndex MUST be 0.
|
|
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
|
|
only increase (never decrease or reset).
|
|
- **INV-4**: AdvanceTurn MUST be a pure function of the current
|
|
encounter state. Given identical input, output MUST be identical.
|
|
- **INV-5**: Every successful AdvanceTurn MUST emit at least one
|
|
domain event (TurnAdvanced). No silent state changes.
|
|
|
|
## Requirements *(mandatory)*
|
|
|
|
### Functional Requirements
|
|
|
|
- **FR-001**: The domain MUST expose an AdvanceTurn operation that
|
|
accepts an Encounter and returns the next Encounter state plus
|
|
emitted domain events.
|
|
- **FR-002**: AdvanceTurn MUST increment activeIndex by 1, wrapping
|
|
to 0 when past the last combatant.
|
|
- **FR-003**: When activeIndex wraps to 0, roundNumber MUST
|
|
increment by 1.
|
|
- **FR-004**: AdvanceTurn on an empty encounter MUST return an error
|
|
without modifying state or emitting events.
|
|
- **FR-005**: Domain events MUST be returned as values from the
|
|
operation, not dispatched via side effects.
|
|
|
|
### Out of Scope (MVP baseline does not include)
|
|
|
|
- Initiative rolling or combatant ordering logic
|
|
- Hit points, damage, conditions, or status effects
|
|
- Adding or removing combatants mid-encounter
|
|
- Persistence, serialization, or storage
|
|
- UI, CLI, or any adapter layer
|
|
- Agent behavior or suggestions
|
|
|
|
## Success Criteria *(mandatory)*
|
|
|
|
### Measurable Outcomes
|
|
|
|
- **SC-001**: All 8 acceptance scenarios pass as deterministic,
|
|
pure-function tests with no I/O dependencies.
|
|
- **SC-002**: Invariants INV-1 through INV-5 are verified by tests.
|
|
- **SC-003**: The domain module has zero imports from application,
|
|
adapter, or agent layers (layer boundary compliance).
|