350 lines
13 KiB
Markdown
350 lines
13 KiB
Markdown
# 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.
|