Step 9.1: add HQ screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b5ce59d8fd
commit
1eb96beb22
|
|
@ -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.
|
- **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.
|
- **`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.
|
- **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).
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { Route, Routes } from "react-router";
|
import { Route, Routes } from "react-router";
|
||||||
import { useApi } from "./api/context";
|
import { useApi } from "./api/context";
|
||||||
import { startClockTicker, syncClock } from "./clock/clock";
|
import { startClockTicker, syncClock } from "./clock/clock";
|
||||||
|
import { formatCredits } from "./format";
|
||||||
import { Layout } from "./layout/Layout";
|
import { Layout } from "./layout/Layout";
|
||||||
import { SCREENS } from "./routes";
|
import { SCREENS } from "./routes";
|
||||||
|
import { HQ } from "./screens/HQ";
|
||||||
import { Placeholder } from "./screens/Placeholder";
|
import { Placeholder } from "./screens/Placeholder";
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
import { usePlayerState } from "./state/player";
|
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
|
|
||||||
export function formatCredits(credits: number): string {
|
/** Built screens by path; the rest show a placeholder. */
|
||||||
return `${credits.toLocaleString("en-US")} cr`;
|
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
|
||||||
}
|
"/": <HQ />,
|
||||||
|
};
|
||||||
|
|
||||||
/** Syncs the clock with the server once, then ticks it every second. */
|
/** Syncs the clock with the server once, then ticks it every second. */
|
||||||
function useServerClock() {
|
function useServerClock() {
|
||||||
|
|
@ -27,6 +30,7 @@ function useServerClock() {
|
||||||
export function App() {
|
export function App() {
|
||||||
useServerClock();
|
useServerClock();
|
||||||
const state = usePlayerState();
|
const state = usePlayerState();
|
||||||
|
useRefetchOnArrival(state.data ?? null, state.refetch);
|
||||||
|
|
||||||
if (state.isPending) {
|
if (state.isPending) {
|
||||||
return <p className="p-6 text-slate-400">Loading…</p>;
|
return <p className="p-6 text-slate-400">Loading…</p>;
|
||||||
|
|
@ -48,7 +52,7 @@ export function App() {
|
||||||
key={screen.path}
|
key={screen.path}
|
||||||
index={screen.path === "/"}
|
index={screen.path === "/"}
|
||||||
path={screen.path === "/" ? undefined : screen.path}
|
path={screen.path === "/" ? undefined : screen.path}
|
||||||
element={<Placeholder title={screen.title} />}
|
element={SCREEN_COMPONENTS[screen.path] ?? <Placeholder title={screen.title} />}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -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 <span className="tabular-nums">{remaining > 0 ? formatDuration(remaining) : done}</span>;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<section className="space-y-4 p-4">
|
||||||
|
<h1 className="text-xl font-semibold">HQ</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Stat label="Credits" value={formatCredits(state.company.credits)} />
|
||||||
|
<Stat label="Game date" value={formatGameTime(realToGame(now, content.config))} />
|
||||||
|
<Stat label="Ships idle" value={String(idle)} />
|
||||||
|
<Stat label="Ships in flight" value={String(flying)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-800 p-3">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-300">Next arrival</h2>
|
||||||
|
{next ? (
|
||||||
|
<p className="mt-1">
|
||||||
|
{locationName(next.originId)} → {locationName(next.destinationId)} in{" "}
|
||||||
|
<Countdown to={next.arrivalAt} />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Nothing in flight.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.tutorial && (
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3">
|
||||||
|
<h2 className="font-semibold text-amber-300">
|
||||||
|
Save up for your first ship and an Earth bay
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">
|
||||||
|
{formatCredits(state.company.credits)} of {formatCredits(goal)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Tutorial goal"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
className="mt-2 h-2 overflow-hidden rounded bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="h-full bg-amber-400" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-right text-xs text-slate-400">{progress}%</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-800 p-3">
|
||||||
|
<div className="text-xs text-slate-400">{label}</div>
|
||||||
|
<div className="mt-1 font-semibold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<N extends RouteName>(name: N) {
|
||||||
|
const api = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
|
@ -18,17 +18,19 @@ export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | nu
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server player state (Step 8.5). Refetched when the app regains focus and,
|
* Server player state (Step 8.5), shared by every screen. Refetched when the app
|
||||||
* via `useRefetchOnArrival`, when an in-flight dispatch arrives.
|
* regains focus; `App` also runs `useRefetchOnArrival` once for arrivals.
|
||||||
*/
|
*/
|
||||||
export function usePlayerState() {
|
export function usePlayerState() {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: PLAYER_STATE_KEY,
|
queryKey: PLAYER_STATE_KEY,
|
||||||
queryFn: () => fetchPlayerState(api),
|
queryFn: () => 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;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,9 +6,14 @@ import { fakeApi, renderApp, samplePlayerState, unauthorized } from "./render";
|
||||||
|
|
||||||
describe("guest onboarding", () => {
|
describe("guest onboarding", () => {
|
||||||
it("shows the start screen without a session; the button creates a guest and opens HQ", async () => {
|
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({
|
const api = fakeApi({
|
||||||
getPlayerState: unauthorized,
|
getPlayerState: () => (signedUp ? samplePlayerState() : unauthorized()),
|
||||||
createGuest: () => ({ playerId: "p1", state: samplePlayerState() }),
|
createGuest: () => {
|
||||||
|
signedUp = true;
|
||||||
|
return { playerId: "p1", state: samplePlayerState() };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
renderApp(api, "/fleet");
|
renderApp(api, "/fleet");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,21 @@
|
||||||
|
|
||||||
## Phase 8 — Client Foundation
|
## 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.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.
|
||||||
- [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.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.
|
- [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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue