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