Developer tools for pilots, ships and discovery
CI / check (push) Successful in 7m29s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-08 21:47:08 -04:00
parent ec8971a16a
commit e3829bb8a1
10 changed files with 443 additions and 10 deletions

View File

@ -1325,3 +1325,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`restore.sh` defaults to a separate `deadhead_restore` database**, so the routine test restore never touches live data; recovering the live database is a documented, explicit command with the server stopped. - **`restore.sh` defaults to a separate `deadhead_restore` database**, so the routine test restore never touches live data; recovering the live database is a documented, explicit command with the server stopped.
- **The step's test compares a player row by row**: after a guest's sign-up and tutorial run, the check backs up, restores into a fresh database and compares that player's rows across nine tables as JSON; they match exactly. - **The step's test compares a player row by row**: after a guest's sign-up and tutorial run, the check backs up, restores into a fresh database and compares that player's rows across nine tables as JSON; they match exactly.
- **`check.sh` disables Git Bash's path conversion** (`MSYS_NO_PATHCONV`) so container paths reach Docker unchanged on Windows; its temporary settings file sits next to the compose file (`deploy/.check.env`, git- and docker-ignored) for the same reason. - **`check.sh` disables Git Bash's path conversion** (`MSYS_NO_PATHCONV`) so container paths reach Docker unchanged on Windows; its temporary settings file sits next to the compose file (`deploy/.check.env`, git- and docker-ignored) for the same reason.
## More developer tools (user request)
- **Pilot and ship tools act on one chosen pilot or ship** (pickers in Settings → Developer) and answer with the whole player state, so the screen updates without a refetch. Like the other developer routes they are registered outside production only.
- **"Level up" sets XP to the next level's threshold** rather than adding a fixed amount, so each press is exactly one level and skill points follow from the server's own rules. At the top level it does nothing.
- **"Max upgrades" installs every upgrade at its cap for that model** (per-model caps from CR-009/CR-012), free and without shop time; "Finish shop work" ends a job now, so the usual "out of the shop" note still appears.
- **"Move here" keeps the rules that matter**: only an idle ship, only to a discovered place. Its pilot moves with it, as assigned pilots follow their ship.
- **"Discover everywhere"** marks every location discovered (no first-contact pay), for testing range, reach and boards.

View File

