Compare commits

...

5 Commits

Author SHA1 Message Date
fireshaper b5ce59d8fd 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>
2026-10-03 01:57:59 -04:00
fireshaper 6a6e20d30f Step 8.4: add guest onboarding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:54:25 -04:00
fireshaper ba25066105 Step 8.3: add server-synced clock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:52:42 -04:00
fireshaper 5558296b77 Step 8.2: add typed API client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:51:29 -04:00
fireshaper acf550c901 Step 8.1: add Vite + React client shell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:50:26 -04:00
30 changed files with 2012 additions and 46 deletions

View File

@ -360,3 +360,38 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig. - **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`. - **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled. - **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
## Step 8.1
- **Client stack:** Vite 8 + React 19, Tailwind CSS 4 (via `@tailwindcss/vite`, no config file), React Router 8 in declarative mode (`<Routes>`; `BrowserRouter` in `main.tsx`, `MemoryRouter` in tests). Component tests use Vitest with jsdom and Testing Library (`apps/web/vitest.config.ts`).
- **The dev server proxies `/api` to `http://127.0.0.1:3000`**, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
- **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
- **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
- **ESLint gives `apps/web` browser globals.**
## Step 8.2
- **One generic `call(name, input)`** rather than a generated method per route. Types come from the catalog (`RouteRequest` / `RouteResponse`), so a new route needs no client change.
- **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20).
- **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format).
- **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters.
## Step 8.3
- **The clock offset lives in a Zustand store** (`useClock`), as the tech stack prescribes for real-time client state. `serverNow()` is `Date.now() + offset`; the device clock only affects visuals and is never trusted for outcomes.
- **Sync uses the midpoint of the round trip:** offset = server time − (sent + received) / 2.
- **A one-second ticker** (`startClockTicker`) refreshes `now` in the store so components re-render; components read `useClock(s => s.now)`.
- **The header shows in-game time** as `YYYY-MM-DD HH:MM` (UTC), from sim `realToGame`.
## Step 8.4
- **"Is there a session?" is decided by the server:** the client loads player state on start-up; `401 unauthorized` means no session, so it shows the start screen. The session itself is Better Auth's persistent cookie, which the browser keeps across reloads ("persist the session"); the client stores nothing. Expired sessions land on the start screen too.
- **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.

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Deadhead: Space Freight Tycoon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -3,9 +3,33 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": { "scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
},
"dependencies": {
"@deadhead/content": "workspace:*",
"@deadhead/schema": "workspace:*",
"@deadhead/sim": "workspace:*",
"@tanstack/react-query": "^5.104.1",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"tailwindcss": "^4.3.3",
"vite": "^8.3.2"
} }
} }

57
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,57 @@
import { useEffect } from "react";
import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes";
import { Placeholder } from "./screens/Placeholder";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState } from "./state/player";
export function formatCredits(credits: number): string {
return `${credits.toLocaleString("en-US")} cr`;
}
/** Syncs the clock with the server once, then ticks it every second. */
function useServerClock() {
const api = useApi();
useEffect(() => {
syncClock(api).catch(() => {
// Offline or server down: keep the device clock until the next launch.
});
return startClockTicker();
}, [api]);
}
/** The whole client. Router, API and query providers come from the caller. */
export function App() {
useServerClock();
const state = usePlayerState();
if (state.isPending) {
return <p className="p-6 text-slate-400">Loading…</p>;
}
if (state.isError) {
return (
<p role="alert" className="p-6 text-red-400">
Could not reach the server.
</p>
);
}
if (state.data === null) return <StartScreen />;
return (
<Routes>
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
{SCREENS.map((screen) => (
<Route
key={screen.path}
index={screen.path === "/"}
path={screen.path === "/" ? undefined : screen.path}
element={<Placeholder title={screen.title} />}
/>
))}
</Route>
</Routes>
);
}

View File

