From b5ce59d8fdd22f21c020029a2a84ae4c76c27c08 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:57:59 -0400 Subject: [PATCH] Step 8.5: add player state store with arrival refetch Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ apps/web/src/state/player.ts | 34 ++++++++++++- apps/web/test/player-state.test.tsx | 79 +++++++++++++++++++++++++++++ memory-bank/architecture.md | 16 ++++++ memory-bank/progress.md | 2 +- 5 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 apps/web/test/player-state.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 4016179..25ebf35 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -389,3 +389,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **After "Start your company"** the guest response's state is written straight into the query cache and the app navigates to HQ, so there is no extra request. - **TanStack Query and an API context arrive here** rather than in Step 8.5, because onboarding needs the state query. The header shows live credits from it. - **Client test helpers** (`apps/web/test/render.tsx`): `fakeApi` (per-route handlers), `samplePlayerState`, and `renderApp` (fresh query cache, memory router). + +## Step 8.5 + +- **Arrival refetch is driven by the ticking server clock** (`useClock`), not by per-dispatch timers: when `now` passes an in-flight dispatch's `arrivalAt`, state is refetched, which makes the server resolve it lazily. Each dispatch triggers one refetch; Phase 7's job covers anything the client misses. +- **`refetchOnWindowFocus: true` is set explicitly** (it is TanStack Query's default) to document the requirement. +- **Tests fake only `Date` and `setInterval`** for the arrival case, leaving `setTimeout` real so TanStack Query's scheduling works; the focus test runs on real timers. diff --git a/apps/web/src/state/player.ts b/apps/web/src/state/player.ts index 49632f8..05317bb 100644 --- a/apps/web/src/state/player.ts +++ b/apps/web/src/state/player.ts @@ -1,7 +1,9 @@ import type { PlayerState } from "@deadhead/schema"; import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; import { ApiError, type ApiClient } from "../api/client"; import { useApi } from "../api/context"; +import { useClock } from "../clock/clock"; export const PLAYER_STATE_KEY = ["player-state"] as const; @@ -15,7 +17,37 @@ export async function fetchPlayerState(api: ApiClient): Promise fetchPlayerState(api) }); + const query = useQuery({ + queryKey: PLAYER_STATE_KEY, + queryFn: () => fetchPlayerState(api), + refetchOnWindowFocus: true, + }); + useRefetchOnArrival(query.data ?? null, query.refetch); + return query; +} + +/** + * Refetches once the server clock passes the earliest in-flight arrival, so the + * server resolves it and the UI shows the result. Each dispatch triggers at most + * one refetch. + */ +export function useRefetchOnArrival(state: PlayerState | null, refetch: () => unknown) { + const now = useClock((s) => s.now); + const triggered = useRef(new Set()); + + useEffect(() => { + if (!state) return; + const arrived = state.activeDispatches.filter( + (d) => d.arrivalAt <= now && !triggered.current.has(d.id), + ); + if (arrived.length === 0) return; + for (const d of arrived) triggered.current.add(d.id); + void refetch(); + }, [state, now, refetch]); } diff --git a/apps/web/test/player-state.test.tsx b/apps/web/test/player-state.test.tsx new file mode 100644 index 0000000..72ee840 --- /dev/null +++ b/apps/web/test/player-state.test.tsx @@ -0,0 +1,79 @@ +import type { Dispatch } from "@deadhead/schema"; +import { focusManager } from "@tanstack/react-query"; +import { act, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +function inFlight(arrivalAt: number): Dispatch { + return { + id: "d1", + shipId: "loaner", + pilotId: "avatar", + contract: null, + originId: "earth", + destinationId: "moon", + tutorial: true, + departureAt: NOW - 60_000, + arrivalAt, + distanceMillionKm: 0.39, + fuelCost: 0, + expectedPayout: 240, + seed: 1, + status: "in-flight", + resolvedAt: null, + }; +} + +const stateCalls = (api: ReturnType) => + api.call.mock.calls.filter(([name]) => name === "getPlayerState").length; + +describe("player state store", () => { + beforeEach(() => { + // Fake only the clock and the 1 s ticker; leave setTimeout real for React Query. + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: false, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("refetches automatically when the clock passes an in-flight arrival", async () => { + const api = fakeApi({ + getServerTime: () => ({ realTime: Date.now(), gameTime: 0 }), + getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }), + }); + renderApp(api); + await screen.findByRole("heading", { level: 1, name: "HQ" }); + expect(stateCalls(api)).toBe(1); + + act(() => vi.advanceTimersByTime(29_000)); + expect(stateCalls(api)).toBe(1); + + act(() => vi.advanceTimersByTime(2_000)); + await waitFor(() => expect(stateCalls(api)).toBe(2)); + + // The same arrival does not trigger again on later ticks. + act(() => vi.advanceTimersByTime(5_000)); + expect(stateCalls(api)).toBe(2); + }); +}); + +describe("player state store on focus", () => { + afterEach(() => focusManager.setFocused(undefined)); + + it("refetches when the app regains focus", async () => { + const api = fakeApi({ getPlayerState: () => samplePlayerState() }); + renderApp(api); + await screen.findByRole("heading", { level: 1, name: "HQ" }); + const before = stateCalls(api); + + // Leaving and returning to the app, as the browser reports it to TanStack Query. + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + await waitFor(() => expect(stateCalls(api)).toBeGreaterThan(before)); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index ba056ac..1070e94 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -404,3 +404,19 @@ pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a | `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods | The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production. + +## Client (`apps/web`) + +Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In development, Vite proxies `/api` to the server at `127.0.0.1:3000` (same origin, decision 0.5.20). + +| Module | Role | +|---|---| +| `src/api/client.ts` | `createApiClient().call(route, input)`: typed by the route catalog; validates responses; throws `ApiError` (server code) or `ApiValidationError` | +| `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) | +| `src/clock/clock.ts` | Zustand `useClock`: server offset, `serverNow()`, 1 s ticker | +| `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no session; refetch on focus and on arrival | +| `src/App.tsx` | Clock sync; loading / start screen / routed layout | +| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | +| `src/routes.ts` | Screen list shared by router and navigation | + +Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 853a705..6687269 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -81,4 +81,4 @@ - [x] **Step 8.2 — Typed API client.** `apps/web/src/api/client.ts`: `createApiClient().call(name, input)` built from the route catalog: path params filled, GET query / POST JSON body, `credentials: "include"`, responses validated (`ApiValidationError`), server errors raised as `ApiError` with code and message. - [x] **Step 8.3 — Server-synced clock.** `apps/web/src/clock`: Zustand `useClock` store, `syncClock(api)` (offset from server time, half round trip), `serverNow()`, `startClockTicker()` (1 s), and a `GameClock` header component showing in-game time. - [x] **Step 8.4 — Guest onboarding.** No session (state request returns 401) shows `StartScreen`; "Start your company" calls `createGuest`, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and an `ApiProvider` context added; header shows live credits. Verified end to end through the Vite proxy. -- [ ] Step 8.5 — Player state store +- [x] **Step 8.5 — Player state store.** `usePlayerState` (TanStack Query) refetches on window focus and, via `useRefetchOnArrival`, once per dispatch when the server clock passes its arrival time.