Step 10K.11: developer tools in non-production builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 00:05:58 -04:00
parent 5030341eb7
commit c15b5ef74e
12 changed files with 294 additions and 3 deletions

View File

@ -1110,3 +1110,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Version is the web package's version, bumped to 0.10.0** (it was 0.0.0) to mark the Phase 10 build; **build is the git short hash** at build time ("dev" when built outside git), injected with Vite `define`. Tests use fixed stand-ins. - **Version is the web package's version, bumped to 0.10.0** (it was 0.0.0) to mark the Phase 10 build; **build is the git short hash** at build time ("dev" when built outside git), injected with Vite `define`. Tests use fixed stand-ins.
- **"Current screen" in debug info is the last screen before Settings**: the button lives in Settings, so the useful answer is where the player was when they opened it. The layout records it. - **"Current screen" in debug info is the last screen before Settings**: the button lives in Settings, so the useful answer is where the player was when they opened it. The layout records it.
- **Player state now includes `playerId`** for debug info; it was only returned at sign-up. - **Player state now includes `playerId`** for debug info; it was only returned at sign-up.
## Step 10K.11
- **"Advance" adds to the offset** (`devAdvanceClock`), where the existing clock-offset route sets it outright; the client doesn't know the server's offset, and E2E tests keep using clock-offset unchanged.
- **The developer routes are in the catalog** (so the client calls them typed, as the CR's §6 asks) but are only registered outside production, like clock-offset; in production all three return 404 (tested). They require a session so they act on the caller's player.
- **The clock is shared:** advancing it moves time for every player on that server, which is fine for development servers.

View File

@ -12,6 +12,7 @@ import type { AppContext } from "./routes/context";
import { registerDispatchRoutes } from "./routes/dispatches"; import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet"; import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player"; import { registerPlayerRoutes } from "./routes/player";
import { registerDevRoutes } from "./routes/dev";
import { registerTestClockRoute } from "./routes/test-clock"; import { registerTestClockRoute } from "./routes/test-clock";
export interface AppDeps { export interface AppDeps {
@ -65,7 +66,10 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
registerBoardRoutes(app, ctx); registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx); registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, ctx); registerFleetRoutes(app, ctx);
if (config.env !== "production") registerTestClockRoute(app, clock); if (config.env !== "production") {
registerTestClockRoute(app, clock);
registerDevRoutes(app, ctx, clock);
}
return app; return app;
} }

View File

@ -0,0 +1,37 @@
import { routes } from "@deadhead/schema";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import type { OffsetClock } from "../clock";
import { companies } from "../db/schema";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/**
* Developer tools (CR-011): fast-forward the clock and add credits. Only
* registered outside production, like the clock-offset route (decision 0.5.18).
*/
export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void {
registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => {
clock.setOffset(clock.offsetMs() + input.advanceMs);
return { offsetMs: clock.offsetMs(), realTime: clock.now() };
});
registerRoute(app, routes.devAddCredits, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => {
const [company] = await tx
.update(companies)
.set({ credits: sql`${companies.credits} + ${input.amount}` })
.where(eq(companies.playerId, playerId))
.returning();
return {
company: {
id: company!.id,
name: company!.name,
credits: company!.credits,
loanBalance: company!.loanBalance,
},
};
}),
);
}

View File