@ -1,15 +1,20 @@
import { content, upgradeMaxLevel } from "@deadhead/content";
import { routes } from "@deadhead/schema"; import { routes } from "@deadhead/schema";
import { eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { OffsetClock } from "../clock"; import type { OffsetClock } from "../clock";
import { companies } from "../db/schema"; import { companies, discoveredLocations, pilots, ships, shipUpgrades } from "../db/schema";
import { gameError } from "../http/errors";
import { registerRoute } from "../http/route"; import { registerRoute } from "../http/route";
import { requireOwnPilot, requireOwnShip } from "../game/pilots";
import { loadPlayerState } from "../game/player-state";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import { withPlayer } from "./with-player"; import { withPlayer } from "./with-player";
/** /**
* Developer tools (CR-011): fast-forward the clock and add credits. Only * Developer tools (CR-011): fast-forward the clock, add credits, and adjust
* registered outside production, like the clock-offset route (decision 0.5.18). * pilots, ships and discoveries for testing. Only registered outside
* production, like the clock-offset route (decision 0.5.18).
*/ */
export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void { export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock: OffsetClock): void {
registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => { registerRoute(app, routes.devAdvanceClock, ctx.options, async (input) => {
@ -34,4 +39,89 @@ export function registerDevRoutes(app: FastifyInstance, ctx: AppContext, clock:
}; };
}), }),
); );
// Level up: XP to the next level's threshold (skill points follow from XP).
// Clear rest: duty back to zero and any rest ended now.
registerRoute(app, routes.devPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
if (input.action === "level-up") {
const thresholds = content.config.pilotLevelXpThresholds;
const next = thresholds.find((xp) => xp > pilot.xp);
if (next !== undefined) {
await tx.update(pilots).set({ xp: next }).where(eq(pilots.id, pilot.id));
}
} else {
await tx
.update(pilots)
.set({ dutyHours: 0, restEndsAt: null })
.where(eq(pilots.id, pilot.id));
}
return loadPlayerState(tx, playerId, now);
}),
);
registerRoute(app, routes.devShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
const ship = await requireOwnShip(tx, playerId, input.shipId);
switch (input.action) {
case "max-upgrades": {
// Every upgrade at its cap for this model, installed at once and free.
const rows = content.upgrades
.map((u) => ({
shipId: ship.id,
upgradeId: u.id,
level: upgradeMaxLevel(u, ship.modelId),
}))
.filter((r) => r.level > 0);
await tx.delete(shipUpgrades).where(eq(shipUpgrades.shipId, ship.id));
if (rows.length > 0) await tx.insert(shipUpgrades).values(rows);
break;
}
case "reset-upgrades":
await tx.delete(shipUpgrades).where(eq(shipUpgrades.shipId, ship.id));
break;
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) {
await tx.update(ships).set({ inShopUntil: now }).where(eq(ships.id, ship.id));
}
break;
case "move": {
if (ship.status !== "idle") {
throw gameError("ship-not-idle", "Only an idle ship can be moved.");
}
const [known] = await tx
.select()
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
eq(discoveredLocations.locationId, input.locationId),
),
);
if (!known) {
throw gameError(
"location-undiscovered",
"Ships can only be moved to discovered places.",
);
}
await tx.update(ships).set({ locationId: input.locationId }).where(eq(ships.id, ship.id));
break;
}
}
return loadPlayerState(tx, playerId, now);
}),
);
// Every location discovered, for testing range and reach.
registerRoute(app, routes.devDiscoverAll, ctx.options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await tx
.insert(discoveredLocations)
.values(content.locations.map((l) => ({ playerId, locationId: l.id, discoveredAt: now })))
.onConflictDoNothing();
return loadPlayerState(tx, playerId, now);
}),
);
} }

View File

