Files
initiative/specs/001-advance-turn/plan.md

13 KiB
Raw Blame History

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/domainpackages/applicationapps/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:

  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)

{
  "$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)

  • 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
  • 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
  • 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
  • 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
  • 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
  • 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 18 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.