Developer tools for pilots, ships and discovery
CI / check (push) Successful in 7m29s
Details
CI / check (push) Successful in 7m29s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec8971a16a
commit
e3829bb8a1
|
|
@ -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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { content, upgradeMaxLevel } from "@deadhead/content";
|
||||
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 { 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 { requireOwnPilot, requireOwnShip } from "../game/pilots";
|
||||
import { loadPlayerState } from "../game/player-state";
|
||||
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).
|
||||
* Developer tools (CR-011): fast-forward the clock, add credits, and adjust
|
||||
* 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 {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { buildApp } from "../src/app";
|
||||
import { createRecordingQueue } from "../src/jobs/queue";
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
testContract,
|
||||
useTestApp,
|
||||
} from "./game";
|
||||
import { pilots, ships } from "../src/db/schema";
|
||||
import { testConfig } from "./helpers";
|
||||
|
||||
const database = useTestDatabase();
|
||||
|
|
@ -64,10 +67,90 @@ describe("developer tools (Step 10K.11)", () => {
|
|||
["/api/test/clock-offset", { offsetMs: 1 }],
|
||||
["/api/test/advance-clock", { advanceMs: 1000 }],
|
||||
["/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) {
|
||||
const response = await app.inject({ method: "POST", url, payload });
|
||||
expect(response.statusCode, url).toBe(404);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import type { PlayerState, RouteRequest } from "@deadhead/schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useApi } from "../api/context";
|
||||
import { serverNow, syncClock } from "../clock/clock";
|
||||
import { locationName } from "../format";
|
||||
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
||||
import { PLAYER_STATE_KEY } from "../state/player";
|
||||
import { Button } from "../ui";
|
||||
|
||||
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
|
||||
* 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() {
|
||||
const api = useApi();
|
||||
|
|
@ -20,6 +26,11 @@ export function DevTools() {
|
|||
await queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY });
|
||||
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({
|
||||
mutationFn: (advanceMs: number) => api.call("devAdvanceClock", { advanceMs }),
|
||||
onSuccess: refresh,
|
||||
|
|
@ -28,12 +39,38 @@ export function DevTools() {
|
|||
mutationFn: () => api.call("devAddCredits", { amount: 10_000 }),
|
||||
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 busy = advance.isPending || credits.isPending;
|
||||
const error = advance.error ?? credits.error;
|
||||
const busy =
|
||||
advance.isPending ||
|
||||
credits.isPending ||
|
||||
pilotTool.isPending ||
|
||||
shipTool.isPending ||
|
||||
discoverAll.isPending;
|
||||
const error =
|
||||
advance.error ?? credits.error ?? pilotTool.error ?? shipTool.error ?? discoverAll.error;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
needsServer
|
||||
|
|
@ -73,7 +110,125 @@ export function DevTools() {
|
|||
>
|
||||
Finish all flights
|
||||
</Button>
|
||||
<Button
|
||||
needsServer
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || discovered.length === state.locations.length}
|
||||
onClick={() => discoverAll.mutate()}
|
||||
>
|
||||
Discover everywhere
|
||||
</Button>
|
||||
</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 && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
{errorMessage(error)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
describe("developer tools in Settings (Step 10K.11)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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", {}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
|
||||
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
**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`.
|
||||
|
|
|
|||
|
|
@ -122,3 +122,16 @@ export const devAdvanceClockResponseSchema = z.object({
|
|||
export const devAddCreditsRequestSchema = z.object({
|
||||
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 }),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
pilotResponseSchema,
|
||||
backupCodeResponseSchema,
|
||||
devAddCreditsRequestSchema,
|
||||
devPilotRequestSchema,
|
||||
devShipRequestSchema,
|
||||
devAdvanceClockRequestSchema,
|
||||
devAdvanceClockResponseSchema,
|
||||
backupCodeStatusSchema,
|
||||
|
|
@ -194,6 +196,27 @@ export const routes = {
|
|||
response: companyResponseSchema,
|
||||
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: {
|
||||
method: "POST",
|
||||
path: "/api/company/new",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ describe("route catalog", () => {
|
|||
"restoreCompany",
|
||||
"devAdvanceClock",
|
||||
"devAddCredits",
|
||||
"devPilot",
|
||||
"devShip",
|
||||
"devDiscoverAll",
|
||||
"newCompany",
|
||||
"renameCompany",
|
||||
"repayLoan",
|
||||
|
|
|
|||
Loading…
Reference in New Issue