Implement the 002-add-combatant feature that adds the possibility to add new combatants to an encounter

This commit is contained in:
Lukas
2026-03-03 23:11:07 +01:00
parent 187f98fc52
commit 0de68100c8
15 changed files with 914 additions and 16 deletions

View File

@@ -1,16 +1,38 @@
import { type FormEvent, useState } from "react";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
function formatEvent(e: ReturnType<typeof useEncounter>["events"][number]) {
switch (e.type) {
case "TurnAdvanced":
return `Turn: ${e.previousCombatantId}${e.newCombatantId} (round ${e.roundNumber})`;
case "RoundAdvanced":
return `Round advanced to ${e.newRoundNumber}`;
case "CombatantAdded":
return `Added combatant: ${e.name}`;
}
}
export function App() { export function App() {
const { encounter, events, advanceTurn } = useEncounter(); const { encounter, events, advanceTurn, addCombatant } = useEncounter();
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
const [nameInput, setNameInput] = useState("");
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (nameInput.trim() === "") return;
addCombatant(nameInput);
setNameInput("");
};
return ( return (
<div> <div>
<h1>Initiative Tracker</h1> <h1>Initiative Tracker</h1>
{activeCombatant && (
<p> <p>
Round {encounter.roundNumber} Current: {activeCombatant.name} Round {encounter.roundNumber} Current: {activeCombatant.name}
</p> </p>
)}
<ul> <ul>
{encounter.combatants.map((c, i) => ( {encounter.combatants.map((c, i) => (
@@ -20,6 +42,16 @@ export function App() {
))} ))}
</ul> </ul>
<form onSubmit={handleAdd}>
<input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Combatant name"
/>
<button type="submit">Add Combatant</button>
</form>
<button type="button" onClick={advanceTurn}> <button type="button" onClick={advanceTurn}>
Next Turn Next Turn
</button> </button>
@@ -29,11 +61,7 @@ export function App() {
<h2>Events</h2> <h2>Events</h2>
<ul> <ul>
{events.map((e, i) => ( {events.map((e, i) => (
<li key={`${e.type}-${i}`}> <li key={`${e.type}-${i}`}>{formatEvent(e)}</li>
{e.type === "TurnAdvanced"
? `Turn: ${e.previousCombatantId}${e.newCombatantId} (round ${e.roundNumber})`
: `Round advanced to ${e.newRoundNumber}`}
</li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -1,5 +1,8 @@
import type { EncounterStore } from "@initiative/application"; import type { EncounterStore } from "@initiative/application";
import { advanceTurnUseCase } from "@initiative/application"; import {
addCombatantUseCase,
advanceTurnUseCase,
} from "@initiative/application";
import type { DomainEvent, Encounter } from "@initiative/domain"; import type { DomainEvent, Encounter } from "@initiative/domain";
import { import {
combatantId, combatantId,
@@ -28,20 +31,38 @@ export function useEncounter() {
const encounterRef = useRef(encounter); const encounterRef = useRef(encounter);
encounterRef.current = encounter; encounterRef.current = encounter;
const advanceTurn = useCallback(() => { const makeStore = useCallback((): EncounterStore => {
const store: EncounterStore = { return {
get: () => encounterRef.current, get: () => encounterRef.current,
save: (e) => setEncounter(e), save: (e) => setEncounter(e),
}; };
}, []);
const result = advanceTurnUseCase(store); const advanceTurn = useCallback(() => {
const result = advanceTurnUseCase(makeStore());
if (isDomainError(result)) { if (isDomainError(result)) {
return; return;
} }
setEvents((prev) => [...prev, ...result]); setEvents((prev) => [...prev, ...result]);
}, []); }, [makeStore]);
return { encounter, events, advanceTurn } as const; const nextId = useRef(0);
const addCombatant = useCallback(
(name: string) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
return { encounter, events, advanceTurn, addCombatant } as const;
} }

View File

@@ -0,0 +1,24 @@
import {
addCombatant,
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function addCombatantUseCase(
store: EncounterStore,
id: CombatantId,
name: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = addCombatant(encounter, id, name);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -1,2 +1,3 @@
export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js"; export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export type { EncounterStore } from "./ports.js"; export type { EncounterStore } from "./ports.js";

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
// --- Helpers ---
function makeCombatant(name: string): Combatant {
return { id: combatantId(name), name };
}
const A = makeCombatant("A");
const B = makeCombatant("B");
const C = makeCombatant("C");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(encounter: Encounter, id: string, name: string) {
const result = addCombatant(encounter, combatantId(id), name);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios ---
describe("addCombatant", () => {
describe("acceptance scenarios", () => {
it("scenario 1: add to empty encounter", () => {
const e = enc([], 0, 1);
const { encounter, events } = successResult(e, "gandalf", "Gandalf");
expect(encounter.combatants).toEqual([
{ id: combatantId("gandalf"), name: "Gandalf" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("gandalf"),
name: "Gandalf",
position: 0,
},
]);
});
it("scenario 2: add to encounter with [A, B]", () => {
const e = enc([A, B], 0, 1);
const { encounter, events } = successResult(e, "C", "C");
expect(encounter.combatants).toEqual([
A,
B,
{ id: combatantId("C"), name: "C" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("C"),
name: "C",
position: 2,
},
]);
});
it("scenario 3: add during mid-round does not change active combatant", () => {
const e = enc([A, B, C], 2, 3);
const { encounter, events } = successResult(e, "D", "D");
expect(encounter.combatants).toHaveLength(4);
expect(encounter.combatants[3]).toEqual({
id: combatantId("D"),
name: "D",
});
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(3);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("D"),
name: "D",
position: 3,
},
]);
});
it("scenario 4: two sequential adds preserve order", () => {
const e = enc([A]);
const first = successResult(e, "B", "B");
const second = successResult(first.encounter, "C", "C");
expect(second.encounter.combatants).toEqual([
A,
{ id: combatantId("B"), name: "B" },
{ id: combatantId("C"), name: "C" },
]);
expect(first.events).toHaveLength(1);
expect(second.events).toHaveLength(1);
});
it("scenario 5: empty name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
});
describe("invariants", () => {
it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => {
const e = enc([]);
const result = addCombatant(e, combatantId("a"), "A");
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex remains valid after adding", () => {
const scenarios: Encounter[] = [
enc([], 0, 1),
enc([A], 0, 1),
enc([A, B, C], 2, 3),
];
for (const e of scenarios) {
const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter;
if (combatants.length > 0) {
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
}
});
it("INV-3: roundNumber is preserved (never decreases)", () => {
const e = enc([A, B], 1, 5);
const { encounter } = successResult(e, "C", "C");
expect(encounter.roundNumber).toBe(5);
});
it("INV-4: determinism — same input produces same output", () => {
const e = enc([A, B], 1, 3);
const result1 = addCombatant(e, combatantId("x"), "X");
const result2 = addCombatant(e, combatantId("x"), "X");
expect(result1).toEqual(result2);
});
it("INV-5: every success emits exactly one CombatantAdded event", () => {
const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)];
for (const e of scenarios) {
const result = successResult(e, "z", "Z");
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe("CombatantAdded");
}
});
it("INV-6: addCombatant does not change activeIndex or roundNumber", () => {
const e = enc([A, B, C], 2, 7);
const { encounter } = successResult(e, "D", "D");
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(7);
});
it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
id: combatantId("C"),
name: "C",
});
// Existing combatants preserve order
expect(encounter.combatants[0]).toEqual(A);
expect(encounter.combatants[1]).toEqual(B);
});
});
});

View File

@@ -0,0 +1,50 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface AddCombatantSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that adds a combatant to the end of an encounter's list.
*
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
* FR-002: Appends new combatant to end of combatants list.
* FR-004: Rejects empty/whitespace-only names with DomainError.
* FR-005: Does not alter activeIndex or roundNumber.
* FR-006: Events returned as values, not dispatched via side effects.
*/
export function addCombatant(
encounter: Encounter,
id: CombatantId,
name: string,
): AddCombatantSuccess | DomainError {
const trimmed = name.trim();
if (trimmed === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Combatant name must not be empty",
};
}
const position = encounter.combatants.length;
return {
encounter: {
combatants: [...encounter.combatants, { id, name: trimmed }],
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CombatantAdded",
combatantId: id,
name: trimmed,
position,
},
],
};
}

View File

@@ -12,4 +12,11 @@ export interface RoundAdvanced {
readonly newRoundNumber: number; readonly newRoundNumber: number;
} }
export type DomainEvent = TurnAdvanced | RoundAdvanced; export interface CombatantAdded {
readonly type: "CombatantAdded";
readonly combatantId: CombatantId;
readonly name: string;
readonly position: number;
}
export type DomainEvent = TurnAdvanced | RoundAdvanced | CombatantAdded;

View File

@@ -1,6 +1,8 @@
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
export { advanceTurn } from "./advance-turn.js"; export { advanceTurn } from "./advance-turn.js";
export type { export type {
CombatantAdded,
DomainEvent, DomainEvent,
RoundAdvanced, RoundAdvanced,
TurnAdvanced, TurnAdvanced,

View File

@@ -0,0 +1,35 @@
# Specification Quality Checklist: Add Combatant
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- Assumption documented: CombatantId is passed in rather than generated internally, keeping domain pure.

View File

@@ -0,0 +1,77 @@
# Data Model: Add Combatant
**Feature**: 002-add-combatant
**Date**: 2026-03-03
## Entities
### Combatant (existing, unchanged)
| Field | Type | Constraints |
|-------|------|-------------|
| id | CombatantId (branded string) | Unique, required |
| name | string | Non-empty after trimming, required |
### Encounter (existing, unchanged)
| Field | Type | Constraints |
|-------|------|-------------|
| combatants | readonly Combatant[] | Ordered list, may be empty |
| activeIndex | number | 0 <= activeIndex < combatants.length (or 0 if empty) |
| roundNumber | number | Positive integer >= 1, only increases |
## Domain Events
### CombatantAdded (new)
| Field | Type | Description |
|-------|------|-------------|
| type | "CombatantAdded" (literal) | Discriminant for the DomainEvent union |
| combatantId | CombatantId | Id of the newly added combatant |
| name | string | Name of the newly added combatant |
| position | number | Zero-based index where the combatant was placed |
## State Transitions
### AddCombatant
**Input**: Encounter + CombatantId + name (string)
**Preconditions**:
- Name must be non-empty after trimming
**Transition**:
- New combatant `{ id, name: trimmedName }` appended to end of combatants list
- activeIndex unchanged
- roundNumber unchanged
**Postconditions**:
- combatants.length increased by 1
- New combatant is at index `combatants.length - 1`
- All existing combatants preserve their order and index positions
- INV-2 satisfied (activeIndex still valid for the now-larger list)
**Events emitted**: Exactly one `CombatantAdded`
**Error cases**:
- Empty or whitespace-only name → DomainError `{ code: "invalid-name" }`
## Function Signatures
### Domain Layer
```
addCombatant(encounter, id, name) → { encounter, events } | DomainError
```
### Application Layer
```
addCombatantUseCase(store, id, name) → DomainEvent[] | DomainError
```
## Validation Rules
| Rule | Layer | Error Code |
|------|-------|------------|
| Name non-empty after trim | Domain | invalid-name |

View File

@@ -0,0 +1,76 @@
# Implementation Plan: Add Combatant
**Branch**: `002-add-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/002-add-combatant/spec.md`
## Summary
Add a pure domain function `addCombatant` that appends a new combatant to the end of an encounter's combatant list without altering the active turn or round. The feature follows the same pattern as `advanceTurn`: a pure function returning updated state plus domain events, with an application-layer use case and a React adapter hook.
## Technical Context
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: None for domain; React 19 for web adapter
**Storage**: In-memory (React state via hook)
**Testing**: Vitest
**Target Platform**: Browser (Vite dev server)
**Project Type**: Monorepo (pnpm workspaces): domain library + application library + web app
**Performance Goals**: N/A (pure synchronous function)
**Constraints**: Domain must remain pure — no I/O, no randomness
**Scale/Scope**: Single-user local app
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | `addCombatant` is a pure function. CombatantId is passed in as input, not generated internally. |
| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, hook in `apps/web`. No reverse imports. |
| III. Agent Boundary | PASS | No agent layer involvement in this feature. |
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. Key assumption (id passed in) is documented. |
| V. Escalation Gates | PASS | Implementation stays within spec scope. |
| VI. MVP Baseline Language | PASS | Out-of-scope items use "MVP baseline does not include". |
| VII. No Gameplay Rules | PASS | No gameplay mechanics in constitution. |
All gates pass. No violations to justify.
## Project Structure
### Documentation (this feature)
```text
specs/002-add-combatant/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (via /speckit.tasks)
```
### Source Code (repository root)
```text
packages/domain/src/
├── types.ts # Encounter, Combatant, CombatantId (existing)
├── events.ts # DomainEvent union (add CombatantAdded)
├── add-combatant.ts # NEW: addCombatant pure function
├── advance-turn.ts # Existing (unchanged)
├── index.ts # Re-exports (add new exports)
└── __tests__/
├── advance-turn.test.ts # Existing (unchanged)
└── add-combatant.test.ts # NEW: acceptance + invariant tests
packages/application/src/
├── ports.ts # EncounterStore (unchanged)
├── add-combatant-use-case.ts # NEW: orchestrates addCombatant
├── advance-turn-use-case.ts # Existing (unchanged)
└── index.ts # Re-exports (add new exports)
apps/web/src/
├── App.tsx # Update: add combatant input + button
└── hooks/
└── use-encounter.ts # Update: expose addCombatant callback
```
**Structure Decision**: Follows the established monorepo layout. Each domain operation gets its own file (matching `advance-turn.ts` pattern). No new packages or directories needed beyond the existing structure.

View File

@@ -0,0 +1,47 @@
# Quickstart: Add Combatant
**Feature**: 002-add-combatant
## Prerequisites
```bash
pnpm install
```
## Development
```bash
pnpm test:watch # Watch all tests
pnpm vitest run packages/domain/src/__tests__/add-combatant.test.ts # Run feature tests
pnpm --filter web dev # Dev server at localhost:5173
```
## Merge Gate
```bash
pnpm check # Must pass before commit (format + lint + typecheck + test)
```
## Implementation Order
1. **Domain event** — Add `CombatantAdded` to `events.ts` and the `DomainEvent` union
2. **Domain function** — Create `add-combatant.ts` with the pure `addCombatant` function
3. **Domain exports** — Update `index.ts` to re-export new items
4. **Domain tests** — Create `add-combatant.test.ts` with all 6 acceptance scenarios + invariant checks
5. **Application use case** — Create `add-combatant-use-case.ts`
6. **Application exports** — Update `index.ts` to re-export
7. **Web hook** — Update `use-encounter.ts` to expose `addCombatant` callback
8. **Web UI** — Update `App.tsx` with name input and add button
## Key Files
| File | Action | Purpose |
|------|--------|---------|
| `packages/domain/src/events.ts` | Edit | Add CombatantAdded event type |
| `packages/domain/src/add-combatant.ts` | Create | Pure addCombatant function |
| `packages/domain/src/index.ts` | Edit | Export new items |
| `packages/domain/src/__tests__/add-combatant.test.ts` | Create | Acceptance + invariant tests |
| `packages/application/src/add-combatant-use-case.ts` | Create | Use case orchestration |
| `packages/application/src/index.ts` | Edit | Export new use case |
| `apps/web/src/hooks/use-encounter.ts` | Edit | Add combatant hook callback |
| `apps/web/src/App.tsx` | Edit | Name input + add button UI |

View File

@@ -0,0 +1,40 @@
# Research: Add Combatant
**Feature**: 002-add-combatant
**Date**: 2026-03-03
## Research Summary
No NEEDS CLARIFICATION items existed in the technical context. The feature is straightforward and follows established patterns. Research focused on confirming existing patterns and the one key design decision.
## Decision 1: CombatantId Generation Strategy
**Decision**: CombatantId is passed into the domain function as an argument, not generated internally.
**Rationale**: The domain layer must remain pure and deterministic (Constitution Principle I). Generating IDs internally would require either randomness (UUID) or side effects (counter with mutable state), both of which violate purity. By accepting the id as input, `addCombatant(encounter, id, name)` is a pure function: same inputs always produce the same output.
**Alternatives considered**:
- Generate UUID inside domain: Violates deterministic core principle. Tests would be non-deterministic.
- Pass an id-generator function: Adds unnecessary complexity. The application layer can generate the id and pass it in.
**Who generates the id**: The application layer (use case) or adapter layer (hook) generates the CombatantId before calling the domain function. This matches how `createEncounter` already works — callers construct `Combatant` objects with pre-assigned ids.
## Decision 2: Function Signature Pattern
**Decision**: Follow the `advanceTurn` pattern — standalone pure function returning a success result or DomainError.
**Rationale**: Consistency with the existing codebase. `advanceTurn` returns `AdvanceTurnSuccess | DomainError`, so `addCombatant` will return `AddCombatantSuccess | DomainError` with the same shape: `{ encounter, events }`.
**Alternatives considered**:
- Method on an Encounter class: Project uses plain interfaces and free functions, not classes.
- Mutating the encounter in place: Violates immutability convention (all fields are `readonly`).
## Decision 3: Name Validation Approach
**Decision**: Trim whitespace, then reject empty strings. The domain function validates the name.
**Rationale**: Name validation is a domain rule (what constitutes a valid combatant name), so it belongs in the domain layer. Trimming before checking prevents whitespace-only names from slipping through.
**Alternatives considered**:
- Validate in application layer: Would allow invalid data to reach domain if called from a different adapter. Domain should protect its own invariants.
- Accept any string: Would allow empty-name combatants, violating spec FR-004.

View File

@@ -0,0 +1,161 @@
# 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.

View File

@@ -0,0 +1,129 @@
# Tasks: Add Combatant
**Input**: Design documents from `/specs/002-add-combatant/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
**Tests**: Included — spec success criteria SC-001 and SC-002 require all acceptance scenarios and invariants to be verified by tests.
**Organization**: Single user story (P1). Tasks follow the established `advanceTurn` pattern across all three layers.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1)
- Include exact file paths in descriptions
---
## Phase 1: Foundational (Domain Event)
**Purpose**: Add the CombatantAdded event type that all layers depend on
- [x] T001 Add CombatantAdded event interface and extend DomainEvent union in packages/domain/src/events.ts
**Checkpoint**: CombatantAdded event type available for import
---
## Phase 2: User Story 1 - Add Combatant to Encounter (Priority: P1) 🎯 MVP
**Goal**: A game master can add a new combatant to an existing encounter. The combatant is appended to the end of the initiative list without changing the active turn or round.
**Independent Test**: Call `addCombatant` with an Encounter, a CombatantId, and a name. Assert the returned Encounter has the new combatant at the end, activeIndex and roundNumber unchanged, and a CombatantAdded event emitted.
### Domain Layer
- [x] T002 [US1] Create addCombatant pure function in packages/domain/src/add-combatant.ts
- [x] T003 [US1] Export addCombatant and AddCombatantSuccess from packages/domain/src/index.ts
### Domain Tests
- [x] T004 [US1] Create acceptance tests (6 scenarios) and invariant tests (INV-1 through INV-7) in packages/domain/src/__tests__/add-combatant.test.ts
### Application Layer
- [x] T005 [P] [US1] Create addCombatantUseCase in packages/application/src/add-combatant-use-case.ts
- [x] T006 [US1] Export addCombatantUseCase from packages/application/src/index.ts
### Web Adapter
- [x] T007 [US1] Add addCombatant callback to useEncounter hook in apps/web/src/hooks/use-encounter.ts
- [x] T008 [US1] Add combatant name input and add button to apps/web/src/App.tsx
**Checkpoint**: All 6 acceptance scenarios pass. User can type a name and add a combatant via the UI. `pnpm check` passes.
---
## Phase 3: Polish & Cross-Cutting Concerns
- [x] T009 Run pnpm check (format + lint + typecheck + test) and fix any issues
- [x] T010 Verify layer boundary compliance (domain has no outer-layer imports)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Foundational (Phase 1)**: No dependencies — start immediately
- **User Story 1 (Phase 2)**: Depends on T001 (CombatantAdded event type)
- **Polish (Phase 3)**: Depends on all Phase 2 tasks
### Within User Story 1
```
T001 (event type)
├── T002 (domain function) → T003 (domain exports) → T004 (domain tests)
└── T005 (use case) ──────→ T006 (app exports) → T007 (hook) → T008 (UI)
```
- T002 depends on T001 (needs CombatantAdded type)
- T003 depends on T002 (exports the new function)
- T004 depends on T003 (tests import from index)
- T005 depends on T003 (use case imports domain function) — can run in parallel with T004
- T006 depends on T005
- T007 depends on T006
- T008 depends on T007
### Parallel Opportunities
- T004 (domain tests) and T005 (use case) can run in parallel after T003
- T009 and T010 can run in parallel
---
## Parallel Example: After T003
```
# These two tasks touch different packages and can run in parallel:
T004: "Acceptance + invariant tests in packages/domain/src/__tests__/add-combatant.test.ts"
T005: "Use case in packages/application/src/add-combatant-use-case.ts"
```
---
## Implementation Strategy
### MVP (This Feature)
1. T001: Add event type (foundation)
2. T002T003: Domain function + exports
3. T004 + T005 in parallel: Tests + use case
4. T006T008: Application exports → hook → UI
5. T009T010: Verify everything passes
### Validation
After T004: All 6 acceptance scenarios pass as pure-function tests
After T008: UI allows adding combatants by name
After T009: `pnpm check` passes clean (merge gate)
---
## Notes
- Follow the `advanceTurn` pattern for function signature, result type, and error handling
- CombatantId is passed in as input (generated by caller), not created inside domain
- Name is trimmed then validated; empty after trim returns DomainError with code "invalid-name"
- Commit after each task or logical group
- Total: 10 tasks (1 foundational + 7 US1 + 2 polish)