Step 8.5: add player state store with arrival refetch
CI / check (push) Successful in 1m54s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:57:59 -04:00
parent 6a6e20d30f
commit b5ce59d8fd
5 changed files with 135 additions and 2 deletions

View File

@ -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.

View File

@ -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<PlayerState | nu
}
}
/**
* Server player state (Step 8.5). Refetched when the app regains focus and,
* via `useRefetchOnArrival`, when an in-flight dispatch arrives.
*/
export function usePlayerState() {
const api = useApi();
return useQuery({ queryKey: PLAYER_STATE_KEY, queryFn: () => 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<string>());
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]);
}

View File

@ -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<typeof fakeApi>) =>
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));
});
});

View File

@ -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.

View File

@ -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.