162 lines
6.2 KiB
Markdown
162 lines
6.2 KiB
Markdown
# Feature Specification: Add Combatant
|
|
|
|
**Feature Branch**: `002-add-combatant`
|
|
**Created**: 2026-03-03
|
|
**Status**: Draft
|
|
**Input**: User description: "let us add a spec for the option to add a combatant to the encounter. a new combatant is added to the end of the list."
|
|
|
|
## User Scenarios & Testing *(mandatory)*
|
|
|
|
### User Story 1 - Add Combatant to Encounter (Priority: P1)
|
|
|
|
A game master adds a new combatant to an existing encounter. The new
|
|
combatant is appended to the end of the initiative order. This allows
|
|
late-joining participants or newly discovered enemies to enter combat.
|
|
|
|
**Why this priority**: Adding combatants is the foundational mutation
|
|
for populating an encounter. Without it, the encounter has no
|
|
participants and no other feature (turn advancement, removal) is useful.
|
|
|
|
**Independent Test**: Can be fully tested as a pure state transition
|
|
with no I/O, persistence, or UI. Given an Encounter value and an
|
|
AddCombatant action with a name, assert the resulting Encounter value
|
|
and emitted domain events.
|
|
|
|
**Acceptance Scenarios**:
|
|
|
|
1. **Given** an empty encounter (no combatants, activeIndex 0,
|
|
roundNumber 1),
|
|
**When** AddCombatant with name "Gandalf",
|
|
**Then** combatants is [Gandalf], activeIndex is 0,
|
|
roundNumber is 1,
|
|
and a CombatantAdded event is emitted with the new combatant's
|
|
id and name "Gandalf" and position 0.
|
|
|
|
2. **Given** an encounter with combatants [A, B], activeIndex 0,
|
|
roundNumber 1,
|
|
**When** AddCombatant with name "C",
|
|
**Then** combatants is [A, B, C], activeIndex is 0,
|
|
roundNumber is 1,
|
|
and a CombatantAdded event is emitted with position 2.
|
|
|
|
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
|
roundNumber 3,
|
|
**When** AddCombatant with name "D",
|
|
**Then** combatants is [A, B, C, D], activeIndex is 2,
|
|
roundNumber is 3,
|
|
and a CombatantAdded event is emitted with position 3.
|
|
The active combatant does not change.
|
|
|
|
4. **Given** an encounter with combatants [A],
|
|
**When** AddCombatant is applied twice with names "B" then "C",
|
|
**Then** combatants is [A, B, C] in that order.
|
|
Each operation emits its own CombatantAdded event.
|
|
|
|
5. **Given** an encounter with combatants [A, B],
|
|
**When** AddCombatant with an empty name "",
|
|
**Then** the operation MUST fail with a validation error.
|
|
No events are emitted. State is unchanged.
|
|
|
|
6. **Given** an encounter with combatants [A, B],
|
|
**When** AddCombatant with a whitespace-only name " ",
|
|
**Then** the operation MUST fail with a validation error.
|
|
No events are emitted. State is unchanged.
|
|
|
|
---
|
|
|
|
### Edge Cases
|
|
|
|
- Empty name or whitespace-only name: AddCombatant MUST return a
|
|
DomainError (no state change, no events).
|
|
- Adding to an empty encounter: the new combatant becomes the first
|
|
and only participant; activeIndex remains 0.
|
|
- Adding during mid-round: the activeIndex must not shift; the
|
|
currently active combatant stays active.
|
|
- Duplicate names: allowed. Combatants are distinguished by their
|
|
unique id, not by name.
|
|
|
|
## Domain Model *(mandatory)*
|
|
|
|
### Key Entities
|
|
|
|
- **Combatant**: An identified participant in the encounter with a
|
|
unique CombatantId (branded string) and a name (non-empty string).
|
|
- **Encounter**: The aggregate root. Contains an ordered list of
|
|
combatants, an activeIndex pointing to the current combatant, and
|
|
a roundNumber (positive integer, starting at 1).
|
|
|
|
### Domain Events
|
|
|
|
- **CombatantAdded**: Emitted on every successful AddCombatant.
|
|
Carries: combatantId, name, position (zero-based index where the
|
|
combatant was inserted).
|
|
|
|
### Invariants
|
|
|
|
- **INV-1** (preserved): An encounter MAY have zero combatants.
|
|
- **INV-2** (preserved): If combatants.length > 0, activeIndex MUST
|
|
satisfy 0 <= activeIndex < combatants.length. If
|
|
combatants.length == 0, activeIndex MUST be 0.
|
|
- **INV-3** (preserved): roundNumber MUST be a positive integer
|
|
(>= 1) and MUST only increase.
|
|
- **INV-4**: AddCombatant MUST be a pure function of the current
|
|
encounter state and the input name. Given identical input, output
|
|
MUST be identical (except for id generation — see Assumptions).
|
|
- **INV-5**: Every successful AddCombatant MUST emit exactly one
|
|
CombatantAdded event. No silent state changes.
|
|
- **INV-6**: AddCombatant MUST NOT change the activeIndex or
|
|
roundNumber of the encounter.
|
|
- **INV-7**: The new combatant MUST be appended to the end of the
|
|
combatants list (last position).
|
|
|
|
## Requirements *(mandatory)*
|
|
|
|
### Functional Requirements
|
|
|
|
- **FR-001**: The domain MUST expose an AddCombatant operation that
|
|
accepts an Encounter and a combatant name, and returns the updated
|
|
Encounter state plus emitted domain events.
|
|
- **FR-002**: AddCombatant MUST append the new combatant to the end
|
|
of the combatants list.
|
|
- **FR-003**: AddCombatant MUST assign a unique CombatantId to the
|
|
new combatant.
|
|
- **FR-004**: AddCombatant MUST reject empty or whitespace-only names
|
|
by returning a DomainError without modifying state or emitting
|
|
events.
|
|
- **FR-005**: AddCombatant MUST NOT alter the activeIndex or
|
|
roundNumber of the encounter.
|
|
- **FR-006**: Domain events MUST be returned as values from the
|
|
operation, not dispatched via side effects.
|
|
|
|
### Out of Scope (MVP baseline does not include)
|
|
|
|
- Removing combatants from an encounter
|
|
- Reordering combatants after adding
|
|
- Initiative score or automatic sorting
|
|
- Combatant attributes beyond name (HP, conditions, stats)
|
|
- Maximum combatant count limits
|
|
- Persistence, serialization, or storage
|
|
- UI or any adapter layer
|
|
|
|
## Assumptions
|
|
|
|
- CombatantId generation is the caller's responsibility (passed in or
|
|
generated by the application layer), keeping the domain function
|
|
pure and deterministic. The domain function will accept a
|
|
CombatantId as part of its input rather than generating one
|
|
internally.
|
|
- Name validation trims whitespace; a name that is empty after
|
|
trimming is invalid.
|
|
|
|
## Success Criteria *(mandatory)*
|
|
|
|
### Measurable Outcomes
|
|
|
|
- **SC-001**: All 6 acceptance scenarios pass as deterministic,
|
|
pure-function tests with no I/O dependencies.
|
|
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
|
|
- **SC-003**: The domain module has zero imports from application,
|
|
adapter, or agent layers (layer boundary compliance).
|
|
- **SC-004**: Adding a combatant to an encounter preserves all
|
|
existing combatants and their order unchanged.
|