@ -0,0 +1,98 @@
import {
errorResponseSchema,
routes,
type ErrorCode,
type RouteName,
type RouteRequest,
type RouteResponse,
} from "@deadhead/schema";
/** The server answered with its documented error format. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
/** The server's response did not match the route catalog. */
export class ApiValidationError extends Error {
constructor(
readonly route: RouteName,
message: string,
) {
super(`Unexpected response from ${route}: ${message}`);
this.name = "ApiValidationError";
}
}
export interface ApiClientOptions {
/** Defaults to the global `fetch`. */
fetch?: typeof fetch;
/** Defaults to "" (same origin, decision 0.5.20). */
baseUrl?: string;
}
export interface ApiClient {
call<N extends RouteName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>>;
}
/** Builds a request URL and body from a route and its input. */
function buildRequest(path: string, method: "GET" | "POST", input: Record<string, unknown>) {
const rest = { ...input };
const url = path.replace(/:(\w+)/g, (_match, param: string) => {
const value = rest[param];
delete rest[param];
return encodeURIComponent(String(value));
});
if (method === "GET") {
const query = new URLSearchParams(
Object.entries(rest).map(([key, value]) => [key, String(value)]),
).toString();
return { url: query ? `${url}?${query}` : url, body: undefined };
}
return { url, body: Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined };
}
/** A client for every catalog route: typed inputs and validated, typed outputs. */
export function createApiClient(options: ApiClientOptions = {}): ApiClient {
const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
const baseUrl = options.baseUrl ?? "";
return {
async call(name, input) {
const route = routes[name];
const { url, body } = buildRequest(
route.path,
route.method,
input as Record<string, unknown>,
);
const response = await doFetch(baseUrl + url, {
method: route.method,
credentials: "include",
headers: body ? { "content-type": "application/json" } : {},
body,
});
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
const parsed = errorResponseSchema.safeParse(json);
if (parsed.success) {
throw new ApiError(response.status, parsed.data.error.code, parsed.data.error.message);
}
throw new ApiError(
response.status,
"internal-error",
`Request failed (${response.status}).`,
);
}
const parsed = route.response.safeParse(json);
if (!parsed.success) throw new ApiValidationError(name, parsed.error.message);
return parsed.data as RouteResponse<typeof name>;
},
};
}

View File

@ -0,0 +1,15 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ApiClient } from "./client";
const ApiContext = createContext<ApiClient | null>(null);
export function ApiProvider({ api, children }: { api: ApiClient; children: ReactNode }) {
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
}
/** The API client; tests provide a fake one. */
export function useApi(): ApiClient {
const api = useContext(ApiContext);
if (!api) throw new Error("useApi needs an <ApiProvider>");
return api;
}

View File

@ -0,0 +1,14 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { useClock } from "./clock";
/** "2100-03-14 08:00": the in-game date and hour, in UTC. */
export function formatGameTime(gameTime: number): string {
return new Date(gameTime).toISOString().slice(0, 16).replace("T", " ");
}
/** The ticking in-game clock shown in the header. */
export function GameClock() {
const now = useClock((state) => state.now);
return <time>{formatGameTime(realToGame(now, content.config))}</time>;
}

View File

@ -0,0 +1,42 @@
import type { RealTime } from "@deadhead/sim";
import { create } from "zustand";
import type { ApiClient } from "../api/client";
interface ClockState {
/** Server time minus device time. A wrong device clock only shifts visuals. */
offsetMs: number;
synced: boolean;
/** Server-adjusted time, refreshed every second by the ticker. */
now: RealTime;
}
export const useClock = create<ClockState>(() => ({
offsetMs: 0,
synced: false,
now: Date.now(),
}));
/** The current time on the server's clock: device time plus the synced offset. */
export function serverNow(): RealTime {
return Date.now() + useClock.getState().offsetMs;
}
/**
* Fetches server time and stores the offset. The server's reading is assumed
* to be taken halfway through the round trip.
*/
export async function syncClock(api: ApiClient): Promise<void> {
const sentAt = Date.now();
const { realTime } = await api.call("getServerTime", {});
const receivedAt = Date.now();
const offsetMs = realTime - (sentAt + receivedAt) / 2;
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
}
/** Updates `now` every second. Returns a function that stops the ticker. */
export function startClockTicker(intervalMs = 1000): () => void {
const tick = () => useClock.setState({ now: serverNow() });
tick();
const id = setInterval(tick, intervalMs);
return () => clearInterval(id);
}

1
apps/web/src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -1 +0,0 @@
export {};

View File

