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.
|
- **`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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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", {}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ describe("route catalog", () => {
|
||||||
"restoreCompany",
|
"restoreCompany",
|
||||||
"devAdvanceClock",
|
"devAdvanceClock",
|
||||||
"devAddCredits",
|
"devAddCredits",
|
||||||
|
"devPilot",
|
||||||
|
"devShip",
|
||||||
|
"devDiscoverAll",
|
||||||
"newCompany",
|
"newCompany",
|
||||||
"renameCompany",
|
"renameCompany",
|
||||||
"repayLoan",
|
"repayLoan",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue