Replace 250ms click timer and double-click detection with immediate single-click rename for all combatant types. Add a BookOpen icon before the name on bestiary rows as the dedicated stat block trigger. Remove auto-show stat block on turn advance. Update specs to match: consistent collapse/expand terminology, book icon requirements, no row-click stat block behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
27 KiB
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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
-
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.
-
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.
-
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.
-
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.
-
Given an encounter with combatants [A] and activeIndex 0, When the GM removes combatant A, Then the encounter has [], activeIndex is 0, roundNumber unchanged.
-
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:
-
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.
-
Given the remove button is in confirm state, When the user clicks it again, Then the combatant is removed from the encounter.
-
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.
-
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.
-
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.
-
Given a destructive button has keyboard focus, When the user presses Enter or Space, Then the button enters confirm state.
-
Given a destructive button is in confirm state with focus, When the user presses Enter or Space, Then the destructive action executes.
-
Given a destructive button is in confirm state with focus, When the user presses Escape, Then the button reverts to its original state.
-
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:
-
Given an encounter with combatants [Alice, Bob], When the user updates Bob's name to "Robert", Then the encounter contains [Alice, Robert] and a
CombatantUpdatedevent is emitted with the combatant's id, old name, and new name. -
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:
-
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.
-
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.
-
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. Clicking the combatant's name immediately enters inline edit mode — no delay, no timer, consistent for all combatant types. A cursor-text cursor on hover signals that the name is editable. Stat block access is handled separately via a dedicated book icon (see specs/004-bestiary/spec.md, FR-062).
Acceptance Scenarios:
-
Given a combatant row is visible, When the user clicks the combatant name, Then inline edit mode is entered immediately for that combatant's name — no delay or timer.
-
Given a combatant row is visible on a pointer device, When the user hovers over the combatant name, Then the cursor changes to a text cursor (
cursor-text) to signal editability. -
Given inline edit mode has been entered, 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.
-
Given a bestiary combatant row and a custom combatant row, When the user clicks either combatant's name, Then the behavior is identical — inline edit mode is entered immediately in both cases.
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:
-
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.
-
Given an encounter with a single combatant, When the user activates the clear encounter action and confirms, Then the encounter is fully cleared.
-
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:
-
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.
-
Given the trash button is in confirm state, When the user clicks it again, Then the entire encounter is cleared.
-
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.
-
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:
-
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.
-
Given an encounter that has been modified (combatant added, removed, or renamed), When the user reloads the page, Then the latest state is reflected.
-
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:
-
Given no saved encounter exists in the browser, When the user opens the application, Then the default demo encounter is displayed.
-
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:
-
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.
-
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-emptyname, and optionalinitiative,maxHp,currentHp,ac,conditions,isConcentrating, andcreatureIdfields. - Encounter: The aggregate root. Contains an ordered
readonlylist of combatants, anactiveIndex(zero-based integer), and aroundNumber(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,activeIndexMUST satisfy0 <= activeIndex < combatants.length. Ifcombatants.length == 0,activeIndexMUST be 0. - INV-3:
roundNumberMUST be a positive integer (>= 1) and MUST only increase during normal turn advancement. Clearing resets it to 1. - INV-4:
CombatantIdvalues 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:
activeIndexunchanged. - Removed combatant is before the active one:
activeIndexdecrements by 1. - Removed combatant is the active one and is not last:
activeIndexstays at the same integer value (the next combatant in line becomes active). - Removed combatant is the active one and is last:
activeIndexwraps to 0. - Last remaining combatant is removed (encounter becomes empty):
activeIndexis 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 a single click on the name. Clicking the name MUST enter inline edit mode immediately — no delay, no timer, consistent for all combatant types. The name MUST display a cursor-text cursor on hover to signal editability. The updated name MUST be immediately visible after submission. The 250ms click timer and double-click detection logic MUST be removed entirely.
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;
activeIndexremains 0. - Adding during mid-round:
activeIndexis never shifted by an add operation. - Duplicate combatant names: Permitted. Combatants are distinguished by
CombatantId. - Removing the last combatant: Encounter becomes empty;
activeIndexis 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;
CombatantUpdatedevent 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 click behavior is uniform: A single click on any combatant's name enters inline edit mode immediately. There is no gesture disambiguation (no timer, no double-click detection). Stat block access is handled via the dedicated book icon on bestiary rows (see
specs/004-bestiary/spec.md, FR-062).
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, androundNumberunchanged. - SC-003: All six remove-combatant acceptance scenarios pass as automated tests covering every
activeIndexadjustment 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
ConfirmButtonconfirm 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
CombatantIdgeneration 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,
roundNumber1,activeIndex0). 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
localStoragekey is sufficient for the MVP (one encounter at a time). - Cross-tab synchronization is not required for the MVP baseline.
- The
ConfirmButton5-second timeout is a fixed value and is not configurable in the MVP baseline. - The
Checkicon from the Lucide icon library is used for theConfirmButtonconfirm state. - The inline name-edit mechanism is activated by a single click on the name. A
cursor-textcursor on hover signals editability. There is no double-click or long-press gesture; stat block access uses a dedicated book icon on bestiary rows.