Files
initiative/specs/001-combatant-management/spec.md
Lukas 96a7b2d00e Remove pencil icon, use cursor-text to signal editable name
The hover-revealed pencil icon caused layout shift on rows with
conditions. Modern UIs (Figma, Notion, Linear) rely on double-click
without a visible edit icon. Replace with cursor-text on hover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:32:56 +01:00

27 KiB
Raw Blame History

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 or long-presses on touch devices. A cursor-text cursor on hover signals that the name is editable.

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 the cursor changes to a text cursor (cursor-text) to signal editability.

  4. 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.

  5. 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-007FR-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 or long-pressing on touch devices. The name MUST display a cursor-text cursor on hover to signal editability. 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.
  • Touch edit affordance: No hover-dependent affordance is shown 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 or long-press (touch). A cursor-text cursor on hover signals editability. Single-clicking the name opens the stat block panel.