@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
import { GameClock } from "../clock/GameClock";
import { SCREENS } from "../routes";
export interface LayoutProps {
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
credits?: ReactNode;
clock?: ReactNode;
}
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps) {
return (
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
<span aria-label="Credits">{credits}</span>
<span aria-label="Game clock" className="text-slate-400">
{clock}
</span>
</div>
</header>
<main className="flex-1 overflow-x-hidden pb-20">
<Outlet />
</main>
<nav
aria-label="Main"
className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
>
{SCREENS.map((screen) => (
<NavLink
key={screen.path}
to={screen.path}
end={screen.path === "/"}
className={({ isActive }) =>
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
}
>
{screen.label}
</NavLink>
))}
</nav>
</div>
);
}

23
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import { createApiClient } from "./api/client";
import { ApiProvider } from "./api/context";
import "./index.css";
const api = createApiClient();
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ApiProvider api={api}>
<BrowserRouter>
<App />
</BrowserRouter>
</ApiProvider>
</QueryClientProvider>
</StrictMode>,
);

9
apps/web/src/routes.ts Normal file
View File

@ -0,0 +1,9 @@
/** The app's top-level screens, in bottom-navigation order. */
export const SCREENS = [
{ path: "/", title: "HQ", label: "HQ" },
{ path: "/contracts", title: "Contracts", label: "Contracts" },
{ path: "/fleet", title: "Fleet", label: "Fleet" },
{ path: "/pilots", title: "Pilots", label: "Pilots" },
{ path: "/market", title: "Market", label: "Market" },
{ path: "/locations", title: "Locations", label: "Places" },
] as const;

View File

@ -0,0 +1,9 @@
/** A screen that has not been built yet (Phase 9 fills these in). */
export function Placeholder({ title }: { title: string }) {
return (
<section className="p-4">
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
</section>
);
}

View File

@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { PLAYER_STATE_KEY } from "../state/player";
/** First launch: create a guest account (decision: guests first, accounts later). */
export function StartScreen() {
const api = useApi();
const queryClient = useQueryClient();
const navigate = useNavigate();
const start = useMutation({
mutationFn: () => api.call("createGuest", {}),
onSuccess: ({ state }) => {
// The session cookie is now set; the response already holds the new state.
queryClient.setQueryData(PLAYER_STATE_KEY, state);
void navigate("/");
},
});
return (
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-slate-950 p-6 text-center text-slate-100">
<div>
<h1 className="text-3xl font-bold tracking-wide">DEADHEAD</h1>
<p className="mt-1 text-slate-400">Space Freight Tycoon</p>
</div>
<p className="max-w-xs text-sm text-slate-300">
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
empire across the solar system.
</p>
<button
type="button"
onClick={() => start.mutate()}
disabled={start.isPending}
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-950 disabled:opacity-60"
>
{start.isPending ? "Starting…" : "Start your company"}
</button>
{start.isError && (
<p role="alert" className="text-sm text-red-400">
Could not start a company. Check your connection and try again.
</p>
)}
</main>
);
}

View File

