From 42a07a07ff4a4934825f58a0c96faf92efd6ef3b Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 3 Mar 2026 13:01:08 +0100 Subject: [PATCH] =?UTF-8?q?T007=E2=80=93T011:=20implement=20AdvanceTurn=20?= =?UTF-8?q?domain=20logic=20(pure=20function,=20events,=20invariants,=208?= =?UTF-8?q?=20acceptance=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/src/__tests__/advance-turn.test.ts | 233 ++++++++++++++++++ packages/domain/src/advance-turn.ts | 65 +++++ packages/domain/src/events.ts | 15 ++ packages/domain/src/index.ts | 17 +- packages/domain/src/types.ts | 68 +++++ specs/001-advance-turn/tasks.md | 10 +- 6 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 packages/domain/src/__tests__/advance-turn.test.ts create mode 100644 packages/domain/src/advance-turn.ts create mode 100644 packages/domain/src/events.ts create mode 100644 packages/domain/src/types.ts diff --git a/packages/domain/src/__tests__/advance-turn.test.ts b/packages/domain/src/__tests__/advance-turn.test.ts new file mode 100644 index 0000000..944cead --- /dev/null +++ b/packages/domain/src/__tests__/advance-turn.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { advanceTurn, isDomainError } from "../advance-turn.js"; +import type { DomainEvent } from "../events.js"; +import { + type Combatant, + combatantId, + createEncounter, + type Encounter, +} 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 encounter( + combatants: Combatant[], + activeIndex: number, + roundNumber: number, +): Encounter { + const result = createEncounter(combatants, activeIndex, roundNumber); + if (isDomainError(result)) { + throw new Error(`Test setup failed: ${result.message}`); + } + return result; +} + +function successResult(enc: Encounter) { + const result = advanceTurn(enc); + if (isDomainError(result)) { + throw new Error(`Expected success, got error: ${result.message}`); + } + return result; +} + +// --- Acceptance Scenarios --- + +describe("advanceTurn", () => { + describe("acceptance scenarios", () => { + it("scenario 1: advances from first to second combatant", () => { + const enc = encounter([A, B, C], 0, 1); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(1); + expect(next.roundNumber).toBe(1); + expect(events).toEqual([ + { + type: "TurnAdvanced", + previousCombatantId: combatantId("A"), + newCombatantId: combatantId("B"), + roundNumber: 1, + }, + ]); + }); + + it("scenario 2: advances from second to third combatant", () => { + const enc = encounter([A, B, C], 1, 1); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(2); + expect(next.roundNumber).toBe(1); + expect(events).toEqual([ + { + type: "TurnAdvanced", + previousCombatantId: combatantId("B"), + newCombatantId: combatantId("C"), + roundNumber: 1, + }, + ]); + }); + + it("scenario 3: wraps from last combatant to first, increments round", () => { + const enc = encounter([A, B, C], 2, 1); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(0); + expect(next.roundNumber).toBe(2); + expect(events).toEqual([ + { + type: "TurnAdvanced", + previousCombatantId: combatantId("C"), + newCombatantId: combatantId("A"), + roundNumber: 2, + }, + { + type: "RoundAdvanced", + newRoundNumber: 2, + }, + ]); + }); + + it("scenario 4: wraps at round 5 to round 6 (not hardcoded)", () => { + const enc = encounter([A, B, C], 2, 5); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(0); + expect(next.roundNumber).toBe(6); + expect(events[0]).toMatchObject({ + type: "TurnAdvanced", + roundNumber: 6, + }); + expect(events[1]).toEqual({ + type: "RoundAdvanced", + newRoundNumber: 6, + }); + }); + + it("scenario 5: single combatant always wraps", () => { + const enc = encounter([A], 0, 1); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(0); + expect(next.roundNumber).toBe(2); + expect(events).toEqual([ + { + type: "TurnAdvanced", + previousCombatantId: combatantId("A"), + newCombatantId: combatantId("A"), + roundNumber: 2, + }, + { + type: "RoundAdvanced", + newRoundNumber: 2, + }, + ]); + }); + + it("scenario 6: two advances on a 2-combatant encounter completes a round", () => { + const enc = encounter([A, B], 0, 1); + + const first = successResult(enc); + expect(first.encounter.activeIndex).toBe(1); + expect(first.encounter.roundNumber).toBe(1); + + const second = successResult(first.encounter); + expect(second.encounter.activeIndex).toBe(0); + expect(second.encounter.roundNumber).toBe(2); + }); + + it("scenario 7: empty combatant list returns error", () => { + const enc: Encounter = { + combatants: [], + activeIndex: 0, + roundNumber: 1, + }; + const result = advanceTurn(enc); + + expect(isDomainError(result)).toBe(true); + if (isDomainError(result)) { + expect(result.code).toBe("invalid-encounter"); + } + }); + + it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => { + let enc = encounter([A, B, C], 0, 1); + + enc = successResult(enc).encounter; + enc = successResult(enc).encounter; + enc = successResult(enc).encounter; + + expect(enc.activeIndex).toBe(0); + expect(enc.roundNumber).toBe(2); + }); + }); + + describe("invariants", () => { + it("INV-1: createEncounter rejects empty combatant list", () => { + const result = createEncounter([]); + expect(isDomainError(result)).toBe(true); + }); + + it("INV-2: activeIndex always in bounds across all scenarios", () => { + const combatants = [A, B, C]; + let enc = encounter(combatants, 0, 1); + + for (let i = 0; i < 10; i++) { + const result = successResult(enc); + expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0); + expect(result.encounter.activeIndex).toBeLessThan(combatants.length); + enc = result.encounter; + } + }); + + it("INV-3: roundNumber never decreases", () => { + let enc = encounter([A, B, C], 0, 1); + let prevRound = enc.roundNumber; + + for (let i = 0; i < 10; i++) { + const result = successResult(enc); + expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(prevRound); + prevRound = result.encounter.roundNumber; + enc = result.encounter; + } + }); + + it("INV-4: determinism — same input produces same output", () => { + const enc = encounter([A, B, C], 1, 3); + const result1 = advanceTurn(enc); + const result2 = advanceTurn(enc); + expect(result1).toEqual(result2); + }); + + it("INV-5: every success emits at least TurnAdvanced", () => { + const scenarios: Encounter[] = [ + encounter([A, B, C], 0, 1), + encounter([A, B, C], 2, 1), + encounter([A], 0, 1), + ]; + + for (const enc of scenarios) { + const result = successResult(enc); + const hasTurnAdvanced = result.events.some( + (e: DomainEvent) => e.type === "TurnAdvanced", + ); + expect(hasTurnAdvanced).toBe(true); + } + }); + + it("event ordering: on wrap, events are [TurnAdvanced, RoundAdvanced]", () => { + const enc = encounter([A, B, C], 2, 1); + const { events } = successResult(enc); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("TurnAdvanced"); + expect(events[1].type).toBe("RoundAdvanced"); + }); + }); +}); diff --git a/packages/domain/src/advance-turn.ts b/packages/domain/src/advance-turn.ts new file mode 100644 index 0000000..4be94bc --- /dev/null +++ b/packages/domain/src/advance-turn.ts @@ -0,0 +1,65 @@ +import type { DomainEvent } from "./events.js"; +import type { DomainError, Encounter } from "./types.js"; +import { isDomainError } from "./types.js"; + +interface AdvanceTurnSuccess { + readonly encounter: Encounter; + readonly events: DomainEvent[]; +} + +/** + * Pure function that advances the turn to the next combatant. + * + * FR-001: Accepts an Encounter and returns next state + events. + * FR-002: Increments activeIndex by 1, wrapping to 0. + * FR-003: When wrapping, increments roundNumber by 1. + * FR-004: Empty encounter returns error (no state change, no events). + * FR-005: Events returned as values, not dispatched via side effects. + */ +export function advanceTurn( + encounter: Encounter, +): AdvanceTurnSuccess | DomainError { + // FR-004 / INV-1: reject empty encounters + if (encounter.combatants.length === 0) { + return { + kind: "domain-error", + code: "invalid-encounter", + message: "Cannot advance turn on an encounter with no combatants", + }; + } + + const previousIndex = encounter.activeIndex; + const nextIndex = (previousIndex + 1) % encounter.combatants.length; + const wraps = nextIndex === 0; + const newRoundNumber = wraps + ? encounter.roundNumber + 1 + : encounter.roundNumber; + + const events: DomainEvent[] = [ + { + type: "TurnAdvanced", + previousCombatantId: encounter.combatants[previousIndex].id, + newCombatantId: encounter.combatants[nextIndex].id, + roundNumber: newRoundNumber, + }, + ]; + + // Event ordering contract: TurnAdvanced first, then RoundAdvanced + if (wraps) { + events.push({ + type: "RoundAdvanced", + newRoundNumber, + }); + } + + return { + encounter: { + combatants: encounter.combatants, + activeIndex: nextIndex, + roundNumber: newRoundNumber, + }, + events, + }; +} + +export { isDomainError }; diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts new file mode 100644 index 0000000..90cbf7a --- /dev/null +++ b/packages/domain/src/events.ts @@ -0,0 +1,15 @@ +import type { CombatantId } from "./types.js"; + +export interface TurnAdvanced { + readonly type: "TurnAdvanced"; + readonly previousCombatantId: CombatantId; + readonly newCombatantId: CombatantId; + readonly roundNumber: number; +} + +export interface RoundAdvanced { + readonly type: "RoundAdvanced"; + readonly newRoundNumber: number; +} + +export type DomainEvent = TurnAdvanced | RoundAdvanced; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index cb0ff5c..0ec1c97 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1 +1,16 @@ -export {}; +export { advanceTurn } from "./advance-turn.js"; + +export type { + DomainEvent, + RoundAdvanced, + TurnAdvanced, +} from "./events.js"; +export { + type Combatant, + type CombatantId, + combatantId, + createEncounter, + type DomainError, + type Encounter, + isDomainError, +} from "./types.js"; diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts new file mode 100644 index 0000000..f830039 --- /dev/null +++ b/packages/domain/src/types.ts @@ -0,0 +1,68 @@ +/** Branded string type for combatant identity. */ +export type CombatantId = string & { readonly __brand: "CombatantId" }; + +export function combatantId(id: string): CombatantId { + return id as CombatantId; +} + +export interface Combatant { + readonly id: CombatantId; + readonly name: string; +} + +export interface Encounter { + readonly combatants: readonly Combatant[]; + readonly activeIndex: number; + readonly roundNumber: number; +} + +export interface DomainError { + readonly kind: "domain-error"; + readonly code: string; + readonly message: string; +} + +function domainError(code: string, message: string): DomainError { + return { kind: "domain-error", code, message }; +} + +/** + * Creates a valid Encounter, enforcing INV-1, INV-2, INV-3. + * - INV-1: At least one combatant required. + * - INV-2: activeIndex defaults to 0 (always in bounds). + * - INV-3: roundNumber defaults to 1 (positive integer). + */ +export function createEncounter( + combatants: readonly Combatant[], + activeIndex = 0, + roundNumber = 1, +): Encounter | DomainError { + if (combatants.length === 0) { + return domainError( + "invalid-encounter", + "An encounter must have at least one combatant", + ); + } + if (activeIndex < 0 || activeIndex >= combatants.length) { + return domainError( + "invalid-encounter", + `activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`, + ); + } + if (roundNumber < 1 || !Number.isInteger(roundNumber)) { + return domainError( + "invalid-encounter", + `roundNumber must be a positive integer, got ${roundNumber}`, + ); + } + return { combatants, activeIndex, roundNumber }; +} + +export function isDomainError(value: unknown): value is DomainError { + return ( + typeof value === "object" && + value !== null && + "kind" in value && + (value as DomainError).kind === "domain-error" + ); +} diff --git a/specs/001-advance-turn/tasks.md b/specs/001-advance-turn/tasks.md index 2f65b5d..8f257eb 100644 --- a/specs/001-advance-turn/tasks.md +++ b/specs/001-advance-turn/tasks.md @@ -34,11 +34,11 @@ **Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed. -- [ ] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3 -- [ ] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes) -- [ ] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005 -- [ ] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap -- [ ] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter` +- [X] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3 +- [X] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes) +- [X] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005 +- [X] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap +- [X] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter` **Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.