Step 8.4: add guest onboarding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:54:25 -04:00
parent ba25066105
commit 6a6e20d30f
10 changed files with 283 additions and 26 deletions

View File

@ -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. - **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)`. - **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`. - **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).

View File

@ -1,13 +1,48 @@
import { useEffect } from "react";
import { Route, Routes } from "react-router"; import { Route, Routes } from "react-router";
import { useApi } from "./api/context";
import { startClockTicker, syncClock } from "./clock/clock";
import { Layout } from "./layout/Layout"; import { Layout } from "./layout/Layout";
import { SCREENS } from "./routes"; import { SCREENS } from "./routes";
import { Placeholder } from "./screens/Placeholder"; 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() { 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 ( return (
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
{SCREENS.map((screen) => ( {SCREENS.map((screen) => (
<Route <Route
key={screen.path} key={screen.path}

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

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

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,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<PlayerState | null> {
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) });
}

View File

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

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

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

View File

@ -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.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.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.
- [ ] 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 - [ ] Step 8.5 — Player state store