Step 14.7: the client runs the game on the device
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
29f64f1ac2
commit
4c165b9b16
|
|
@ -32,7 +32,7 @@ jobs:
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
- run: pnpm coverage
|
- run: pnpm coverage
|
||||||
# Browser tests against the production build (Phase 11: PWA and offline).
|
# Browser tests against the web build alone (PWA, and the game on the device, CR-014).
|
||||||
- run: pnpm --filter @deadhead/web build
|
- run: pnpm --filter @deadhead/web build:e2e
|
||||||
- run: pnpm --filter @deadhead/web exec playwright install --with-deps chromium
|
- run: pnpm --filter @deadhead/web exec playwright install --with-deps chromium
|
||||||
- run: pnpm --filter @deadhead/web e2e
|
- run: pnpm --filter @deadhead/web e2e
|
||||||
|
|
|
||||||
|
|
@ -1384,3 +1384,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The loop replaces the pg-boss arrival job**: arrivals were already resolved lazily before every command, so the loop's job is to keep the save and screen current while the app is open and nothing is being clicked, and to catch up the moment it opens or comes back.
|
- **The loop replaces the pg-boss arrival job**: arrivals were already resolved lazily before every command, so the loop's job is to keep the save and screen current while the app is open and nothing is being clicked, and to catch up the moment it opens or comes back.
|
||||||
- **Rest endings and finished shop jobs change no state** (they are times compared with now), so the loop only reports them, for the screen to refresh; a pass that resolves nothing writes nothing.
|
- **Rest endings and finished shop jobs change no state** (they are times compared with now), so the loop only reports them, for the screen to refresh; a pass that resolves nothing writes nothing.
|
||||||
- **Passes never overlap**: a tick that comes while one is running reuses it, so a resume during an interval tick doesn't run resolution twice at once (it would be harmless anyway; resolution is idempotent).
|
- **Passes never overlap**: a tick that comes while one is running reuses it, so a resume during an interval tick doesn't run resolution twice at once (it would be harmless anyway; resolution is idempotent).
|
||||||
|
|
||||||
|
## Step 14.7
|
||||||
|
|
||||||
|
- **The client keeps its API layer and swaps the transport**: `createEngineClient` implements the same `ApiClient` over engine commands and turns refusals into the same `ApiError`s (status and code as the server sent), so every screen, hook and component test stays as it was.
|
||||||
|
- **The clock still "syncs"**, but with the engine clock (`getServerTime` is now the device clock plus the developer offset), so the developer tools' fast-forward shows in the UI as before.
|
||||||
|
- **Offline mode (Step 11.2) is removed** (CR-014 §6): there's nothing to be offline from. The cached last state, the offline banner, `needsServer` buttons and the connection store went with it. The "while you were away" sheet and the deal show whenever they apply again.
|
||||||
|
- **Developer tools in test builds**: `vite build --mode e2e` reads `.env.e2e` (`VITE_DEV_TOOLS=true`), which turns on developer commands, persists the developer clock offset in local storage (so a fast-forward survives a reload) and exposes `window.deadhead.call` for browser tests. Deployable builds (`pnpm build`) have none of it.
|
||||||
|
- **Browser tests are the web build alone**: the API server left the Playwright config; helpers run engine commands through the hook. Two waits were needed that a server never needed: the hook appears only after the page takes the tab lock, and an acknowledgement is written in the page, so a test must not reload before it is saved.
|
||||||
|
- **"Closed while it landed" (12.4)** is now a new page opening with the developer offset already past the arrival: the engine resolves the trip on load, exactly as for a player who closed the app.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Browser-test builds (vite build --mode e2e): developer tools and the window.deadhead
|
||||||
|
# hook for engine commands. Never used for a deployable build.
|
||||||
|
VITE_DEV_TOOLS=true
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { advanceTo, dispatchFromEarthBoard, logbook, playToDeal, state } from "./support";
|
import { dispatchFromEarthBoard, logbook, playToDeal, serverNow, state } from "./support";
|
||||||
|
|
||||||
test.describe("end to end (Phase 12)", () => {
|
test.describe("end to end (Phase 12)", () => {
|
||||||
test("12.4: a delivery that lands while the app is closed shows on return", async ({
|
test("12.4: a delivery that lands while the app is closed shows on return", async ({
|
||||||
|
|
@ -11,10 +11,18 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
const before = await state(page);
|
const before = await state(page);
|
||||||
const flight = before.activeDispatches.find((d) => d.id === dispatchId)!;
|
const flight = before.activeDispatches.find((d) => d.id === dispatchId)!;
|
||||||
|
|
||||||
// Close the app, let the server clock pass the arrival, come back.
|
// Close the app; it opens again after the arrival time (the developer clock
|
||||||
|
// offset is moved on before the new page loads, as if time had passed).
|
||||||
|
const skip = flight.arrivalAt - (await serverNow(page)) + 1_000;
|
||||||
|
const offset = await page.evaluate(() =>
|
||||||
|
Number(localStorage.getItem("deadhead:dev-clock-offset") ?? 0),
|
||||||
|
);
|
||||||
await page.close();
|
await page.close();
|
||||||
const later = await context.newPage();
|
const later = await context.newPage();
|
||||||
await advanceTo(later, flight.arrivalAt);
|
await later.addInitScript(
|
||||||
|
(value) => localStorage.setItem("deadhead:dev-clock-offset", String(value)),
|
||||||
|
offset + skip,
|
||||||
|
);
|
||||||
await later.goto("/");
|
await later.goto("/");
|
||||||
|
|
||||||
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
|
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,7 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).
|
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).
|
||||||
await page.goto("/locations");
|
await page.goto("/locations");
|
||||||
await expect(page.getByRole("button", { name: "Lease bay at Mars" })).toBeEnabled();
|
await expect(page.getByRole("button", { name: "Lease bay at Mars" })).toBeEnabled();
|
||||||
const marsBoard = await page.request.get("/api/locations/mars/board");
|
expect((await board(page, "mars")).contracts.length).toBeGreaterThan(0);
|
||||||
expect(marsBoard.ok()).toBe(true);
|
|
||||||
expect((await state(page)).bays.map((b) => b.locationId)).toEqual(["earth"]);
|
expect((await state(page)).bays.map((b) => b.locationId)).toEqual(["earth"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { advanceTo, dismissLogbook, dispatchFromEarthBoard, playToDeal, state } from "./support";
|
||||||
|
|
||||||
|
test.describe("the game runs on the device (CR-014 Step 14.7)", () => {
|
||||||
|
test("plays the opening with every network request outside the app's own files blocked", async ({
|
||||||
|
page,
|
||||||
|
baseURL,
|
||||||
|
}) => {
|
||||||
|
const origin = new URL(baseURL!).origin;
|
||||||
|
const blocked: string[] = [];
|
||||||
|
await page.route("**/*", (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
// Only the app's own static files may load; there is no API any more.
|
||||||
|
if (url.origin === origin && !url.pathname.startsWith("/api/")) return route.continue();
|
||||||
|
blocked.push(url.href);
|
||||||
|
return route.abort("internetdisconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start a company, fly the tutorial, shake on the deal, dispatch a contract.
|
||||||
|
const owner = await playToDeal(page);
|
||||||
|
expect(owner.tutorial).toBeNull();
|
||||||
|
expect(owner.ships.map((s) => s.modelId)).toEqual(["courier", "clipper"]);
|
||||||
|
const dispatchId = await dispatchFromEarthBoard(page);
|
||||||
|
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
|
||||||
|
await advanceTo(page, flight.arrivalAt);
|
||||||
|
await page.reload();
|
||||||
|
await dismissLogbook(page);
|
||||||
|
expect((await state(page)).activeDispatches).toEqual([]);
|
||||||
|
|
||||||
|
expect(blocked).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
|
||||||
import { expect, test, type Page } from "@playwright/test";
|
|
||||||
import { samplePlayerState, shipExtras } from "../test/fixtures";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A company with one ship in flight (Earth → Moon, 20 minutes left) and one idle
|
|
||||||
* at Earth, as the server would return it.
|
|
||||||
*/
|
|
||||||
function midgame(now: number): PlayerState {
|
|
||||||
const base = samplePlayerState({ tutorial: null });
|
|
||||||
const flight: Dispatch = {
|
|
||||||
id: "d1",
|
|
||||||
shipId: "s-fly",
|
|
||||||
pilotId: "p2",
|
|
||||||
contract: null,
|
|
||||||
originId: "earth",
|
|
||||||
destinationId: "moon",
|
|
||||||
tutorial: false,
|
|
||||||
departureAt: now - 60_000,
|
|
||||||
arrivalAt: now + 20 * 60_000,
|
|
||||||
distanceMillionKm: 0.38,
|
|
||||||
fuelCost: 160,
|
|
||||||
expectedPayout: 0,
|
|
||||||
seed: 1,
|
|
||||||
status: "in-flight",
|
|
||||||
resolvedAt: null,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
company: { id: "co1", name: "Deadhead Freight", credits: 50_000, loanBalance: 0 },
|
|
||||||
bays: [{ id: "b1", locationId: "earth" }],
|
|
||||||
ships: [
|
|
||||||
{
|
|
||||||
id: "s-idle",
|
|
||||||
modelId: "courier",
|
|
||||||
name: "Dusty Mule",
|
|
||||||
status: "idle",
|
|
||||||
locationId: "earth",
|
|
||||||
dispatchId: null,
|
|
||||||
pilotId: "avatar",
|
|
||||||
lastArrivalAt: 0,
|
|
||||||
...shipExtras("courier"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s-fly",
|
|
||||||
modelId: "clipper",
|
|
||||||
name: "Slow Ox",
|
|
||||||
status: "in-flight",
|
|
||||||
locationId: null,
|
|
||||||
dispatchId: "d1",
|
|
||||||
pilotId: "p2",
|
|
||||||
lastArrivalAt: 0,
|
|
||||||
...shipExtras("clipper"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pilots: [
|
|
||||||
{ ...base.pilots[0]!, assignedShipId: "s-idle" },
|
|
||||||
{
|
|
||||||
...base.pilots[0]!,
|
|
||||||
id: "p2",
|
|
||||||
name: "Rae Okafor",
|
|
||||||
isAvatar: false,
|
|
||||||
assignedShipId: "s-fly",
|
|
||||||
status: "in-flight",
|
|
||||||
locationId: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeDispatches: [flight],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stands in for the API server; with `offline` set, every request fails as if unplugged. */
|
|
||||||
async function fakeServer(page: Page) {
|
|
||||||
const server = { offline: false, state: midgame(Date.now()) };
|
|
||||||
await page.route("**/api/**", async (route) => {
|
|
||||||
if (server.offline) return route.abort("internetdisconnected");
|
|
||||||
const path = new URL(route.request().url()).pathname;
|
|
||||||
if (path === "/api/state") return route.fulfill({ json: server.state });
|
|
||||||
if (path === "/api/time") return route.fulfill({ json: { realTime: Date.now(), gameTime: 0 } });
|
|
||||||
if (path.endsWith("/board")) {
|
|
||||||
return route.fulfill({ json: { contracts: [], periodEndsAt: Date.now() + 600_000 } });
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
json: { error: { code: "not-found", message: "Not in this test" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = async (page: Page) =>
|
|
||||||
Number(await page.getByRole("progressbar").first().getAttribute("aria-valuenow"));
|
|
||||||
|
|
||||||
test.describe("offline shell (Step 11.2)", () => {
|
|
||||||
test("opens from the cache with the last state, keeps flights moving, and blocks actions", async ({
|
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
const server = await fakeServer(page);
|
|
||||||
await page.goto("/fleet");
|
|
||||||
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
|
|
||||||
await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
|
|
||||||
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeEnabled();
|
|
||||||
|
|
||||||
// Network off, then a full reload: everything must come from the device.
|
|
||||||
server.offline = true;
|
|
||||||
await context.setOffline(true);
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
|
|
||||||
await expect(page.getByText(/Offline — showing last update from/)).toBeVisible();
|
|
||||||
await expect(page.getByText("Slow Ox")).toBeVisible();
|
|
||||||
|
|
||||||
// The flight's progress bar keeps advancing from the cached dispatch.
|
|
||||||
const before = await progress(page);
|
|
||||||
await expect.poll(() => progress(page), { timeout: 10_000 }).toBeGreaterThan(before);
|
|
||||||
|
|
||||||
// Server actions are disabled; reading and navigating still work.
|
|
||||||
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeDisabled();
|
|
||||||
await page.getByRole("link", { name: "Shop" }).first().click();
|
|
||||||
await expect(page.getByRole("button", { name: "Buy Courier" })).toBeDisabled();
|
|
||||||
|
|
||||||
// Settings (CR-011): server-backed ones off, display ones still work.
|
|
||||||
await page.goto("/settings").catch(() => undefined);
|
|
||||||
await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible();
|
|
||||||
for (const name of ["Rename", "Back up company", "Restore a company", "Start a new company"]) {
|
|
||||||
await expect(page.getByRole("button", { name })).toBeDisabled();
|
|
||||||
}
|
|
||||||
const clock24 = page.getByRole("radio", { name: "24-hour" });
|
|
||||||
await page.getByText("24-hour", { exact: true }).click();
|
|
||||||
await expect(clock24).toBeChecked();
|
|
||||||
|
|
||||||
// Back online: the banner goes and actions return.
|
|
||||||
server.offline = false;
|
|
||||||
await context.setOffline(false);
|
|
||||||
await expect(page.getByText(/Offline — showing last update/)).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Rename" })).toBeEnabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,44 +4,40 @@ import { checkRoute, effectiveStats, realToGame, wouldStrand } from "@deadhead/s
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helpers for end-to-end play (Phase 12) against the real server. The page's
|
* Helpers for end-to-end play (Phase 12, CR-014): the game runs in the page, so
|
||||||
* request context shares the browser's session cookie, so API calls act as the
|
* tests run engine commands through the test build's `window.deadhead` hook
|
||||||
* player on screen.
|
* and fast-forward with the developer clock. No server is involved.
|
||||||
*/
|
*/
|
||||||
async function api<T>(
|
export async function run<T>(page: Page, name: string, input: unknown = {}): Promise<T> {
|
||||||
page: Page,
|
// After a load the game installs the hook once it holds the tab.
|
||||||
method: "GET" | "POST",
|
await page.waitForFunction(() => window.deadhead !== undefined);
|
||||||
path: string,
|
return (await page.evaluate(([command, data]) => window.deadhead!.call(command as never, data), [
|
||||||
data?: unknown,
|
name,
|
||||||
): Promise<T> {
|
input,
|
||||||
const response =
|
] as const)) as T;
|
||||||
method === "GET" ? await page.request.get(path) : await page.request.post(path, { data });
|
|
||||||
expect(response.ok(), `${method} ${path}: ${await response.text()}`).toBe(true);
|
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const state = (page: Page) => api<PlayerState>(page, "GET", "/api/state");
|
export const state = (page: Page) => run<PlayerState>(page, "getPlayerState");
|
||||||
export const serverNow = async (page: Page) =>
|
export const serverNow = async (page: Page) =>
|
||||||
(await api<{ realTime: number }>(page, "GET", "/api/time")).realTime;
|
(await run<{ realTime: number }>(page, "getServerTime")).realTime;
|
||||||
export const board = (page: Page, locationId: string) =>
|
export const board = (page: Page, locationId: string) =>
|
||||||
api<{ contracts: Contract[] }>(page, "GET", `/api/locations/${locationId}/board`);
|
run<{ contracts: Contract[] }>(page, "getContractBoard", { locationId });
|
||||||
|
|
||||||
/** Moves the (shared) server clock forward. */
|
/** Moves the game clock forward (the developer offset). */
|
||||||
export async function advance(page: Page, ms: number) {
|
export async function advance(page: Page, ms: number) {
|
||||||
await api(page, "POST", "/api/test/advance-clock", { advanceMs: Math.max(1, Math.ceil(ms)) });
|
await run(page, "devAdvanceClock", { advanceMs: Math.max(1, Math.ceil(ms)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves the server clock to just after `time`, if it isn't there yet. */
|
/** Moves the game clock to just after `time`, if it isn't there yet. */
|
||||||
export async function advanceTo(page: Page, time: number) {
|
export async function advanceTo(page: Page, time: number) {
|
||||||
const now = await serverNow(page);
|
const now = await serverNow(page);
|
||||||
if (time > now) await advance(page, time - now + 1_000);
|
if (time > now) await advance(page, time - now + 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addCredits = (page: Page, amount: number) =>
|
export const addCredits = (page: Page, amount: number) => run(page, "devAddCredits", { amount });
|
||||||
api(page, "POST", "/api/test/add-credits", { amount });
|
|
||||||
|
|
||||||
export const dispatchContract = (page: Page, contractId: string, shipId: string, pilotId: string) =>
|
export const dispatchContract = (page: Page, contractId: string, shipId: string, pilotId: string) =>
|
||||||
api<{ dispatch: Dispatch }>(page, "POST", "/api/dispatches", {
|
run<{ dispatch: Dispatch }>(page, "createDispatch", {
|
||||||
kind: "contract",
|
kind: "contract",
|
||||||
contractId,
|
contractId,
|
||||||
shipId,
|
shipId,
|
||||||
|
|
@ -51,9 +47,7 @@ export const dispatchContract = (page: Page, contractId: string, shipId: string,
|
||||||
export const acknowledgeAll = async (page: Page) => {
|
export const acknowledgeAll = async (page: Page) => {
|
||||||
const { summaries } = await state(page);
|
const { summaries } = await state(page);
|
||||||
if (summaries.length > 0) {
|
if (summaries.length > 0) {
|
||||||
await api(page, "POST", "/api/summaries/acknowledge", {
|
await run(page, "acknowledgeSummaries", { dispatchIds: summaries.map((s) => s.dispatchId) });
|
||||||
dispatchIds: summaries.map((s) => s.dispatchId),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -70,6 +64,8 @@ export async function dismissLogbook(page: Page) {
|
||||||
await expect(sheet).toBeVisible();
|
await expect(sheet).toBeVisible();
|
||||||
await sheet.getByRole("button").last().click();
|
await sheet.getByRole("button").last().click();
|
||||||
await expect(sheet).toBeHidden();
|
await expect(sheet).toBeHidden();
|
||||||
|
// The acknowledgement is saved in the page; don't navigate away before it lands.
|
||||||
|
await expect.poll(async () => (await state(page)).summaries.length).toBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A brand-new company: "Start your company" from the start screen. */
|
/** A brand-new company: "Start your company" from the start screen. */
|
||||||
|
|
@ -125,25 +121,26 @@ export function nextMarsWindow(from: number): number {
|
||||||
throw new Error("no Mars window found");
|
throw new Error("no Mars window found");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tries to dispatch; returns the flight, or null if the server refuses (not eligible). */
|
/** Tries to dispatch; returns the flight, or null if the engine refuses (not eligible). */
|
||||||
export async function tryDispatch(
|
export async function tryDispatch(
|
||||||
page: Page,
|
page: Page,
|
||||||
contractId: string,
|
contractId: string,
|
||||||
shipId: string,
|
shipId: string,
|
||||||
pilotId: string,
|
pilotId: string,
|
||||||
): Promise<Dispatch | null> {
|
): Promise<Dispatch | null> {
|
||||||
const response = await page.request.post("/api/dispatches", {
|
try {
|
||||||
data: { kind: "contract", contractId, shipId, pilotId },
|
return (await dispatchContract(page, contractId, shipId, pilotId)).dispatch;
|
||||||
});
|
} catch {
|
||||||
return response.ok() ? ((await response.json()) as { dispatch: Dispatch }).dispatch : null;
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buyShip = (page: Page, modelId: string, bayId: string) =>
|
export const buyShip = (page: Page, modelId: string, bayId: string) =>
|
||||||
api<{ ship: { id: string } }>(page, "POST", "/api/ships", { modelId, bayId });
|
run<{ ship: { id: string } }>(page, "buyShip", { modelId, bayId });
|
||||||
export const hirePilot = (page: Page, locationId: string) =>
|
export const hirePilot = (page: Page, locationId: string) =>
|
||||||
api<{ pilot: { id: string; name: string } }>(page, "POST", "/api/pilots", { locationId });
|
run<{ pilot: { id: string; name: string } }>(page, "hirePilot", { locationId });
|
||||||
export const assignPilot = (page: Page, pilotId: string, shipId: string) =>
|
export const assignPilot = (page: Page, pilotId: string, shipId: string) =>
|
||||||
api(page, "POST", `/api/pilots/${pilotId}/assign`, { pilotId, shipId });
|
run(page, "assignPilot", { pilotId, shipId });
|
||||||
|
|
||||||
const BOARD_PERIOD_MS = content.config.contractBoardPeriodRealMinutes * 60_000;
|
const BOARD_PERIOD_MS = content.config.contractBoardPeriodRealMinutes * 60_000;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"icons": "node scripts/icons.mjs",
|
"icons": "node scripts/icons.mjs",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test",
|
||||||
|
"build:e2e": "node scripts/map-art.mjs --if-available && vite build --mode e2e"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadhead/content": "workspace:*",
|
"@deadhead/content": "workspace:*",
|
||||||
|
"@deadhead/engine": "workspace:*",
|
||||||
"@deadhead/schema": "workspace:*",
|
"@deadhead/schema": "workspace:*",
|
||||||
"@deadhead/sim": "workspace:*",
|
"@deadhead/sim": "workspace:*",
|
||||||
"@fontsource/barlow": "^5.3.0",
|
"@fontsource/barlow": "^5.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const WEB_PORT = 4173;
|
const WEB_PORT = 4173;
|
||||||
const API_PORT = 3135;
|
|
||||||
const WEB = `http://127.0.0.1:${WEB_PORT}`;
|
const WEB = `http://127.0.0.1:${WEB_PORT}`;
|
||||||
|
|
||||||
// Local runs read the repo-root .env (TEST_DATABASE_URL); CI sets real variables.
|
|
||||||
const envFile = fileURLToPath(new URL("../../.env", import.meta.url));
|
|
||||||
if (existsSync(envFile)) process.loadEnvFile(envFile);
|
|
||||||
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browser tests against the production build (Phases 11–12): service worker,
|
* Browser tests against the web build alone (Phases 11–12, CR-014): the game
|
||||||
* install, offline, and end-to-end play against a real API server on the test
|
* runs in the page, saved in IndexedDB. Build with `pnpm build:e2e` first
|
||||||
* database. Run `pnpm build` first. The server clock is shared, and the
|
* (developer tools and the `window.deadhead` test hook on). The tests move the
|
||||||
* end-to-end tests move it forward, so tests run one at a time.
|
* game clock with the developer offset, so they run one at a time.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "e2e",
|
testDir: "e2e",
|
||||||
|
|
@ -25,31 +17,9 @@ export default defineConfig({
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
use: { baseURL: WEB, ...devices["Desktop Chrome"] },
|
use: { baseURL: WEB, ...devices["Desktop Chrome"] },
|
||||||
webServer: [
|
webServer: {
|
||||||
{
|
|
||||||
// The API on the test database, migrated first; developer routes on (not production).
|
|
||||||
command: "pnpm --filter @deadhead/server db:migrate && pnpm --filter @deadhead/server start",
|
|
||||||
cwd: "../..",
|
|
||||||
url: `http://127.0.0.1:${API_PORT}/api/health`,
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120_000,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "test",
|
|
||||||
PORT: String(API_PORT),
|
|
||||||
LOG_LEVEL: "warn",
|
|
||||||
DATABASE_URL: databaseUrl ?? "",
|
|
||||||
BETTER_AUTH_URL: WEB,
|
|
||||||
BETTER_AUTH_SECRET:
|
|
||||||
process.env.BETTER_AUTH_SECRET ?? "e2e-only-secret-not-for-production-use-0000",
|
|
||||||
WORKERS_ENABLED: "false",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The built app, with /api proxied to the server above (same origin, decision 0.5.20).
|
|
||||||
command: `pnpm exec vite preview --port ${WEB_PORT} --strictPort --host 127.0.0.1`,
|
command: `pnpm exec vite preview --port ${WEB_PORT} --strictPort --host 127.0.0.1`,
|
||||||
url: WEB,
|
url: WEB,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: { API_PROXY_TARGET: `http://127.0.0.1:${API_PORT}` },
|
|
||||||
},
|
},
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
|
import { lazy, Suspense, 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";
|
||||||
|
|
@ -15,9 +15,7 @@ import { Locations } from "./screens/Locations";
|
||||||
import { Market } from "./screens/Market";
|
import { Market } from "./screens/Market";
|
||||||
import { Pilots } from "./screens/Pilots";
|
import { Pilots } from "./screens/Pilots";
|
||||||
import { Settings } from "./screens/Settings";
|
import { Settings } from "./screens/Settings";
|
||||||
import { useConnection, useConnectionEvents } from "./state/connection";
|
import { useMotionPreference } from "./state/preferences";
|
||||||
import { usePreferences, useMotionPreference } from "./state/preferences";
|
|
||||||
import { formatTimeOfDay } from "./clock/timeFormat";
|
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
import { Button } from "./ui";
|
import { Button } from "./ui";
|
||||||
|
|
@ -39,30 +37,22 @@ const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||||
"/locations": <Locations />,
|
"/locations": <Locations />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Syncs the clock with the server once, then ticks it every second. */
|
/** Reads the game clock (the engine's device clock, CR-014), then ticks it every second. */
|
||||||
function useServerClock() {
|
function useGameClock() {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncClock(api).catch(() => {
|
void syncClock(api);
|
||||||
// Offline or server down: keep the device clock until the next launch.
|
|
||||||
});
|
|
||||||
return startClockTicker();
|
return startClockTicker();
|
||||||
}, [api]);
|
}, [api]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The whole client. Router, API and query providers come from the caller. */
|
/** The whole client. Router, API and query providers come from the caller. */
|
||||||
export function App() {
|
export function App() {
|
||||||
useServerClock();
|
useGameClock();
|
||||||
useMotionPreference();
|
useMotionPreference();
|
||||||
const state = usePlayerState();
|
const state = usePlayerState();
|
||||||
useRefetchOnArrival(state.data ?? null, state.refetch);
|
useRefetchOnArrival(state.data ?? null, state.refetch);
|
||||||
const { refetch } = state;
|
const retry = () => void state.refetch();
|
||||||
const retry = () => void refetch();
|
|
||||||
// Back online: refresh at once (Step 11.2).
|
|
||||||
useConnectionEvents(useCallback(() => void refetch(), [refetch]));
|
|
||||||
const online = useConnection((s) => s.online);
|
|
||||||
const lastUpdate = useConnection((s) => s.lastUpdate);
|
|
||||||
const clockFormat = usePreferences((p) => p.clock);
|
|
||||||
|
|
||||||
if (state.isPending) {
|
if (state.isPending) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -75,7 +65,7 @@ export function App() {
|
||||||
// The first load failed: nothing to show yet.
|
// The first load failed: nothing to show yet.
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
|
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
|
||||||
<p className="font-display text-lg text-rose-300">Could not reach the server.</p>
|
<p className="font-display text-lg text-rose-300">Could not load the game.</p>
|
||||||
<Button onClick={retry} disabled={state.isFetching}>
|
<Button onClick={retry} disabled={state.isFetching}>
|
||||||
{state.isFetching ? "Retrying…" : "Retry"}
|
{state.isFetching ? "Retrying…" : "Retry"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -84,17 +74,13 @@ export function App() {
|
||||||
}
|
}
|
||||||
if (state.data === null) return <StartScreen />;
|
if (state.data === null) return <StartScreen />;
|
||||||
|
|
||||||
// Offline, or a later refresh failed: keep showing the last state (Step 11.2).
|
// A later refresh failed: keep showing the last state, with a way to retry.
|
||||||
const banner =
|
const banner = state.isError ? (
|
||||||
!online || state.isError ? (
|
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
|
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
|
||||||
>
|
>
|
||||||
<span>
|
<span>Couldn't refresh the game.</span>
|
||||||
Offline — showing last update
|
|
||||||
{lastUpdate !== null && ` from ${formatTimeOfDay(lastUpdate, clockFormat)}`}.
|
|
||||||
</span>
|
|
||||||
<Button variant="danger" size="sm" onClick={retry}>
|
<Button variant="danger" size="sm" onClick={retry}>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -125,9 +111,8 @@ export function App() {
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
{/* Both exist to send something to the server: wait until back online. */}
|
<AwaySummary />
|
||||||
{online && <AwaySummary />}
|
<DealModal />
|
||||||
{online && <DealModal />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { CommandName } from "@deadhead/engine";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { createEngineClient } from "./api/engine-client";
|
||||||
|
import { ApiProvider } from "./api/context";
|
||||||
|
import { startResolutionLoop, type Game } from "./platform/game";
|
||||||
|
import { PLAYER_STATE_KEY } from "./state/player";
|
||||||
|
import { Button } from "./ui";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** Test builds only: run engine commands from browser tests (CR-014). */
|
||||||
|
deadhead?: { call(name: CommandName, input: unknown): Promise<unknown> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = "starting" | "here" | "elsewhere";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the game in this tab (decision 0.5.90): takes the tab lock, provides the
|
||||||
|
* engine as the API, and keeps the resolution loop going. Another tab holding
|
||||||
|
* the game gets "open in another tab" with "Use here".
|
||||||
|
*/
|
||||||
|
export function GameRoot({ game, children }: { game: Game; children: ReactNode }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const api = useMemo(() => createEngineClient(game.engine), [game]);
|
||||||
|
const [status, setStatus] = useState<Status>("starting");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
game.lock.onLost(() => setStatus("elsewhere"));
|
||||||
|
let cancelled = false;
|
||||||
|
void game.lock.acquire().then((held) => {
|
||||||
|
if (!cancelled) setStatus(held ? "here" : "elsewhere");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [game]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "here") return;
|
||||||
|
const loop = startResolutionLoop(game, () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["board"] });
|
||||||
|
});
|
||||||
|
if (game.devTools) {
|
||||||
|
window.deadhead = { call: (name, input) => game.engine.call(name, input as never) };
|
||||||
|
}
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [status, game, queryClient]);
|
||||||
|
|
||||||
|
const useHere = async () => {
|
||||||
|
if (await game.lock.acquire({ takeOver: true })) {
|
||||||
|
// Continue from whatever the other tab saved.
|
||||||
|
game.engine.core.reload();
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
setStatus("here");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "starting") {
|
||||||
|
return (
|
||||||
|
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "elsewhere") {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
|
||||||
|
<p className="font-display text-lg text-ink-100">Deadhead is open in another tab.</p>
|
||||||
|
<Button onClick={() => void useHere()}>Use here</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <ApiProvider api={api}>{children}</ApiProvider>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { CommandError, NOT_FOUND_CODES, type CommandName, type GameEngine } from "@deadhead/engine";
|
||||||
|
import type { RouteName } from "@deadhead/schema";
|
||||||
|
import { ApiError, type ApiClient } from "./client";
|
||||||
|
|
||||||
|
/** The HTTP status the server sent for a code, so screens read refusals as before. */
|
||||||
|
function statusFor(code: CommandError["code"]): number {
|
||||||
|
if (code === "validation-error") return 400;
|
||||||
|
if (code === "unauthorized") return 401;
|
||||||
|
return NOT_FOUND_CODES.has(code) ? 404 : 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typed client over the engine instead of HTTP (CR-014 Step 14.7): the
|
||||||
|
* same catalog names, inputs and outputs, and refusals as the same `ApiError`s.
|
||||||
|
*/
|
||||||
|
export function createEngineClient(engine: GameEngine): ApiClient {
|
||||||
|
return {
|
||||||
|
async call(name, input) {
|
||||||
|
try {
|
||||||
|
return (await engine.call(name as RouteName as CommandName, input as never)) as never;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
throw new ApiError(statusFor(error.code), error.code, error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,38 +3,27 @@ import { create } from "zustand";
|
||||||
import type { ApiClient } from "../api/client";
|
import type { ApiClient } from "../api/client";
|
||||||
|
|
||||||
interface ClockState {
|
interface ClockState {
|
||||||
/** Server time minus device time. A wrong device clock only shifts visuals. */
|
/** Game clock minus device time: the engine's developer offset (CR-014); zero otherwise. */
|
||||||
offsetMs: number;
|
offsetMs: number;
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
/** Server-adjusted time, refreshed every second by the ticker. */
|
/** Server-adjusted time, refreshed every second by the ticker. */
|
||||||
now: RealTime;
|
now: RealTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OFFSET_KEY = "deadhead:clock-offset";
|
export const useClock = create<ClockState>(() => ({
|
||||||
|
offsetMs: 0,
|
||||||
|
synced: false,
|
||||||
|
now: Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
/** The last synced offset, so an offline start still shows server time (Step 11.2). */
|
/** The current time on the game clock (the engine's device clock): device time plus the synced offset. */
|
||||||
function savedOffset(): number {
|
|
||||||
try {
|
|
||||||
const value = Number(localStorage.getItem(OFFSET_KEY));
|
|
||||||
return Number.isFinite(value) ? value : 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useClock = create<ClockState>(() => {
|
|
||||||
const offsetMs = savedOffset();
|
|
||||||
return { offsetMs, synced: false, now: Date.now() + offsetMs };
|
|
||||||
});
|
|
||||||
|
|
||||||
/** The current time on the server's clock: device time plus the synced offset. */
|
|
||||||
export function serverNow(): RealTime {
|
export function serverNow(): RealTime {
|
||||||
return Date.now() + useClock.getState().offsetMs;
|
return Date.now() + useClock.getState().offsetMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches server time and stores the offset. The server's reading is assumed
|
* Reads the engine clock ("getServerTime") and stores its offset from the
|
||||||
* to be taken halfway through the round trip.
|
* device clock, which is the developer offset when one is set (CR-014).
|
||||||
*/
|
*/
|
||||||
export async function syncClock(api: ApiClient): Promise<void> {
|
export async function syncClock(api: ApiClient): Promise<void> {
|
||||||
const sentAt = Date.now();
|
const sentAt = Date.now();
|
||||||
|
|
@ -42,11 +31,6 @@ export async function syncClock(api: ApiClient): Promise<void> {
|
||||||
const receivedAt = Date.now();
|
const receivedAt = Date.now();
|
||||||
const offsetMs = realTime - (sentAt + receivedAt) / 2;
|
const offsetMs = realTime - (sentAt + receivedAt) / 2;
|
||||||
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
|
useClock.setState({ offsetMs, synced: true, now: Date.now() + offsetMs });
|
||||||
try {
|
|
||||||
localStorage.setItem(OFFSET_KEY, String(offsetMs));
|
|
||||||
} catch {
|
|
||||||
// Storage blocked: an offline start uses the device clock.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates `now` every second. Returns a function that stops the ticker. */
|
/** Updates `now` every second. Returns a function that stops the ticker. */
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,6 @@ export function DealModal() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
size="lg"
|
size="lg"
|
||||||
block
|
block
|
||||||
onClick={() => accept.mutate()}
|
onClick={() => accept.mutate()}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ export function GoalsCard() {
|
||||||
</span>
|
</span>
|
||||||
{ready && (
|
{ready && (
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="success"
|
variant="success"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={claim.isPending}
|
disabled={claim.isPending}
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,7 @@ export function LoanCard() {
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
|
className="min-h-11 min-w-0 flex-1 rounded-lg border border-space-600 bg-space-950/70 px-3 font-display tabular-nums placeholder:text-ink-500 focus:border-amber-400 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button type="submit" disabled={input === "" || problem !== null || repay.isPending}>
|
||||||
needsServer
|
|
||||||
type="submit"
|
|
||||||
disabled={input === "" || problem !== null || repay.isPending}
|
|
||||||
>
|
|
||||||
Pay down
|
Pay down
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,22 @@ import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { createApiClient } from "./api/client";
|
import { GameRoot } from "./GameRoot";
|
||||||
import { ApiProvider } from "./api/context";
|
import { createGame } from "./platform/game";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const api = createApiClient();
|
// The whole game runs here, on the device (CR-014): no server.
|
||||||
|
const game = createGame();
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ApiProvider api={api}>
|
<GameRoot game={game}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ApiProvider>
|
</GameRoot>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import {
|
||||||
|
DeviceClock,
|
||||||
|
GameEngine,
|
||||||
|
IndexedDbBackend,
|
||||||
|
MemoryBackend,
|
||||||
|
ResolutionLoop,
|
||||||
|
SaveStorage,
|
||||||
|
TabLock,
|
||||||
|
type LockManagerLike,
|
||||||
|
type LoopHost,
|
||||||
|
} from "@deadhead/engine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The game on this device (CR-014): save storage, the device clock, the tab
|
||||||
|
* lock and the engine, wired to the browser. Native storage and notifications
|
||||||
|
* join in Step 14.9.
|
||||||
|
*/
|
||||||
|
export interface Game {
|
||||||
|
engine: GameEngine;
|
||||||
|
lock: TabLock;
|
||||||
|
storage: SaveStorage;
|
||||||
|
clock: DeviceClock;
|
||||||
|
devTools: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Developer tools: development builds, and test builds made with VITE_DEV_TOOLS=true. */
|
||||||
|
export const DEV_TOOLS = !import.meta.env.PROD || import.meta.env.VITE_DEV_TOOLS === "true";
|
||||||
|
|
||||||
|
const DEV_OFFSET_KEY = "deadhead:dev-clock-offset";
|
||||||
|
|
||||||
|
function savedDevOffset(): number {
|
||||||
|
try {
|
||||||
|
const value = Number(localStorage.getItem(DEV_OFFSET_KEY));
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGame(): Game {
|
||||||
|
const storage = new SaveStorage(
|
||||||
|
typeof indexedDB === "undefined" ? new MemoryBackend() : new IndexedDbBackend(indexedDB),
|
||||||
|
);
|
||||||
|
const clock = new DeviceClock(Date.now, { offsetMs: DEV_TOOLS ? savedDevOffset() : 0 });
|
||||||
|
if (DEV_TOOLS) {
|
||||||
|
// The developer clock offset survives a reload (test builds fast-forward, then reload).
|
||||||
|
const advance = clock.advance.bind(clock);
|
||||||
|
clock.advance = (ms) => {
|
||||||
|
advance(ms);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DEV_OFFSET_KEY, String(clock.offsetMs));
|
||||||
|
} catch {
|
||||||
|
// Storage blocked: the offset lasts until the page reloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const locks = (navigator as Navigator & { locks?: LockManagerLike }).locks;
|
||||||
|
const lock = new TabLock(locks);
|
||||||
|
const engine = new GameEngine({
|
||||||
|
storage,
|
||||||
|
clock,
|
||||||
|
canWrite: () => lock.held,
|
||||||
|
devTools: DEV_TOOLS,
|
||||||
|
// Responses are checked in development, as the server did outside production.
|
||||||
|
validateResponses: !import.meta.env.PROD,
|
||||||
|
});
|
||||||
|
return { engine, lock, storage, clock, devTools: DEV_TOOLS };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser as the loop's host: a timer, and the page becoming visible again. */
|
||||||
|
export const browserLoopHost: LoopHost = {
|
||||||
|
setInterval: (fn, ms) => window.setInterval(fn, ms),
|
||||||
|
clearInterval: (handle) => window.clearInterval(handle as number),
|
||||||
|
onResume(fn) {
|
||||||
|
const listener = () => {
|
||||||
|
if (document.visibilityState === "visible") fn();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", listener);
|
||||||
|
return () => document.removeEventListener("visibilitychange", listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startResolutionLoop(game: Game, onChange: () => void): ResolutionLoop {
|
||||||
|
const loop = new ResolutionLoop(game.engine, browserLoopHost, onChange);
|
||||||
|
void loop.start();
|
||||||
|
return loop;
|
||||||
|
}
|
||||||
|
|
@ -67,13 +67,7 @@ export function BackupCompany() {
|
||||||
<p className="mt-2 text-sm text-amber-200">
|
<p className="mt-2 text-sm text-amber-200">
|
||||||
Your old code will stop working as soon as you generate a new one.
|
Your old code will stop working as soon as you generate a new one.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
||||||
needsServer
|
|
||||||
block
|
|
||||||
className="mt-3"
|
|
||||||
disabled={create.isPending}
|
|
||||||
onClick={() => create.mutate()}
|
|
||||||
>
|
|
||||||
Generate a new code
|
Generate a new code
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -85,13 +79,7 @@ export function BackupCompany() {
|
||||||
A backup code lets you open this company on any device, or get it back if this one is
|
A backup code lets you open this company on any device, or get it back if this one is
|
||||||
lost. No email or password.
|
lost. No email or password.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button block className="mt-3" disabled={create.isPending} onClick={() => create.mutate()}>
|
||||||
needsServer
|
|
||||||
block
|
|
||||||
className="mt-3"
|
|
||||||
disabled={create.isPending}
|
|
||||||
onClick={() => create.mutate()}
|
|
||||||
>
|
|
||||||
Create a backup code
|
Create a backup code
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|
@ -100,7 +88,7 @@ export function BackupCompany() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||||
Back up company
|
Back up company
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{open && (
|
||||||
|
|
@ -142,7 +130,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
||||||
Restore a company
|
Restore a company
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{open && (
|
||||||
|
|
@ -181,7 +169,7 @@ export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button needsServer type="submit" block disabled={!code.trim() || restore.isPending}>
|
<Button type="submit" block disabled={!code.trim() || restore.isPending}>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" block onClick={() => setOpen(false)}>
|
<Button variant="secondary" block onClick={() => setOpen(false)}>
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,7 @@ export function CompanyName() {
|
||||||
<span className="text-ink-400">Company name</span>
|
<span className="text-ink-400">Company name</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
|
<span className="font-display font-semibold text-ink-50">{state.company.name}</span>
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => setDraft(state.company.name)}>
|
||||||
needsServer
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setDraft(state.company.name)}
|
|
||||||
>
|
|
||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -65,7 +60,7 @@ export function CompanyName() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button needsServer size="sm" type="submit" disabled={!check.success || rename.isPending}>
|
<Button size="sm" type="submit" disabled={!check.success || rename.isPending}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
|
<Button size="sm" variant="secondary" onClick={() => setDraft(null)}>
|
||||||
|
|
@ -88,7 +83,7 @@ export function NewCompany() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button needsServer variant="danger" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="danger" size="sm" onClick={() => setOpen(true)}>
|
||||||
Start a new company
|
Start a new company
|
||||||
</Button>
|
</Button>
|
||||||
{open && (
|
{open && (
|
||||||
|
|
@ -115,7 +110,6 @@ export function NewCompany() {
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="danger"
|
variant="danger"
|
||||||
block
|
block
|
||||||
disabled={typed !== name || start.isPending}
|
disabled={typed !== name || start.isPending}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ export function DevTools() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|
@ -82,7 +81,6 @@ export function DevTools() {
|
||||||
Advance 10 minutes
|
Advance 10 minutes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|
@ -90,17 +88,10 @@ export function DevTools() {
|
||||||
>
|
>
|
||||||
Advance 1 hour
|
Advance 1 hour
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => credits.mutate()}
|
|
||||||
>
|
|
||||||
Add 10,000 credits
|
Add 10,000 credits
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || lastArrival <= serverNow()}
|
disabled={busy || lastArrival <= serverNow()}
|
||||||
|
|
@ -111,7 +102,6 @@ export function DevTools() {
|
||||||
Finish all flights
|
Finish all flights
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || discovered.length === state.locations.length}
|
disabled={busy || discovered.length === state.locations.length}
|
||||||
|
|
@ -138,7 +128,6 @@ export function DevTools() {
|
||||||
</select>
|
</select>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !pilot}
|
disabled={busy || !pilot}
|
||||||
|
|
@ -147,7 +136,6 @@ export function DevTools() {
|
||||||
Level up
|
Level up
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !pilot || (pilot.dutyHours === 0 && !pilot.resting)}
|
disabled={busy || !pilot || (pilot.dutyHours === 0 && !pilot.resting)}
|
||||||
|
|
@ -176,7 +164,6 @@ export function DevTools() {
|
||||||
</select>
|
</select>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !ship}
|
disabled={busy || !ship}
|
||||||
|
|
@ -185,7 +172,6 @@ export function DevTools() {
|
||||||
Max upgrades
|
Max upgrades
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !ship || Object.keys(ship.upgrades).length === 0}
|
disabled={busy || !ship || Object.keys(ship.upgrades).length === 0}
|
||||||
|
|
@ -194,7 +180,6 @@ export function DevTools() {
|
||||||
Reset upgrades
|
Reset upgrades
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !ship || ship.inShopUntil === null}
|
disabled={busy || !ship || ship.inShopUntil === null}
|
||||||
|
|
@ -217,7 +202,6 @@ export function DevTools() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={busy || !ship || ship.status !== "idle" || ship.locationId === destination}
|
disabled={busy || !ship || ship.status !== "idle" || ship.locationId === destination}
|
||||||
|
|
|
||||||
|
|
@ -426,12 +426,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
|
||||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={confirm} disabled={!selected || dispatch.isPending} className="flex-1">
|
||||||
needsServer
|
|
||||||
onClick={confirm}
|
|
||||||
disabled={!selected || dispatch.isPending}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { strandedText } from "./DispatchSheet";
|
||||||
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||||
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { useOnline } from "../state/connection";
|
|
||||||
import {
|
import {
|
||||||
Art,
|
Art,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -161,7 +160,6 @@ function ShipRow(props: {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={props.onDeadhead}
|
onClick={props.onDeadhead}
|
||||||
|
|
@ -206,7 +204,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
const reposition = useGameAction("createDispatch");
|
const reposition = useGameAction("createDispatch");
|
||||||
const online = useOnline();
|
|
||||||
// Effective stats: upgraded fuel tanks reach farther (CR-009).
|
// Effective stats: upgraded fuel tanks reach farther (CR-009).
|
||||||
const model = shipStatsOf(ship);
|
const model = shipStatsOf(ship);
|
||||||
const origin = ship.locationId!;
|
const origin = ship.locationId!;
|
||||||
|
|
@ -263,7 +260,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={locationName(option.id)}
|
aria-label={locationName(option.id)}
|
||||||
disabled={option.problem !== null || reposition.isPending || !online}
|
disabled={option.problem !== null || reposition.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reposition.mutate(
|
reposition.mutate(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -292,12 +292,7 @@ function LocationBoard({
|
||||||
· expires in <Countdown to={contract.expiresAt} done="expired" />
|
· expires in <Countdown to={contract.expiresAt} done="expired" />
|
||||||
</span>
|
</span>
|
||||||
{onSelect && (
|
{onSelect && (
|
||||||
<Button
|
<Button size="sm" onClick={() => onSelect(contract)} className="shrink-0">
|
||||||
needsServer
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSelect(contract)}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
Dispatch
|
Dispatch
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -355,7 +350,6 @@ function NothingHere({
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={rest.isPending || state.tutorial !== null}
|
disabled={rest.isPending || state.tutorial !== null}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ export function Locations() {
|
||||||
<>
|
<>
|
||||||
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
|
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
aria-label={`Lease bay at ${location.name}`}
|
aria-label={`Lease bay at ${location.name}`}
|
||||||
block
|
block
|
||||||
disabled={!affordable || lease.isPending}
|
disabled={!affordable || lease.isPending}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@ export function Market() {
|
||||||
<Reach model={model} />
|
<Reach model={model} />
|
||||||
<OwnedUpgraded modelId={model.id} />
|
<OwnedUpgraded modelId={model.id} />
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
aria-label={`Buy ${model.name}`}
|
aria-label={`Buy ${model.name}`}
|
||||||
block
|
block
|
||||||
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
disabled={!affordable || state.bays.length === 0 || buy.isPending}
|
||||||
|
|
@ -207,7 +206,6 @@ function DeliveryPicker(props: {
|
||||||
})}
|
})}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
block
|
block
|
||||||
disabled={bayId === null || props.pending}
|
disabled={bayId === null || props.pending}
|
||||||
onClick={() => bayId && props.onBuy(bayId)}
|
onClick={() => bayId && props.onBuy(bayId)}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
shipModelName,
|
shipModelName,
|
||||||
} from "../format";
|
} from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { useOnline } from "../state/connection";
|
|
||||||
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
import { Button, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
||||||
/** XP needed for the pilot's current and next level, or null at max level. */
|
/** XP needed for the pilot's current and next level, or null at max level. */
|
||||||
|
|
@ -78,7 +77,6 @@ function HirePilot() {
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
disabled={!hasBay || state.company.credits < cost || hire.isPending}
|
||||||
onClick={() => hire.mutate({ locationId: bay })}
|
onClick={() => hire.mutate({ locationId: bay })}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
|
|
@ -99,7 +97,6 @@ function HirePilot() {
|
||||||
function PilotCard({ pilot }: { pilot: Pilot }) {
|
function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const spend = useGameAction("spendSkillPoint");
|
const spend = useGameAction("spendSkillPoint");
|
||||||
const online = useOnline();
|
|
||||||
const progress = levelProgress(pilot.xp, pilot.level);
|
const progress = levelProgress(pilot.xp, pilot.level);
|
||||||
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
const ship = state.ships.find((s) => s.id === pilot.assignedShipId);
|
||||||
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
|
const lockedToLoaner = pilot.isAvatar && state.tutorial !== null;
|
||||||
|
|
@ -201,7 +198,7 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
aria-label={`Raise ${skill.name} for ${pilot.name}`}
|
||||||
disabled={spend.isPending || !online}
|
disabled={spend.isPending}
|
||||||
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
onClick={() => spend.mutate({ pilotId: pilot.id, skillId: skill.id })}
|
||||||
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
|
className="grid size-8 shrink-0 animate-glow place-items-center rounded-lg border border-amber-400/70 bg-amber-500/15 text-amber-200 transition active:scale-95"
|
||||||
>
|
>
|
||||||
|
|
@ -287,7 +284,6 @@ function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={rest.isPending}
|
disabled={rest.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
@ -343,7 +339,6 @@ function AssignShip({ pilot }: { pilot: Pilot }) {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={!shipId || assign.isPending}
|
disabled={!shipId || assign.isPending}
|
||||||
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
onClick={() => assign.mutate({ pilotId: pilot.id, shipId })}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={start.isPending}
|
disabled={start.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ export function StartScreen() {
|
||||||
empire across the solar system.
|
empire across the solar system.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={() => start.mutate()}
|
onClick={() => start.mutate()}
|
||||||
disabled={start.isPending}
|
disabled={start.isPending}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,6 @@ export function TutorialBoard() {
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<Button
|
<Button
|
||||||
needsServer
|
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={runInFlight || fly.isPending}
|
disabled={runInFlight || fly.isPending}
|
||||||
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import type { PlayerState, RouteName, RouteRequest, RouteResponse } from "@deadh
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { markOffline } from "./connection";
|
import { PLAYER_STATE_KEY, usePlayerState } from "./player";
|
||||||
import { isNetworkError, PLAYER_STATE_KEY, usePlayerState } from "./player";
|
|
||||||
|
|
||||||
/** Player state on screens that only render once it has loaded (see `App`). */
|
/** Player state on screens that only render once it has loaded (see `App`). */
|
||||||
export function useLoadedPlayer(): PlayerState {
|
export function useLoadedPlayer(): PlayerState {
|
||||||
|
|
@ -21,10 +20,6 @@ export function useGameAction<N extends RouteName>(name: N) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
|
return useMutation<RouteResponse<N>, Error, RouteRequest<N>>({
|
||||||
mutationFn: (input) => api.call(name, input),
|
mutationFn: (input) => api.call(name, input),
|
||||||
// A request that never reached the server means we're offline (Step 11.2).
|
|
||||||
onError: (error) => {
|
|
||||||
if (isNetworkError(error)) markOffline();
|
|
||||||
},
|
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
||||||
|
|
@ -34,8 +29,5 @@ export function useGameAction<N extends RouteName>(name: N) {
|
||||||
|
|
||||||
/** The server's message for a failed action, or a generic one. */
|
/** The server's message for a failed action, or a generic one. */
|
||||||
export function errorMessage(error: unknown): string {
|
export function errorMessage(error: unknown): string {
|
||||||
if (error instanceof ApiError) return error.message;
|
return error instanceof ApiError ? error.message : "Something went wrong. Try again.";
|
||||||
return isNetworkError(error)
|
|
||||||
? "You're offline. Try again once you're back online."
|
|
||||||
: "Something went wrong. Try again.";
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the server can be reached (Step 11.2). Offline when the browser says
|
|
||||||
* so or when a request failed without reaching the server; back online on the
|
|
||||||
* browser's "online" event or the next successful request.
|
|
||||||
*/
|
|
||||||
interface ConnectionState {
|
|
||||||
online: boolean;
|
|
||||||
/** When the player state on screen was fetched (real ms); null before the first. */
|
|
||||||
lastUpdate: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useConnection = create<ConnectionState>(() => ({
|
|
||||||
online: typeof navigator === "undefined" ? true : navigator.onLine,
|
|
||||||
lastUpdate: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const markOnline = (lastUpdate: number) =>
|
|
||||||
useConnection.setState({ online: true, lastUpdate });
|
|
||||||
export const markOffline = () => useConnection.setState({ online: false });
|
|
||||||
|
|
||||||
/** True while the server can be reached; server actions are disabled otherwise. */
|
|
||||||
export function useOnline(): boolean {
|
|
||||||
return useConnection((s) => s.online);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Follows the browser's online/offline events; `onOnline` refetches. */
|
|
||||||
export function useConnectionEvents(onOnline: () => void) {
|
|
||||||
useEffect(() => {
|
|
||||||
const goOnline = () => {
|
|
||||||
useConnection.setState({ online: true });
|
|
||||||
onOnline();
|
|
||||||
};
|
|
||||||
window.addEventListener("online", goOnline);
|
|
||||||
window.addEventListener("offline", markOffline);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("online", goOnline);
|
|
||||||
window.removeEventListener("offline", markOffline);
|
|
||||||
};
|
|
||||||
}, [onOnline]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const LAST_STATE_KEY = "deadhead:last-state";
|
|
||||||
|
|
||||||
interface SavedState {
|
|
||||||
state: PlayerState;
|
|
||||||
savedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last player state fetched on this device, so the app can open offline
|
|
||||||
* (Step 11.2). A cache only: the server stays authoritative, and a device that
|
|
||||||
* blocks storage simply has nothing to show offline.
|
|
||||||
*/
|
|
||||||
export function saveLastState(state: PlayerState | null, savedAt: number) {
|
|
||||||
try {
|
|
||||||
if (state === null) localStorage.removeItem(LAST_STATE_KEY);
|
|
||||||
else localStorage.setItem(LAST_STATE_KEY, JSON.stringify({ state, savedAt }));
|
|
||||||
} catch {
|
|
||||||
// Storage blocked or full: offline start just won't have a state to show.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadLastState(): SavedState | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LAST_STATE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const saved = JSON.parse(raw) as { state: unknown; savedAt: unknown };
|
|
||||||
// Saved by an older build with a different shape: ignore it.
|
|
||||||
const state = playerStateSchema.safeParse(saved.state);
|
|
||||||
if (!state.success || typeof saved.savedAt !== "number") return null;
|
|
||||||
return { state: state.data, savedAt: saved.savedAt };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +1,18 @@
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { ApiError, ApiValidationError, type ApiClient } from "../api/client";
|
import { ApiError, type ApiClient } from "../api/client";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { loadLastState, markOffline, markOnline, saveLastState, useConnection } from "./connection";
|
|
||||||
|
|
||||||
export const PLAYER_STATE_KEY = ["player-state"] as const;
|
export const PLAYER_STATE_KEY = ["player-state"] as const;
|
||||||
|
|
||||||
/** The request never reached the server (offline, DNS, server down), as opposed to an error reply. */
|
/** Player state, or null when there is no company on this device yet ("unauthorized"). */
|
||||||
export function isNetworkError(error: unknown): boolean {
|
|
||||||
return !(error instanceof ApiError) && !(error instanceof ApiValidationError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Player state, or null when there is no session (the server says 401). Each
|
|
||||||
* fetched state is kept on the device; when the server can't be reached, the
|
|
||||||
* last one is returned instead and the app goes offline (Step 11.2).
|
|
||||||
*/
|
|
||||||
export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | null> {
|
export async function fetchPlayerState(api: ApiClient): Promise<PlayerState | null> {
|
||||||
try {
|
try {
|
||||||
const state = await api.call("getPlayerState", {});
|
return await api.call("getPlayerState", {});
|
||||||
const now = Date.now();
|
|
||||||
saveLastState(state, now);
|
|
||||||
markOnline(now);
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.code === "unauthorized") {
|
if (error instanceof ApiError && error.code === "unauthorized") return null;
|
||||||
saveLastState(null, Date.now());
|
|
||||||
markOnline(Date.now());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (isNetworkError(error)) {
|
|
||||||
markOffline();
|
|
||||||
const saved = loadLastState();
|
|
||||||
if (saved) {
|
|
||||||
useConnection.setState({ lastUpdate: saved.savedAt });
|
|
||||||
return saved.state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { ButtonHTMLAttributes } from "react";
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
import { useOnline } from "../state/connection";
|
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
|
||||||
export type ButtonSize = "sm" | "md" | "lg";
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
@ -42,8 +41,6 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
/** Full width. */
|
/** Full width. */
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
/** Calls the server (or opens a sheet that does): disabled while offline (Step 11.2). */
|
|
||||||
needsServer?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
|
|
@ -52,20 +49,9 @@ export function Button({
|
||||||
block,
|
block,
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
needsServer = false,
|
|
||||||
disabled,
|
|
||||||
title,
|
|
||||||
...rest
|
...rest
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const online = useOnline();
|
|
||||||
const offline = needsServer && !online;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button type={type} className={`${buttonClass(variant, size, block)} ${className}`} {...rest} />
|
||||||
type={type}
|
|
||||||
className={`${buttonClass(variant, size, block)} ${className}`}
|
|
||||||
disabled={disabled || offline}
|
|
||||||
title={offline ? "Unavailable offline" : title}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { ApiError } from "../src/api/client";
|
|
||||||
import { loadLastState, saveLastState, useConnection } from "../src/state/connection";
|
|
||||||
import { fetchPlayerState } from "../src/state/player";
|
|
||||||
import { Button } from "../src/ui";
|
|
||||||
import { fakeApi, samplePlayerState } from "./render";
|
|
||||||
|
|
||||||
const networkDown = () => {
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("offline cache (Step 11.2)", () => {
|
|
||||||
it("keeps each fetched state and goes online", async () => {
|
|
||||||
const state = samplePlayerState();
|
|
||||||
await expect(fetchPlayerState(fakeApi({ getPlayerState: () => state }))).resolves.toEqual(
|
|
||||||
state,
|
|
||||||
);
|
|
||||||
expect(loadLastState()?.state).toEqual(state);
|
|
||||||
expect(useConnection.getState()).toMatchObject({ online: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the last state when the server can't be reached, and goes offline", async () => {
|
|
||||||
const state = samplePlayerState({ playerId: "cached" });
|
|
||||||
saveLastState(state, 1234);
|
|
||||||
await expect(fetchPlayerState(fakeApi({ getPlayerState: networkDown }))).resolves.toEqual(
|
|
||||||
state,
|
|
||||||
);
|
|
||||||
expect(useConnection.getState()).toEqual({ online: false, lastUpdate: 1234 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still fails a first load offline with nothing saved", async () => {
|
|
||||||
await expect(fetchPlayerState(fakeApi({ getPlayerState: networkDown }))).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not fall back on a server error reply, only on no reply", async () => {
|
|
||||||
saveLastState(samplePlayerState(), 1);
|
|
||||||
const failing = fakeApi({
|
|
||||||
getPlayerState: () => {
|
|
||||||
throw new ApiError(500, "internal-error", "Boom");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect(fetchPlayerState(failing)).rejects.toThrow("Boom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forgets the state when signed out, and ignores a saved state of another shape", async () => {
|
|
||||||
saveLastState(samplePlayerState(), 1);
|
|
||||||
saveLastState(null, 2);
|
|
||||||
expect(loadLastState()).toBeNull();
|
|
||||||
localStorage.setItem("deadhead:last-state", JSON.stringify({ state: { old: 1 }, savedAt: 3 }));
|
|
||||||
expect(loadLastState()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("server actions offline", () => {
|
|
||||||
it("disables a needsServer button only while offline", () => {
|
|
||||||
render(
|
|
||||||
<>
|
|
||||||
<Button needsServer>Buy</Button>
|
|
||||||
<Button>Close</Button>
|
|
||||||
</>,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "Buy" })).toBeEnabled();
|
|
||||||
act(() => useConnection.setState({ online: false }));
|
|
||||||
expect(screen.getByRole("button", { name: "Buy" })).toBeDisabled();
|
|
||||||
expect(screen.getByRole("button", { name: "Buy" })).toHaveAttribute(
|
|
||||||
"title",
|
|
||||||
"Unavailable offline",
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "Close" })).toBeEnabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import {
|
||||||
|
DeviceClock,
|
||||||
|
GameEngine,
|
||||||
|
MemoryBackend,
|
||||||
|
SaveStorage,
|
||||||
|
TabLock,
|
||||||
|
type LockManagerLike,
|
||||||
|
} from "@deadhead/engine";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, 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 { useApi } from "../src/api/context";
|
||||||
|
import { createEngineClient } from "../src/api/engine-client";
|
||||||
|
import { GameRoot } from "../src/GameRoot";
|
||||||
|
import type { Game } from "../src/platform/game";
|
||||||
|
|
||||||
|
function testGame(locks?: LockManagerLike): Game {
|
||||||
|
const storage = new SaveStorage(new MemoryBackend());
|
||||||
|
const clock = new DeviceClock(() => Date.parse("2100-01-01T00:00:00Z"));
|
||||||
|
const lock = new TabLock(locks);
|
||||||
|
const engine = new GameEngine({ storage, clock, canWrite: () => lock.held, devTools: false });
|
||||||
|
return { engine, lock, storage, clock, devTools: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the client calls the engine (Step 14.7)", () => {
|
||||||
|
it("answers as the server did, refusals included", async () => {
|
||||||
|
const api = createEngineClient(testGame().engine);
|
||||||
|
await expect(api.call("getPlayerState", {})).rejects.toEqual(
|
||||||
|
new ApiError(401, "unauthorized", "There is no company on this device yet."),
|
||||||
|
);
|
||||||
|
const created = await api.call("createGuest", {});
|
||||||
|
expect(created.state.tutorial).not.toBeNull();
|
||||||
|
const refused = await api.call("acceptDeal", {}).catch((e: unknown) => e);
|
||||||
|
expect(refused).toBeInstanceOf(ApiError);
|
||||||
|
expect(refused).toMatchObject({ status: 409, code: "deal-not-available" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Web Locks with one holder: `ifAvailable` gets nothing while it is held; `steal` takes it. */
|
||||||
|
class OneHolderLocks implements LockManagerLike {
|
||||||
|
#holder: { reject: (e: Error) => void } | null = null;
|
||||||
|
request(
|
||||||
|
_name: string,
|
||||||
|
options: { ifAvailable?: boolean; steal?: boolean },
|
||||||
|
callback: (lock: unknown) => unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (this.#holder && options.ifAvailable) return Promise.resolve(callback(null));
|
||||||
|
if (this.#holder && options.steal) this.#holder.reject(new Error("stolen"));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.#holder = { reject };
|
||||||
|
Promise.resolve(callback({})).then(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows that the engine is reachable through the API context. */
|
||||||
|
function Probe() {
|
||||||
|
useApi();
|
||||||
|
return <p>Game running here</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("one tab runs the game (Step 14.7, decision 0.5.90)", () => {
|
||||||
|
it("shows 'open in another tab' until Use here takes the game over", async () => {
|
||||||
|
const locks = new OneHolderLocks();
|
||||||
|
// Another tab already has the game.
|
||||||
|
await new TabLock(locks).acquire();
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<GameRoot game={testGame(locks)}>
|
||||||
|
<Probe />
|
||||||
|
</GameRoot>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Deadhead is open in another tab.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Game running here")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Use here" }));
|
||||||
|
expect(await screen.findByText("Game running here")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the game at once when no other tab has it", async () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<GameRoot game={testGame(new OneHolderLocks())}>
|
||||||
|
<Probe />
|
||||||
|
</GameRoot>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Game running here")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, configure } from "@testing-library/react";
|
import { cleanup, configure } from "@testing-library/react";
|
||||||
import { afterEach, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
import { useConnection } from "../src/state/connection";
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
// The board remembers what was seen per location (CR-008); start each test fresh.
|
// The board remembers what was seen per location (CR-008); start each test fresh.
|
||||||
if (typeof localStorage !== "undefined") localStorage.clear();
|
if (typeof localStorage !== "undefined") localStorage.clear();
|
||||||
// Offline in one test must not leak into the next (Step 11.2).
|
|
||||||
useConnection.setState({ online: true, lastUpdate: null });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
|
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const networkDown = () => {
|
||||||
|
|
||||||
const afterTutorial = samplePlayerState({ tutorial: null });
|
const afterTutorial = samplePlayerState({ tutorial: null });
|
||||||
|
|
||||||
describe("network errors", () => {
|
describe("load errors", () => {
|
||||||
afterEach(() => focusManager.setFocused(undefined));
|
afterEach(() => focusManager.setFocused(undefined));
|
||||||
|
|
||||||
it.each(SCREENS)(
|
it.each(SCREENS)(
|
||||||
|
|
@ -26,7 +26,7 @@ describe("network errors", () => {
|
||||||
path,
|
path,
|
||||||
);
|
);
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alert");
|
||||||
expect(alert).toHaveTextContent("Could not reach the server.");
|
expect(alert).toHaveTextContent("Could not load the game.");
|
||||||
|
|
||||||
online = true;
|
online = true;
|
||||||
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
||||||
|
|
@ -35,7 +35,7 @@ describe("network errors", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(SCREENS)(
|
it.each(SCREENS)(
|
||||||
"$title: a failed refresh keeps the screen and shows an offline banner",
|
"$title: a failed refresh keeps the screen and shows an error banner",
|
||||||
async ({ path, title }) => {
|
async ({ path, title }) => {
|
||||||
let online = true;
|
let online = true;
|
||||||
renderApp(
|
renderApp(
|
||||||
|
|
@ -52,13 +52,13 @@ describe("network errors", () => {
|
||||||
focusManager.setFocused(false);
|
focusManager.setFocused(false);
|
||||||
focusManager.setFocused(true);
|
focusManager.setFocused(true);
|
||||||
});
|
});
|
||||||
const banner = await screen.findByText(/Offline — showing last update/);
|
const banner = await screen.findByText("Couldn't refresh the game.");
|
||||||
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 1, name: title })).toBeInTheDocument();
|
||||||
|
|
||||||
online = true;
|
online = true;
|
||||||
await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" }));
|
await userEvent.click(within(banner.parentElement!).getByRole("button", { name: "Retry" }));
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
expect(screen.queryByText(/Offline — showing last update/)).not.toBeInTheDocument();
|
expect(screen.queryByText("Couldn't refresh the game.")).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,8 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
||||||
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||||
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
||||||
|
|
||||||
|
Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). The sections on HTTP, sessions and offline mode below are historical until the server is removed (14.11).
|
||||||
|
|
||||||
Session: Better Auth's cookie (`credentials: "include"`). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids.
|
Session: Better Auth's cookie (`credentials: "include"`). On the device the client keeps only caches and preferences: the last player state and clock offset (offline start), device preferences, the device id and seen board ids.
|
||||||
|
|
||||||
Offline (Step 11.2): a request that gets no reply marks the app offline and `getPlayerState` falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with `needsServer` (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's `online` event refetches.
|
Offline (Step 11.2): a request that gets no reply marks the app offline and `getPlayerState` falls back to the cached state; the banner reads "Offline — showing last update from HH:MM". Buttons with `needsServer` (every server action and the sheets that lead to one) are disabled while offline; the away summary and the deal wait until back online. The browser's `online` event refetches.
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
|
||||||
- [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7).
|
- [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7).
|
||||||
- [x] **Step 14.5 — Port the game logic.** `packages/engine/src/game/`: every server endpoint is an engine command with the same request and response schemas (`GameEngine.call(name, input)`: input checked against the route catalog, due arrivals resolved first as the server's `withPlayer` did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: `state` (player state, ships, pilots, goals), `company` (starting save, deal, loan, rename, goals), `boards`, `dispatch`, `fleet` (bays, ships, pilots, skills, upgrades, summaries), `resolve` (arrivals, pruning), `commands` (the table), plus the server's names and messages. Refusals are `CommandError`s with the server's codes; no company yet answers `unauthorized`, as before. Developer commands only with `devTools`; the backup-code endpoints (`getBackupCode`, `createBackupCode`, `restoreCompany`) are not ported (replaced in 14.10). Save additions: `stats` (contract and Priority deliveries, for goals) and each ship's `lastArrivalAt`, so pruning never changes progress; config `boardRetentionPeriods` (24, from the server's cleanup job) and `savedDispatchRetentionRealHours` (168). Tests: 33 server test files ported to `packages/engine/test/ported/` over `test/support.ts` (the server helpers' names and shapes, with `call` routing old endpoint paths to commands and answering with the server's status codes, and `edit`/`readSave`/`addShip`/`addPilot` in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). `test/catalog.test.ts` checks that every command has an engine test.
|
- [x] **Step 14.5 — Port the game logic.** `packages/engine/src/game/`: every server endpoint is an engine command with the same request and response schemas (`GameEngine.call(name, input)`: input checked against the route catalog, due arrivals resolved first as the server's `withPlayer` did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: `state` (player state, ships, pilots, goals), `company` (starting save, deal, loan, rename, goals), `boards`, `dispatch`, `fleet` (bays, ships, pilots, skills, upgrades, summaries), `resolve` (arrivals, pruning), `commands` (the table), plus the server's names and messages. Refusals are `CommandError`s with the server's codes; no company yet answers `unauthorized`, as before. Developer commands only with `devTools`; the backup-code endpoints (`getBackupCode`, `createBackupCode`, `restoreCompany`) are not ported (replaced in 14.10). Save additions: `stats` (contract and Priority deliveries, for goals) and each ship's `lastArrivalAt`, so pruning never changes progress; config `boardRetentionPeriods` (24, from the server's cleanup job) and `savedDispatchRetentionRealHours` (168). Tests: 33 server test files ported to `packages/engine/test/ported/` over `test/support.ts` (the server helpers' names and shapes, with `call` routing old endpoint paths to commands and answering with the server's status codes, and `edit`/`readSave`/`addShip`/`addPilot` in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). `test/catalog.test.ts` checks that every command has an engine test.
|
||||||
- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.
|
- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.
|
||||||
- [ ] Step 14.7 — Switch the client to the engine
|
- [x] **Step 14.7 — Switch the client to the engine.** `src/platform/game.ts` builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the `DeviceClock`, the Web Locks `TabLock`, and the `GameEngine` (developer commands in development builds and in `vite build --mode e2e` test builds via `.env.e2e`; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). `src/api/engine-client.ts`: the typed `ApiClient` over engine commands, refusals turned into the same `ApiError`s (status and code), so screens and TanStack Query are unchanged. `src/GameRoot.tsx`: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and `visibilitychange`; a change refetches state and boards), and exposes `window.deadhead.call` in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, `needsServer` buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with `build:e2e`. Tests: `test/on-device.test.tsx` (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); `e2e/14-7-on-device.e2e.ts` (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine.
|
||||||
- [ ] Step 14.8 — Company creation on the device
|
- [ ] Step 14.8 — Company creation on the device
|
||||||
- [ ] Step 14.9 — Local notifications
|
- [ ] Step 14.9 — Local notifications
|
||||||
- [ ] Step 14.10 — Settings changes
|
- [ ] Step 14.10 — Settings changes
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ importers:
|
||||||
'@deadhead/content':
|
'@deadhead/content':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/content
|
version: link:../../packages/content
|
||||||
|
'@deadhead/engine':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/engine
|
||||||
'@deadhead/schema':
|
'@deadhead/schema':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/schema
|
version: link:../../packages/schema
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue