diff --git a/DECISIONS.md b/DECISIONS.md index 0645f2f..2a8e928 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/routes/dev.ts b/apps/server/src/routes/dev.ts index 24a74a5..2419eaa 100644 --- a/apps/server/src/routes/dev.ts +++ b/apps/server/src/routes/dev.ts @@ -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); + }), + ); } diff --git a/apps/server/test/dev-tools.test.ts b/apps/server/test/dev-tools.test.ts index a3ad6f0..9f8d36e 100644 --- a/apps/server/test/dev-tools.test.ts +++ b/apps/server/test/dev-tools.test.ts @@ -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) => { + 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); + }); }); diff --git a/apps/web/src/screens/DevTools.tsx b/apps/web/src/screens/DevTools.tsx index 4c3428a..8fbff19 100644 --- a/apps/web/src/screens/DevTools.tsx +++ b/apps/web/src/screens/DevTools.tsx @@ -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 ( -
+
+
+ + {state.pilots.length > 0 && ( +
+ Pilot + +
+ + +
+
+ )} + + {state.ships.length > 0 && ( +
+ Ship + +
+ + + +
+
+ + +
+
+ )} + {error && (

{errorMessage(error)} diff --git a/apps/web/test/dev-tools.test.tsx b/apps/web/test/dev-tools.test.tsx index 817909d..b95b681 100644 --- a/apps/web/test/dev-tools.test.tsx +++ b/apps/web/test/dev-tools.test.tsx @@ -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", {})); + }); }); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index d42562b..30b3fd6 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4019dc9..2d7974a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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-.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`. diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 542f5d1..674d409 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -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 }), +]); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 2abe8cc..3b44af8 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -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", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index 1fbe226..8299d42 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -29,6 +29,9 @@ describe("route catalog", () => { "restoreCompany", "devAdvanceClock", "devAddCredits", + "devPilot", + "devShip", + "devDiscoverAll", "newCompany", "renameCompany", "repayLoan",