# Feature Specification: Combatant Management **Feature Branch**: `001-combatant-management` **Created**: 2026-03-03 **Status**: Implemented ## Overview Combatant Management covers the complete lifecycle of combatants within an encounter: adding (individually or in batch), editing, removing, clearing the entire encounter, persisting encounter state across page reloads, and the confirmation UX applied to all destructive actions. --- ## User Scenarios & Testing *(mandatory)* ### Adding Combatants **Story A1 — Add a single combatant (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, allowing late-joining participants or newly discovered enemies to enter combat. **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, 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. --- > **Batch add and custom creature workflows** are defined in `specs/004-bestiary/spec.md` (Stories US-S2, US-S3). Those stories cover the bestiary search dropdown, count badge, batch confirm, and custom creature stat fields. This spec covers only the domain-level AddCombatant operation that those workflows invoke. --- ### Removing Combatants **Story B1 — Remove a combatant from an active encounter (Priority: P1)** A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant. The combatant disappears from the initiative order and the turn continues correctly without disruption. **Acceptance Scenarios**: 1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted. 2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged. 3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active), roundNumber unchanged. 4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged. 5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged. 6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with code `"combatant-not-found"`, and the encounter is unchanged. --- **Story B2 — Inline confirmation before removing (Priority: P1)** A user clicking the remove (X) button on a combatant row is protected from accidental deletion by a two-step inline confirmation flow. **Acceptance Scenarios**: 1. **Given** a combatant row is visible, **When** the user clicks the remove (X) button once, **Then** the button transitions to a confirm state showing a checkmark icon on a red/danger background with a scale pulse animation. 2. **Given** the remove button is in confirm state, **When** the user clicks it again, **Then** the combatant is removed from the encounter. 3. **Given** the remove button is in confirm state, **When** 5 seconds elapse without a second click, **Then** the button reverts to its original X icon and default styling. 4. **Given** the remove button is in confirm state, **When** the user clicks outside the button, **Then** the button reverts to its original state without removing the combatant. 5. **Given** the remove button is in confirm state, **When** the user presses Escape, **Then** the button reverts to its original state without removing the combatant. 6. **Given** a destructive button has keyboard focus, **When** the user presses Enter or Space, **Then** the button enters confirm state. 7. **Given** a destructive button is in confirm state with focus, **When** the user presses Enter or Space, **Then** the destructive action executes. 8. **Given** a destructive button is in confirm state with focus, **When** the user presses Escape, **Then** the button reverts to its original state. 9. **Given** a destructive button is in confirm state, **When** the button loses focus (e.g., Tab away), **Then** the button reverts to its original state. --- ### Editing Combatants **Story C1 — Rename a combatant (Priority: P1)** A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state. **Acceptance Scenarios**: 1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name. 2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved. --- **Story C2 — Error feedback on invalid edit (Priority: P2)** A user attempts to edit a combatant that no longer exists or provides an invalid name. The system returns a clear error without modifying the encounter. **Acceptance Scenarios**: 1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged. 2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged. 3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged. --- **Story C3 — Rename trigger UX (Priority: P1)** A user wants to rename a combatant. Single-clicking the name opens the stat block panel instead of entering edit mode. To rename, the user double-clicks the name, clicks a hover-revealed pencil icon, or long-presses on touch devices. **Acceptance Scenarios**: 1. **Given** a combatant row is visible, **When** the user single-clicks the combatant name, **Then** the stat block panel opens or toggles — inline edit mode is NOT entered. 2. **Given** a combatant row is visible, **When** the user double-clicks the combatant name, **Then** inline edit mode is entered for that combatant's name. 3. **Given** a combatant row is visible on a pointer device, **When** the user hovers over the combatant name, **Then** a pencil icon appears next to the name. 4. **Given** the pencil icon is visible, **When** the user clicks it, **Then** inline edit mode is entered for that combatant's name. 5. **Given** a combatant row is visible on a touch device, **When** the user long-presses the combatant name, **Then** inline edit mode is entered for that combatant's name. 6. **Given** a combatant row is visible on a touch device, **When** the user views the combatant name without hovering, **Then** no pencil icon is permanently visible. 7. **Given** inline edit mode has been entered (via any trigger), **When** the user types a new name and presses Enter or blurs the field, **Then** the name is committed. **When** the user presses Escape, **Then** the edit is cancelled and the original name is restored. --- ### Clearing the Encounter **Story D1 — Clear encounter to start fresh (Priority: P1)** As a DM who has just finished a combat encounter, I want to clear the entire encounter with a single confirmed action so that I can quickly set up a new combat without manually removing each combatant one by one. **Acceptance Scenarios**: 1. **Given** an encounter with multiple combatants at round 3, **When** the user activates the clear encounter action and confirms, **Then** all combatants are removed, the round number resets to 1, and the active turn index resets to 0. 2. **Given** an encounter with a single combatant, **When** the user activates the clear encounter action and confirms, **Then** the encounter is fully cleared. 3. **Given** an encounter has no combatants, **When** the user views the clear button, **Then** it is disabled and cannot be activated. --- **Story D2 — Inline confirmation before clearing (Priority: P1)** A user clicks the trash button to clear the entire encounter. Instead of a browser confirm dialog, the trash button itself transitions into a red confirm state with a checkmark icon and a scale pulse. A second click clears the encounter; otherwise the button reverts after 5 seconds or on dismiss. **Acceptance Scenarios**: 1. **Given** an encounter has combatants, **When** the user clicks the clear encounter (trash) button once, **Then** the button transitions to a confirm state with a checkmark icon on a red/danger background with a scale pulse animation. 2. **Given** the trash button is in confirm state, **When** the user clicks it again, **Then** the entire encounter is cleared. 3. **Given** the trash button is in confirm state, **When** 5 seconds pass, the user clicks outside, or the user presses Escape, **Then** the button reverts to its original trash icon and default styling without clearing the encounter. 4. **Given** a confirmation prompt is displayed, **When** the user cancels, **Then** the encounter remains unchanged. --- ### Persistence **Story E1 — Encounter survives page reload (Priority: P1)** A user is managing a combat encounter. They accidentally refresh the page or their browser restarts. When the page reloads, the encounter is restored exactly as it was — same combatants, same active turn, same round number. **Acceptance Scenarios**: 1. **Given** an encounter with combatants, active turn, and round number, **When** the user reloads the page, **Then** the encounter is restored with all state intact. 2. **Given** an encounter that has been modified (combatant added, removed, or renamed), **When** the user reloads the page, **Then** the latest state is reflected. 3. **Given** the user advances the turn multiple times, **When** the user reloads the page, **Then** the active turn and round number are preserved. --- **Story E2 — Fresh start with no saved data (Priority: P2)** A first-time user opens the application with no previously saved encounter. The application shows the default demo encounter so the user can immediately start exploring. **Acceptance Scenarios**: 1. **Given** no saved encounter exists in the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. 2. **Given** saved encounter data has been manually cleared from the browser, **When** the user opens the application, **Then** the default demo encounter is displayed. --- **Story E3 — Graceful handling of corrupt data (Priority: P3)** Saved data may become invalid (e.g., manually edited in dev tools, schema changes between versions). The application handles this gracefully rather than crashing. **Acceptance Scenarios**: 1. **Given** the saved encounter data is malformed or unparseable, **When** the user opens the application, **Then** the default demo encounter is displayed and the corrupt data is discarded. 2. **Given** the saved data is missing required fields, **When** the user opens the application, **Then** the default demo encounter is displayed. --- ## Domain Model ### Key Entities - **Combatant**: An identified participant with a unique `CombatantId` (branded string), a required non-empty `name`, and optional `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating`, and `creatureId` fields. - **Encounter**: The aggregate root. Contains an ordered `readonly` list of combatants, an `activeIndex` (zero-based integer), and a `roundNumber` (positive integer, starting at 1). > Queued Creature and Custom Creature Input entities are defined in `specs/004-bestiary/spec.md`. ### Domain Events - **CombatantAdded**: Emitted on every successful AddCombatant. Carries: `combatantId`, `name`, `position` (zero-based index). - **CombatantRemoved**: Emitted on every successful RemoveCombatant. Carries: `combatantId`, `name`. - **CombatantUpdated**: Emitted on every successful EditCombatant. Carries: `combatantId`, `oldName`, `newName`. ### Invariants - **INV-1**: An encounter MAY have zero combatants (after clearing or removing the last combatant). - **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 during normal turn advancement. Clearing resets it to 1. - **INV-4**: `CombatantId` values MUST be unique within an encounter. - **INV-5**: All domain state transitions (add, remove, edit, clear) are pure functions; no I/O, randomness, or clocks. - **INV-6**: Every successful state transition emits exactly one corresponding domain event. No silent state changes. - **INV-7**: AddCombatant and RemoveCombatant MUST NOT change the `roundNumber`. - **INV-8**: EditCombatant MUST NOT change `activeIndex`, `roundNumber`, or the combatant's position in the list. --- ## Requirements *(mandatory)* ### Functional Requirements #### FR-001 — Add: Append combatant The domain MUST expose an AddCombatant operation that accepts an Encounter and a combatant name (plus a pre-generated `CombatantId`), and returns the updated Encounter plus emitted domain events. The new combatant MUST be appended to the end of the combatants list. #### FR-002 — Add: Reject invalid names AddCombatant MUST reject empty or whitespace-only names by returning a `DomainError` without modifying state or emitting events. Name validation trims whitespace; a name that is empty after trimming is invalid. #### FR-003 — Add: Preserve activeIndex and roundNumber AddCombatant MUST NOT alter the `activeIndex` or `roundNumber` of the encounter. #### FR-004 — Add: Unique CombatantId AddCombatant MUST assign a unique `CombatantId` to the new combatant. Id generation is the caller's responsibility (application layer), keeping the domain function pure. #### FR-005 — Add: Duplicate names allowed Duplicate combatant names are permitted. Combatants are distinguished solely by their unique `CombatantId`. #### FR-006 — Add: UI form The UI MUST provide an add-combatant form accessible from the bottom bar. The search field MUST display action-oriented placeholder text (e.g., "Search creatures to add..."). > FR-007 through FR-013 (batch add and custom creature) are defined in `specs/004-bestiary/spec.md` (FR-007–FR-015). #### FR-014 — Remove: Domain operation The domain MUST expose a RemoveCombatant operation that accepts an Encounter and a `CombatantId`, and returns the updated Encounter plus emitted domain events. #### FR-015 — Remove: Error on unknown ID RemoveCombatant MUST return a domain error with code `"combatant-not-found"` when the given `CombatantId` does not match any combatant in the encounter. #### FR-016 — Remove: activeIndex adjustment RemoveCombatant MUST adjust `activeIndex` according to these rules: - Removed combatant is **after** the active one: `activeIndex` unchanged. - Removed combatant is **before** the active one: `activeIndex` decrements by 1. - Removed combatant **is** the active one and is not last: `activeIndex` stays at the same integer value (the next combatant in line becomes active). - Removed combatant **is** the active one and **is last**: `activeIndex` wraps to 0. - Last remaining combatant is removed (encounter becomes empty): `activeIndex` is set to 0. #### FR-017 — Remove: roundNumber preserved RemoveCombatant MUST preserve `roundNumber` unchanged. #### FR-018 — Remove: UI control The UI MUST provide a remove control for each combatant row. #### FR-019 — Remove: ConfirmButton The remove control MUST use the `ConfirmButton` two-step confirmation pattern (see FR-025 through FR-030). Silent no-op on domain error (combatant already gone). #### FR-020 — Edit: Domain operation The domain MUST expose an EditCombatant operation that accepts an Encounter, a `CombatantId`, and a new name, and returns the updated Encounter plus emitted domain events. #### FR-021 — Edit: Error on unknown ID EditCombatant MUST return a `"combatant-not-found"` error when the provided id does not match any combatant. #### FR-022 — Edit: Reject invalid names EditCombatant MUST return an `"invalid-name"` error when the new name is empty or whitespace-only. The same trimming rules as AddCombatant apply. #### FR-023 — Edit: Preserve position and counters EditCombatant MUST preserve the combatant's position in the list, `activeIndex`, and `roundNumber`. Setting a name to the same value it already has is treated as a valid update; a `CombatantUpdated` event is still emitted. #### FR-024 — Edit: UI The UI MUST provide an inline name-edit mechanism for each combatant, activated by double-clicking the name, clicking a hover-revealed pencil icon, or long-pressing on touch devices. Single-clicking the name MUST open/toggle the stat block panel, not enter edit mode. The updated name MUST be immediately visible after submission. #### FR-025 — ConfirmButton: Reusable component The system MUST provide a reusable `ConfirmButton` component that wraps any icon button to add a two-step confirmation flow. #### FR-026 — ConfirmButton: Confirm state on first activation On first activation (click, Enter, or Space), the button MUST transition to a confirm state displaying a checkmark icon on a red/danger background with a scale pulse animation. #### FR-027 — ConfirmButton: Auto-revert after 5 seconds The button MUST automatically revert to its original state after 5 seconds if not confirmed. #### FR-028 — ConfirmButton: Cancel on outside click, Escape, or focus loss Clicking outside the button, pressing Escape, or moving focus away MUST cancel the confirm state and revert the button. #### FR-029 — ConfirmButton: Execute on second activation A second activation (click, Enter, or Space) while in confirm state MUST execute the destructive action. #### FR-030 — ConfirmButton: Independent state per instance Each `ConfirmButton` instance MUST manage its confirm state independently of other instances. #### FR-031 — Clear: Domain operation The domain MUST expose a ClearEncounter operation that removes all combatants, resets `roundNumber` to 1, and resets `activeIndex` to 0. #### FR-032 — Clear: UI button with ConfirmButton The UI MUST provide a clear encounter button that uses the `ConfirmButton` pattern. The button MUST be disabled when the encounter has no combatants. #### FR-033 — Clear: Cancellation leaves state unchanged Cancelling the confirmation (via timeout, outside click, Escape, or focus loss) MUST leave the encounter completely unchanged. #### FR-034 — Clear: Cleared state persisted After clearing, the empty encounter state MUST be persisted so that a page refresh does not restore the previous encounter. #### FR-035 — Persistence: Save on every change The system MUST save the full encounter state (combatants, `activeIndex`, `roundNumber`) to browser `localStorage` after every state change. #### FR-036 — Persistence: Restore on load The system MUST restore the saved encounter state when the application loads, if valid saved data exists. #### FR-037 — Persistence: Fallback to demo encounter The system MUST fall back to the default demo encounter when no saved data exists or saved data is invalid/corrupt. #### FR-038 — Persistence: No crash on storage failure The system MUST NOT crash or show an error to the user when storage is unavailable or data is corrupt. When storage is unavailable, the application falls back to in-memory-only behavior. #### FR-039 — Persistence: Preserve combatant identity across reloads The system MUST preserve combatant `CombatantId` values, names, and any other persisted fields across reloads, so that new combatants added after a reload do not collide with existing IDs. #### FR-040 — Domain events as values All domain events MUST be returned as plain data values from operations, not dispatched via side effects. --- ## Edge Cases - **Empty name**: AddCombatant and EditCombatant return a `DomainError`; state and events are unchanged. - **Whitespace-only name**: Treated identically to empty name after trimming. - **Adding to an empty encounter**: The new combatant becomes the first and only participant; `activeIndex` remains 0. - **Adding during mid-round**: `activeIndex` is never shifted by an add operation. - **Duplicate combatant names**: Permitted. Combatants are distinguished by `CombatantId`. - **Removing the last combatant**: Encounter becomes empty; `activeIndex` is set to 0. - **Removing with unknown ID**: Returns `"combatant-not-found"` error; state unchanged. Removing the same ID twice: second call returns an error. - **Removing from empty encounter**: Covered by the unknown-ID error (no IDs exist). - **Editing a combatant to the same name**: Valid; `CombatantUpdated` event is still emitted. - **Editing a combatant in an empty encounter**: Returns `"combatant-not-found"` error. - **Clearing an already empty encounter**: The clear button is disabled; no operation is executed. - **Clearing and reloading**: The empty (cleared) state is persisted; the previous encounter is not restored. - **Storage quota exceeded**: Persistence silently fails; current in-memory session continues normally. - **Multiple browser tabs**: MVP baseline does not include cross-tab synchronization. Each tab operates independently; the last tab to save wins. > Batch add and custom creature edge cases are defined in `specs/004-bestiary/spec.md`. - **ConfirmButton: rapid triple-click**: First click enters confirm state; second executes the action; subsequent clicks are no-ops. - **ConfirmButton: component unmounts in confirm state**: The auto-revert timer MUST be cleaned up to prevent memory leaks or stale state updates. - **ConfirmButton: two instances in confirm state simultaneously**: Each manages its own state independently. - **ConfirmButton: combatant row re-renders while in confirm state**: Confirm state persists through re-renders as long as combatant identity is stable. - **Name single-click vs double-click**: A single click on the combatant name opens the stat block panel; only a completed double-click enters inline edit mode. The system must disambiguate between the two gestures. - **Pencil icon on touch devices**: The hover pencil icon MUST NOT be permanently visible on touch devices. Long-press is the touch equivalent for entering edit mode. - **Long-press threshold**: The long-press duration should follow platform conventions (typically ~500ms). A short tap must not trigger edit mode. --- ## Success Criteria *(mandatory)* - **SC-001**: All add-combatant acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies. - **SC-002**: Adding a combatant to an encounter preserves all existing combatants, their order, `activeIndex`, and `roundNumber` unchanged. - **SC-003**: All six remove-combatant acceptance scenarios pass as automated tests covering every `activeIndex` adjustment rule. - **SC-004**: The round number never changes as a result of a remove operation. - **SC-005**: Users can rename any combatant in the encounter in a single action without disrupting turn order, active combatant, or round number. - **SC-006**: Invalid edit attempts (missing combatant, empty or whitespace-only name) produce a domain error with no state change and no emitted events. - **SC-007**: All destructive actions (remove combatant, clear encounter) require exactly two deliberate user interactions to execute, eliminating single-click accidental mutations. - **SC-008**: The `ConfirmButton` confirm state auto-reverts reliably after 5 seconds. All confirmation flows are fully operable via keyboard alone. > SC-009 and SC-010 (batch add and custom creature success criteria) are defined in `specs/004-bestiary/spec.md`. - **SC-011**: Users can reload the page and see their encounter fully restored, with zero data loss. - **SC-012**: First-time users see the demo encounter immediately on first visit with no extra steps. - **SC-013**: 100% of corrupt or missing data scenarios result in a usable application (demo encounter displayed), never a crash or blank screen. - **SC-014**: After clearing, the encounter tracker displays an empty state with round and turn counters at their initial values, and this state persists across page refreshes. - **SC-015**: The domain module has zero imports from the application, adapter, or UI layers (layer boundary compliance verified by automated check). --- ## Assumptions - `CombatantId` generation is the caller's responsibility (application layer), keeping domain functions pure and deterministic. - Name validation trims whitespace; a name that is empty after trimming is invalid. - No uniqueness constraint on combatant names — multiple combatants may share the same name. - Clearing results in an empty encounter state (no combatants, `roundNumber` 1, `activeIndex` 0). The user will then add new combatants using the existing add-combatant flow. - MVP baseline does not include undo/restore functionality after clearing or removing. Once confirmed, the action is final. - MVP baseline does not include encounter history or the ability to save/archive encounters before clearing. - A single `localStorage` key is sufficient for the MVP (one encounter at a time). - Cross-tab synchronization is not required for the MVP baseline. - The `ConfirmButton` 5-second timeout is a fixed value and is not configurable in the MVP baseline. - The `Check` icon from the Lucide icon library is used for the `ConfirmButton` confirm state. - The inline name-edit mechanism is activated by double-click, hover pencil icon, or long-press (touch). Single-clicking the name opens the stat block panel.