Step 8.5: add player state store with arrival refetch
CI / check (push) Successful in 1m54s
Details
CI / check (push) Successful in 1m54s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a6e20d30f
commit
b5ce59d8fd
|
|
@ -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.
|
- **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.
|
- **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).
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { ApiError, type ApiClient } from "../api/client";
|
import { ApiError, type ApiClient } from "../api/client";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
|
import { useClock } from "../clock/clock";
|
||||||
|
|
||||||
export const PLAYER_STATE_KEY = ["player-state"] as const;
|
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() {
|
export function usePlayerState() {
|
||||||
const api = useApi();
|
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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
| `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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue