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",