@ -0,0 +1,53 @@
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;
/** Player state, or null when there is no session (the server says 401). */
export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | null> {
try {
return await api.call("getPlayerState", {});
} catch (error) {
if (error instanceof ApiError && error.code === "unauthorized") return null;
throw error;
}
}
/**
* 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();
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,72 @@
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { ApiError, ApiValidationError, createApiClient } from "../src/api/client";
function mockFetch(status: number, body: unknown) {
return vi.fn(
async (_url: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}
describe("typed API client", () => {
it("returns a valid response, typed", async () => {
const fetch = mockFetch(200, { realTime: 1000, gameTime: 60_000 });
const api = createApiClient({ fetch });
const time = await api.call("getServerTime", {});
expect(time).toEqual({ realTime: 1000, gameTime: 60_000 });
expectTypeOf(time.realTime).toEqualTypeOf<number>();
expect(fetch).toHaveBeenCalledWith("/api/time", expect.objectContaining({ method: "GET" }));
});
it("raises a validation error when the response breaks its schema", async () => {
const api = createApiClient({ fetch: mockFetch(200, { realTime: "soon" }) });
await expect(api.call("getServerTime", {})).rejects.toBeInstanceOf(ApiValidationError);
});
it("fills path parameters and sends the rest as a JSON body", async () => {
const pilot = {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
};
const fetch = mockFetch(200, { pilot });
const api = createApiClient({ fetch });
await api.call("assignPilot", { pilotId: "p1", shipId: "s1" });
const [url, init] = fetch.mock.calls[0]!;
expect(url).toBe("/api/pilots/p1/assign");
expect(init).toMatchObject({ method: "POST", credentials: "include" });
expect(JSON.parse(init!.body as string)).toEqual({ shipId: "s1" });
});
it("puts path parameters in GET URLs", async () => {
const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 });
await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" });
expect(fetch.mock.calls[0]![0]).toBe("/api/bays/moon/board");
});
it("throws the server's error code and message", async () => {
const fetch = mockFetch(409, {
error: { code: "insufficient-funds", message: "Not enough credits." },
});
const error = await createApiClient({ fetch })
.call("buyShip", { modelId: "courier" })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
status: 409,
code: "insufficient-funds",
message: "Not enough credits.",
});
});
});

View File

@ -0,0 +1,63 @@
import { content } from "@deadhead/content";
import { realToGame } from "@deadhead/sim";
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ApiClient } from "../src/api/client";
import { serverNow, startClockTicker, syncClock, useClock } from "../src/clock/clock";
import { formatGameTime, GameClock } from "../src/clock/GameClock";
const DEVICE_NOW = Date.parse("2100-01-01T00:00:00Z");
const TEN_MINUTES = 10 * 60_000;
/** An API whose server clock runs `aheadMs` ahead of the device. */
function serverAhead(aheadMs: number): ApiClient {
return {
call: vi.fn(async () => ({ realTime: Date.now() + aheadMs, gameTime: 0 })),
} as unknown as ApiClient;
}
describe("server-synced clock", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(DEVICE_NOW);
useClock.setState({ offsetMs: 0, synced: false, now: DEVICE_NOW });
});
afterEach(() => vi.useRealTimers());
it("returns a time 10 minutes ahead when the server is 10 minutes ahead", async () => {
await syncClock(serverAhead(TEN_MINUTES));
expect(useClock.getState().synced).toBe(true);
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES);
vi.advanceTimersByTime(5000);
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES + 5000);
});
it("works when the server is behind the device", async () => {
await syncClock(serverAhead(-30_000));
expect(serverNow()).toBe(DEVICE_NOW - 30_000);
});
it("ticks the store every second", async () => {
await syncClock(serverAhead(TEN_MINUTES));
const stop = startClockTicker();
vi.advanceTimersByTime(3000);
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
stop();
vi.advanceTimersByTime(3000);
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
});
it("shows the in-game time in the game clock and keeps it ticking", async () => {
await syncClock(serverAhead(0));
const stop = startClockTicker();
render(<GameClock />);
const at = (real: number) => formatGameTime(realToGame(real, content.config));
expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
// One real minute is one game hour.
act(() => vi.advanceTimersByTime(60_000));
expect(screen.getByText(at(DEVICE_NOW + 60_000))).toBeInTheDocument();
stop();
});
});

View File

@ -0,0 +1,39 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { SCREENS } from "../src/routes";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const signedIn = () =>
fakeApi({
getPlayerState: () => samplePlayerState({ company: { id: "c", name: "D", credits: 1234 } }),
});
describe("app layout", () => {
it("renders the header with credits and game clock, and the bottom navigation", async () => {
renderApp(signedIn());
expect(await screen.findByRole("banner")).toBeInTheDocument();
expect(screen.getByLabelText("Credits")).toHaveTextContent("1,234 cr");
expect(screen.getByLabelText("Game clock")).toHaveTextContent(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/,
);
const nav = screen.getByRole("navigation", { name: "Main" });
expect(within(nav).getAllByRole("link")).toHaveLength(SCREENS.length);
});
it.each(SCREENS)("shows the $title placeholder at $path", async ({ path, title }) => {
renderApp(signedIn(), path);
expect(await screen.findByRole("heading", { level: 1, name: title })).toBeInTheDocument();
});
it("navigates between screens with the bottom navigation", async () => {
renderApp(signedIn());
const nav = await screen.findByRole("navigation", { name: "Main" });
await userEvent.click(within(nav).getByRole("link", { name: "Fleet" }));
expect(screen.getByRole("heading", { level: 1, name: "Fleet" })).toBeInTheDocument();
expect(within(nav).getByRole("link", { name: "Fleet" })).toHaveAttribute(
"aria-current",
"page",
);
});
});

View File