@ -0,0 +1,73 @@
import { companyResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue";
import { useTestDatabase } from "./db";
import {
call,
getState,
insertDispatch,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./game";
import { testConfig } from "./helpers";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("developer tools (Step 10K.11)", () => {
it("advances the server clock", async () => {
const guest = await signUp(testApp().app);
const before = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
const response = await call(testApp().app, guest, "POST", "/api/test/advance-clock", {
advanceMs: 600_000,
});
expect(response.statusCode).toBe(200);
const after = (await call(testApp().app, guest, "GET", "/api/time")).json().realTime as number;
expect(after - before).toBeGreaterThanOrEqual(600_000);
});
it("adds credits", async () => {
const guest = await signUp(testApp().app);
const start = (await getState(testApp().app, guest)).company.credits;
const response = await call(testApp().app, guest, "POST", "/api/test/add-credits", {
amount: 10_000,
});
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(start + 10_000);
});
it("lands every flight when advanced to the last arrival", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const flight = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(1);
await call(app, owner, "POST", "/api/test/advance-clock", {
advanceMs: flight.arrivalAt - clock.now + 1000,
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(0);
});
it("does not exist in production", async () => {
const app = buildApp(testConfig({ NODE_ENV: "production" }), {
database: database(),
jobs: createRecordingQueue(),
});
for (const [url, payload] of [
["/api/test/clock-offset", { offsetMs: 1 }],
["/api/test/advance-clock", { advanceMs: 1000 }],
["/api/test/add-credits", { amount: 10_000 }],
] as const) {
const response = await app.inject({ method: "POST", url, payload });
expect(response.statusCode, url).toBe(404);
}
await app.close();
});
});

View File

@ -0,0 +1,75 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useApi } from "../api/context";
import { serverNow, syncClock } from "../clock/clock";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Button } from "../ui";
const MINUTE = 60_000;
/**
* Settings → Developer (non-production builds only, CR-011): fast-forward the
* server clock, add credits, or land every flight now.
*/
export function DevTools() {
const api = useApi();
const queryClient = useQueryClient();
const state = useLoadedPlayer();
const refresh = async () => {
await syncClock(api);
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] });
};
const advance = useMutation({
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
onSuccess: refresh,
});
const credits = useMutation({
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
onSuccess: refresh,
});
const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt));
const busy = advance.isPending || credits.isPending;
const error = advance.error ?? credits.error;
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(10 * MINUTE)}
>
Advance 10 minutes
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => advance.mutate(60 * MINUTE)}
>
Advance 1 hour
</Button>
<Button variant="secondary" size="sm" disabled={busy} onClick={() => credits.mutate()}>
Add 10,000 credits
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || lastArrival <= serverNow()}
onClick={() =>
advance.mutate(Math.max(1000, Math.ceil(lastArrival - serverNow()) + 1000))
}
>
Finish all flights
</Button>
</div>
{error && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(error)}
</p>
)}
</div>
);
}

View File

