13 KiB
Implementation Plan: Advance Turn
Branch: 001-advance-turn | Date: 2026-03-03 | Spec: 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)
specs/001-advance-turn/
├── spec.md
└── plan.md
Source Code (repository root)
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)
{
"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:
- Scan
packages/domain/src/**/*.ts— assert zero imports from@initiative/application,apps/,react,vite. - Scan
packages/application/src/**/*.ts— assert zero imports fromapps/,react,vite. - 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)
{
"$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.jsonat root: strict mode, composite projects, path aliases (@initiative/domain,@initiative/application).- Each package extends
tsconfig.base.jsonwith its owninclude/references. apps/web/tsconfig.jsonreferences 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)
-
T001 Initialize pnpm workspace and root config
- Create
pnpm-workspace.yamllistingpackages/*andapps/* - Create
.nvmrcpinning Node 22 - Create root
package.jsonwithpackageManagerfield pinning pnpm 10.6 and scripts (check, test, lint, format, typecheck) - Create
biome.jsonat root - Create
tsconfig.base.json(strict, composite, path aliases) - Acceptance:
pnpm installsucceeds;biome check .runs without config errors
- Create
-
T002 [P] Create
packages/domainpackage skeletonpackage.json(name:@initiative/domain, no dependencies)tsconfig.jsonextending base, composite: true- Empty
src/index.ts - Acceptance:
tsc --build packages/domainsucceeds
-
T003 [P] Create
packages/applicationpackage skeletonpackage.json(name:@initiative/application, depends on@initiative/domain)tsconfig.jsonextending base, references domain- Empty
src/index.ts - Acceptance:
tsc --build packages/applicationsucceeds
-
T004 [P] Create
apps/webpackage skeletonpackage.jsonwith React, Vite, depends on both packagestsconfig.jsonreferencing both packagesvite.config.ts(minimal)index.html+src/main.tsx+src/App.tsx(placeholder)- Acceptance:
pnpm --filter web devstarts;vite buildsucceeds
-
T005 Configure Vitest
- Add
vitestas root dev dependency - Create
vitest.config.tsat root (workspace mode) or per package as needed - Verify
pnpm testruns (0 tests, exits clean) - Acceptance:
pnpm testexits 0
- Add
-
T006 Create layer boundary check script
scripts/check-layer-boundaries.mjs: scans domain and application source for forbidden importspackages/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.tsCombatantId(branded string or opaque type)Combatant(carries a CombatantId)Encounter(combatants array, activeIndex, roundNumber)- Factory function
createEncounterthat 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.tsTurnAdvanced { previousCombatantId, newCombatantId, roundNumber }RoundAdvanced { newRoundNumber }DomainEvent = TurnAdvanced | RoundAdvanced- Acceptance: types compile; events are plain data (no classes with methods)
-
T009 Implement
advanceTurninpackages/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
- Signature:
-
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"
- Re-export types, events,
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.tsEncounterStoreport:get(): Encounter,save(e: Encounter)- Acceptance: compiles; no imports from adapters or React
-
T013 Implement
AdvanceTurnUseCaseinpackages/application/src/advance-turn-use-case.ts- Accepts
EncounterStoreport - Calls
advanceTurnfrom domain, saves result, returns events - Acceptance: compiles; imports only from
@initiative/domainand local ports
- Accepts
-
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
useEncounterhook inapps/web/src/hooks/use-encounter.ts- In-memory implementation of
EncounterStoreport (React state) - Exposes current encounter state +
advanceTurnaction - Initializes with a hardcoded 3-combatant encounter for demo
- Acceptance: hook compiles; usable in a React component
- In-memory implementation of
-
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 buildsucceeds; 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.