# Implementation Plan: Advance Turn **Branch**: `001-advance-turn` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md) **Input**: Feature specification from `/specs/001-advance-turn/spec.md` ## Summary Implement the AdvanceTurn domain operation as a pure function that transitions an Encounter to the next combatant, wrapping rounds and emitting TurnAdvanced / RoundAdvanced domain events. Stand up the pnpm monorepo skeleton, Biome tooling, and Vitest test harness so that all constitution merge-gate requirements are satisfied from the first commit. ## Technical Context **Node**: 22 LTS (pinned via `.nvmrc`) **Language/Version**: TypeScript 5.8 (strict mode) **Primary Dependencies**: React 19 (pin to major; minor upgrades allowed), Vite 6.2 **Storage**: In-memory only (MVP baseline) **Testing**: Vitest 3.0 **Lint/Format**: Biome 2.0.0 (exact version, single tool — no Prettier, no ESLint) **Package Manager**: pnpm 10.6 (pinned via `packageManager` field in root `package.json`) **Target Platform**: Static web app (modern browsers) **Project Type**: Monorepo — library packages + web app **Performance Goals**: N/A (walking skeleton) **Constraints**: Domain package must have zero React/Vite imports **Scale/Scope**: Single feature, ~5 source files, ~1 test file ## Constitution Check | Principle | Status | Notes | |-----------|--------|-------| | I. Deterministic Domain Core | PASS | `advanceTurn` is a pure function; no I/O, randomness, or clocks | | II. Layered Architecture | PASS | `packages/domain` → `packages/application` → `apps/web`; strict dependency direction enforced by automated import check | | III. Agent Boundary | N/A | Agent layer out of scope for this feature | | IV. Clarification-First | PASS | No ambiguous decisions remain; spec fully clarified | | V. Escalation Gates | PASS | Scope strictly limited to spec; out-of-scope items listed | | VI. MVP Baseline Language | PASS | No permanent bans; "MVP baseline does not include" used | | VII. No Gameplay Rules | PASS | No gameplay mechanics in plan or constitution | | Merge Gate | PASS | `pnpm check` script runs format, lint, typecheck, test | ## Project Structure ### Documentation (this feature) ```text specs/001-advance-turn/ ├── spec.md └── plan.md ``` ### Source Code (repository root) ```text packages/ ├── domain/ │ ├── package.json │ ├── tsconfig.json │ └── src/ │ ├── types.ts # CombatantId, Combatant, Encounter │ ├── events.ts # TurnAdvanced, RoundAdvanced, DomainEvent │ ├── advance-turn.ts # advanceTurn pure function │ └── index.ts # public barrel export ├── application/ │ ├── package.json │ ├── tsconfig.json │ └── src/ │ ├── ports.ts # EncounterStore port interface │ ├── advance-turn-use-case.ts │ └── index.ts apps/ └── web/ ├── package.json ├── tsconfig.json ├── vite.config.ts ├── index.html └── src/ ├── main.tsx ├── App.tsx └── hooks/ └── use-encounter.ts # Testing (co-located with domain package) packages/domain/ └── src/ └── __tests__/ └── advance-turn.test.ts # Root config ├── .nvmrc # pins Node 22 ├── pnpm-workspace.yaml ├── biome.json ├── tsconfig.base.json └── package.json # packageManager field pins pnpm; root scripts ``` **Structure Decision**: pnpm workspace monorepo with two packages (`domain`, `application`) and one app (`web`). Domain is framework-agnostic TypeScript. Application imports domain only. Web app (React + Vite) imports both. ## Tooling & Merge Gate ### Scripts (root package.json) ```jsonc { "scripts": { "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint .", "lint:fix": "biome lint --write .", "typecheck": "tsc --build", "test": "vitest run", "test:watch": "vitest", "check": "biome check . && tsc --build && vitest run" } } ``` `pnpm check` is the single merge gate: format + lint + typecheck + test. The layer boundary check runs as a Vitest test (see below), so it executes as part of `vitest run` — no separate script needed. ### Layer Boundary Enforcement Biome does not natively support cross-package import restrictions. A lightweight `scripts/check-layer-boundaries.mjs` script will: 1. Scan `packages/domain/src/**/*.ts` — assert zero imports from `@initiative/application`, `apps/`, `react`, `vite`. 2. Scan `packages/application/src/**/*.ts` — assert zero imports from `apps/`, `react`, `vite`. 3. Exit non-zero on violation with a clear error message. This script is invoked by a Vitest test (`packages/domain/src/__tests__/layer-boundaries.test.ts`) so it runs automatically as part of `vitest run` inside `pnpm check`. No separate `check:layer` script is needed — the layer boundary check is guaranteed to execute on every merge-gate run. ### Biome Configuration (biome.json) ```jsonc { "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "organizeImports": { "enabled": true }, "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 80 }, "linter": { "enabled": true, "rules": { "recommended": true } } } ``` ### TypeScript Configuration - `tsconfig.base.json` at root: strict mode, composite projects, path aliases (`@initiative/domain`, `@initiative/application`). - Each package extends `tsconfig.base.json` with its own `include`/`references`. - `apps/web/tsconfig.json` references both packages. ## Milestones ### Milestone 1: Tooling & Domain (walking skeleton) Stand up monorepo, Biome, Vitest, TypeScript project references, and implement the complete AdvanceTurn domain logic with all tests. **Exit criteria**: `pnpm check` passes. All 8 acceptance scenarios green. Layer boundary check green. No React or Vite dependencies in `packages/domain` or `packages/application`. ### Milestone 2: Application + Minimal Web Shell Wire up the application use case and a minimal React UI that displays the encounter state and has a single "Next Turn" button. **Exit criteria**: `pnpm check` passes. Clicking "Next Turn" in the browser advances the turn with correct round wrapping. The app builds with `vite build`. ## Task List ### Phase 1: Setup (Milestone 1) - [X] **T001** Initialize pnpm workspace and root config - Create `pnpm-workspace.yaml` listing `packages/*` and `apps/*` - Create `.nvmrc` pinning Node 22 - Create root `package.json` with `packageManager` field pinning pnpm 10.6 and scripts (check, test, lint, format, typecheck) - Create `biome.json` at root - Create `tsconfig.base.json` (strict, composite, path aliases) - **Acceptance**: `pnpm install` succeeds; `biome check .` runs without config errors - [X] **T002** [P] Create `packages/domain` package skeleton - `package.json` (name: `@initiative/domain`, no dependencies) - `tsconfig.json` extending base, composite: true - Empty `src/index.ts` - **Acceptance**: `tsc --build packages/domain` succeeds - [X] **T003** [P] Create `packages/application` package skeleton - `package.json` (name: `@initiative/application`, depends on `@initiative/domain`) - `tsconfig.json` extending base, references domain - Empty `src/index.ts` - **Acceptance**: `tsc --build packages/application` succeeds - [X] **T004** [P] Create `apps/web` package skeleton - `package.json` with React, Vite, depends on both packages - `tsconfig.json` referencing both packages - `vite.config.ts` (minimal) - `index.html` + `src/main.tsx` + `src/App.tsx` (placeholder) - **Acceptance**: `pnpm --filter web dev` starts; `vite build` succeeds - [X] **T005** Configure Vitest - Add `vitest` as root dev dependency - Create `vitest.config.ts` at root (workspace mode) or per package as needed - Verify `pnpm test` runs (0 tests, exits clean) - **Acceptance**: `pnpm test` exits 0 - [X] **T006** Create layer boundary check script - `scripts/check-layer-boundaries.mjs`: scans domain and application source for forbidden imports - `packages/domain/src/__tests__/layer-boundaries.test.ts`: wraps the script as a Vitest test - **Acceptance**: test passes on clean skeleton; fails if a forbidden import is manually added (verify, then remove) ### Phase 2: Domain Implementation (Milestone 1) - [ ] **T007** Define domain types in `packages/domain/src/types.ts` - `CombatantId` (branded string or opaque type) - `Combatant` (carries a CombatantId) - `Encounter` (combatants array, activeIndex, roundNumber) - Factory function `createEncounter` that validates INV-1, INV-2, INV-3 - **Acceptance**: types compile; `createEncounter([])` returns error; `createEncounter([a])` returns valid Encounter - [ ] **T008** [P] Define domain events in `packages/domain/src/events.ts` - `TurnAdvanced { previousCombatantId, newCombatantId, roundNumber }` - `RoundAdvanced { newRoundNumber }` - `DomainEvent = TurnAdvanced | RoundAdvanced` - **Acceptance**: types compile; events are plain data (no classes with methods) - [ ] **T009** Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` - Signature: `(encounter: Encounter) => { encounter: Encounter; events: DomainEvent[] } | DomainError` - Implements FR-001 through FR-005 - Returns error for empty combatant list (INV-1) - Emits TurnAdvanced on every call (INV-5) - Emits TurnAdvanced then RoundAdvanced on wrap (event order contract) - **Acceptance**: compiles; satisfies type contract - [ ] **T010** Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` - Scenarios 1–8 from spec (Given/When/Then) - INV-1: empty encounter rejected - INV-2: activeIndex always in bounds (property check across scenarios) - INV-3: roundNumber never decreases - INV-4: determinism — same input produces same output (call twice, assert deep equal) - INV-5: every success emits at least TurnAdvanced - Event ordering: on wrap, events array is [TurnAdvanced, RoundAdvanced] in that order - **Acceptance**: `pnpm test` — all tests green; `pnpm check` — full pipeline green - [ ] **T011** Export public API from `packages/domain/src/index.ts` - Re-export types, events, `advanceTurn`, `createEncounter` - **Acceptance**: consuming packages can `import { advanceTurn } from "@initiative/domain"` **Milestone 1 checkpoint**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. ### Phase 3: Application + Web Shell (Milestone 2) - [ ] **T012** Define port interface in `packages/application/src/ports.ts` - `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)` - **Acceptance**: compiles; no imports from adapters or React - [ ] **T013** Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` - Accepts `EncounterStore` port - Calls `advanceTurn` from domain, saves result, returns events - **Acceptance**: compiles; imports only from `@initiative/domain` and local ports - [ ] **T014** Export public API from `packages/application/src/index.ts` - Re-export use case and port types - **Acceptance**: consuming app can import from `@initiative/application` - [ ] **T015** Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` - In-memory implementation of `EncounterStore` port (React state) - Exposes current encounter state + `advanceTurn` action - Initializes with a hardcoded 3-combatant encounter for demo - **Acceptance**: hook compiles; usable in a React component - [ ] **T016** Wire up `App.tsx` - Display: current combatant name, round number, combatant list with active indicator - Single "Next Turn" button calling the use case - Display emitted events (optional, for demo clarity) - **Acceptance**: `vite build` succeeds; clicking "Next Turn" cycles through combatants and increments rounds correctly **Milestone 2 checkpoint**: `pnpm check` passes. App runs in browser. Full round-trip from button click → domain pure function → UI update verified manually. ## Risks & Open Questions | # | Item | Severity | Mitigation | |---|------|----------|------------| | 1 | pnpm workspace + TypeScript project references can have path resolution quirks with Vite | Low | Use `vite-tsconfig-paths` plugin if needed; test early in T004 | | 2 | Biome config format may change across versions | Low | Pinned to exact 2.0.0; `$schema` in config validates structure | | 3 | Layer boundary script is a lightweight grep — not a full architectural fitness function | Low | Sufficient for walking skeleton; can upgrade to a Biome plugin or `dependency-cruiser` later if needed | ## Complexity Tracking No constitution violations. No complexity justifications needed.