From 4c2e0a47e6e59255ac5946b65d3d58c0b9a7a025 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 3 Mar 2026 13:11:33 +0100 Subject: [PATCH] =?UTF-8?q?T012=E2=80=93T016:=20Phase=203=20application=20?= =?UTF-8?q?+=20web=20shell=20(use=20case,=20ports,=20React=20hook,=20UI,?= =?UTF-8?q?=20README)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- README.md | 28 +++++++++++ apps/web/src/App.tsx | 42 ++++++++++++++++- apps/web/src/hooks/use-encounter.ts | 47 +++++++++++++++++++ .../application/src/advance-turn-use-case.ts | 21 +++++++++ packages/application/src/index.ts | 3 +- packages/application/src/ports.ts | 6 +++ specs/001-advance-turn/tasks.md | 10 ++-- 7 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 README.md create mode 100644 apps/web/src/hooks/use-encounter.ts create mode 100644 packages/application/src/advance-turn-use-case.ts create mode 100644 packages/application/src/ports.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d778e7 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Initiative Tracker + +A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds. + +## Prerequisites + +- Node.js 22 +- pnpm 10.6+ + +## Getting Started + +```sh +pnpm install +pnpm --filter web dev +``` + +Open the URL printed in your terminal (typically `http://localhost:5173`). + +The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts. + +## Scripts + +| Command | Description | +|---------|-------------| +| `pnpm --filter web dev` | Start the dev server | +| `pnpm --filter web build` | Production build | +| `pnpm test` | Run all tests | +| `pnpm check` | Full merge gate (format, lint, typecheck, test) | diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4781b57..fc46af6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,43 @@ +import { useEncounter } from "./hooks/use-encounter"; + export function App() { - return
Initiative Tracker
; + const { encounter, events, advanceTurn } = useEncounter(); + const activeCombatant = encounter.combatants[encounter.activeIndex]; + + return ( +
+

Initiative Tracker

+ +

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

+ +
    + {encounter.combatants.map((c, i) => ( +
  • + {i === encounter.activeIndex ? `▶ ${c.name}` : c.name} +
  • + ))} +
+ + + + {events.length > 0 && ( +
+

Events

+
    + {events.map((e, i) => ( +
  • + {e.type === "TurnAdvanced" + ? `Turn: ${e.previousCombatantId} → ${e.newCombatantId} (round ${e.roundNumber})` + : `Round advanced to ${e.newRoundNumber}`} +
  • + ))} +
+
+ )} +
+ ); } diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts new file mode 100644 index 0000000..26170cd --- /dev/null +++ b/apps/web/src/hooks/use-encounter.ts @@ -0,0 +1,47 @@ +import type { EncounterStore } from "@initiative/application"; +import { advanceTurnUseCase } from "@initiative/application"; +import type { DomainEvent, Encounter } from "@initiative/domain"; +import { + combatantId, + createEncounter, + isDomainError, +} from "@initiative/domain"; +import { useCallback, useRef, useState } from "react"; + +function createDemoEncounter(): Encounter { + const result = createEncounter([ + { id: combatantId("1"), name: "Aria" }, + { id: combatantId("2"), name: "Brak" }, + { id: combatantId("3"), name: "Cael" }, + ]); + + if (isDomainError(result)) { + throw new Error(`Failed to create demo encounter: ${result.message}`); + } + + return result; +} + +export function useEncounter() { + const [encounter, setEncounter] = useState(createDemoEncounter); + const [events, setEvents] = useState([]); + const encounterRef = useRef(encounter); + encounterRef.current = encounter; + + const advanceTurn = useCallback(() => { + const store: EncounterStore = { + get: () => encounterRef.current, + save: (e) => setEncounter(e), + }; + + const result = advanceTurnUseCase(store); + + if (isDomainError(result)) { + return; + } + + setEvents((prev) => [...prev, ...result]); + }, []); + + return { encounter, events, advanceTurn } as const; +} diff --git a/packages/application/src/advance-turn-use-case.ts b/packages/application/src/advance-turn-use-case.ts new file mode 100644 index 0000000..f6555ab --- /dev/null +++ b/packages/application/src/advance-turn-use-case.ts @@ -0,0 +1,21 @@ +import { + advanceTurn, + type DomainError, + type DomainEvent, + isDomainError, +} from "@initiative/domain"; +import type { EncounterStore } from "./ports.js"; + +export function advanceTurnUseCase( + store: EncounterStore, +): DomainEvent[] | DomainError { + const encounter = store.get(); + const result = advanceTurn(encounter); + + if (isDomainError(result)) { + return result; + } + + store.save(result.encounter); + return result.events; +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index cb0ff5c..530c4be 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1 +1,2 @@ -export {}; +export { advanceTurnUseCase } from "./advance-turn-use-case.js"; +export type { EncounterStore } from "./ports.js"; diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts new file mode 100644 index 0000000..cae0f7e --- /dev/null +++ b/packages/application/src/ports.ts @@ -0,0 +1,6 @@ +import type { Encounter } from "@initiative/domain"; + +export interface EncounterStore { + get(): Encounter; + save(encounter: Encounter): void; +} diff --git a/specs/001-advance-turn/tasks.md b/specs/001-advance-turn/tasks.md index 8f257eb..7650083 100644 --- a/specs/001-advance-turn/tasks.md +++ b/specs/001-advance-turn/tasks.md @@ -48,11 +48,11 @@ **Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button. -- [ ] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)` -- [ ] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events -- [ ] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types -- [ ] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo -- [ ] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events +- [X] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)` +- [X] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events +- [X] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types +- [X] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo +- [X] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events **Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.