From 7d440677be89928595c283ada697db1edcb46a83 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 5 Mar 2026 23:11:11 +0100 Subject: [PATCH] Implement the 012-turn-navigation feature that adds a RetreatTurn domain operation and relocates turn controls to a navigation bar at the top of the encounter tracker Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + apps/web/src/App.tsx | 17 +- apps/web/src/components/action-bar.tsx | 6 +- apps/web/src/components/turn-navigation.tsx | 59 ++++++ apps/web/src/hooks/use-encounter.ts | 12 ++ packages/application/src/index.ts | 1 + .../application/src/retreat-turn-use-case.ts | 21 ++ .../domain/src/__tests__/retreat-turn.test.ts | 184 ++++++++++++++++++ packages/domain/src/events.ts | 16 +- packages/domain/src/index.ts | 3 + packages/domain/src/retreat-turn.ts | 59 ++++++ .../checklists/requirements.md | 34 ++++ .../contracts/domain-api.md | 55 ++++++ specs/012-turn-navigation/data-model.md | 58 ++++++ specs/012-turn-navigation/plan.md | 76 ++++++++ specs/012-turn-navigation/quickstart.md | 49 +++++ specs/012-turn-navigation/research.md | 49 +++++ specs/012-turn-navigation/spec.md | 106 ++++++++++ specs/012-turn-navigation/tasks.md | 153 +++++++++++++++ 19 files changed, 946 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/turn-navigation.tsx create mode 100644 packages/application/src/retreat-turn-use-case.ts create mode 100644 packages/domain/src/__tests__/retreat-turn.test.ts create mode 100644 packages/domain/src/retreat-turn.ts create mode 100644 specs/012-turn-navigation/checklists/requirements.md create mode 100644 specs/012-turn-navigation/contracts/domain-api.md create mode 100644 specs/012-turn-navigation/data-model.md create mode 100644 specs/012-turn-navigation/plan.md create mode 100644 specs/012-turn-navigation/quickstart.md create mode 100644 specs/012-turn-navigation/research.md create mode 100644 specs/012-turn-navigation/spec.md create mode 100644 specs/012-turn-navigation/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 3ddb598..f2339f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline) - TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input) - N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input) +- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation) ## Recent Changes - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b96786b..a480bdc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,13 @@ import { ActionBar } from "./components/action-bar"; import { CombatantRow } from "./components/combatant-row"; +import { TurnNavigation } from "./components/turn-navigation"; import { useEncounter } from "./hooks/use-encounter"; export function App() { const { encounter, advanceTurn, + retreatTurn, addCombatant, removeCombatant, editCombatant, @@ -13,7 +15,6 @@ export function App() { setHp, adjustHp, } = useEncounter(); - const activeCombatant = encounter.combatants[encounter.activeIndex]; return (
@@ -22,13 +23,15 @@ export function App() {

Initiative Tracker

- {activeCombatant && ( -

- Round {encounter.roundNumber} — Current: {activeCombatant.name} -

- )} + {/* Turn Navigation */} + + {/* Combatant List */}
{encounter.combatants.length === 0 ? ( @@ -52,7 +55,7 @@ export function App() {
{/* Action Bar */} - +
); } diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 312d534..26e95bd 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -4,10 +4,9 @@ import { Input } from "./ui/input"; interface ActionBarProps { onAddCombatant: (name: string) => void; - onAdvanceTurn: () => void; } -export function ActionBar({ onAddCombatant, onAdvanceTurn }: ActionBarProps) { +export function ActionBar({ onAddCombatant }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); const handleAdd = (e: FormEvent) => { @@ -31,9 +30,6 @@ export function ActionBar({ onAddCombatant, onAdvanceTurn }: ActionBarProps) { Add - ); } diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx new file mode 100644 index 0000000..56846c0 --- /dev/null +++ b/apps/web/src/components/turn-navigation.tsx @@ -0,0 +1,59 @@ +import type { Encounter } from "@initiative/domain"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "./ui/button"; + +interface TurnNavigationProps { + encounter: Encounter; + onAdvanceTurn: () => void; + onRetreatTurn: () => void; +} + +export function TurnNavigation({ + encounter, + onAdvanceTurn, + onRetreatTurn, +}: TurnNavigationProps) { + const hasCombatants = encounter.combatants.length > 0; + const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; + const activeCombatant = encounter.combatants[encounter.activeIndex]; + + return ( +
+ + +
+ {activeCombatant ? ( + <> + Round {encounter.roundNumber} + + {" "} + — {activeCombatant.name} + + + ) : ( + No combatants + )} +
+ + +
+ ); +} diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 551a857..51bee8e 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -5,6 +5,7 @@ import { advanceTurnUseCase, editCombatantUseCase, removeCombatantUseCase, + retreatTurnUseCase, setHpUseCase, setInitiativeUseCase, } from "@initiative/application"; @@ -79,6 +80,16 @@ export function useEncounter() { setEvents((prev) => [...prev, ...result]); }, [makeStore]); + const retreatTurn = useCallback(() => { + const result = retreatTurnUseCase(makeStore()); + + if (isDomainError(result)) { + return; + } + + setEvents((prev) => [...prev, ...result]); + }, [makeStore]); + const nextId = useRef(deriveNextId(encounter)); const addCombatant = useCallback( @@ -164,6 +175,7 @@ export function useEncounter() { encounter, events, advanceTurn, + retreatTurn, addCombatant, removeCombatant, editCombatant, diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 0e037e7..f32e304 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -4,5 +4,6 @@ export { advanceTurnUseCase } from "./advance-turn-use-case.js"; export { editCombatantUseCase } from "./edit-combatant-use-case.js"; export type { EncounterStore } from "./ports.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; +export { retreatTurnUseCase } from "./retreat-turn-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js"; diff --git a/packages/application/src/retreat-turn-use-case.ts b/packages/application/src/retreat-turn-use-case.ts new file mode 100644 index 0000000..af088af --- /dev/null +++ b/packages/application/src/retreat-turn-use-case.ts @@ -0,0 +1,21 @@ +import { + type DomainError, + type DomainEvent, + isDomainError, + retreatTurn, +} from "@initiative/domain"; +import type { EncounterStore } from "./ports.js"; + +export function retreatTurnUseCase( + store: EncounterStore, +): DomainEvent[] | DomainError { + const encounter = store.get(); + const result = retreatTurn(encounter); + + if (isDomainError(result)) { + return result; + } + + store.save(result.encounter); + return result.events; +} diff --git a/packages/domain/src/__tests__/retreat-turn.test.ts b/packages/domain/src/__tests__/retreat-turn.test.ts new file mode 100644 index 0000000..e6c47be --- /dev/null +++ b/packages/domain/src/__tests__/retreat-turn.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { DomainEvent } from "../events.js"; +import { retreatTurn } from "../retreat-turn.js"; +import { + type Combatant, + combatantId, + createEncounter, + type Encounter, + isDomainError, +} 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 = retreatTurn(enc); + if (isDomainError(result)) { + throw new Error(`Expected success, got error: ${result.message}`); + } + return result; +} + +// --- Acceptance Scenarios --- + +describe("retreatTurn", () => { + describe("acceptance scenarios", () => { + it("scenario 1: mid-round retreat — retreats from second to first combatant", () => { + const enc = encounter([A, B, C], 1, 1); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(0); + expect(next.roundNumber).toBe(1); + expect(events).toEqual([ + { + type: "TurnRetreated", + previousCombatantId: combatantId("B"), + newCombatantId: combatantId("A"), + roundNumber: 1, + }, + ]); + }); + + it("scenario 2: round-boundary retreat — wraps from first combatant to last, decrements round", () => { + const enc = encounter([A, B, C], 0, 2); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(2); + expect(next.roundNumber).toBe(1); + expect(events).toEqual([ + { + type: "TurnRetreated", + previousCombatantId: combatantId("A"), + newCombatantId: combatantId("C"), + roundNumber: 1, + }, + { + type: "RoundRetreated", + newRoundNumber: 1, + }, + ]); + }); + + it("scenario 3: start-of-encounter error — cannot retreat at round 1 index 0", () => { + const enc = encounter([A, B, C], 0, 1); + const result = retreatTurn(enc); + + expect(isDomainError(result)).toBe(true); + if (isDomainError(result)) { + expect(result.code).toBe("no-previous-turn"); + } + }); + + it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => { + const enc = encounter([A], 0, 2); + const { encounter: next, events } = successResult(enc); + + expect(next.activeIndex).toBe(0); + expect(next.roundNumber).toBe(1); + expect(events).toEqual([ + { + type: "TurnRetreated", + previousCombatantId: combatantId("A"), + newCombatantId: combatantId("A"), + roundNumber: 1, + }, + { + type: "RoundRetreated", + newRoundNumber: 1, + }, + ]); + }); + + it("scenario 5: empty-encounter error", () => { + const enc: Encounter = { + combatants: [], + activeIndex: 0, + roundNumber: 1, + }; + const result = retreatTurn(enc); + + expect(isDomainError(result)).toBe(true); + if (isDomainError(result)) { + expect(result.code).toBe("invalid-encounter"); + } + }); + }); + + describe("invariants", () => { + it("determinism — same input produces same output", () => { + const enc = encounter([A, B, C], 1, 3); + const result1 = retreatTurn(enc); + const result2 = retreatTurn(enc); + expect(result1).toEqual(result2); + }); + + it("activeIndex always in bounds after retreat", () => { + const combatants = [A, B, C]; + // Start at round 4 so we can retreat many times + let enc = encounter(combatants, 2, 4); + + for (let i = 0; i < 9; i++) { + const result = successResult(enc); + expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0); + expect(result.encounter.activeIndex).toBeLessThan(combatants.length); + enc = result.encounter; + } + }); + + it("roundNumber never goes below 1", () => { + let enc = encounter([A, B, C], 2, 2); + + // Retreat through rounds — should stop at round 1 index 0 + while (!(enc.roundNumber === 1 && enc.activeIndex === 0)) { + const result = successResult(enc); + expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(1); + enc = result.encounter; + } + }); + + it("every success emits at least TurnRetreated", () => { + const scenarios: Encounter[] = [ + encounter([A, B, C], 1, 1), + encounter([A, B, C], 0, 2), + encounter([A], 0, 2), + ]; + + for (const enc of scenarios) { + const result = successResult(enc); + const hasTurnRetreated = result.events.some( + (e: DomainEvent) => e.type === "TurnRetreated", + ); + expect(hasTurnRetreated).toBe(true); + } + }); + + it("event ordering: on wrap, events are [TurnRetreated, RoundRetreated]", () => { + const enc = encounter([A, B, C], 0, 2); + const { events } = successResult(enc); + + expect(events).toHaveLength(2); + expect(events[0].type).toBe("TurnRetreated"); + expect(events[1].type).toBe("RoundRetreated"); + }); + }); +}); diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index 4da2ce6..7b201b7 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -56,6 +56,18 @@ export interface CurrentHpAdjusted { readonly delta: number; } +export interface TurnRetreated { + readonly type: "TurnRetreated"; + readonly previousCombatantId: CombatantId; + readonly newCombatantId: CombatantId; + readonly roundNumber: number; +} + +export interface RoundRetreated { + readonly type: "RoundRetreated"; + readonly newRoundNumber: number; +} + export type DomainEvent = | TurnAdvanced | RoundAdvanced @@ -64,4 +76,6 @@ export type DomainEvent = | CombatantUpdated | InitiativeSet | MaxHpSet - | CurrentHpAdjusted; + | CurrentHpAdjusted + | TurnRetreated + | RoundRetreated; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5d534c6..00d0d5b 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -14,12 +14,15 @@ export type { InitiativeSet, MaxHpSet, RoundAdvanced, + RoundRetreated, TurnAdvanced, + TurnRetreated, } from "./events.js"; export { type RemoveCombatantSuccess, removeCombatant, } from "./remove-combatant.js"; +export { retreatTurn } from "./retreat-turn.js"; export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetInitiativeSuccess, diff --git a/packages/domain/src/retreat-turn.ts b/packages/domain/src/retreat-turn.ts new file mode 100644 index 0000000..accb638 --- /dev/null +++ b/packages/domain/src/retreat-turn.ts @@ -0,0 +1,59 @@ +import type { DomainEvent } from "./events.js"; +import type { DomainError, Encounter } from "./types.js"; + +interface RetreatTurnSuccess { + readonly encounter: Encounter; + readonly events: DomainEvent[]; +} + +export function retreatTurn( + encounter: Encounter, +): RetreatTurnSuccess | DomainError { + if (encounter.combatants.length === 0) { + return { + kind: "domain-error", + code: "invalid-encounter", + message: "Cannot retreat turn on an encounter with no combatants", + }; + } + + if (encounter.roundNumber === 1 && encounter.activeIndex === 0) { + return { + kind: "domain-error", + code: "no-previous-turn", + message: "Cannot retreat before the start of the encounter", + }; + } + + const previousIndex = encounter.activeIndex; + const wraps = previousIndex === 0; + const newIndex = wraps ? encounter.combatants.length - 1 : previousIndex - 1; + const newRoundNumber = wraps + ? encounter.roundNumber - 1 + : encounter.roundNumber; + + const events: DomainEvent[] = [ + { + type: "TurnRetreated", + previousCombatantId: encounter.combatants[previousIndex].id, + newCombatantId: encounter.combatants[newIndex].id, + roundNumber: newRoundNumber, + }, + ]; + + if (wraps) { + events.push({ + type: "RoundRetreated", + newRoundNumber, + }); + } + + return { + encounter: { + combatants: encounter.combatants, + activeIndex: newIndex, + roundNumber: newRoundNumber, + }, + events, + }; +} diff --git a/specs/012-turn-navigation/checklists/requirements.md b/specs/012-turn-navigation/checklists/requirements.md new file mode 100644 index 0000000..7a48068 --- /dev/null +++ b/specs/012-turn-navigation/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Turn Navigation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-05 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/012-turn-navigation/contracts/domain-api.md b/specs/012-turn-navigation/contracts/domain-api.md new file mode 100644 index 0000000..6834243 --- /dev/null +++ b/specs/012-turn-navigation/contracts/domain-api.md @@ -0,0 +1,55 @@ +# Domain API Contract: Turn Navigation + +## retreatTurn(encounter: Encounter): RetreatTurnSuccess | DomainError + +### Input +- `encounter`: Encounter (combatants, activeIndex, roundNumber) + +### Success Output +``` +{ + encounter: Encounter // Updated state + events: DomainEvent[] // TurnRetreated, optionally RoundRetreated +} +``` + +### Error Cases + +| Condition | Error Code | Message | +|-----------|------------|---------| +| combatants.length === 0 | `invalid-encounter` | Cannot retreat turn on an encounter with no combatants | +| roundNumber === 1 && activeIndex === 0 | `no-previous-turn` | Cannot retreat before the start of the encounter | + +### Event Contracts + +**TurnRetreated** (always emitted on success): +``` +{ + type: "TurnRetreated" + previousCombatantId: CombatantId // Was active before retreat + newCombatantId: CombatantId // Now active after retreat + roundNumber: number // Round number after retreat +} +``` + +**RoundRetreated** (emitted when crossing round boundary): +``` +{ + type: "RoundRetreated" + newRoundNumber: number // Decremented round number +} +``` + +**Emission order**: TurnRetreated first, then RoundRetreated (when applicable). + +## UI Contract: TurnNavigation Component + +### Props +- `encounter`: Encounter (current state) +- `onAdvanceTurn`: () => void +- `onRetreatTurn`: () => void + +### Behavior +- Previous Turn button: calls onRetreatTurn; disabled when roundNumber === 1 && activeIndex === 0, or combatants.length === 0 +- Next Turn button: calls onAdvanceTurn; disabled when combatants.length === 0 +- Displays: round number and active combatant name diff --git a/specs/012-turn-navigation/data-model.md b/specs/012-turn-navigation/data-model.md new file mode 100644 index 0000000..f094906 --- /dev/null +++ b/specs/012-turn-navigation/data-model.md @@ -0,0 +1,58 @@ +# Data Model: Turn Navigation + +## Existing Entities (unchanged) + +### Encounter +- `combatants`: readonly array of Combatant +- `activeIndex`: number (0-based index into combatants) +- `roundNumber`: positive integer (>= 1) + +### Combatant +- `id`: CombatantId (branded string) +- `name`: string +- `initiative?`: number +- `maxHp?`: number +- `currentHp?`: number + +## New Domain Events + +### TurnRetreated +Emitted on every successful RetreatTurn operation. + +| Field | Type | Description | +|-------|------|-------------| +| type | `"TurnRetreated"` (literal) | Discriminant for event union | +| previousCombatantId | CombatantId | The combatant whose turn was active before retreat | +| newCombatantId | CombatantId | The combatant who is now active after retreat | +| roundNumber | number | The round number after the retreat | + +### RoundRetreated +Emitted when RetreatTurn crosses a round boundary (activeIndex wraps from 0 to last combatant). + +| Field | Type | Description | +|-------|------|-------------| +| type | `"RoundRetreated"` (literal) | Discriminant for event union | +| newRoundNumber | number | The round number after decrementing | + +## State Transitions + +### RetreatTurn + +**Input**: Encounter +**Output**: `{ encounter: Encounter, events: DomainEvent[] }` | `DomainError` + +**Rules**: +1. If `combatants.length === 0` -> DomainError("invalid-encounter") +2. If `roundNumber === 1 && activeIndex === 0` -> DomainError("no-previous-turn") +3. If `activeIndex > 0`: newIndex = activeIndex - 1, roundNumber unchanged +4. If `activeIndex === 0`: newIndex = combatants.length - 1, roundNumber - 1 + +**Events emitted**: +- Always: TurnRetreated +- On round boundary crossing (rule 4): TurnRetreated then RoundRetreated (order matters) + +## Validation Rules + +- RetreatTurn MUST NOT produce roundNumber < 1 +- RetreatTurn MUST NOT produce activeIndex < 0 or >= combatants.length +- RetreatTurn is a pure function: identical input produces identical output diff --git a/specs/012-turn-navigation/plan.md b/specs/012-turn-navigation/plan.md new file mode 100644 index 0000000..ab9b320 --- /dev/null +++ b/specs/012-turn-navigation/plan.md @@ -0,0 +1,76 @@ +# Implementation Plan: Turn Navigation + +**Branch**: `012-turn-navigation` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/012-turn-navigation/spec.md` + +## Summary + +Add a RetreatTurn domain operation (inverse of AdvanceTurn) and relocate both Previous/Next Turn buttons to a new turn navigation bar at the top of the encounter tracker. The domain function is a pure state transition that decrements the active index (wrapping to the last combatant and decrementing round number when crossing a round boundary), with a guard preventing retreat before the encounter start (round 1, index 0). + +## Technical Context + +**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax) +**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) +**Storage**: N/A (no storage changes -- existing localStorage persistence unchanged) +**Testing**: Vitest (pure-function domain tests + component tests) +**Target Platform**: Web browser (single-user, local-first) +**Project Type**: Web application (monorepo: domain + application + web adapter) +**Performance Goals**: Standard web app -- instant UI response (<100ms) +**Constraints**: Domain layer must remain pure (no I/O, no framework imports) +**Scale/Scope**: Single-user encounter tracker, ~5-20 combatants typical + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | RetreatTurn is a pure function: same input = same output. No I/O, randomness, or clocks. | +| II. Layered Architecture | PASS | Domain (retreat-turn.ts) -> Application (retreat-turn-use-case.ts) -> Adapter (React hook + component). Dependency direction preserved. | +| III. Agent Boundary | N/A | No agent layer involved. | +| IV. Clarification-First | PASS | No ambiguities remain in spec. All edge cases resolved. | +| V. Escalation Gates | PASS | Implementation matches spec scope exactly. | +| VI. MVP Baseline Language | PASS | Assumptions use "MVP baseline does not include" phrasing. | +| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/012-turn-navigation/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +packages/domain/src/ +├── retreat-turn.ts # NEW: RetreatTurn pure function +├── events.ts # MODIFY: Add TurnRetreated + RoundRetreated events +├── types.ts # UNCHANGED +├── index.ts # MODIFY: Re-export retreatTurn +└── __tests__/ + └── retreat-turn.test.ts # NEW: All acceptance scenarios + +packages/application/src/ +├── retreat-turn-use-case.ts # NEW: Orchestrates retreatTurn via EncounterStore +├── ports.ts # UNCHANGED +├── index.ts # MODIFY: Re-export new use case +└── ... + +apps/web/src/ +├── components/ +│ ├── turn-navigation.tsx # NEW: Top-placed Previous/Next turn bar +│ └── action-bar.tsx # MODIFY: Remove Next Turn button +├── hooks/ +│ └── use-encounter.ts # MODIFY: Add retreatTurn handler +└── App.tsx # MODIFY: Add TurnNavigation component above combatant list +``` + +**Structure Decision**: Follows existing monorepo layout (domain -> application -> web adapter). New files mirror the existing advance-turn pattern. The turn navigation component is a new UI component placed in the existing components directory. diff --git a/specs/012-turn-navigation/quickstart.md b/specs/012-turn-navigation/quickstart.md new file mode 100644 index 0000000..621784b --- /dev/null +++ b/specs/012-turn-navigation/quickstart.md @@ -0,0 +1,49 @@ +# Quickstart: Turn Navigation + +## Prerequisites + +- Node.js 20+ +- pnpm + +## Setup + +```bash +pnpm install +``` + +## Development + +```bash +pnpm --filter web dev # Start dev server at localhost:5173 +``` + +## Testing + +```bash +# Run all tests +pnpm test + +# Run only retreat-turn domain tests +pnpm vitest run packages/domain/src/__tests__/retreat-turn.test.ts + +# Run tests in watch mode +pnpm test:watch +``` + +## Quality Gate + +```bash +pnpm check # Must pass before every commit (knip + format + lint + typecheck + test) +``` + +## Key Files for This Feature + +| File | Layer | Purpose | +|------|-------|---------| +| `packages/domain/src/retreat-turn.ts` | Domain | Pure RetreatTurn function | +| `packages/domain/src/events.ts` | Domain | TurnRetreated + RoundRetreated event types | +| `packages/domain/src/__tests__/retreat-turn.test.ts` | Domain | Acceptance scenario tests | +| `packages/application/src/retreat-turn-use-case.ts` | Application | Use case orchestration | +| `apps/web/src/components/turn-navigation.tsx` | Adapter | Turn nav UI component | +| `apps/web/src/components/action-bar.tsx` | Adapter | Remove Next Turn button | +| `apps/web/src/App.tsx` | Adapter | Wire up new component | diff --git a/specs/012-turn-navigation/research.md b/specs/012-turn-navigation/research.md new file mode 100644 index 0000000..ad16631 --- /dev/null +++ b/specs/012-turn-navigation/research.md @@ -0,0 +1,49 @@ +# Research: Turn Navigation + +## R1: RetreatTurn Domain Pattern + +**Decision**: Implement RetreatTurn as a mirror of the existing AdvanceTurn pattern -- a pure function that accepts an Encounter and returns the updated Encounter plus domain events, or a DomainError. + +**Rationale**: The existing AdvanceTurn function (`packages/domain/src/advance-turn.ts`) establishes a clear pattern: pure function, value-based error handling via DomainError, domain events returned as data. RetreatTurn is its exact inverse, so following the same pattern maintains consistency and testability. + +**Alternatives considered**: +- Generic "move turn" function with direction parameter: Rejected because AdvanceTurn already exists and changing its signature would break the existing API for no benefit. Two focused functions are simpler than one parameterized function. +- Undo/redo stack: Out of scope per spec assumptions. RetreatTurn is a positional operation, not a state-history undo. + +## R2: Boundary Guard (Cannot Retreat Before Start) + +**Decision**: RetreatTurn returns a DomainError when `roundNumber === 1 && activeIndex === 0`. This is the earliest possible encounter state. + +**Rationale**: There is no meaningful "previous turn" before the encounter starts. Allowing retreat past this point would require round 0 or negative rounds, which violates INV-3 (roundNumber >= 1). The spec explicitly requires this guard (FR-003, acceptance scenario 3). + +**Alternatives considered**: +- Silently no-op: Rejected because domain operations should be explicit about failures (constitution principle I). +- Allow round 0 as "setup" round: Out of scope, would require spec amendment. + +## R3: New Domain Events (TurnRetreated, RoundRetreated) + +**Decision**: Introduce two new domain event types: `TurnRetreated` and `RoundRetreated`, mirroring the existing `TurnAdvanced` and `RoundAdvanced` event shapes. + +**Rationale**: The existing event system uses discriminated unions with a `type` field. Retreat events should be distinct from advance events so consumers can differentiate the direction of navigation. The shapes mirror their forward counterparts for consistency. + +**Alternatives considered**: +- Reuse TurnAdvanced/RoundAdvanced with a `direction` field: Rejected because it changes the existing event shape (breaking change) and complicates event consumers that only care about forward progression. + +## R4: UI Placement -- Turn Navigation at Top + +**Decision**: Create a new `TurnNavigation` component positioned between the header and the combatant list. Move the Next Turn button out of ActionBar into this new component. ActionBar retains only the Add Combatant form. + +**Rationale**: The spec requires turn controls at the top of the tracker (FR-007). The current Next Turn button lives in ActionBar at the bottom. Splitting concerns (turn navigation vs. combatant management) improves the component structure and matches the spec's UX intent. + +**Alternatives considered**: +- Keep Next Turn in ActionBar and duplicate at top: Rejected -- redundant controls create confusion. +- Move entire ActionBar to top: Rejected -- the Add Combatant form is secondary to turn navigation and should not compete for top-of-page prominence. + +## R5: Disabled State for Previous Turn Button + +**Decision**: The Previous Turn button renders in a disabled state (using the existing Button component's `disabled` prop) when the encounter is at round 1, activeIndex 0, or when there are no combatants. + +**Rationale**: The spec requires visual indication that Previous Turn is unavailable (FR-008, FR-009). Using the existing Button disabled styling from shadcn/ui-style components ensures visual consistency. + +**Alternatives considered**: +- Hide the button entirely when unavailable: Rejected -- hiding causes layout shifts and makes the control harder to discover. diff --git a/specs/012-turn-navigation/spec.md b/specs/012-turn-navigation/spec.md new file mode 100644 index 0000000..c372008 --- /dev/null +++ b/specs/012-turn-navigation/spec.md @@ -0,0 +1,106 @@ +# Feature Specification: Turn Navigation + +**Feature Branch**: `012-turn-navigation` +**Created**: 2026-03-05 +**Status**: Draft +**Input**: User description: "Introduce a previous Turn button and put both, the previous and the next turn buttons at the top of the tracker. Make the UI/UX modern and sleek. Easy to use." + +## User Scenarios & Testing + +### User Story 1 - Go Back to the Previous Turn (Priority: P1) + +As a game master running a combat encounter, I want to go back to the previous combatant's turn so that I can correct mistakes (e.g., a forgotten action, an incorrectly skipped combatant) without restarting the encounter. + +**Why this priority**: The ability to undo a turn advancement is the core new capability. Without it, game masters must work around mistakes manually, disrupting the flow of combat. + +**Independent Test**: Can be fully tested as a pure state transition. Given an Encounter and a RetreatTurn action, assert the resulting Encounter state and emitted domain events. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatants [A, B, C], activeIndex 1, roundNumber 1, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 1, and a TurnRetreated event is emitted with previousCombatantId B, newCombatantId A, roundNumber 1. +2. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 2, **When** RetreatTurn, **Then** activeIndex is 2, roundNumber is 1, and events are emitted: TurnRetreated (previousCombatantId A, newCombatantId C, roundNumber 1) then RoundRetreated (newRoundNumber 1). +3. **Given** an encounter with combatants [A, B, C], activeIndex 0, roundNumber 1, **When** RetreatTurn, **Then** the operation MUST fail with an error. The encounter is at the very beginning -- there is no previous turn to return to. +4. **Given** an encounter with a single combatant [A], activeIndex 0, roundNumber 3, **When** RetreatTurn, **Then** activeIndex is 0, roundNumber is 2, and events are emitted: TurnRetreated then RoundRetreated (newRoundNumber 2). +5. **Given** an encounter with an empty combatant list, **When** RetreatTurn, **Then** the operation MUST fail with an invalid-encounter error. No events are emitted. State is unchanged. + +--- + +### User Story 2 - Turn Navigation Controls at the Top of the Tracker (Priority: P1) + +As a game master, I want the Previous Turn and Next Turn buttons placed prominently at the top of the encounter tracker so that I can quickly navigate turns without scrolling to the bottom of a long combatant list. + +**Why this priority**: Relocating the turn controls to the top directly improves usability -- the most-used combat controls should be immediately visible and accessible. + +**Independent Test**: Can be fully tested by loading the tracker with combatants and verifying that the turn navigation controls appear above the combatant list and function correctly. + +**Acceptance Scenarios**: + +1. **Given** the encounter tracker is displayed, **When** the user looks at the screen, **Then** the Previous Turn and Next Turn buttons are visible at the top of the tracker, above the combatant list. +2. **Given** the tracker has many combatants requiring scrolling, **When** the user scrolls down, **Then** the turn navigation controls remain accessible at the top (no need to scroll to find them). +3. **Given** the encounter is at round 1 with the first combatant active, **When** the user views the turn controls, **Then** the Previous Turn button is disabled (visually indicating it cannot be used). +4. **Given** the encounter has no combatants, **When** the user views the tracker, **Then** the turn navigation controls are hidden or disabled. + +--- + +### User Story 3 - Modern, Sleek Turn Navigation Design (Priority: P2) + +As a game master, I want the turn navigation controls to have a clean, modern design with clear visual hierarchy so that I can navigate combat intuitively and confidently. + +**Why this priority**: A polished design enhances usability and reduces errors during the fast pace of combat. However, functionality comes first. + +**Independent Test**: Can be fully tested by visually inspecting the turn navigation area and verifying it meets design criteria. + +**Acceptance Scenarios**: + +1. **Given** the turn navigation is displayed, **When** the user looks at the controls, **Then** the Previous and Next buttons are visually distinct with clear labels or icons indicating direction. +2. **Given** the encounter is in progress, **When** the user views the turn navigation area, **Then** the current round number and active combatant name are displayed alongside the navigation controls. +3. **Given** the Previous Turn action is not available (round 1, first combatant), **When** the user views the Previous button, **Then** the button appears in a disabled state that is visually distinct from the active state. + +--- + +### Edge Cases + +- What happens when retreating at the very start of the encounter (round 1, activeIndex 0)? The operation fails with an error; the Previous Turn button is disabled in this state. +- What happens when retreating past the round boundary? The round number decrements by 1 and activeIndex wraps to the last combatant. +- What happens with a single combatant at round 1? RetreatTurn fails because there is no previous turn. The Previous Turn button is disabled. +- What happens when retreating would bring the round number below 1? This cannot happen -- round 1, activeIndex 0 is the earliest possible state and is blocked. + +## Requirements + +### Functional Requirements + +- **FR-001**: The system MUST provide a RetreatTurn operation that moves the active turn to the previous combatant in initiative order. +- **FR-002**: RetreatTurn MUST decrement activeIndex by 1. When activeIndex would go below 0, it MUST wrap to the last combatant and decrement roundNumber by 1. +- **FR-003**: RetreatTurn at round 1 with activeIndex 0 MUST fail with an error. This is the earliest possible encounter state. +- **FR-004**: RetreatTurn on an empty encounter MUST fail with an error without modifying state or emitting events. +- **FR-005**: RetreatTurn MUST emit a TurnRetreated domain event on success. When the round boundary is crossed (going backward), a RoundRetreated event MUST also be emitted, in order: TurnRetreated first, then RoundRetreated. +- **FR-006**: RetreatTurn MUST be a pure function of the current encounter state. Given identical input, output MUST be identical. +- **FR-007**: The Previous Turn and Next Turn buttons MUST be positioned at the top of the encounter tracker, above the combatant list. +- **FR-008**: The Previous Turn button MUST be disabled when the encounter is at its earliest state (round 1, first combatant active). +- **FR-009**: Both turn navigation buttons MUST be disabled or hidden when the encounter has no combatants. +- **FR-010**: The turn navigation area MUST display the current round number and the active combatant's name. +- **FR-011**: The turn navigation buttons MUST have clear directional indicators (icons, labels, or both) so the user can distinguish Previous from Next at a glance. + +### Key Entities + +- **Encounter**: Existing aggregate. Contains combatants, activeIndex, and roundNumber. RetreatTurn operates on this entity. +- **TurnRetreated (new domain event)**: Emitted when the turn is moved backward. Carries: previousCombatantId, newCombatantId, roundNumber. +- **RoundRetreated (new domain event)**: Emitted when retreating crosses a round boundary. Carries: newRoundNumber. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: A user can reverse a turn advancement in under 1 second using a single click on the Previous Turn button. +- **SC-002**: Turn navigation controls are visible without scrolling, regardless of the number of combatants in the encounter. +- **SC-003**: The Previous Turn button is correctly disabled at the earliest encounter state (round 1, first combatant), preventing invalid operations 100% of the time. +- **SC-004**: All RetreatTurn acceptance scenarios pass as deterministic, pure-function tests with no I/O dependencies. +- **SC-005**: Users can identify which direction each button navigates (forward or backward) within 1 second of viewing the controls. + +## Assumptions + +- RetreatTurn is the inverse of AdvanceTurn. It does not restore any other state (e.g., HP changes made during a combatant's turn are not undone). It only changes the active combatant and round number. +- The Next Turn button is relocated from the bottom action bar to the new top navigation area. The bottom action bar retains the "Add Combatant" form. +- The existing AdvanceTurn domain operation and its events remain unchanged. +- The MVP baseline does not include a full undo/redo stack. RetreatTurn is a simple backward step through initiative order, not a state history undo. +- The MVP baseline does not include keyboard shortcuts for Previous/Next Turn navigation. diff --git a/specs/012-turn-navigation/tasks.md b/specs/012-turn-navigation/tasks.md new file mode 100644 index 0000000..9c00ef3 --- /dev/null +++ b/specs/012-turn-navigation/tasks.md @@ -0,0 +1,153 @@ +# Tasks: Turn Navigation + +**Input**: Design documents from `/specs/012-turn-navigation/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: No new project setup needed -- existing monorepo structure is in place. This phase is empty. + +**Checkpoint**: Existing infrastructure is sufficient. Proceed directly to foundational work. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add new domain event types. These are shared by US1 (domain logic) and US2/US3 (UI). + +**CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T001 Add TurnRetreated and RoundRetreated event interfaces to the DomainEvent union in `packages/domain/src/events.ts` + +**Checkpoint**: Foundation ready -- event types defined, user story implementation can begin. + +--- + +## Phase 3: User Story 1 - Go Back to the Previous Turn (Priority: P1) MVP + +**Goal**: Implement the RetreatTurn pure domain function and its application use case so that turns can be reversed programmatically. + +**Independent Test**: Call retreatTurn with various encounter states and verify correct activeIndex, roundNumber, and emitted events. All tests are pure-function assertions with no I/O. + +### Implementation for User Story 1 + +- [x] T003 [US1] Create retreatTurn pure function in `packages/domain/src/retreat-turn.ts` implementing: decrement activeIndex, wrap to last combatant on round boundary, decrement roundNumber on wrap, error on empty encounter, error on round 1 index 0 +- [x] T004 [US1] Write acceptance scenario tests for retreatTurn in `packages/domain/src/__tests__/retreat-turn.test.ts` covering all 5 spec scenarios: mid-round retreat, round-boundary retreat, start-of-encounter error, single-combatant retreat, empty-encounter error +- [x] T005 [US1] Re-export retreatTurn from domain index in `packages/domain/src/index.ts` +- [x] T006 [US1] Create retreatTurnUseCase in `packages/application/src/retreat-turn-use-case.ts` following the advanceTurnUseCase pattern (get encounter from store, call retreatTurn, save result or return error) +- [x] T007 [US1] Export retreatTurnUseCase from application index in `packages/application/src/index.ts` +- [x] T008 [US1] Run `pnpm check` to verify all tests pass, types check, and no lint/format issues + +**Checkpoint**: RetreatTurn domain logic is fully functional and tested. UI work can proceed. + +--- + +## Phase 4: User Story 2 - Turn Navigation Controls at the Top (Priority: P1) + +**Goal**: Create a turn navigation bar at the top of the tracker with Previous and Next Turn buttons, relocating Next Turn from the bottom action bar. + +**Independent Test**: Load the tracker with combatants, verify Previous/Next buttons appear above the combatant list, click Next to advance, click Previous to retreat, verify disabled states. + +### Implementation for User Story 2 + +- [x] T009 [US2] Add retreatTurn handler to the encounter hook in `apps/web/src/hooks/use-encounter.ts` (mirror the existing advanceTurn handler pattern) +- [x] T010 [US2] Create TurnNavigation component in `apps/web/src/components/turn-navigation.tsx` with Previous/Next buttons, round number display, active combatant name, and disabled states (Previous disabled at round 1 index 0 or no combatants; Next disabled when no combatants) +- [x] T011 [US2] Wire TurnNavigation into App between header and combatant list in `apps/web/src/App.tsx`, passing encounter state, onAdvanceTurn, and onRetreatTurn +- [x] T012 [US2] Remove Next Turn button from ActionBar in `apps/web/src/components/action-bar.tsx` (keep only the Add Combatant form) + +**Checkpoint**: Turn navigation is fully functional at the top of the tracker. Previous and Next Turn work correctly with proper disabled states. + +--- + +## Phase 5: User Story 3 - Modern, Sleek Turn Navigation Design (Priority: P2) + +**Goal**: Polish the turn navigation bar with clear visual hierarchy, directional icons, and a clean modern design consistent with the existing shadcn/ui-style components. + +**Independent Test**: Visually inspect the turn navigation area -- buttons have directional icons, round/combatant info is clearly displayed, disabled state is visually distinct, layout is balanced and clean. + +### Implementation for User Story 3 + +- [x] T014 [US3] Add directional icons (e.g., ChevronLeft, ChevronRight from Lucide React) to the Previous/Next buttons in `apps/web/src/components/turn-navigation.tsx` +- [x] T015 [US3] Style the turn navigation bar with proper spacing, border, background, and visual hierarchy consistent with existing card/action-bar styling in `apps/web/src/components/turn-navigation.tsx` +- [x] T016 [US3] Ensure disabled button state has reduced opacity and no hover effects, visually distinct from active state in `apps/web/src/components/turn-navigation.tsx` + +**Checkpoint**: Turn navigation is visually polished with clear directional indicators and modern design. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation across all stories. + +- [x] T018 Verify layer boundary compliance -- retreatTurn in domain has no application/adapter imports (covered by existing `layer-boundaries.test.ts`) +- [x] T019 Run full quality gate with `pnpm check` and verify clean pass +- [x] T020 Verify localStorage persistence handles retreat correctly (existing persistence should work transparently since it saves the Encounter state) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Foundational (Phase 2)**: No dependencies -- can start immediately +- **User Story 1 (Phase 3)**: Depends on Phase 2 (event types must exist) +- **User Story 2 (Phase 4)**: Depends on Phase 3 (retreatTurn domain + use case must exist) +- **User Story 3 (Phase 5)**: Depends on Phase 4 (TurnNavigation component must exist to polish) +- **Polish (Phase 6)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) -- pure domain work, no UI dependency +- **User Story 2 (P1)**: Depends on US1 completion -- needs retreatTurn use case to wire into the UI +- **User Story 3 (P2)**: Depends on US2 completion -- polishes the component created in US2 + +### Within Each User Story + +- Domain function before tests (or TDD: tests first, then function) +- Domain before application use case +- Application use case before adapter/UI wiring +- UI component creation before styling polish + +### Parallel Opportunities + +- T014, T015, T016 can run in parallel (same file but independent style concerns -- may be done in one pass) + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational (event types) +2. Complete Phase 3: User Story 1 (retreatTurn domain + tests + use case) +3. **STOP and VALIDATE**: All acceptance scenarios pass as pure-function tests +4. Domain logic is fully verified before any UI work + +### Incremental Delivery + +1. Phase 2: Foundation -> Event types defined +2. Phase 3: US1 -> RetreatTurn domain logic tested and working (MVP domain!) +3. Phase 4: US2 -> Turn navigation bar at top, Previous/Next buttons functional (MVP UI!) +4. Phase 5: US3 -> Visual polish with icons and consistent styling +5. Phase 6: Polish -> Final cross-cutting validation + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- US2 depends on US1 (needs the domain function); US3 depends on US2 (polishes the component) +- This feature has a linear dependency chain, limiting parallel opportunities +- Commit after each phase checkpoint +- The existing advance-turn pattern (domain -> use case -> hook -> component) serves as the reference implementation for all new code