From fea2bfe39d8def6e654c58b1b1e9b708b889e634 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 4 Mar 2026 17:26:41 +0100 Subject: [PATCH] Implement the 005-set-initiative feature that adds initiative values to combatants with automatic descending sort and active turn preservation Co-Authored-By: Claude Opus 4.6 --- apps/web/src/App.tsx | 20 ++ apps/web/src/hooks/use-encounter.ts | 15 + packages/application/src/index.ts | 1 + .../src/set-initiative-use-case.ts | 24 ++ .../src/__tests__/set-initiative.test.ts | 314 ++++++++++++++++++ packages/domain/src/events.ts | 10 +- packages/domain/src/index.ts | 5 + packages/domain/src/set-initiative.ts | 101 ++++++ packages/domain/src/types.ts | 1 + .../checklists/requirements.md | 34 ++ .../contracts/domain-api.md | 57 ++++ specs/005-set-initiative/data-model.md | 63 ++++ specs/005-set-initiative/plan.md | 83 +++++ specs/005-set-initiative/quickstart.md | 36 ++ specs/005-set-initiative/research.md | 49 +++ specs/005-set-initiative/spec.md | 116 +++++++ specs/005-set-initiative/tasks.md | 179 ++++++++++ 17 files changed, 1107 insertions(+), 1 deletion(-) create mode 100644 packages/application/src/set-initiative-use-case.ts create mode 100644 packages/domain/src/__tests__/set-initiative.test.ts create mode 100644 packages/domain/src/set-initiative.ts create mode 100644 specs/005-set-initiative/checklists/requirements.md create mode 100644 specs/005-set-initiative/contracts/domain-api.md create mode 100644 specs/005-set-initiative/data-model.md create mode 100644 specs/005-set-initiative/plan.md create mode 100644 specs/005-set-initiative/quickstart.md create mode 100644 specs/005-set-initiative/research.md create mode 100644 specs/005-set-initiative/spec.md create mode 100644 specs/005-set-initiative/tasks.md diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 070c2c1..cbc0d87 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,8 @@ function formatEvent(e: ReturnType["events"][number]) { return `Removed combatant: ${e.name}`; case "CombatantUpdated": return `Renamed combatant: ${e.oldName} → ${e.newName}`; + case "InitiativeSet": + return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"} → ${e.newValue ?? "unset"}`; } } @@ -77,6 +79,7 @@ export function App() { addCombatant, removeCombatant, editCombatant, + setInitiative, } = useEncounter(); const activeCombatant = encounter.combatants[encounter.activeIndex]; const [nameInput, setNameInput] = useState(""); @@ -107,6 +110,23 @@ export function App() { isActive={i === encounter.activeIndex} onRename={editCombatant} />{" "} + { + const raw = e.target.value; + if (raw === "") { + setInitiative(c.id, undefined); + } else { + const n = Number.parseInt(raw, 10); + if (!Number.isNaN(n)) { + setInitiative(c.id, n); + } + } + }} + />{" "} diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 368e132..dd61893 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -4,6 +4,7 @@ import { advanceTurnUseCase, editCombatantUseCase, removeCombatantUseCase, + setInitiativeUseCase, } from "@initiative/application"; import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain"; import { @@ -92,6 +93,19 @@ export function useEncounter() { [makeStore], ); + const setInitiative = useCallback( + (id: CombatantId, value: number | undefined) => { + const result = setInitiativeUseCase(makeStore(), id, value); + + if (isDomainError(result)) { + return; + } + + setEvents((prev) => [...prev, ...result]); + }, + [makeStore], + ); + return { encounter, events, @@ -99,5 +113,6 @@ export function useEncounter() { addCombatant, removeCombatant, editCombatant, + setInitiative, } as const; } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 80eb064..2ad9091 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -3,3 +3,4 @@ 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 { setInitiativeUseCase } from "./set-initiative-use-case.js"; diff --git a/packages/application/src/set-initiative-use-case.ts b/packages/application/src/set-initiative-use-case.ts new file mode 100644 index 0000000..3877d67 --- /dev/null +++ b/packages/application/src/set-initiative-use-case.ts @@ -0,0 +1,24 @@ +import { + type CombatantId, + type DomainError, + type DomainEvent, + isDomainError, + setInitiative, +} from "@initiative/domain"; +import type { EncounterStore } from "./ports.js"; + +export function setInitiativeUseCase( + store: EncounterStore, + combatantId: CombatantId, + value: number | undefined, +): DomainEvent[] | DomainError { + const encounter = store.get(); + const result = setInitiative(encounter, combatantId, value); + + if (isDomainError(result)) { + return result; + } + + store.save(result.encounter); + return result.events; +} diff --git a/packages/domain/src/__tests__/set-initiative.test.ts b/packages/domain/src/__tests__/set-initiative.test.ts new file mode 100644 index 0000000..0f8e9e2 --- /dev/null +++ b/packages/domain/src/__tests__/set-initiative.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest"; +import { setInitiative } from "../set-initiative.js"; +import type { Combatant, Encounter } from "../types.js"; +import { combatantId, isDomainError } from "../types.js"; + +// --- Helpers --- + +function makeCombatant(name: string, initiative?: number): Combatant { + return initiative === undefined + ? { id: combatantId(name), name } + : { id: combatantId(name), name, initiative }; +} + +const A = makeCombatant("A"); +const B = makeCombatant("B"); +const C = makeCombatant("C"); +function enc( + combatants: Combatant[], + activeIndex = 0, + roundNumber = 1, +): Encounter { + return { combatants, activeIndex, roundNumber }; +} + +function successResult( + encounter: Encounter, + id: string, + value: number | undefined, +) { + const result = setInitiative(encounter, combatantId(id), value); + if (isDomainError(result)) { + throw new Error(`Expected success, got error: ${result.message}`); + } + return result; +} + +function names(encounter: Encounter): string[] { + return encounter.combatants.map((c) => c.name); +} + +// --- US1: Set Initiative --- + +describe("setInitiative", () => { + describe("US1: set initiative value", () => { + it("AS-1: set initiative on combatant with no initiative", () => { + const e = enc([A, B], 0); + const { encounter, events } = successResult(e, "A", 15); + + expect(encounter.combatants[0].initiative).toBe(15); + expect(events).toEqual([ + { + type: "InitiativeSet", + combatantId: combatantId("A"), + previousValue: undefined, + newValue: 15, + }, + ]); + }); + + it("AS-2: change existing initiative value", () => { + const e = enc([makeCombatant("A", 15), B], 0); + const { encounter, events } = successResult(e, "A", 8); + + const a = encounter.combatants.find((c) => c.id === combatantId("A")); + expect(a?.initiative).toBe(8); + expect(events[0]).toMatchObject({ + previousValue: 15, + newValue: 8, + }); + }); + + it("AS-3: reject non-integer initiative value", () => { + const e = enc([A, B], 0); + const result = setInitiative(e, combatantId("A"), 3.5); + + expect(isDomainError(result)).toBe(true); + if (isDomainError(result)) { + expect(result.code).toBe("invalid-initiative"); + } + }); + + it("AS-3b: reject NaN", () => { + const e = enc([A, B], 0); + const result = setInitiative(e, combatantId("A"), Number.NaN); + expect(isDomainError(result)).toBe(true); + }); + + it("AS-3c: reject Infinity", () => { + const e = enc([A, B], 0); + const result = setInitiative( + e, + combatantId("A"), + Number.POSITIVE_INFINITY, + ); + expect(isDomainError(result)).toBe(true); + }); + + it("AS-4: clear initiative moves combatant to end", () => { + const e = enc([makeCombatant("A", 15), makeCombatant("B", 10)], 0); + const { encounter } = successResult(e, "A", undefined); + + const a = encounter.combatants.find((c) => c.id === combatantId("A")); + expect(a?.initiative).toBeUndefined(); + // A should be after B now + expect(names(encounter)).toEqual(["B", "A"]); + }); + + it("returns error for nonexistent combatant", () => { + const e = enc([A, B], 0); + const result = setInitiative(e, combatantId("nonexistent"), 10); + + expect(isDomainError(result)).toBe(true); + if (isDomainError(result)) { + expect(result.code).toBe("combatant-not-found"); + } + }); + }); + + // --- US2: Automatic Ordering --- + + describe("US2: automatic ordering by initiative", () => { + it("AS-1: orders combatants descending by initiative", () => { + // Start with A(20), B(5), C(15) → should be A(20), C(15), B(5) + const e = enc([ + makeCombatant("A", 20), + makeCombatant("B", 5), + makeCombatant("C", 15), + ]); + // Set C's initiative to trigger reorder (no-op change to force sort) + const { encounter } = successResult(e, "C", 15); + expect(names(encounter)).toEqual(["A", "C", "B"]); + }); + + it("AS-2: changing initiative reorders correctly", () => { + const e = enc([ + makeCombatant("A", 20), + makeCombatant("C", 15), + makeCombatant("B", 5), + ]); + const { encounter } = successResult(e, "B", 25); + expect(names(encounter)).toEqual(["B", "A", "C"]); + }); + + it("AS-3: stable sort for equal initiative values", () => { + const e = enc([makeCombatant("A", 10), makeCombatant("B", 10)]); + // Set A's initiative to same value to confirm stable sort + const { encounter } = successResult(e, "A", 10); + expect(names(encounter)).toEqual(["A", "B"]); + }); + }); + + // --- US3: Combatants Without Initiative --- + + describe("US3: combatants without initiative", () => { + it("AS-1: unset combatants appear after those with initiative", () => { + const e = enc([ + makeCombatant("A", 15), + B, // no initiative + makeCombatant("C", 10), + ]); + const { encounter } = successResult(e, "A", 15); + expect(names(encounter)).toEqual(["A", "C", "B"]); + }); + + it("AS-2: multiple unset combatants preserve relative order", () => { + const e = enc([A, B]); // both no initiative + const { encounter } = successResult(e, "A", undefined); + expect(names(encounter)).toEqual(["A", "B"]); + }); + + it("AS-3: setting initiative moves combatant to correct position", () => { + const e = enc([ + makeCombatant("A", 20), + B, // no initiative + makeCombatant("C", 10), + ]); + const { encounter } = successResult(e, "B", 12); + expect(names(encounter)).toEqual(["A", "B", "C"]); + }); + }); + + // --- US4: Active Turn Preservation --- + + describe("US4: active turn preservation during reorder", () => { + it("AS-1: reorder preserves active turn on different combatant", () => { + // B is active (index 1), change A's initiative + const e = enc( + [makeCombatant("A", 10), makeCombatant("B", 15), makeCombatant("C", 5)], + 1, + ); + // Change A's initiative to 20, causing reorder + const { encounter } = successResult(e, "A", 20); + // New order: A(20), B(15), C(5) + expect(names(encounter)).toEqual(["A", "B", "C"]); + // B should still be active + expect(encounter.combatants[encounter.activeIndex].id).toBe( + combatantId("B"), + ); + }); + + it("AS-2: active combatant's own initiative change preserves turn", () => { + const e = enc( + [makeCombatant("A", 20), makeCombatant("B", 15), makeCombatant("C", 5)], + 0, // A is active + ); + // Change A's initiative to 1, causing it to move to the end + const { encounter } = successResult(e, "A", 1); + // New order: B(15), C(5), A(1) + expect(names(encounter)).toEqual(["B", "C", "A"]); + // A should still be active + expect(encounter.combatants[encounter.activeIndex].id).toBe( + combatantId("A"), + ); + }); + }); + + // --- Invariants --- + + describe("invariants", () => { + it("determinism — same input produces same output", () => { + const e = enc([A, B, C], 1, 3); + const result1 = setInitiative(e, combatantId("A"), 10); + const result2 = setInitiative(e, combatantId("A"), 10); + expect(result1).toEqual(result2); + }); + + it("immutability — input encounter is not mutated", () => { + const e = enc([A, B], 0, 2); + const original = JSON.parse(JSON.stringify(e)); + setInitiative(e, combatantId("A"), 10); + expect(e).toEqual(original); + }); + + it("event shape includes all required fields", () => { + const e = enc([makeCombatant("A", 5), B], 0); + const { events } = successResult(e, "A", 10); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "InitiativeSet", + combatantId: combatantId("A"), + previousValue: 5, + newValue: 10, + }); + }); + + it("roundNumber is never changed", () => { + const e = enc([A, B], 0, 7); + const { encounter } = successResult(e, "A", 10); + expect(encounter.roundNumber).toBe(7); + }); + + it("every success emits exactly one InitiativeSet event", () => { + const scenarios: [Encounter, string, number | undefined][] = [ + [enc([A]), "A", 10], + [enc([A, B], 1), "A", 5], + [enc([makeCombatant("A", 10)]), "A", undefined], + ]; + + for (const [e, id, value] of scenarios) { + const { events } = successResult(e, id, value); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("InitiativeSet"); + } + }); + }); + + // --- Edge Cases --- + + describe("edge cases", () => { + it("zero is a valid initiative value", () => { + const e = enc([A, B], 0); + const { encounter } = successResult(e, "A", 0); + const a = encounter.combatants.find((c) => c.id === combatantId("A")); + expect(a?.initiative).toBe(0); + }); + + it("negative initiative is valid", () => { + const e = enc([A, B], 0); + const { encounter } = successResult(e, "A", -5); + const a = encounter.combatants.find((c) => c.id === combatantId("A")); + expect(a?.initiative).toBe(-5); + }); + + it("negative sorts below positive", () => { + const e = enc([makeCombatant("A", -3), makeCombatant("B", 10)]); + const { encounter } = successResult(e, "A", -3); + expect(names(encounter)).toEqual(["B", "A"]); + }); + + it("all combatants with same initiative preserve order", () => { + const e = enc([ + makeCombatant("A", 10), + makeCombatant("B", 10), + makeCombatant("C", 10), + ]); + const { encounter } = successResult(e, "B", 10); + expect(names(encounter)).toEqual(["A", "B", "C"]); + }); + + it("clearing initiative on last combatant with initiative", () => { + const e = enc([makeCombatant("A", 10), B], 0); + const { encounter } = successResult(e, "A", undefined); + // Both unset now, preserve relative order + expect(names(encounter)).toEqual(["A", "B"]); + }); + + it("undefined value skips integer validation", () => { + const e = enc([A], 0); + const result = setInitiative(e, combatantId("A"), undefined); + expect(isDomainError(result)).toBe(false); + }); + }); +}); diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index 8763751..8f693d9 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -32,9 +32,17 @@ export interface CombatantUpdated { readonly newName: string; } +export interface InitiativeSet { + readonly type: "InitiativeSet"; + readonly combatantId: CombatantId; + readonly previousValue: number | undefined; + readonly newValue: number | undefined; +} + export type DomainEvent = | TurnAdvanced | RoundAdvanced | CombatantAdded | CombatantRemoved - | CombatantUpdated; + | CombatantUpdated + | InitiativeSet; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ea6fef4..dbd2fd6 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -9,6 +9,7 @@ export type { CombatantRemoved, CombatantUpdated, DomainEvent, + InitiativeSet, RoundAdvanced, TurnAdvanced, } from "./events.js"; @@ -16,6 +17,10 @@ export { type RemoveCombatantSuccess, removeCombatant, } from "./remove-combatant.js"; +export { + type SetInitiativeSuccess, + setInitiative, +} from "./set-initiative.js"; export { type Combatant, type CombatantId, diff --git a/packages/domain/src/set-initiative.ts b/packages/domain/src/set-initiative.ts new file mode 100644 index 0000000..fa6329b --- /dev/null +++ b/packages/domain/src/set-initiative.ts @@ -0,0 +1,101 @@ +import type { DomainEvent } from "./events.js"; +import type { CombatantId, DomainError, Encounter } from "./types.js"; + +export interface SetInitiativeSuccess { + readonly encounter: Encounter; + readonly events: DomainEvent[]; +} + +/** + * Pure function that sets, changes, or clears a combatant's initiative value. + * + * After updating the value, combatants are stable-sorted: + * 1. Combatants with initiative — descending by value + * 2. Combatants without initiative — preserve relative order + * + * The active combatant's turn is preserved through the reorder + * by tracking identity (CombatantId) rather than position. + * + * roundNumber is never changed. + */ +export function setInitiative( + encounter: Encounter, + combatantId: CombatantId, + value: number | undefined, +): SetInitiativeSuccess | DomainError { + const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId); + + if (targetIdx === -1) { + return { + kind: "domain-error", + code: "combatant-not-found", + message: `No combatant found with ID "${combatantId}"`, + }; + } + + if (value !== undefined && !Number.isInteger(value)) { + return { + kind: "domain-error", + code: "invalid-initiative", + message: `Initiative must be an integer, got ${value}`, + }; + } + + const target = encounter.combatants[targetIdx]; + const previousValue = target.initiative; + + // Record active combatant's id before reorder + const activeCombatantId = + encounter.combatants.length > 0 + ? encounter.combatants[encounter.activeIndex].id + : undefined; + + // Create new combatants array with updated initiative + const updated = encounter.combatants.map((c) => + c.id === combatantId ? { ...c, initiative: value } : c, + ); + + // Stable sort: initiative descending, undefined last + const indexed = updated.map((c, i) => ({ c, i })); + indexed.sort((a, b) => { + const aHas = a.c.initiative !== undefined; + const bHas = b.c.initiative !== undefined; + + if (aHas && bHas) { + // biome-ignore lint: both checked above + const diff = b.c.initiative! - a.c.initiative!; + return diff !== 0 ? diff : a.i - b.i; + } + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + // Both undefined — preserve relative order + return a.i - b.i; + }); + + const sorted = indexed.map(({ c }) => c); + + // Find active combatant's new index + let newActiveIndex = encounter.activeIndex; + if (activeCombatantId !== undefined) { + const idx = sorted.findIndex((c) => c.id === activeCombatantId); + if (idx !== -1) { + newActiveIndex = idx; + } + } + + return { + encounter: { + combatants: sorted, + activeIndex: newActiveIndex, + roundNumber: encounter.roundNumber, + }, + events: [ + { + type: "InitiativeSet", + combatantId, + previousValue, + newValue: value, + }, + ], + }; +} diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index f830039..e65c3be 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -8,6 +8,7 @@ export function combatantId(id: string): CombatantId { export interface Combatant { readonly id: CombatantId; readonly name: string; + readonly initiative?: number; } export interface Encounter { diff --git a/specs/005-set-initiative/checklists/requirements.md b/specs/005-set-initiative/checklists/requirements.md new file mode 100644 index 0000000..788ef05 --- /dev/null +++ b/specs/005-set-initiative/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Set Initiative + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-04 +**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 validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/005-set-initiative/contracts/domain-api.md b/specs/005-set-initiative/contracts/domain-api.md new file mode 100644 index 0000000..358df24 --- /dev/null +++ b/specs/005-set-initiative/contracts/domain-api.md @@ -0,0 +1,57 @@ +# Domain API Contract: Set Initiative + +## Function Signature + +``` +setInitiative(encounter, combatantId, value) → Success | DomainError +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| encounter | Encounter | Current encounter state | +| combatantId | CombatantId | Target combatant to update | +| value | integer or undefined | New initiative value, or undefined to clear | + +### Success Result + +| Field | Type | Description | +|-------|------|-------------| +| encounter | Encounter | New encounter with updated combatant and reordered list | +| events | DomainEvent[] | Array containing one `InitiativeSet` event | + +### Error Codes + +| Code | Condition | +|------|-----------| +| `combatant-not-found` | No combatant with the given id exists | +| `invalid-initiative` | Value is defined but not an integer | + +### Ordering Contract + +After a successful call, `encounter.combatants` is sorted such that: +1. All combatants with `initiative !== undefined` come before those with `initiative === undefined` +2. Within the "has initiative" group: sorted descending by initiative value +3. Within the "no initiative" group: original relative order preserved +4. Equal initiative values: original relative order preserved (stable sort) + +### Active Turn Contract + +The combatant who was active before the call remains active after: +- `encounter.activeIndex` points to the same combatant (by identity) in the new order +- This holds even if the active combatant's own initiative changes + +### Invariants Preserved + +- INV-1: Empty encounters remain valid (0 combatants allowed) +- INV-2: `activeIndex` remains in bounds after reorder +- INV-3: `roundNumber` is never changed by `setInitiative` + +## Use Case Signature + +``` +setInitiativeUseCase(store, combatantId, value) → DomainEvent[] | DomainError +``` + +Follows the standard use case pattern: get encounter from store, call domain function, save on success, return events or error. diff --git a/specs/005-set-initiative/data-model.md b/specs/005-set-initiative/data-model.md new file mode 100644 index 0000000..c592fc0 --- /dev/null +++ b/specs/005-set-initiative/data-model.md @@ -0,0 +1,63 @@ +# Data Model: Set Initiative + +## Entity Changes + +### Combatant (modified) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| id | CombatantId (branded string) | Yes | Unique identifier | +| name | string | Yes | Display name (non-empty, trimmed) | +| initiative | integer | No | Initiative value for turn ordering. Unset means "not yet rolled." | + +**Validation rules**: +- `initiative` must be an integer when set (no floats, NaN, or Infinity) +- Zero and negative integers are valid +- Unset (`undefined`) is valid — combatant has not rolled initiative yet + +### Encounter (unchanged structure, new ordering behavior) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| combatants | readonly Combatant[] | Yes | Ordered list. Now sorted by initiative descending (unset last, stable sort for ties). | +| activeIndex | number | Yes | Index of the active combatant. Adjusted to follow the active combatant's identity through reorders. | +| roundNumber | number | Yes | Current round (≥ 1). Unchanged by initiative operations. | + +**Ordering invariant**: After any `setInitiative` call, `combatants` is sorted such that: +1. Combatants with initiative come first, ordered highest to lowest +2. Combatants without initiative come last +3. Ties within each group preserve relative insertion order (stable sort) + +## New Domain Event + +### InitiativeSet + +Emitted when a combatant's initiative value is set, changed, or cleared. + +| Field | Type | Description | +|-------|------|-------------| +| type | "InitiativeSet" | Event discriminant | +| combatantId | CombatantId | The combatant whose initiative changed | +| previousValue | integer or undefined | The initiative value before the change | +| newValue | integer or undefined | The initiative value after the change | + +## State Transitions + +### setInitiative(encounter, combatantId, value) + +**Input**: Current encounter, target combatant id, new initiative value (integer or undefined to clear) + +**Output**: Updated encounter with reordered combatants and adjusted activeIndex, plus events + +**Error conditions**: +- `combatant-not-found`: No combatant with the given id exists in the encounter +- `invalid-initiative`: Value is not an integer (when defined) + +**Transition logic**: +1. Find target combatant by id → error if not found +2. Validate value is integer (when defined) → error if invalid +3. Record the active combatant's id (for preservation) +4. Update the target combatant's initiative value +5. Stable-sort combatants: initiative descending, unset last +6. Find the active combatant's new index in the sorted array +7. Return new encounter + `InitiativeSet` event diff --git a/specs/005-set-initiative/plan.md b/specs/005-set-initiative/plan.md new file mode 100644 index 0000000..960e072 --- /dev/null +++ b/specs/005-set-initiative/plan.md @@ -0,0 +1,83 @@ +# Implementation Plan: Set Initiative + +**Branch**: `005-set-initiative` | **Date**: 2026-03-04 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/005-set-initiative/spec.md` + +## Summary + +Add an optional integer initiative property to combatants and a `setInitiative` domain function that sets/changes/clears the value and automatically reorders combatants descending by initiative (unset last, stable sort for ties). The active combatant's turn is preserved through reorders by tracking identity rather than position. + +## Technical Context + +**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax) +**Primary Dependencies**: React 19, Vite +**Storage**: In-memory React state (local-first, single-user MVP) +**Testing**: Vitest +**Target Platform**: Web browser (localhost:5173 dev) +**Project Type**: Web application (monorepo: domain → application → web adapter) +**Performance Goals**: N/A (local in-memory, trivial data sizes) +**Constraints**: Pure domain functions, no I/O in domain layer +**Scale/Scope**: Single-user, single encounter + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | `setInitiative` is a pure function: same encounter + id + value → same result. No I/O, randomness, or clocks. | +| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, UI in `apps/web`. Dependency direction preserved. | +| III. Agent Boundary | N/A | No agent layer involvement in this feature. | +| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. All design decisions are spec-driven. | +| V. Escalation Gates | PASS | Feature scope matches spec exactly. No out-of-scope additions. | +| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for secondary tiebreakers. | +| VII. No Gameplay Rules | PASS | Constitution contains no gameplay mechanics; initiative logic is in the spec. | + +All gates pass. No violations to justify. + +**Post-Design Re-check**: All gates still pass. The `setInitiative` domain function is pure, layering is preserved, and no out-of-scope additions were introduced during design. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-set-initiative/ +├── spec.md +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── domain-api.md +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +packages/domain/src/ +├── types.ts # Modified: add initiative to Combatant +├── events.ts # Modified: add InitiativeSet event +├── set-initiative.ts # New: setInitiative domain function +├── index.ts # Modified: export setInitiative +└── __tests__/ + └── set-initiative.test.ts # New: tests for setInitiative + +packages/application/src/ +├── set-initiative-use-case.ts # New: setInitiativeUseCase +└── index.ts # Modified: export use case + +apps/web/src/ +├── hooks/ +│ └── use-encounter.ts # Modified: add setInitiative callback +└── App.tsx # Modified: add initiative input field +``` + +**Structure Decision**: Follows existing monorepo layered structure. Each new domain operation gets its own file per established convention. + +## Complexity Tracking + +No constitution violations. Table not needed. diff --git a/specs/005-set-initiative/quickstart.md b/specs/005-set-initiative/quickstart.md new file mode 100644 index 0000000..f205e4a --- /dev/null +++ b/specs/005-set-initiative/quickstart.md @@ -0,0 +1,36 @@ +# Quickstart: Set Initiative + +## What This Feature Does + +Adds an optional initiative value to combatants. When set, the encounter automatically sorts combatants from highest to lowest initiative. Combatants without initiative appear at the end. The active turn is preserved through reorders. + +## Key Files to Modify + +1. **`packages/domain/src/types.ts`** — Add `initiative?: number` to `Combatant` +2. **`packages/domain/src/events.ts`** — Add `InitiativeSet` event to the union +3. **`packages/domain/src/set-initiative.ts`** — New domain function (pure, no I/O) +4. **`packages/domain/src/index.ts`** — Export new function and types +5. **`packages/application/src/set-initiative-use-case.ts`** — New use case +6. **`packages/application/src/index.ts`** — Export use case +7. **`apps/web/src/hooks/use-encounter.ts`** — Add `setInitiative` callback +8. **`apps/web/src/App.tsx`** — Add initiative input next to each combatant + +## Implementation Order + +1. Domain types + event (foundation) +2. Domain function + tests (core logic) +3. Application use case (orchestration) +4. Web adapter hook + UI (user-facing) + +## How to Verify + +```bash +pnpm check # Must pass: format + lint + typecheck + test +``` + +## Patterns to Follow + +- Domain functions return `{ encounter, events } | DomainError` — never throw +- Use `readonly` everywhere, create new objects via spread +- Tests live in `packages/domain/src/__tests__/` +- Use cases follow get → call → check error → save → return events diff --git a/specs/005-set-initiative/research.md b/specs/005-set-initiative/research.md new file mode 100644 index 0000000..e8a425d --- /dev/null +++ b/specs/005-set-initiative/research.md @@ -0,0 +1,49 @@ +# Research: Set Initiative + +## R-001: Stable Sort for Initiative Ordering + +**Decision**: Use JavaScript's built-in `Array.prototype.sort()` which is guaranteed stable (ES2019+). Combatants with equal initiative retain their relative order from the original array. + +**Rationale**: All modern browsers and Node.js engines implement stable sort. No external library needed. The existing codebase already relies on insertion-order preservation in array operations. + +**Alternatives considered**: +- Custom merge sort implementation — unnecessary since native sort is stable. +- Separate "sort key" field — over-engineering for the current requirement. + +## R-002: Active Turn Preservation Through Reorder + +**Decision**: After sorting, find the new index of the combatant who was active before the sort (by `CombatantId` identity). Update `activeIndex` to point to that combatant's new position. + +**Rationale**: The existing `removeCombatant` function already demonstrates the pattern of adjusting `activeIndex` to track a specific combatant through array mutations. This approach is simpler than alternatives since we can look up the active combatant's id before sorting, then find its new index after sorting. + +**Alternatives considered**: +- Store active combatant as `activeCombatantId` instead of `activeIndex` — would require changing the `Encounter` type and all downstream consumers. Too broad for this feature. +- Compute a position delta — fragile and error-prone with stable sort edge cases. + +## R-003: Initiative as Optional Property on Combatant + +**Decision**: Add `readonly initiative?: number` to the `Combatant` interface. `undefined` means "not yet set." + +**Rationale**: Matches the spec requirement for combatants without initiative (FR-005). Using `undefined` (optional property) rather than `null` aligns with TypeScript conventions and the existing codebase style (no `null` usage in domain types). + +**Alternatives considered**: +- Separate `InitiativeMap` keyed by `CombatantId` — breaks co-location, complicates sorting, doesn't match the existing pattern where combatant data lives on the `Combatant` type. +- `number | null` — adds a second "empty" representation alongside `undefined`; the codebase has no precedent for `null` in domain types. + +## R-004: Clearing Initiative + +**Decision**: Clearing initiative means setting it to `undefined`. The `setInitiative` function accepts `number | undefined` as the value parameter. When `undefined`, the combatant moves to the end of the order (per FR-003, FR-005). + +**Rationale**: Reuses the same function for set, change, and clear operations. Keeps the API surface minimal. + +**Alternatives considered**: +- Separate `clearInitiative` function — unnecessary given the value can simply be `undefined`. + +## R-005: Integer Validation + +**Decision**: Validate that the initiative value is a safe integer using `Number.isInteger()`. Reject `NaN`, `Infinity`, and floating-point values. Accept zero and negative integers (per FR-009). + +**Rationale**: `Number.isInteger()` handles all edge cases: returns false for `NaN`, `Infinity`, `-Infinity`, and non-integer numbers. Allows the full range of safe integers. + +**Alternatives considered**: +- Branded `Initiative` type — adds type complexity without significant safety benefit since validation happens at the domain boundary. diff --git a/specs/005-set-initiative/spec.md b/specs/005-set-initiative/spec.md new file mode 100644 index 0000000..027870f --- /dev/null +++ b/specs/005-set-initiative/spec.md @@ -0,0 +1,116 @@ +# Feature Specification: Set Initiative + +**Feature Branch**: `005-set-initiative` +**Created**: 2026-03-04 +**Status**: Draft +**Input**: User description: "Allow setting an initiative value for combatants; when initiative is set or changed, the encounter automatically orders combatants so the highest initiative acts first." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Set Initiative for a Combatant (Priority: P1) + +As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative. + +**Why this priority**: Initiative values are the core of this feature — without them, automatic ordering cannot happen. + +**Independent Test**: Can be fully tested by setting an initiative value on a combatant and verifying the value is stored and the combatant list is reordered accordingly. + +**Acceptance Scenarios**: + +1. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user sets initiative to 15 for "Goblin", **Then** "Goblin" has initiative value 15. +2. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user changes initiative to 8, **Then** "Goblin" has initiative value 8. +3. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user attempts to set a non-integer initiative value, **Then** the system rejects the input and the combatant's initiative remains unset. +4. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user clears "Goblin"'s initiative, **Then** "Goblin"'s initiative is unset and "Goblin" moves to the end of the turn order. + +--- + +### User Story 2 - Automatic Ordering by Initiative (Priority: P1) + +As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative so I don't have to manually reorder them. + +**Why this priority**: Automatic ordering is the primary value of initiative — it directly determines turn order. + +**Independent Test**: Can be fully tested by setting initiative values on multiple combatants and verifying the combatant list is sorted highest-first. + +**Acceptance Scenarios**: + +1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5). +2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15). +3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead). + +--- + +### User Story 3 - Combatants Without Initiative (Priority: P2) + +As a game master, I want combatants who haven't had their initiative set yet to appear at the end of the turn order so that the encounter remains usable while I'm still entering initiative values. + +**Why this priority**: This supports the practical workflow of entering initiatives one at a time as players roll. + +**Independent Test**: Can be fully tested by having a mix of combatants with and without initiative values and verifying ordering. + +**Acceptance Scenarios**: + +1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative). +2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added. +3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values. + +--- + +### User Story 4 - Active Turn Preservation During Reorder (Priority: P2) + +As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I don't lose track of whose turn it is. + +**Why this priority**: Changing initiative mid-encounter (e.g., due to a delayed action or correction) must not disrupt the current turn. + +**Independent Test**: Can be fully tested by setting the active combatant, changing another combatant's initiative, and verifying the active turn still points to the same combatant. + +**Acceptance Scenarios**: + +1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B. +2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A. + +--- + +### Edge Cases + +- What happens when a combatant is added without initiative during an ongoing encounter? They appear at the end of the order. +- What happens when all combatants have the same initiative value? Their relative order is preserved (insertion order). +- What happens when initiative is set to zero? Zero is a valid initiative value and is treated normally in sorting. +- What happens when initiative is set to a negative number? Negative values are valid initiative values (some game systems use them). +- What happens when initiative is removed/cleared from a combatant? The combatant moves to the end of the order (treated as unset). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST allow setting an integer initiative value for any combatant in an encounter. +- **FR-002**: System MUST allow changing an existing initiative value for a combatant. +- **FR-003**: System MUST allow clearing a combatant's initiative value (returning it to unset). +- **FR-004**: System MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared. +- **FR-005**: System MUST place combatants without an initiative value after all combatants that have initiative values. +- **FR-006**: System MUST use a stable sort so that combatants with equal initiative (or multiple combatants without initiative) retain their relative order. +- **FR-007**: System MUST preserve the active combatant's turn when reordering occurs — the active turn tracks the combatant identity, not the position. +- **FR-008**: System MUST reject non-integer initiative values and return an error. +- **FR-009**: System MUST accept zero and negative integers as valid initiative values. +- **FR-010**: System MUST emit a domain event when a combatant's initiative is set or changed. + +### Key Entities + +- **Combatant**: Gains an optional initiative property (integer or unset). When set, determines the combatant's position in the encounter's turn order. +- **Encounter**: Combatant ordering becomes initiative-driven. The `activeIndex` must track the active combatant's identity through reorders. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can set initiative for any combatant in a single action. +- **SC-002**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort. +- **SC-003**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder. +- **SC-004**: Combatants without initiative are always displayed after combatants with initiative values. + +## Assumptions + +- Initiative values are integers (no decimals). This matches common tabletop RPG conventions. +- There is no initiative "roll" or randomization in the domain — the user provides the final initiative value. Dice rolling is outside scope. +- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). MVP baseline does not include secondary tiebreakers (e.g., Dexterity modifier). +- Clearing initiative is supported to allow corrections (e.g., a combatant hasn't rolled yet). diff --git a/specs/005-set-initiative/tasks.md b/specs/005-set-initiative/tasks.md new file mode 100644 index 0000000..7cbc73d --- /dev/null +++ b/specs/005-set-initiative/tasks.md @@ -0,0 +1,179 @@ +# Tasks: Set Initiative + +**Input**: Design documents from `/specs/005-set-initiative/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/domain-api.md, quickstart.md + +**Tests**: Tests are included as this project follows TDD conventions (test files exist for all domain functions). + +**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. This phase covers foundational type and event changes shared across all user stories. + +- [x] T001 Add optional `initiative` property to `Combatant` interface in `packages/domain/src/types.ts` +- [x] T002 Add `InitiativeSet` event type (with `combatantId`, `previousValue`, `newValue` fields) to `DomainEvent` union in `packages/domain/src/events.ts` + +**Checkpoint**: Types compile, existing tests still pass (`pnpm check`) + +--- + +## Phase 2: User Story 1 + User Story 2 — Set Initiative & Automatic Ordering (Priority: P1) MVP + +**Goal**: Users can set/change/clear initiative values on combatants, and the encounter automatically reorders combatants from highest to lowest initiative. + +**Independent Test**: Set initiative on multiple combatants and verify the combatant list is sorted descending by initiative value. + +### Tests for User Stories 1 & 2 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [x] T003 [US1] Write acceptance tests for setting initiative (set, change, reject non-integer) in `packages/domain/src/__tests__/set-initiative.test.ts` +- [x] T004 [US2] Write acceptance tests for automatic ordering (descending sort, stable sort for ties, reorder on change) in `packages/domain/src/__tests__/set-initiative.test.ts` +- [x] T005 Write invariant tests (determinism, immutability, event shape, roundNumber unchanged) in `packages/domain/src/__tests__/set-initiative.test.ts` + +### Implementation for User Stories 1 & 2 + +- [x] T006 [US1] [US2] Implement `setInitiative(encounter, combatantId, value)` domain function in `packages/domain/src/set-initiative.ts` — validate combatant exists, validate integer, update initiative, stable-sort descending, emit `InitiativeSet` event +- [x] T007 Export `setInitiative` and related types from `packages/domain/src/index.ts` +- [x] T008 Implement `setInitiativeUseCase(store, combatantId, value)` in `packages/application/src/set-initiative-use-case.ts` following existing use case pattern (get → call → check error → save → return events) +- [x] T009 Export `setInitiativeUseCase` from `packages/application/src/index.ts` + +**Checkpoint**: Domain tests pass, `pnpm check` passes. Core initiative logic is complete. + +--- + +## Phase 3: User Story 3 — Combatants Without Initiative (Priority: P2) + +**Goal**: Combatants without initiative appear after all combatants with initiative, preserving their relative order. + +**Independent Test**: Create a mix of combatants with and without initiative and verify ordering (initiative-set first descending, then unset in insertion order). + +### Tests for User Story 3 + +- [x] T010 [US3] Write acceptance tests for unset-initiative ordering (unset after set, multiple unset preserve order, setting initiative moves combatant up) in `packages/domain/src/__tests__/set-initiative.test.ts` + +### Implementation for User Story 3 + +- [x] T011 [US3] Verify that sort logic in `packages/domain/src/set-initiative.ts` already handles `undefined` initiative correctly (combatants without initiative sort after those with initiative, stable sort within each group) — add handling if not already present in T006 + +**Checkpoint**: All ordering scenarios pass including mixed set/unset combatants. + +--- + +## Phase 4: User Story 4 — Active Turn Preservation During Reorder (Priority: P2) + +**Goal**: The active combatant's turn is preserved when initiative changes cause the combatant list to be reordered. + +**Independent Test**: Set active combatant, change another combatant's initiative causing reorder, verify active turn still points to the same combatant. + +### Tests for User Story 4 + +- [x] T012 [US4] Write acceptance tests for active turn preservation (reorder doesn't shift active turn, active combatant's own initiative change preserves turn) in `packages/domain/src/__tests__/set-initiative.test.ts` + +### Implementation for User Story 4 + +- [x] T013 [US4] Verify that `activeIndex` identity-tracking in `packages/domain/src/set-initiative.ts` works correctly when reordering occurs — the logic (record active id before sort, find new index after sort) should already exist from T006; add or fix if needed + +**Checkpoint**: Active turn is preserved through all reorder scenarios. `pnpm check` passes. + +--- + +## Phase 5: Web Adapter Integration + +**Purpose**: Wire initiative into the React UI so users can actually set initiative values. + +- [x] T014 Add `setInitiative` callback to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — call `setInitiativeUseCase`, handle errors, append events +- [x] T015 Add initiative input field next to each combatant in `apps/web/src/App.tsx` — numeric input, display current value, clear button, call `setInitiative` on change + +**Checkpoint**: Full feature works end-to-end in the browser. `pnpm check` passes. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Edge case coverage and final validation. + +- [x] T016 Write edge case tests (zero initiative, negative initiative, clearing initiative, all same value) in `packages/domain/src/__tests__/set-initiative.test.ts` +- [x] T017 Run `pnpm check` (format + lint + typecheck + test) and fix any issues +- [x] T018 Verify layer boundary compliance (domain imports no framework/adapter code) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies — types and events first +- **Phase 2 (US1+US2 MVP)**: Depends on Phase 1 +- **Phase 3 (US3)**: Depends on Phase 2 (extends sort logic) +- **Phase 4 (US4)**: Depends on Phase 2 (extends activeIndex logic) +- **Phase 5 (Web Adapter)**: Depends on Phases 2–4 (needs complete domain + application layer) +- **Phase 6 (Polish)**: Depends on all previous phases + +### User Story Dependencies + +- **US1 + US2 (P1)**: Combined because sorting is inherent to setting initiative — they share the same domain function +- **US3 (P2)**: Extends the sort logic from US1+US2 to handle `undefined`. Can be developed immediately after Phase 2. +- **US4 (P2)**: Extends the `activeIndex` logic from US1+US2. Can be developed in parallel with US3. + +### Parallel Opportunities + +- **T001 and T002** can run in parallel (different files) +- **T003, T004, T005** can run in parallel (same file but different test groups — practically written together) +- **US3 (Phase 3) and US4 (Phase 4)** can run in parallel after Phase 2 +- **T014 and T015** can run in parallel (different files) + +--- + +## Parallel Example: Phase 2 (MVP) + +```bash +# Tests first (all in same file, written together): +T003: Acceptance tests for setting initiative +T004: Acceptance tests for automatic ordering +T005: Invariant tests + +# Then implementation: +T006: Domain function (core logic) +T007: Domain exports +T008: Application use case (after T006-T007) +T009: Application exports +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2) + +1. Complete Phase 1: Type + event changes +2. Complete Phase 2: Domain function + use case with tests +3. **STOP and VALIDATE**: `pnpm check` passes, initiative setting and ordering works +4. Optionally wire up UI (Phase 5) for a minimal demo + +### Incremental Delivery + +1. Phase 1 → Types ready +2. Phase 2 → MVP: set initiative + auto-ordering works +3. Phase 3 → Unset combatants handled correctly +4. Phase 4 → Active turn preserved through reorders +5. Phase 5 → UI wired up, feature usable in browser +6. Phase 6 → Edge cases covered, quality verified + +--- + +## Notes + +- US1 and US2 are combined in Phase 2 because the domain function `setInitiative` inherently performs both setting and sorting — they cannot be meaningfully separated +- US3 and US4 are separable extensions of the sort and activeIndex logic respectively +- All domain tests follow existing patterns: helper functions for test data, acceptance scenarios mapped from spec, invariant tests for determinism/immutability +- Commit after each phase checkpoint