From c15b5ef74e1c19226733e1f600e8682eddc413a9 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 00:05:58 -0400 Subject: [PATCH] Step 10K.11: developer tools in non-production builds Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ apps/server/src/app.ts | 6 ++- apps/server/src/routes/dev.ts | 37 ++++++++++++++ apps/server/test/dev-tools.test.ts | 73 ++++++++++++++++++++++++++++ apps/web/src/screens/DevTools.tsx | 75 +++++++++++++++++++++++++++++ apps/web/src/screens/Settings.tsx | 3 +- apps/web/test/dev-tools.test.tsx | 57 ++++++++++++++++++++++ memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- packages/schema/src/requests.ts | 16 ++++++ packages/schema/src/routes.ts | 18 +++++++ packages/schema/test/routes.test.ts | 2 + 12 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/routes/dev.ts create mode 100644 apps/server/test/dev-tools.test.ts create mode 100644 apps/web/src/screens/DevTools.tsx create mode 100644 apps/web/test/dev-tools.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index bc71a4e..ebab9a6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **"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. + +## 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. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 77844b1..49b5d76 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -12,6 +12,7 @@ import type { AppContext } from "./routes/context"; import { registerDispatchRoutes } from "./routes/dispatches"; import { registerFleetRoutes } from "./routes/fleet"; import { registerPlayerRoutes } from "./routes/player"; +import { registerDevRoutes } from "./routes/dev"; import { registerTestClockRoute } from "./routes/test-clock"; export interface AppDeps { @@ -65,7 +66,10 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta registerBoardRoutes(app, ctx); registerDispatchRoutes(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; } diff --git a/apps/server/src/routes/dev.ts b/apps/server/src/routes/dev.ts new file mode 100644 index 0000000..24a74a5 --- /dev/null +++ b/apps/server/src/routes/dev.ts @@ -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, + }, + }; + }), + ); +} diff --git a/apps/server/test/dev-tools.test.ts b/apps/server/test/dev-tools.test.ts new file mode 100644 index 0000000..a3ad6f0 --- /dev/null +++ b/apps/server/test/dev-tools.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/screens/DevTools.tsx b/apps/web/src/screens/DevTools.tsx new file mode 100644 index 0000000..ef448ed --- /dev/null +++ b/apps/web/src/screens/DevTools.tsx @@ -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 ( +
+
+ + + + +
+ {error && ( +

+ {errorMessage(error)} +

+ )} +
+ ); +} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index be9ece9..6d22292 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -3,6 +3,7 @@ import { resetMapView } from "../map/camera"; import { About } from "./AboutSettings"; import { BackupCompany, RestoreCompany } from "./BackupSettings"; import { CompanyName, NewCompany } from "./CompanySettings"; +import { DevTools } from "./DevTools"; import { useLoadedPlayer } from "../state/actions"; import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences"; import { Button, Panel, Screen, ScreenHeader } from "../ui"; @@ -80,7 +81,7 @@ export function Settings() { {isDevBuild() && (
-

Development build tools.

+
)} diff --git a/apps/web/test/dev-tools.test.tsx b/apps/web/test/dev-tools.test.tsx new file mode 100644 index 0000000..817909d --- /dev/null +++ b/apps/web/test/dev-tools.test.tsx @@ -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); + }); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 6ac1138..c6b6ae0 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | | `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 | +| `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 | | `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6711416..1c85bc9 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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`. -- [ ] 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 diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 59ecba8..542f5d1 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -106,3 +106,19 @@ export const backupCodeResponseSchema = z.object({ }); /** 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) }); + +/** 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), +}); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 967897d..2abe8cc 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -18,6 +18,9 @@ import { leaseBayRequestSchema, pilotResponseSchema, backupCodeResponseSchema, + devAddCreditsRequestSchema, + devAdvanceClockRequestSchema, + devAdvanceClockResponseSchema, backupCodeStatusSchema, renameCompanyRequestSchema, restoreRequestSchema, @@ -176,6 +179,21 @@ export const routes = { response: createGuestResponseSchema, 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: { method: "POST", path: "/api/company/new", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index cdedc48..1fbe226 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -27,6 +27,8 @@ describe("route catalog", () => { "getBackupCode", "createBackupCode", "restoreCompany", + "devAdvanceClock", + "devAddCredits", "newCompany", "renameCompany", "repayLoan",