Step 10K.11: developer tools in non-production builds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5030341eb7
commit
c15b5ef74e
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ describe("route catalog", () => {
|
||||||
"getBackupCode",
|
"getBackupCode",
|
||||||
"createBackupCode",
|
"createBackupCode",
|
||||||
"restoreCompany",
|
"restoreCompany",
|
||||||
|
"devAdvanceClock",
|
||||||
|
"devAddCredits",
|
||||||
"newCompany",
|
"newCompany",
|
||||||
"renameCompany",
|
"renameCompany",
|
||||||
"repayLoan",
|
"repayLoan",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue