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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
- 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)
|
- 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 handles HP via `adjustHpUseCase`) (011-quick-hp-input)
|
||||||
|
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { ActionBar } from "./components/action-bar";
|
import { ActionBar } from "./components/action-bar";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const {
|
const {
|
||||||
encounter,
|
encounter,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
@@ -13,7 +15,6 @@ export function App() {
|
|||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
} = useEncounter();
|
} = useEncounter();
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8">
|
<div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8">
|
||||||
@@ -22,13 +23,15 @@ export function App() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight">
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
Initiative Tracker
|
Initiative Tracker
|
||||||
</h1>
|
</h1>
|
||||||
{activeCombatant && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Round {encounter.roundNumber} — Current: {activeCombatant.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Turn Navigation */}
|
||||||
|
<TurnNavigation
|
||||||
|
encounter={encounter}
|
||||||
|
onAdvanceTurn={advanceTurn}
|
||||||
|
onRetreatTurn={retreatTurn}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Combatant List */}
|
{/* Combatant List */}
|
||||||
<div className="flex flex-1 flex-col gap-1 overflow-y-auto">
|
<div className="flex flex-1 flex-col gap-1 overflow-y-auto">
|
||||||
{encounter.combatants.length === 0 ? (
|
{encounter.combatants.length === 0 ? (
|
||||||
@@ -52,7 +55,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Bar */}
|
{/* Action Bar */}
|
||||||
<ActionBar onAddCombatant={addCombatant} onAdvanceTurn={advanceTurn} />
|
<ActionBar onAddCombatant={addCombatant} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { Input } from "./ui/input";
|
|||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
onAddCombatant: (name: string) => void;
|
onAddCombatant: (name: string) => void;
|
||||||
onAdvanceTurn: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({ onAddCombatant, onAdvanceTurn }: ActionBarProps) {
|
export function ActionBar({ onAddCombatant }: ActionBarProps) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
const handleAdd = (e: FormEvent) => {
|
||||||
@@ -31,9 +30,6 @@ export function ActionBar({ onAddCombatant, onAdvanceTurn }: ActionBarProps) {
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<Button variant="outline" size="sm" onClick={onAdvanceTurn}>
|
|
||||||
Next Turn
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
apps/web/src/components/turn-navigation.tsx
Normal file
59
apps/web/src/components/turn-navigation.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-muted"
|
||||||
|
onClick={onRetreatTurn}
|
||||||
|
disabled={!hasCombatants || isAtStart}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
{activeCombatant ? (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">Round {encounter.roundNumber}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
— {activeCombatant.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hover:bg-muted"
|
||||||
|
onClick={onAdvanceTurn}
|
||||||
|
disabled={!hasCombatants}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
retreatTurnUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
@@ -79,6 +80,16 @@ export function useEncounter() {
|
|||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
|
const retreatTurn = useCallback(() => {
|
||||||
|
const result = retreatTurnUseCase(makeStore());
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
}, [makeStore]);
|
||||||
|
|
||||||
const nextId = useRef(deriveNextId(encounter));
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
@@ -164,6 +175,7 @@ export function useEncounter() {
|
|||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
editCombatant,
|
editCombatant,
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
|||||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||||
export type { EncounterStore } from "./ports.js";
|
export type { EncounterStore } from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.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 { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
|||||||
21
packages/application/src/retreat-turn-use-case.ts
Normal file
21
packages/application/src/retreat-turn-use-case.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
184
packages/domain/src/__tests__/retreat-turn.test.ts
Normal file
184
packages/domain/src/__tests__/retreat-turn.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,18 @@ export interface CurrentHpAdjusted {
|
|||||||
readonly delta: number;
|
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 =
|
export type DomainEvent =
|
||||||
| TurnAdvanced
|
| TurnAdvanced
|
||||||
| RoundAdvanced
|
| RoundAdvanced
|
||||||
@@ -64,4 +76,6 @@ export type DomainEvent =
|
|||||||
| CombatantUpdated
|
| CombatantUpdated
|
||||||
| InitiativeSet
|
| InitiativeSet
|
||||||
| MaxHpSet
|
| MaxHpSet
|
||||||
| CurrentHpAdjusted;
|
| CurrentHpAdjusted
|
||||||
|
| TurnRetreated
|
||||||
|
| RoundRetreated;
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export type {
|
|||||||
InitiativeSet,
|
InitiativeSet,
|
||||||
MaxHpSet,
|
MaxHpSet,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
|
RoundRetreated,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
export {
|
export {
|
||||||
type RemoveCombatantSuccess,
|
type RemoveCombatantSuccess,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
} from "./remove-combatant.js";
|
} from "./remove-combatant.js";
|
||||||
|
export { retreatTurn } from "./retreat-turn.js";
|
||||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||||
export {
|
export {
|
||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
|
|||||||
59
packages/domain/src/retreat-turn.ts
Normal file
59
packages/domain/src/retreat-turn.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
specs/012-turn-navigation/checklists/requirements.md
Normal file
34
specs/012-turn-navigation/checklists/requirements.md
Normal file
@@ -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`.
|
||||||
55
specs/012-turn-navigation/contracts/domain-api.md
Normal file
55
specs/012-turn-navigation/contracts/domain-api.md
Normal file
@@ -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
|
||||||
58
specs/012-turn-navigation/data-model.md
Normal file
58
specs/012-turn-navigation/data-model.md
Normal file
@@ -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
|
||||||
76
specs/012-turn-navigation/plan.md
Normal file
76
specs/012-turn-navigation/plan.md
Normal file
@@ -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.
|
||||||
49
specs/012-turn-navigation/quickstart.md
Normal file
49
specs/012-turn-navigation/quickstart.md
Normal file
@@ -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 |
|
||||||
49
specs/012-turn-navigation/research.md
Normal file
49
specs/012-turn-navigation/research.md
Normal file
@@ -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.
|
||||||
106
specs/012-turn-navigation/spec.md
Normal file
106
specs/012-turn-navigation/spec.md
Normal file
@@ -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.
|
||||||
153
specs/012-turn-navigation/tasks.md
Normal file
153
specs/012-turn-navigation/tasks.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user