Compare commits
No commits in common. "b5ce59d8fdd22f21c020029a2a84ae4c76c27c08" and "e58e99c156830e641a2ce284fb65f213e9ea80ba" have entirely different histories.
b5ce59d8fd
...
e58e99c156
35
DECISIONS.md
35
DECISIONS.md
|
|
@ -360,38 +360,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
|
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
|
||||||
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
|
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
|
||||||
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
|
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
|
||||||
|
|
||||||
## Step 8.1
|
|
||||||
|
|
||||||
- **Client stack:** Vite 8 + React 19, Tailwind CSS 4 (via `@tailwindcss/vite`, no config file), React Router 8 in declarative mode (`<Routes>`; `BrowserRouter` in `main.tsx`, `MemoryRouter` in tests). Component tests use Vitest with jsdom and Testing Library (`apps/web/vitest.config.ts`).
|
|
||||||
- **The dev server proxies `/api` to `http://127.0.0.1:3000`**, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
|
|
||||||
- **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
|
|
||||||
- **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
|
|
||||||
- **ESLint gives `apps/web` browser globals.**
|
|
||||||
|
|
||||||
## Step 8.2
|
|
||||||
|
|
||||||
- **One generic `call(name, input)`** rather than a generated method per route. Types come from the catalog (`RouteRequest` / `RouteResponse`), so a new route needs no client change.
|
|
||||||
- **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20).
|
|
||||||
- **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format).
|
|
||||||
- **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters.
|
|
||||||
|
|
||||||
## Step 8.3
|
|
||||||
|
|
||||||
- **The clock offset lives in a Zustand store** (`useClock`), as the tech stack prescribes for real-time client state. `serverNow()` is `Date.now() + offset`; the device clock only affects visuals and is never trusted for outcomes.
|
|
||||||
- **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).
|
|
||||||
|
|
||||||
## Step 8.5
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
||||||
<title>Deadhead: Space Freight Tycoon</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -3,33 +3,9 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@deadhead/content": "workspace:*",
|
|
||||||
"@deadhead/schema": "workspace:*",
|
|
||||||
"@deadhead/sim": "workspace:*",
|
|
||||||
"@tanstack/react-query": "^5.104.1",
|
|
||||||
"react": "^19.3.0",
|
|
||||||
"react-dom": "^19.3.0",
|
|
||||||
"react-router": "^8.4.0",
|
|
||||||
"zod": "^4.6.5",
|
|
||||||
"zustand": "^5.0.15"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
|
||||||
"@testing-library/react": "^16.3.3",
|
|
||||||
"@testing-library/user-event": "^14.6.7",
|
|
||||||
"@types/react": "^19.3.0",
|
|
||||||
"@types/react-dom": "^19.3.0",
|
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
|
||||||
"jsdom": "^30.1.1",
|
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"vite": "^8.3.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
||||||
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 <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 (
|
|
||||||
<Routes>
|
|
||||||
<Route element={<Layout credits={formatCredits(state.data.company.credits)} />}>
|
|
||||||
{SCREENS.map((screen) => (
|
|
||||||
<Route
|
|
||||||
key={screen.path}
|
|
||||||
index={screen.path === "/"}
|
|
||||||
path={screen.path === "/" ? undefined : screen.path}
|
|
||||||
element={<Placeholder title={screen.title} />}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import {
|
|
||||||
errorResponseSchema,
|
|
||||||
routes,
|
|
||||||
type ErrorCode,
|
|
||||||
type RouteName,
|
|
||||||
type RouteRequest,
|
|
||||||
type RouteResponse,
|
|
||||||
} from "@deadhead/schema";
|
|
||||||
|
|
||||||
/** The server answered with its documented error format. */
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly status: number,
|
|
||||||
readonly code: ErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The server's response did not match the route catalog. */
|
|
||||||
export class ApiValidationError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly route: RouteName,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Unexpected response from ${route}: ${message}`);
|
|
||||||
this.name = "ApiValidationError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiClientOptions {
|
|
||||||
/** Defaults to the global `fetch`. */
|
|
||||||
fetch?: typeof fetch;
|
|
||||||
/** Defaults to "" (same origin, decision 0.5.20). */
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiClient {
|
|
||||||
call<N extends RouteName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds a request URL and body from a route and its input. */
|
|
||||||
function buildRequest(path: string, method: "GET" | "POST", input: Record<string, unknown>) {
|
|
||||||
const rest = { ...input };
|
|
||||||
const url = path.replace(/:(\w+)/g, (_match, param: string) => {
|
|
||||||
const value = rest[param];
|
|
||||||
delete rest[param];
|
|
||||||
return encodeURIComponent(String(value));
|
|
||||||
});
|
|
||||||
if (method === "GET") {
|
|
||||||
const query = new URLSearchParams(
|
|
||||||
Object.entries(rest).map(([key, value]) => [key, String(value)]),
|
|
||||||
).toString();
|
|
||||||
return { url: query ? `${url}?${query}` : url, body: undefined };
|
|
||||||
}
|
|
||||||
return { url, body: Object.keys(rest).length > 0 ? JSON.stringify(rest) : undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A client for every catalog route: typed inputs and validated, typed outputs. */
|
|
||||||
export function createApiClient(options: ApiClientOptions = {}): ApiClient {
|
|
||||||
const doFetch = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
|
|
||||||
const baseUrl = options.baseUrl ?? "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
async call(name, input) {
|
|
||||||
const route = routes[name];
|
|
||||||
const { url, body } = buildRequest(
|
|
||||||
route.path,
|
|
||||||
route.method,
|
|
||||||
input as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const response = await doFetch(baseUrl + url, {
|
|
||||||
method: route.method,
|
|
||||||
credentials: "include",
|
|
||||||
headers: body ? { "content-type": "application/json" } : {},
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const json: unknown = await response.json().catch(() => undefined);
|
|
||||||
if (!response.ok) {
|
|
||||||
const parsed = errorResponseSchema.safeParse(json);
|
|
||||||
if (parsed.success) {
|
|
||||||
throw new ApiError(response.status, parsed.data.error.code, parsed.data.error.message);
|
|
||||||
}
|
|
||||||
throw new ApiError(
|
|
||||||
response.status,
|
|
||||||
"internal-error",
|
|
||||||
`Request failed (${response.status}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = route.response.safeParse(json);
|
|
||||||
if (!parsed.success) throw new ApiValidationError(name, parsed.error.message);
|
|
||||||
return parsed.data as RouteResponse<typeof name>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { content } from "@deadhead/content";
|
|
||||||
import { realToGame } from "@deadhead/sim";
|
|
||||||
import { useClock } from "./clock";
|
|
||||||
|
|
||||||
/** "2100-03-14 08:00": the in-game date and hour, in UTC. */
|
|
||||||
export function formatGameTime(gameTime: number): string {
|
|
||||||
return new Date(gameTime).toISOString().slice(0, 16).replace("T", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ticking in-game clock shown in the header. */
|
|
||||||
export function GameClock() {
|
|
||||||
const now = useClock((state) => state.now);
|
|
||||||
return <time>{formatGameTime(realToGame(now, content.config))}</time>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import type { RealTime } from "@deadhead/sim";
|
|
||||||
import { create } from "zustand";
|
|
||||||
import type { ApiClient } from "../api/client";
|
|
||||||
|
|
||||||
interface ClockState {
|
|
||||||
/** Server time minus device time. A wrong device clock only shifts visuals. */
|
|
||||||
offsetMs: number;
|
|
||||||
synced: boolean;
|
|
||||||
/** Server-adjusted time, refreshed every second by the ticker. */
|
|
||||||
now: RealTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useClock = create<ClockState>(() => ({
|
|
||||||
offsetMs: 0,
|
|
||||||
synced: false,
|
|
||||||
now: Date.now(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** The current time on the server's clock: device time plus the synced offset. */
|
|
||||||
export function serverNow(): RealTime {
|
|
||||||
return Date.now() + useClock.getState().offsetMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches server time and stores the offset. The server's reading is assumed
|
|
||||||
* to be taken halfway through the round trip.
|
|
||||||
*/
|
|
||||||
export async function syncClock(api: ApiClient): Promise<void> {
|
|
||||||
const sentAt = Date.now();
|
|
||||||
const { realTime } = await api.call("getServerTime", {});
|
|
||||||
const receivedAt = Date.now();
|
|
||||||
const offsetMs = realTime - (sentAt + receivedAt) / 2;
|
|
||||||
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Updates `now` every second. Returns a function that stops the ticker. */
|
|
||||||
export function startClockTicker(intervalMs = 1000): () => void {
|
|
||||||
const tick = () => useClock.setState({ now: serverNow() });
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, intervalMs);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export {};
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { NavLink, Outlet } from "react-router";
|
|
||||||
import { GameClock } from "../clock/GameClock";
|
|
||||||
import { SCREENS } from "../routes";
|
|
||||||
|
|
||||||
export interface LayoutProps {
|
|
||||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
|
||||||
credits?: ReactNode;
|
|
||||||
clock?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Header with credits and game clock, the current screen, and a bottom tab bar. */
|
|
||||||
export function Layout({ credits = "—", clock = <GameClock /> }: LayoutProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-dvh flex-col bg-slate-950 text-slate-100">
|
|
||||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-950/95 px-4 py-2">
|
|
||||||
<span className="truncate text-sm font-bold tracking-wide">DEADHEAD</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
|
|
||||||
<span aria-label="Credits">{credits}</span>
|
|
||||||
<span aria-label="Game clock" className="text-slate-400">
|
|
||||||
{clock}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex-1 overflow-x-hidden pb-20">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<nav
|
|
||||||
aria-label="Main"
|
|
||||||
className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
|
||||||
>
|
|
||||||
{SCREENS.map((screen) => (
|
|
||||||
<NavLink
|
|
||||||
key={screen.path}
|
|
||||||
to={screen.path}
|
|
||||||
end={screen.path === "/"}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`truncate px-1 py-3 text-center text-xs ${isActive ? "font-semibold text-amber-400" : "text-slate-400"}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{screen.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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(
|
|
||||||
<StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ApiProvider api={api}>
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
</ApiProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/** The app's top-level screens, in bottom-navigation order. */
|
|
||||||
export const SCREENS = [
|
|
||||||
{ path: "/", title: "HQ", label: "HQ" },
|
|
||||||
{ path: "/contracts", title: "Contracts", label: "Contracts" },
|
|
||||||
{ path: "/fleet", title: "Fleet", label: "Fleet" },
|
|
||||||
{ path: "/pilots", title: "Pilots", label: "Pilots" },
|
|
||||||
{ path: "/market", title: "Market", label: "Market" },
|
|
||||||
{ path: "/locations", title: "Locations", label: "Places" },
|
|
||||||
] as const;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/** A screen that has not been built yet (Phase 9 fills these in). */
|
|
||||||
export function Placeholder({ title }: { title: string }) {
|
|
||||||
return (
|
|
||||||
<section className="p-4">
|
|
||||||
<h1 className="text-xl font-semibold">{title}</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">Coming soon.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { ApiError, type ApiClient } from "../api/client";
|
|
||||||
import { useApi } from "../api/context";
|
|
||||||
import { useClock } from "../clock/clock";
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server player state (Step 8.5). Refetched when the app regains focus and,
|
|
||||||
* via `useRefetchOnArrival`, when an in-flight dispatch arrives.
|
|
||||||
*/
|
|
||||||
export function usePlayerState() {
|
|
||||||
const api = useApi();
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: PLAYER_STATE_KEY,
|
|
||||||
queryFn: () => fetchPlayerState(api),
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
});
|
|
||||||
useRefetchOnArrival(query.data ?? null, query.refetch);
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refetches once the server clock passes the earliest in-flight arrival, so the
|
|
||||||
* server resolves it and the UI shows the result. Each dispatch triggers at most
|
|
||||||
* one refetch.
|
|
||||||
*/
|
|
||||||
export function useRefetchOnArrival(state: PlayerState | null, refetch: () => unknown) {
|
|
||||||
const now = useClock((s) => s.now);
|
|
||||||
const triggered = useRef(new Set<string>());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state) return;
|
|
||||||
const arrived = state.activeDispatches.filter(
|
|
||||||
(d) => d.arrivalAt <= now && !triggered.current.has(d.id),
|
|
||||||
);
|
|
||||||
if (arrived.length === 0) return;
|
|
||||||
for (const d of arrived) triggered.current.add(d.id);
|
|
||||||
void refetch();
|
|
||||||
}, [state, now, refetch]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
||||||
import { ApiError, ApiValidationError, createApiClient } from "../src/api/client";
|
|
||||||
|
|
||||||
function mockFetch(status: number, body: unknown) {
|
|
||||||
return vi.fn(
|
|
||||||
async (_url: RequestInfo | URL, _init?: RequestInit) =>
|
|
||||||
new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("typed API client", () => {
|
|
||||||
it("returns a valid response, typed", async () => {
|
|
||||||
const fetch = mockFetch(200, { realTime: 1000, gameTime: 60_000 });
|
|
||||||
const api = createApiClient({ fetch });
|
|
||||||
const time = await api.call("getServerTime", {});
|
|
||||||
expect(time).toEqual({ realTime: 1000, gameTime: 60_000 });
|
|
||||||
expectTypeOf(time.realTime).toEqualTypeOf<number>();
|
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/time", expect.objectContaining({ method: "GET" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raises a validation error when the response breaks its schema", async () => {
|
|
||||||
const api = createApiClient({ fetch: mockFetch(200, { realTime: "soon" }) });
|
|
||||||
await expect(api.call("getServerTime", {})).rejects.toBeInstanceOf(ApiValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fills path parameters and sends the rest as a JSON body", async () => {
|
|
||||||
const pilot = {
|
|
||||||
id: "p1",
|
|
||||||
name: "Rae Okafor",
|
|
||||||
isAvatar: false,
|
|
||||||
xp: 0,
|
|
||||||
level: 0,
|
|
||||||
skills: {},
|
|
||||||
skillPointsAvailable: 0,
|
|
||||||
assignedShipId: "s1",
|
|
||||||
status: "idle",
|
|
||||||
locationId: "earth",
|
|
||||||
};
|
|
||||||
const fetch = mockFetch(200, { pilot });
|
|
||||||
const api = createApiClient({ fetch });
|
|
||||||
await api.call("assignPilot", { pilotId: "p1", shipId: "s1" });
|
|
||||||
|
|
||||||
const [url, init] = fetch.mock.calls[0]!;
|
|
||||||
expect(url).toBe("/api/pilots/p1/assign");
|
|
||||||
expect(init).toMatchObject({ method: "POST", credentials: "include" });
|
|
||||||
expect(JSON.parse(init!.body as string)).toEqual({ shipId: "s1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts path parameters in GET URLs", async () => {
|
|
||||||
const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 });
|
|
||||||
await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" });
|
|
||||||
expect(fetch.mock.calls[0]![0]).toBe("/api/bays/moon/board");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws the server's error code and message", async () => {
|
|
||||||
const fetch = mockFetch(409, {
|
|
||||||
error: { code: "insufficient-funds", message: "Not enough credits." },
|
|
||||||
});
|
|
||||||
const error = await createApiClient({ fetch })
|
|
||||||
.call("buyShip", { modelId: "courier" })
|
|
||||||
.catch((e: unknown) => e);
|
|
||||||
expect(error).toBeInstanceOf(ApiError);
|
|
||||||
expect(error).toMatchObject({
|
|
||||||
status: 409,
|
|
||||||
code: "insufficient-funds",
|
|
||||||
message: "Not enough credits.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { content } from "@deadhead/content";
|
|
||||||
import { realToGame } from "@deadhead/sim";
|
|
||||||
import { act, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ApiClient } from "../src/api/client";
|
|
||||||
import { serverNow, startClockTicker, syncClock, useClock } from "../src/clock/clock";
|
|
||||||
import { formatGameTime, GameClock } from "../src/clock/GameClock";
|
|
||||||
|
|
||||||
const DEVICE_NOW = Date.parse("2100-01-01T00:00:00Z");
|
|
||||||
const TEN_MINUTES = 10 * 60_000;
|
|
||||||
|
|
||||||
/** An API whose server clock runs `aheadMs` ahead of the device. */
|
|
||||||
function serverAhead(aheadMs: number): ApiClient {
|
|
||||||
return {
|
|
||||||
call: vi.fn(async () => ({ realTime: Date.now() + aheadMs, gameTime: 0 })),
|
|
||||||
} as unknown as ApiClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("server-synced clock", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(DEVICE_NOW);
|
|
||||||
useClock.setState({ offsetMs: 0, synced: false, now: DEVICE_NOW });
|
|
||||||
});
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it("returns a time 10 minutes ahead when the server is 10 minutes ahead", async () => {
|
|
||||||
await syncClock(serverAhead(TEN_MINUTES));
|
|
||||||
expect(useClock.getState().synced).toBe(true);
|
|
||||||
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
expect(serverNow()).toBe(DEVICE_NOW + TEN_MINUTES + 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("works when the server is behind the device", async () => {
|
|
||||||
await syncClock(serverAhead(-30_000));
|
|
||||||
expect(serverNow()).toBe(DEVICE_NOW - 30_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ticks the store every second", async () => {
|
|
||||||
await syncClock(serverAhead(TEN_MINUTES));
|
|
||||||
const stop = startClockTicker();
|
|
||||||
vi.advanceTimersByTime(3000);
|
|
||||||
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
|
|
||||||
stop();
|
|
||||||
vi.advanceTimersByTime(3000);
|
|
||||||
expect(useClock.getState().now).toBe(DEVICE_NOW + TEN_MINUTES + 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the in-game time in the game clock and keeps it ticking", async () => {
|
|
||||||
await syncClock(serverAhead(0));
|
|
||||||
const stop = startClockTicker();
|
|
||||||
render(<GameClock />);
|
|
||||||
const at = (real: number) => formatGameTime(realToGame(real, content.config));
|
|
||||||
expect(screen.getByText(at(DEVICE_NOW))).toBeInTheDocument();
|
|
||||||
|
|
||||||
// One real minute is one game hour.
|
|
||||||
act(() => vi.advanceTimersByTime(60_000));
|
|
||||||
expect(screen.getByText(at(DEVICE_NOW + 60_000))).toBeInTheDocument();
|
|
||||||
stop();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { screen, within } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { SCREENS } from "../src/routes";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./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", 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", 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 () => {
|
|
||||||
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(
|
|
||||||
"aria-current",
|
|
||||||
"page",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import type { Dispatch } from "@deadhead/schema";
|
|
||||||
import { focusManager } from "@tanstack/react-query";
|
|
||||||
import { act, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { useClock } from "../src/clock/clock";
|
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
|
||||||
|
|
||||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
|
||||||
|
|
||||||
function inFlight(arrivalAt: number): Dispatch {
|
|
||||||
return {
|
|
||||||
id: "d1",
|
|
||||||
shipId: "loaner",
|
|
||||||
pilotId: "avatar",
|
|
||||||
contract: null,
|
|
||||||
originId: "earth",
|
|
||||||
destinationId: "moon",
|
|
||||||
tutorial: true,
|
|
||||||
departureAt: NOW - 60_000,
|
|
||||||
arrivalAt,
|
|
||||||
distanceMillionKm: 0.39,
|
|
||||||
fuelCost: 0,
|
|
||||||
expectedPayout: 240,
|
|
||||||
seed: 1,
|
|
||||||
status: "in-flight",
|
|
||||||
resolvedAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stateCalls = (api: ReturnType<typeof fakeApi>) =>
|
|
||||||
api.call.mock.calls.filter(([name]) => name === "getPlayerState").length;
|
|
||||||
|
|
||||||
describe("player state store", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Fake only the clock and the 1 s ticker; leave setTimeout real for React Query.
|
|
||||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
|
||||||
vi.setSystemTime(NOW);
|
|
||||||
useClock.setState({ offsetMs: 0, synced: false, now: NOW });
|
|
||||||
});
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it("refetches automatically when the clock passes an in-flight arrival", async () => {
|
|
||||||
const api = fakeApi({
|
|
||||||
getServerTime: () => ({ realTime: Date.now(), gameTime: 0 }),
|
|
||||||
getPlayerState: () => samplePlayerState({ activeDispatches: [inFlight(NOW + 30_000)] }),
|
|
||||||
});
|
|
||||||
renderApp(api);
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
|
||||||
expect(stateCalls(api)).toBe(1);
|
|
||||||
|
|
||||||
act(() => vi.advanceTimersByTime(29_000));
|
|
||||||
expect(stateCalls(api)).toBe(1);
|
|
||||||
|
|
||||||
act(() => vi.advanceTimersByTime(2_000));
|
|
||||||
await waitFor(() => expect(stateCalls(api)).toBe(2));
|
|
||||||
|
|
||||||
// The same arrival does not trigger again on later ticks.
|
|
||||||
act(() => vi.advanceTimersByTime(5_000));
|
|
||||||
expect(stateCalls(api)).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("player state store on focus", () => {
|
|
||||||
afterEach(() => focusManager.setFocused(undefined));
|
|
||||||
|
|
||||||
it("refetches when the app regains focus", async () => {
|
|
||||||
const api = fakeApi({ getPlayerState: () => samplePlayerState() });
|
|
||||||
renderApp(api);
|
|
||||||
await screen.findByRole("heading", { level: 1, name: "HQ" });
|
|
||||||
const before = stateCalls(api);
|
|
||||||
|
|
||||||
// Leaving and returning to the app, as the browser reports it to TanStack Query.
|
|
||||||
act(() => {
|
|
||||||
focusManager.setFocused(false);
|
|
||||||
focusManager.setFocused(true);
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(stateCalls(api)).toBeGreaterThan(before));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup } from "@testing-library/react";
|
|
||||||
import { afterEach } from "vitest";
|
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"include": ["src", "test"]
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["src", "test", "vite.config.ts", "vitest.config.ts"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
server: {
|
|
||||||
// Same-domain API in development too (decision 0.5.20). The server listens on
|
|
||||||
// IPv4 only, so target 127.0.0.1 rather than localhost.
|
|
||||||
proxy: { "/api": "http://127.0.0.1:3000" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineProject } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineProject({
|
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
|
||||||
environment: "jsdom",
|
|
||||||
setupFiles: ["./test/setup.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -15,19 +15,6 @@ export default tseslint.config(
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: { ...globals.node },
|
globals: { ...globals.node },
|
||||||
},
|
},
|
||||||
rules: {
|
|
||||||
// Allow deliberately unused names such as placeholder parameters (_url).
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"error",
|
|
||||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["apps/web/**/*.{ts,tsx}"],
|
|
||||||
languageOptions: {
|
|
||||||
globals: { ...globals.browser },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],
|
files: ["packages/sim/**/*.ts", "packages/content/**/*.ts"],
|
||||||
|
|
|
||||||
|
|
@ -404,19 +404,3 @@ pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a
|
||||||
| `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods |
|
| `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods |
|
||||||
|
|
||||||
The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production.
|
The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production.
|
||||||
|
|
||||||
## Client (`apps/web`)
|
|
||||||
|
|
||||||
Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In development, Vite proxies `/api` to the server at `127.0.0.1:3000` (same origin, decision 0.5.20).
|
|
||||||
|
|
||||||
| Module | Role |
|
|
||||||
|---|---|
|
|
||||||
| `src/api/client.ts` | `createApiClient().call(route, input)`: typed by the route catalog; validates responses; throws `ApiError` (server code) or `ApiValidationError` |
|
|
||||||
| `src/api/context.tsx` | `ApiProvider` / `useApi` (tests inject a fake) |
|
|
||||||
| `src/clock/clock.ts` | Zustand `useClock`: server offset, `serverNow()`, 1 s ticker |
|
|
||||||
| `src/state/player.ts` | `usePlayerState` (TanStack Query, key `player-state`): `null` means no session; refetch on focus and on arrival |
|
|
||||||
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
|
|
||||||
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
|
|
||||||
| `src/routes.ts` | Screen list shared by router and navigation |
|
|
||||||
|
|
||||||
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,3 @@
|
||||||
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
|
- [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
|
||||||
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
|
- [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it.
|
||||||
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
|
- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.
|
||||||
|
|
||||||
## 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.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.
|
|
||||||
|
|
|
||||||
1117
pnpm-lock.yaml
1117
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue