diff --git a/DECISIONS.md b/DECISIONS.md
index cd7589f..4016179 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -382,3 +382,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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).
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index e3d2536..237cbf0 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,13 +1,48 @@
+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";
-/** Routes for every screen. The router itself is provided by the caller. */
+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
Loading…
;
+ }
+ if (state.isError) {
+ return (
+
+ Could not reach the server.
+
+ );
+ }
+ if (state.data === null) return ;
+
return (
- }>
+ }>
{SCREENS.map((screen) => (
(null);
+
+export function ApiProvider({ api, children }: { api: ApiClient; children: ReactNode }) {
+ return {children};
+}
+
+/** The API client; tests provide a fake one. */
+export function useApi(): ApiClient {
+ const api = useContext(ApiContext);
+ if (!api) throw new Error("useApi needs an ");
+ return api;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 190dceb..b51674d 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,13 +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(
-
-
-
+
+
+
+
+
+
+ ,
);
diff --git a/apps/web/src/screens/StartScreen.tsx b/apps/web/src/screens/StartScreen.tsx
new file mode 100644
index 0000000..66e2897
--- /dev/null
+++ b/apps/web/src/screens/StartScreen.tsx
@@ -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 (
+
+
+
DEADHEAD
+
Space Freight Tycoon
+
+
+ Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
+ empire across the solar system.
+
+
+ {start.isError && (
+
+ Could not start a company. Check your connection and try again.
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/state/player.ts b/apps/web/src/state/player.ts
new file mode 100644
index 0000000..49632f8
--- /dev/null
+++ b/apps/web/src/state/player.ts
@@ -0,0 +1,21 @@
+import type { PlayerState } from "@deadhead/schema";
+import { useQuery } from "@tanstack/react-query";
+import { ApiError, type ApiClient } from "../api/client";
+import { useApi } from "../api/context";
+
+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 {
+ try {
+ return await api.call("getPlayerState", {});
+ } catch (error) {
+ if (error instanceof ApiError && error.code === "unauthorized") return null;
+ throw error;
+ }
+}
+
+export function usePlayerState() {
+ const api = useApi();
+ return useQuery({ queryKey: PLAYER_STATE_KEY, queryFn: () => fetchPlayerState(api) });
+}
diff --git a/apps/web/test/layout.test.tsx b/apps/web/test/layout.test.tsx
index d00b4d9..a98a0c1 100644
--- a/apps/web/test/layout.test.tsx
+++ b/apps/web/test/layout.test.tsx
@@ -1,36 +1,34 @@
-import { render, screen, within } from "@testing-library/react";
+import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
-import { App } from "../src/App";
import { SCREENS } from "../src/routes";
+import { fakeApi, renderApp, samplePlayerState } from "./render";
-function renderAt(path: string) {
- return 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", () => {
- renderAt("/");
- expect(screen.getByRole("banner")).toBeInTheDocument();
- expect(screen.getByLabelText("Credits")).toBeInTheDocument();
- expect(screen.getByLabelText("Game clock")).toBeInTheDocument();
+ 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", ({ path, title }) => {
- renderAt(path);
- expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
+ 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 () => {
- renderAt("/");
- const nav = screen.getByRole("navigation", { name: "Main" });
+ 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(
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx
new file mode 100644
index 0000000..175ef08
--- /dev/null
+++ b/apps/web/test/onboarding.test.tsx
@@ -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();
+ });
+});
diff --git a/apps/web/test/render.tsx b/apps/web/test/render.tsx
new file mode 100644
index 0000000..d11f772
--- /dev/null
+++ b/apps/web/test/render.tsx
@@ -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) => RouteResponse | Promise>;
+};
+
+/**
+ * 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 {
+ 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(
+
+
+
+
+
+
+ ,
+ );
+ return { ...result, queryClient };
+}
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index ef7fa84..853a705 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -80,5 +80,5 @@
- [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.
-- [ ] Step 8.4 — Guest onboarding
+- [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