@ -3,6 +3,7 @@ import { resetMapView } from "../map/camera";
import { About } from "./AboutSettings"; import { About } from "./AboutSettings";
import { BackupCompany, RestoreCompany } from "./BackupSettings"; import { BackupCompany, RestoreCompany } from "./BackupSettings";
import { CompanyName, NewCompany } from "./CompanySettings"; import { CompanyName, NewCompany } from "./CompanySettings";
import { DevTools } from "./DevTools";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
import { Button, Panel, Screen, ScreenHeader } from "../ui"; import { Button, Panel, Screen, ScreenHeader } from "../ui";
@ -80,7 +81,7 @@ export function Settings() {
{isDevBuild() && ( {isDevBuild() && (
<Section title="Developer"> <Section title="Developer">
<p className="text-sm text-ink-400">Development build tools.</p> <DevTools />
</Section> </Section>
)} )}
</Screen> </Screen>

View File

@ -0,0 +1,57 @@
import type { Dispatch } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render";
describe("developer tools in Settings (Step 10K.11)", () => {
it("advances the clock, adds credits and finishes flights", async () => {
const now = Date.now();
const flight = {
id: "d1",
shipId: "loaner",
pilotId: "avatar",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: true,
departureAt: now - 60_000,
arrivalAt: now + 5 * 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
} as unknown as Dispatch;
const api = fakeApi({
getPlayerState: () => samplePlayerState({ activeDispatches: [flight] }),
devAdvanceClock: ({ advanceMs }) => ({ offsetMs: advanceMs, realTime: now + advanceMs }),
devAddCredits: ({ amount }) => ({
company: { id: "co1", name: "D", credits: amount, loanBalance: 0 },
}),
});
renderApp(api, "/settings");
const dev = await screen.findByRole("region", { name: "Developer" });
await userEvent.click(within(dev).getByRole("button", { name: "Advance 10 minutes" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 600_000 }),
);
await userEvent.click(within(dev).getByRole("button", { name: "Advance 1 hour" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devAdvanceClock", { advanceMs: 3_600_000 }),
);
await userEvent.click(within(dev).getByRole("button", { name: "Add 10,000 credits" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("devAddCredits", { amount: 10_000 }));
await userEvent.click(within(dev).getByRole("button", { name: "Finish all flights" }));
await waitFor(() => {
const advances = api.call.mock.calls.filter(([name]) => name === "devAdvanceClock");
const last = advances.at(-1)![1] as { advanceMs: number };
// To just past the last arrival (5 minutes away), give or take the test's own runtime.
expect(last.advanceMs).toBeGreaterThan(5 * 60_000 - 5_000);
expect(last.advanceMs).toBeLessThan(5 * 60_000 + 5_000);
});
});
});

View File

@ -65,6 +65,8 @@ The `routes` catalog is the single source for both server and client. Each entry
| `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes | | `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes |
| `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes | | `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes |
| `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no | | `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no |
| `devAdvanceClock` | `POST /api/test/advance-clock` | `advanceMs` | `{ offsetMs, realTime }` | yes; non-production only |
| `devAddCredits` | `POST /api/test/add-credits` | `amount` | `{ company }` | yes; non-production only |
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes | | `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes |
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes | | `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |

View File

@ -262,5 +262,5 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company). - [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company).
- [x] **Step 10K.9 — Client: backup and restore flows.** `screens/BackupSettings.tsx`: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends `x-device-id` (`api/device.ts`) on restore. Test: `backup-ui.test.tsx`. - [x] **Step 10K.9 — Client: backup and restore flows.** `screens/BackupSettings.tsx`: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends `x-device-id` (`api/device.ts`) on restore. Test: `backup-ui.test.tsx`.
- [x] **Step 10K.10 — Client: About and debug info.** Vite bakes in `__APP_VERSION__` (web package version, now 0.10.0) and `__APP_BUILD__` (git short hash, "dev" outside git) — `src/build-info.ts`. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains `playerId`. Test: `about.test.tsx`. - [x] **Step 10K.10 — Client: About and debug info.** Vite bakes in `__APP_VERSION__` (web package version, now 0.10.0) and `__APP_BUILD__` (git short hash, "dev" outside git) — `src/build-info.ts`. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains `playerId`. Test: `about.test.tsx`.
- [ ] Step 10K.11 — Client and server: developer tools (non-production only) - [x] **Step 10K.11 — Client and server: developer tools (non-production only).** Catalog routes `devAdvanceClock` (`POST /api/test/advance-clock`, `{ advanceMs }`) and `devAddCredits` (`POST /api/test/add-credits`, `{ amount }`), registered with the clock-offset route only when NODE_ENV isn't production (`routes/dev.ts`). Settings → Developer (`screens/DevTools.tsx`): Advance 10 minutes, Advance 1 hour, Add 10,000 credits, Finish all flights (advances to just past the latest arrival); afterwards the client re-syncs its clock and reloads state and boards. Tests: `dev-tools.test.ts` (server, including 404s in production) and `dev-tools.test.tsx` (client).
- [ ] Step 10K.12 — Plan amendments and regression - [ ] Step 10K.12 — Plan amendments and regression

View File

@ -106,3 +106,19 @@ export const backupCodeResponseSchema = z.object({
}); });
/** A backup code typed by the player; spaces, dashes and case don't matter. */ /** A backup code typed by the player; spaces, dashes and case don't matter. */
export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) }); export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) });
/** Developer tools, non-production only (CR-011). */
export const devAdvanceClockRequestSchema = z.object({
advanceMs: z
.number()
.int()
.positive()
.max(30 * 24 * 3_600_000),
});
export const devAdvanceClockResponseSchema = z.object({
offsetMs: z.number().int(),
realTime: timeSchema,
});
export const devAddCreditsRequestSchema = z.object({
amount: z.number().int().positive().max(10_000_000),
});

View File

@ -18,6 +18,9 @@ import {
leaseBayRequestSchema, leaseBayRequestSchema,
pilotResponseSchema, pilotResponseSchema,
backupCodeResponseSchema, backupCodeResponseSchema,
devAddCreditsRequestSchema,
devAdvanceClockRequestSchema,
devAdvanceClockResponseSchema,
backupCodeStatusSchema, backupCodeStatusSchema,
renameCompanyRequestSchema, renameCompanyRequestSchema,
restoreRequestSchema, restoreRequestSchema,
@ -176,6 +179,21 @@ export const routes = {
response: createGuestResponseSchema, response: createGuestResponseSchema,
auth: false, auth: false,
}, },
/** Non-production only (CR-011): not registered when NODE_ENV is production. */
devAdvanceClock: {
method: "POST",
path: "/api/test/advance-clock",
request: devAdvanceClockRequestSchema,
response: devAdvanceClockResponseSchema,
auth: true,
},
devAddCredits: {
method: "POST",
path: "/api/test/add-credits",
request: devAddCreditsRequestSchema,
response: companyResponseSchema,
auth: true,
},
newCompany: { newCompany: {
method: "POST", method: "POST",
path: "/api/company/new", path: "/api/company/new",

View File

@ -27,6 +27,8 @@ describe("route catalog", () => {
"getBackupCode", "getBackupCode",
"createBackupCode", "createBackupCode",
"restoreCompany", "restoreCompany",
"devAdvanceClock",
"devAddCredits",
"newCompany", "newCompany",
"renameCompany", "renameCompany",
"repayLoan", "repayLoan",