@ -0,0 +1,43 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ApiError } from "../src/api/client";
import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
describe("guest onboarding", () => {
it("shows the start screen without a session; the button creates a guest and opens HQ", async () => {
const api = fakeApi({
getPlayerState: unauthorized,
createGuest: () => ({ playerId: "p1", state: samplePlayerState() }),
});
renderApp(api, "/fleet");
const start = await screen.findByRole("button", { name: "Start your company" });
expect(screen.queryByRole("navigation", { name: "Main" })).not.toBeInTheDocument();
await userEvent.click(start);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(api.call).toHaveBeenCalledWith("createGuest", {});
});
it("skips the start screen when a stored session is still valid", async () => {
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
renderApp(api);
expect(await screen.findByRole("heading", { level: 1, name: "HQ" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start your company" })).not.toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("createGuest", expect.anything());
});
it("shows an error and stays on the start screen if creating the guest fails", async () => {
const api = fakeApi({
getPlayerState: unauthorized,
createGuest: () => {
throw new ApiError(500, "internal-error", "boom");
},
});
renderApp(api);
await userEvent.click(await screen.findByRole("button", { name: "Start your company" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not start a company");
expect(screen.getByRole("button", { name: "Start your company" })).toBeEnabled();
});
});

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));
});
});

83
apps/web/test/render.tsx Normal file
View File

@ -0,0 +1,83 @@
import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { vi } from "vitest";
import { App } from "../src/App";
import { ApiError, type ApiClient } from "../src/api/client";
import { ApiProvider } from "../src/api/context";
type Handlers = {
[N in RouteName]?: (input: RouteRequest<N>) => RouteResponse<N> | Promise<RouteResponse<N>>;
};
/**
* A fake API: each route answers from its handler. Unhandled routes fail with
* `not-found`; `getServerTime` defaults to the device clock.
*/
export function fakeApi(handlers: Handlers) {
const call = vi.fn(async (name: RouteName, input: unknown) => {
if (name === "getServerTime" && !handlers.getServerTime) {
return { realTime: Date.now(), gameTime: 0 };
}
const handler = handlers[name] as ((input: unknown) => unknown) | undefined;
if (!handler) throw new ApiError(404, "not-found", `No fake for ${name}`);
return handler(input);
});
return { call } as unknown as ApiClient & { call: typeof call };
}
export const unauthorized = () => {
throw new ApiError(401, "unauthorized", "Sign in to continue.");
};
/** A brand-new tutorial player, as guest sign-up returns it. */
export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
return {
company: { id: "co1", name: "Deadhead Freight", credits: 0 },
ships: [],
pilots: [
{
id: "avatar",
name: "Captain",
isAvatar: true,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
},
],
bays: [],
locations: [
{ id: "earth", discovered: true },
{ id: "moon", discovered: true },
{ id: "mars", discovered: false },
{ id: "ceres-station", discovered: false },
{ id: "jupiter-cloud-platform", discovered: false },
],
tutorial: { loanerLocationId: "earth", runInFlight: false },
activeDispatches: [],
summaries: [],
...overrides,
};
}
/** Renders the whole app at `path` with a fresh query cache and the given API. */
export function renderApp(api: ApiClient, path = "/") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const result = render(
<QueryClientProvider client={queryClient}>
<ApiProvider api={api}>
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
</ApiProvider>
</QueryClientProvider>,
);
return { ...result, queryClient };
}

5
apps/web/test/setup.ts Normal file
View File

@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup());

View File

@ -1,4 +1,9 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"include": ["src", "test"] "compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "test", "vite.config.ts", "vitest.config.ts"]
} }

12
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
// Same-domain API in development too (decision 0.5.20). The server listens on
// IPv4 only, so target 127.0.0.1 rather than localhost.
proxy: { "/api": "http://127.0.0.1:3000" },
},
});

10
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineProject } from "vitest/config";
export default defineProject({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
},
});

View File

@ -15,6 +15,19 @@ export default tseslint.config(
languageOptions: { languageOptions: {
globals: { ...globals.node }, globals: { ...globals.node },
}, },
rules: {
// Allow deliberately unused names such as placeholder parameters (_url).
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
languageOptions: {
globals: { ...globals.browser },
},
}, },
{ {
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"], files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],

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

View File

@ -74,3 +74,11 @@
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss. - [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it. - [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay. - [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
## Phase 8 — Client Foundation
- [x] **Step 8.1 — Vite + React app.** `apps/web`: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies `/api` to `127.0.0.1:3000`. Manual 375px check left to the user.
- [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.
- [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.

File diff suppressed because it is too large Load Diff