@ -1,4 +1,6 @@
import { companyResponseSchema } from "@deadhead/schema"; import { content, upgradeMaxLevel } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app"; import { buildApp } from "../src/app";
import { createRecordingQueue } from "../src/jobs/queue"; import { createRecordingQueue } from "../src/jobs/queue";
@ -12,6 +14,7 @@ import {
testContract, testContract,
useTestApp, useTestApp,
} from "./game"; } from "./game";
import { pilots, ships } from "../src/db/schema";
import { testConfig } from "./helpers"; import { testConfig } from "./helpers";
const database = useTestDatabase(); const database = useTestDatabase();
@ -64,10 +67,90 @@ describe("developer tools (Step 10K.11)", () => {
["/api/test/clock-offset", { offsetMs: 1 }], ["/api/test/clock-offset", { offsetMs: 1 }],
["/api/test/advance-clock", { advanceMs: 1000 }], ["/api/test/advance-clock", { advanceMs: 1000 }],
["/api/test/add-credits", { amount: 10_000 }], ["/api/test/add-credits", { amount: 10_000 }],
["/api/test/pilot", { pilotId: "p", action: "level-up" }],
["/api/test/ship", { shipId: "s", action: "max-upgrades" }],
["/api/test/discover-all", {}],
] as const) { ] as const) {
const response = await app.inject({ method: "POST", url, payload }); const response = await app.inject({ method: "POST", url, payload });
expect(response.statusCode, url).toBe(404); expect(response.statusCode, url).toBe(404);
} }
await app.close(); await app.close();
}); });
it("levels a pilot up to the next level's XP, and clears duty and rest", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
await database()
.db.update(pilots)
.set({ xp: 40, dutyHours: 9, restEndsAt: clock.now + 3_600_000 })
.where(eq(pilots.id, owner.avatarId));
const pilotAfter = async (action: string) => {
const response = await call(app, owner, "POST", "/api/test/pilot", {
pilotId: owner.avatarId,
action,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).pilots.find((p) => p.id === owner.avatarId)!;
};
const levelled = await pilotAfter("level-up");
expect(levelled.xp).toBe(content.config.pilotLevelXpThresholds[0]);
expect(levelled.level).toBe(1);
expect(levelled.skillPointsAvailable).toBe(content.config.skillPointsPerLevel);
expect((await pilotAfter("level-up")).level).toBe(2);
const rested = await pilotAfter("clear-rest");
expect(rested).toMatchObject({ dutyHours: 0, resting: false, restEndsAt: null });
});
it("maxes and resets a ship's upgrades, and finishes shop work", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
const ship = async (input: Record<string, unknown>) => {
const response = await call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
...input,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).ships.find((s) => s.id === owner.shipId)!;
};
const maxed = await ship({ action: "max-upgrades" });
for (const upgrade of content.upgrades) {
expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier"));
}
expect(maxed.stats.fuelRangeMillionKm).toBeGreaterThan(
content.shipModel("courier")!.fuelRangeMillionKm,
);
expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({});
await database()
.db.update(ships)
.set({ inShopUntil: clock.now + 600_000, shopUpgradeId: "engines", shopLevel: 1 })
.where(eq(ships.id, owner.shipId));
expect((await ship({ action: "finish-shop" })).inShopUntil).toBeNull();
});
it("moves an idle ship to a discovered place only", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const move = (locationId: string) =>
call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
action: "move",
locationId,
});
const moved = await move("moon");
expect(moved.statusCode).toBe(200);
expect(playerStateSchema.parse(moved.json()).ships[0]!.locationId).toBe("moon");
const refused = await move("mars");
expect(refused.statusCode).toBe(409);
expect(errorResponseSchema.parse(refused.json()).error.code).toBe("location-undiscovered");
});
it("discovers every location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", "/api/test/discover-all", {});
const { locations } = playerStateSchema.parse(response.json());
expect(locations.every((l) => l.discovered)).toBe(true);
expect(locations).toHaveLength(content.locations.length);
});
}); });

View File

