diff --git a/DECISIONS.md b/DECISIONS.md index 25ebf35..18d6127 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -395,3 +395,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 9.1 + +- **Tutorial goal cost = cheapest ship model + Earth bay lease**, read from content, so it follows any rebalancing. +- **The player-state query is fresh for 10 seconds** (`staleTime`), so screens that read it do not each refetch on mount. Returning to the app (`refetchOnWindowFocus: "always"`), arrivals and actions still refetch immediately. +- **The arrival watcher runs once, in `App`,** not in every component that reads player state. +- **Shared screen helpers:** `useLoadedPlayer()` (screens render only after state has loaded), `useGameAction(route)` (call an endpoint, then invalidate player state and boards), `Countdown` (live, from the synced clock), and `src/format.ts` (credits, durations, names). diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 237cbf0..0ebd555 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,16 +1,19 @@ -import { useEffect } from "react"; +import { useEffect, type ReactNode } from "react"; import { Route, Routes } from "react-router"; import { useApi } from "./api/context"; import { startClockTicker, syncClock } from "./clock/clock"; +import { formatCredits } from "./format"; import { Layout } from "./layout/Layout"; import { SCREENS } from "./routes"; +import { HQ } from "./screens/HQ"; import { Placeholder } from "./screens/Placeholder"; import { StartScreen } from "./screens/StartScreen"; -import { usePlayerState } from "./state/player"; +import { usePlayerState, useRefetchOnArrival } from "./state/player"; -export function formatCredits(credits: number): string { - return `${credits.toLocaleString("en-US")} cr`; -} +/** Built screens by path; the rest show a placeholder. */ +const SCREEN_COMPONENTS: Partial> = { + "/": , +}; /** Syncs the clock with the server once, then ticks it every second. */ function useServerClock() { @@ -27,6 +30,7 @@ function useServerClock() { export function App() { useServerClock(); const state = usePlayerState(); + useRefetchOnArrival(state.data ?? null, state.refetch); if (state.isPending) { return

Loading…

; @@ -48,7 +52,7 @@ export function App() { key={screen.path} index={screen.path === "/"} path={screen.path === "/" ? undefined : screen.path} - element={} + element={SCREEN_COMPONENTS[screen.path] ?? } /> ))} diff --git a/apps/web/src/components/Countdown.tsx b/apps/web/src/components/Countdown.tsx new file mode 100644 index 0000000..5e4f5d1 --- /dev/null +++ b/apps/web/src/components/Countdown.tsx @@ -0,0 +1,9 @@ +import { useClock } from "../clock/clock"; +import { formatDuration } from "../format"; + +/** Live time remaining until `to` (server clock); shows `done` once it has passed. */ +export function Countdown({ to, done = "Arriving…" }: { to: number; done?: string }) { + const now = useClock((s) => s.now); + const remaining = to - now; + return {remaining > 0 ? formatDuration(remaining) : done}; +} diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts new file mode 100644 index 0000000..782fb6a --- /dev/null +++ b/apps/web/src/format.ts @@ -0,0 +1,42 @@ +import { content } from "@deadhead/content"; + +export function formatCredits(credits: number): string { + return `${Math.round(credits).toLocaleString("en-US")} cr`; +} + +/** A duration as the two largest units: "2h 05m", "4m 09s", "45s". */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (hours > 0) return `${hours}h ${pad(minutes)}m`; + if (minutes > 0) return `${minutes}m ${pad(seconds)}s`; + return `${seconds}s`; +} + +/** A game-hours duration, e.g. "5.4 h" or "250 h". */ +export function formatGameHours(hours: number): string { + return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`; +} + +export function formatMillionKm(distance: number): string { + return distance < 10 ? `${distance.toFixed(2)}M km` : `${Math.round(distance)}M km`; +} + +export function locationName(id: string): string { + return content.location(id)?.name ?? id; +} + +export function shipModelName(id: string): string { + return content.shipModel(id)?.name ?? id; +} + +export function cargoName(id: string): string { + return content.cargoType(id)?.name ?? id; +} + +export function skillName(id: string): string { + return content.skill(id)?.name ?? id; +} diff --git a/apps/web/src/screens/HQ.tsx b/apps/web/src/screens/HQ.tsx new file mode 100644 index 0000000..6677262 --- /dev/null +++ b/apps/web/src/screens/HQ.tsx @@ -0,0 +1,80 @@ +import { content } from "@deadhead/content"; +import { realToGame } from "@deadhead/sim"; +import { useClock } from "../clock/clock"; +import { formatGameTime } from "../clock/GameClock"; +import { Countdown } from "../components/Countdown"; +import { formatCredits, locationName } from "../format"; +import { useLoadedPlayer } from "../state/actions"; + +/** Cost of the tutorial goal: the cheapest ship plus an Earth bay. */ +export function tutorialGoalCost(): number { + const cheapest = Math.min(...content.shipModels.map((s) => s.price)); + return cheapest + (content.location("earth")?.bayLeasePrice ?? 0); +} + +export function HQ() { + const state = useLoadedPlayer(); + const now = useClock((s) => s.now); + const idle = state.ships.filter((s) => s.status === "idle").length; + const flying = state.ships.filter((s) => s.status === "in-flight").length; + const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0]; + + const goal = tutorialGoalCost(); + const progress = Math.min(100, Math.floor((state.company.credits / goal) * 100)); + + return ( +
+

HQ

+ +
+ + + + +
+ +
+

Next arrival

+ {next ? ( +

+ {locationName(next.originId)} → {locationName(next.destinationId)} in{" "} + +

+ ) : ( +

Nothing in flight.

+ )} +
+ + {state.tutorial && ( +
+

+ Save up for your first ship and an Earth bay +

+

+ {formatCredits(state.company.credits)} of {formatCredits(goal)} +

+
+
+
+

{progress}%

+
+ )} +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/state/actions.ts b/apps/web/src/state/actions.ts new file mode 100644 index 0000000..383f016 --- /dev/null +++ b/apps/web/src/state/actions.ts @@ -0,0 +1,33 @@ +import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadhead/schema"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApiError } from "../api/client"; +import { useApi } from "../api/context"; +import { PLAYER_STATE_KEY, usePlayerState } from "./player"; + +/** Player state on screens that only render once it has loaded (see `App`). */ +export function useLoadedPlayer(): PlayerState { + const { data } = usePlayerState(); + if (!data) throw new Error("useLoadedPlayer used before player state loaded"); + return data; +} + +/** + * Calls one game endpoint, then refreshes player state (and any boards) so + * every screen sees the result. + */ +export function useGameAction(name: N) { + const api = useApi(); + const queryClient = useQueryClient(); + return useMutation, Error, RouteRequest>({ + mutationFn: (input) => api.call(name, input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); + await queryClient.invalidateQueries({ queryKey: ["board"] }); + }, + }); +} + +/** The server's message for a failed action, or a generic one. */ +export function errorMessage(error: unknown): string { + return error instanceof ApiError ? error.message : "Something went wrong. Try again."; +} diff --git a/apps/web/src/state/player.ts b/apps/web/src/state/player.ts index 05317bb..1996561 100644 --- a/apps/web/src/state/player.ts +++ b/apps/web/src/state/player.ts @@ -18,17 +18,19 @@ export async function fetchPlayerState(api: ApiClient): Promise fetchPlayerState(api), - refetchOnWindowFocus: true, + // Screens share this query; fresh for 10 s so mounting a screen does not refetch. + // Returning to the app, arrivals and actions always refetch. + staleTime: 10_000, + refetchOnWindowFocus: "always", }); - useRefetchOnArrival(query.data ?? null, query.refetch); return query; } diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx new file mode 100644 index 0000000..c3fd907 --- /dev/null +++ b/apps/web/test/hq.test.tsx @@ -0,0 +1,92 @@ +import type { Dispatch, Ship } from "@deadhead/schema"; +import { act, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { tutorialGoalCost } from "../src/screens/HQ"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, status: Ship["status"]): Ship => ({ + id, + modelId: "courier", + name: id, + status, + locationId: status === "idle" ? "earth" : null, + dispatchId: status === "idle" ? null : `d-${id}`, + pilotId: null, +}); + +const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({ + id: `d-${shipId}`, + shipId, + pilotId: "avatar", + contract: null, + originId: "earth", + destinationId: "moon", + tutorial: false, + departureAt: NOW - 60_000, + arrivalAt, + distanceMillionKm: 0.39, + fuelCost: 50, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, +}); + +describe("HQ screen", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows idle and in-flight counts and counts down to the next arrival", async () => { + const state = samplePlayerState({ + tutorial: null, + ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")], + activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)], + }); + renderApp(fakeApi({ getPlayerState: () => state })); + await screen.findByRole("heading", { level: 1, name: "HQ" }); + + const statValue = (label: string) => screen.getByText(label).nextElementSibling; + expect(statValue("Ships idle")).toHaveTextContent("1"); + expect(statValue("Ships in flight")).toHaveTextContent("2"); + + const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!; + expect(within(next).getByText("1m 05s")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(10_000)); + expect(within(next).getByText("55s")).toBeInTheDocument(); + + expect(screen.queryByText(/Save up for your first ship/)).not.toBeInTheDocument(); + }); + + it("shows tutorial goal progress as a percentage of a ship plus an Earth bay", async () => { + const goal = tutorialGoalCost(); + const state = samplePlayerState({ + company: { id: "c", name: "D", credits: Math.round(goal * 0.4) }, + }); + renderApp(fakeApi({ getPlayerState: () => state })); + + expect( + await screen.findByText("Save up for your first ship and an Earth bay"), + ).toBeInTheDocument(); + expect(screen.getByRole("progressbar", { name: "Tutorial goal" })).toHaveAttribute( + "aria-valuenow", + "40", + ); + expect(screen.getByText("40%")).toBeInTheDocument(); + expect(screen.getByText("Nothing in flight.")).toBeInTheDocument(); + }); + + it("caps tutorial progress at 100%", async () => { + const state = samplePlayerState({ + company: { id: "c", name: "D", credits: tutorialGoalCost() * 3 }, + }); + renderApp(fakeApi({ getPlayerState: () => state })); + expect(await screen.findByText("100%")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 175ef08..aab9f92 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -6,9 +6,14 @@ 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 () => { + // Like the real server: no session until the guest is created. + let signedUp = false; const api = fakeApi({ - getPlayerState: unauthorized, - createGuest: () => ({ playerId: "p1", state: samplePlayerState() }), + getPlayerState: () => (signedUp ? samplePlayerState() : unauthorized()), + createGuest: () => { + signedUp = true; + return { playerId: "p1", state: samplePlayerState() }; + }, }); renderApp(api, "/fleet"); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6687269..eed319b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -77,8 +77,21 @@ ## 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.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: passed on the user's iPhone. - [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. + +## Phase 9 — Client Screens + +- [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`). +- [ ] Step 9.2 — "While you were away" summary +- [ ] Step 9.3 — Tutorial contract board +- [ ] Step 9.4 — Bay contract boards +- [ ] Step 9.5 — Dispatch flow +- [ ] Step 9.6 — Fleet screen +- [ ] Step 9.7 — Pilots screen +- [ ] Step 9.8 — Market screen +- [ ] Step 9.9 — Locations screen +- [ ] Step 9.10 — Empty and error states