@ -1,15 +1,21 @@
import type { PlayerState, RouteRequest } from "@deadhead/schema";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { serverNow, syncClock } from "../clock/clock"; import { serverNow, syncClock } from "../clock/clock";
import { locationName } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions"; import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player"; import { PLAYER_STATE_KEY } from "../state/player";
import { Button } from "../ui"; import { Button } from "../ui";
const MINUTE = 60_000; const MINUTE = 60_000;
const selectClass =
"min-w-0 flex-1 rounded-lg border border-space-600 bg-space-900 px-2 py-1.5 text-sm text-ink-100";
/** /**
* Settings → Developer (non-production builds only, CR-011): fast-forward the * Settings → Developer (non-production builds only, CR-011): fast-forward the
* server clock, add credits, or land every flight now. * server clock, add credits, land every flight now, and adjust pilots, ships
* and discoveries for testing.
*/ */
export function DevTools() { export function DevTools() {
const api = useApi(); const api = useApi();
@ -20,6 +26,11 @@ export function DevTools() {
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
await queryClient.invalidateQueries({ queryKey: ["board"] }); await queryClient.invalidateQueries({ queryKey: ["board"] });
}; };
/** The pilot, ship and discovery tools answer with the new state. */
const applyState = async (next: PlayerState) => {
queryClient.setQueryData(PLAYER_STATE_KEY, next);
await queryClient.invalidateQueries({ queryKey: ["board"] });
};
const advance = useMutation({ const advance = useMutation({
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }), mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
onSuccess: refresh, onSuccess: refresh,
@ -28,12 +39,38 @@ export function DevTools() {
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }), mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
onSuccess: refresh, onSuccess: refresh,
}); });
const pilotTool = useMutation({
mutationFn: (input: RouteRequest<"devPilot">) => api.call("devPilot", input),
onSuccess: applyState,
});
const shipTool = useMutation({
mutationFn: (input: RouteRequest<"devShip">) => api.call("devShip", input),
onSuccess: applyState,
});
const discoverAll = useMutation({
mutationFn: () => api.call("devDiscoverAll", {}),
onSuccess: applyState,
});
const [pilotId, setPilotId] = useState(state.pilots[0]?.id ?? "");
const [shipId, setShipId] = useState(state.ships[0]?.id ?? "");
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
const [destination, setDestination] = useState(discovered[0] ?? "earth");
const pilot = state.pilots.find((p) => p.id === pilotId);
const ship = state.ships.find((s) => s.id === shipId);
const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt)); const lastArrival = Math.max(0, ...state.activeDispatches.map((d) => d.arrivalAt));
const busy = advance.isPending || credits.isPending; const busy =
const error = advance.error ?? credits.error; advance.isPending ||
credits.isPending ||
pilotTool.isPending ||
shipTool.isPending ||
discoverAll.isPending;
const error =
advance.error ?? credits.error ?? pilotTool.error ?? shipTool.error ?? discoverAll.error;
return ( return (
<div className="space-y-2"> <div className="space-y-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
needsServer needsServer
@ -73,7 +110,125 @@ export function DevTools() {
> >
Finish all flights Finish all flights
</Button> </Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || discovered.length === state.locations.length}
onClick={() => discoverAll.mutate()}
>
Discover everywhere
</Button>
</div> </div>
{state.pilots.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs tracking-wider text-ink-400 uppercase">Pilot</legend>
<select
aria-label="Pilot"
value={pilotId}
onChange={(e) => setPilotId(e.target.value)}
className={selectClass}
>
{state.pilots.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · level {p.level} · {p.xp} XP
</option>
))}
</select>
<div className="flex flex-wrap gap-2">
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !pilot}
onClick={() => pilotTool.mutate({ pilotId, action: "level-up" })}
>
Level up
</Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !pilot || (pilot.dutyHours === 0 && !pilot.resting)}
onClick={() => pilotTool.mutate({ pilotId, action: "clear-rest" })}
>
Clear duty and rest
</Button>
</div>
</fieldset>
)}
{state.ships.length > 0 && (
<fieldset className="space-y-2">
<legend className="text-xs tracking-wider text-ink-400 uppercase">Ship</legend>
<select
aria-label="Ship"
value={shipId}
onChange={(e) => setShipId(e.target.value)}
className={selectClass}
>
{state.ships.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {s.modelId} · {s.locationId ? locationName(s.locationId) : "in flight"}
</option>
))}
</select>
<div className="flex flex-wrap gap-2">
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !ship}
onClick={() => shipTool.mutate({ shipId, action: "max-upgrades" })}
>
Max upgrades
</Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !ship || Object.keys(ship.upgrades).length === 0}
onClick={() => shipTool.mutate({ shipId, action: "reset-upgrades" })}
>
Reset upgrades
</Button>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !ship || ship.inShopUntil === null}
onClick={() => shipTool.mutate({ shipId, action: "finish-shop" })}
>
Finish shop work
</Button>
</div>
<div className="flex gap-2">
<select
aria-label="Move to"
value={destination}
onChange={(e) => setDestination(e.target.value)}
className={selectClass}
>
{discovered.map((id) => (
<option key={id} value={id}>
{locationName(id)}
</option>
))}
</select>
<Button
needsServer
variant="secondary"
size="sm"
disabled={busy || !ship || ship.status !== "idle" || ship.locationId === destination}
onClick={() => shipTool.mutate({ shipId, action: "move", locationId: destination })}
>
Move here
</Button>
</div>
</fieldset>
)}
{error && ( {error && (
<p role="alert" className="text-sm text-rose-300"> <p role="alert" className="text-sm text-rose-300">
{errorMessage(error)} {errorMessage(error)}

View File

@ -2,7 +2,7 @@ import type { Dispatch } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState } from "./render"; import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
describe("developer tools in Settings (Step 10K.11)", () => { describe("developer tools in Settings (Step 10K.11)", () => {
it("advances the clock, adds credits and finishes flights", async () => { it("advances the clock, adds credits and finishes flights", async () => {
@ -54,4 +54,55 @@ describe("developer tools in Settings (Step 10K.11)", () => {
expect(last.advanceMs).toBeLessThan(5 * 60_000 + 5_000); expect(last.advanceMs).toBeLessThan(5 * 60_000 + 5_000);
}); });
}); });
it("levels a pilot, maxes a ship's upgrades, moves it and discovers everywhere", async () => {
const base = samplePlayerState({ tutorial: null });
const state = {
...base,
ships: [
{
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle" as const,
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: "avatar",
},
],
pilots: [{ ...base.pilots[0]!, assignedShipId: "s1" }],
};
const api = fakeApi({
getPlayerState: () => state,
devPilot: () => state,
devShip: () => state,
devDiscoverAll: () => state,
});
renderApp(api, "/settings");
const dev = await screen.findByRole("region", { name: "Developer" });
await userEvent.click(within(dev).getByRole("button", { name: "Level up" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devPilot", { pilotId: "avatar", action: "level-up" }),
);
await userEvent.click(within(dev).getByRole("button", { name: "Max upgrades" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devShip", { shipId: "s1", action: "max-upgrades" }),
);
// Already at Earth: pick the Moon, then move.
expect(within(dev).getByRole("button", { name: "Move here" })).toBeDisabled();
await userEvent.selectOptions(within(dev).getByLabelText("Move to"), "moon");
await userEvent.click(within(dev).getByRole("button", { name: "Move here" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("devShip", {
shipId: "s1",
action: "move",
locationId: "moon",
}),
);
await userEvent.click(within(dev).getByRole("button", { name: "Discover everywhere" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("devDiscoverAll", {}));
});
}); });

View File

@ -67,6 +67,9 @@ The `routes` catalog is the single source for both server and client. Each entry
| `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 | | `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 | | `devAddCredits` | `POST /api/test/add-credits` | `amount` | `{ company }` | yes; non-production only |
| `devPilot` | `POST /api/test/pilot` | `pilotId`, `action`: `level-up` (XP to the next level) / `clear-rest` (duty 0, rest ended) | `PlayerState` | yes; non-production only |
| `devShip` | `POST /api/test/ship` | `shipId`, `action`: `max-upgrades` / `reset-upgrades` / `finish-shop` / `move` (+ `locationId`, idle ship, discovered place) | `PlayerState` | yes; non-production only |
| `devDiscoverAll` | `POST /api/test/discover-all` | — | `PlayerState` | 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

@ -326,3 +326,7 @@ Choices (asked at the start of the phase, decision 0.5.86): a home server runnin
- [x] **Step 13.3 — Database backups.** A `backup` service (the Postgres 18 image, scripts in `deploy/backup/`): `schedule.sh` runs `backup.sh` daily at `BACKUP_HOUR`:00 (`TZ`), which writes `deadhead-<UTC stamp>.dump` (`pg_dump` custom format, via a `.partial` name) into `BACKUP_DIR` (a host folder, e.g. a NAS mount) and deletes dumps older than `BACKUP_KEEP_DAYS` (default 14). `restore.sh [dump] [database]` loads a dump (default: the newest, into a new `deadhead_restore` database). README: backups, a test restore, and recovering the live database. Test: `deploy/check.sh` takes a backup of the running stack, restores the newest dump into a fresh database, and compares the test guest's rows (player, company, ships, pilots, bays, discoveries, boards, dispatches, accepted contracts) between the live and restored databases: identical (13/13 checks pass). - [x] **Step 13.3 — Database backups.** A `backup` service (the Postgres 18 image, scripts in `deploy/backup/`): `schedule.sh` runs `backup.sh` daily at `BACKUP_HOUR`:00 (`TZ`), which writes `deadhead-<UTC stamp>.dump` (`pg_dump` custom format, via a `.partial` name) into `BACKUP_DIR` (a host folder, e.g. a NAS mount) and deletes dumps older than `BACKUP_KEEP_DAYS` (default 14). `restore.sh [dump] [database]` loads a dump (default: the newest, into a new `deadhead_restore` database). README: backups, a test restore, and recovering the live database. Test: `deploy/check.sh` takes a backup of the running stack, restores the newest dump into a fresh database, and compares the test guest's rows (player, company, ships, pilots, bays, discoveries, boards, dispatches, accepted contracts) between the live and restored databases: identical (13/13 checks pass).
**Phase 13 complete — waiting for review.** Manual checks for the user: deploy on the home server (DNS, ports 80/443, `deploy/.env`), load the site on desktop and a phone, start a guest and dispatch a tutorial run; confirm a dump appears on the NAS after the first night. **Phase 13 complete — waiting for review.** Manual checks for the user: deploy on the home server (DNS, ports 80/443, `deploy/.env`), load the site on desktop and a phone, start a guest and dispatch a tutorial run; confirm a dump appears on the NAS after the first night.
## Follow-ups
- [x] **More developer tools (user request).** Settings → Developer gains a pilot picker (Level up: XP to the next level, so skill points appear; Clear duty and rest), a ship picker (Max upgrades: every upgrade at its cap, installed at once and free; Reset upgrades; Finish shop work; Move an idle ship to a discovered place) and Discover everywhere. Server routes `devPilot`, `devShip`, `devDiscoverAll` (`/api/test/...`, non-production only, answer with the player state). Tests: server `dev-tools.test.ts` (each action, move refused to an undiscovered place, all routes 404 in production), web `dev-tools.test.tsx`.

View File

@ -122,3 +122,16 @@ export const devAdvanceClockResponseSchema = z.object({
export const devAddCreditsRequestSchema = z.object({ export const devAddCreditsRequestSchema = z.object({
amount: z.number().int().positive().max(10_000_000), amount: z.number().int().positive().max(10_000_000),
}); });
/** A pilot to the next level's XP, or out of duty and rest. */
export const devPilotRequestSchema = z.object({
pilotId: idSchema,
action: z.enum(["level-up", "clear-rest"]),
});
/** A ship's upgrades maxed or removed, its shop work finished, or moved while idle. */
export const devShipRequestSchema = z.discriminatedUnion("action", [
z.object({
shipId: idSchema,
action: z.enum(["max-upgrades", "reset-upgrades", "finish-shop"]),
}),
z.object({ shipId: idSchema, action: z.literal("move"), locationId: idSchema }),
]);

View File

@ -19,6 +19,8 @@ import {
pilotResponseSchema, pilotResponseSchema,
backupCodeResponseSchema, backupCodeResponseSchema,
devAddCreditsRequestSchema, devAddCreditsRequestSchema,
devPilotRequestSchema,
devShipRequestSchema,
devAdvanceClockRequestSchema, devAdvanceClockRequestSchema,
devAdvanceClockResponseSchema, devAdvanceClockResponseSchema,
backupCodeStatusSchema, backupCodeStatusSchema,
@ -194,6 +196,27 @@ export const routes = {
response: companyResponseSchema, response: companyResponseSchema,
auth: true, auth: true,
}, },
devPilot: {
method: "POST",
path: "/api/test/pilot",
request: devPilotRequestSchema,
response: playerStateSchema,
auth: true,
},
devShip: {
method: "POST",
path: "/api/test/ship",
request: devShipRequestSchema,
response: playerStateSchema,
auth: true,
},
devDiscoverAll: {
method: "POST",
path: "/api/test/discover-all",
request: emptyRequestSchema,
response: playerStateSchema,
auth: true,
},
newCompany: { newCompany: {
method: "POST", method: "POST",
path: "/api/company/new", path: "/api/company/new",

View File

@ -29,6 +29,9 @@ describe("route catalog", () => {
"restoreCompany", "restoreCompany",
"devAdvanceClock", "devAdvanceClock",
"devAddCredits", "devAddCredits",
"devPilot",
"devShip",
"devDiscoverAll",
"newCompany", "newCompany",
"renameCompany", "renameCompany",
"repayLoan", "repayLoan",