From 924151d1a800ff9c817bf2f87a3561d7b4a9074b Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 14:55:51 -0400 Subject: [PATCH] Step 10B.5: prove ships no longer strand without a bay Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 ++ apps/server/src/game/dispatch.ts | 3 +- apps/server/test/reposition.test.ts | 39 +++++------- apps/server/test/stranded.test.ts | 97 +++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 apps/server/test/stranded.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index ef44ae6..ee0654e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -638,3 +638,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The route keeps its name, `getContractBoard`, with a new path `/api/locations/:locationId/board`.** The client builds requests from the catalog, so it follows automatically. - **Check order:** unknown location `404 not-found`, then undiscovered `409 location-undiscovered` (decision 0.5.36). Bays are not consulted. - **Discovery by a delivery shows up on the same request:** the endpoint runs inside `withPlayer`, which resolves due arrivals first. + +## Step 10B.5 + +- **No server code changed beyond a comment:** contract dispatch already validated only the ship, pilot, contract and credits, and the contract lookup searches every stored location board. The step is the regression test for the stranded-ship bug. +- **The test picks the Moon→Earth Courier job from the real generated board** (destination Earth, 20 t or less, no skill), relying on the guaranteed first slot of the smallest mass band, not a hand-placed contract. diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 8b61866..430047c 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -203,7 +203,8 @@ export function shipState(row: typeof ships.$inferSelect): ShipState { } /** - * Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10): + * Dispatches an owned ship and its pilot on a location-board contract, from + * wherever the ship is (Steps 6.10, 10B.5; no bay involved): * sim validation, fuel charged, contract accepted, ship and pilot in flight. */ export async function dispatchContract( diff --git a/apps/server/test/reposition.test.ts b/apps/server/test/reposition.test.ts index 382c960..df37e98 100644 --- a/apps/server/test/reposition.test.ts +++ b/apps/server/test/reposition.test.ts @@ -1,38 +1,26 @@ -import { randomUUID } from "node:crypto"; import { dispatchResponseSchema, errorResponseSchema } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; -import { bays } from "../src/db/schema"; -import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db"; -import { call, getState, signUp, useTestApp } from "./game"; +import { setCredits, useTestDatabase } from "./db"; +import { call, getState, signUpOwner, useTestApp } from "./game"; const database = useTestDatabase(); const testApp = useTestApp(database); -async function ownerWithMoonBay(withMoonBay = true) { - const { app } = testApp(); - const { db } = database(); - const guest = await signUp(app); - const shipId = await makeOwner(db, guest.playerId, guest.avatarId); - await setCredits(db, guest.playerId, 10_000); - if (withMoonBay) { - await db.insert(bays).values({ - id: randomUUID(), - playerId: guest.playerId, - locationId: "moon", - createdAt: TEST_NOW, - }); - } - return { guest, shipId }; +/** An owner with only the Earth bay, a crewed Courier at Earth and 10,000 credits. */ +async function owner() { + const guest = await signUpOwner(testApp().app, database().db); + await setCredits(database().db, guest.playerId, 10_000); + return guest; } -describe("reposition dispatch", () => { - it("flies the ship empty to another bay, costing only fuel", async () => { +describe("deadhead dispatch", () => { + it("flies the ship empty to a discovered location without a bay, costing only fuel", async () => { const { app, clock } = testApp(); - const { guest, shipId } = await ownerWithMoonBay(); + const guest = await owner(); const response = await call(app, guest, "POST", "/api/dispatches", { kind: "reposition", - shipId, + shipId: guest.shipId, pilotId: guest.avatarId, destinationId: "moon", }); @@ -43,6 +31,7 @@ describe("reposition dispatch", () => { clock.now = dispatch.arrivalAt; const state = await getState(app, guest); + expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" }); expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 }); expect(state.company.credits).toBe(10_000 - dispatch.fuelCost); @@ -50,10 +39,10 @@ describe("reposition dispatch", () => { it("refuses an undiscovered destination and changes nothing", async () => { const { app } = testApp(); - const { guest, shipId } = await ownerWithMoonBay(false); + const guest = await owner(); const response = await call(app, guest, "POST", "/api/dispatches", { kind: "reposition", - shipId, + shipId: guest.shipId, pilotId: guest.avatarId, destinationId: "mars", }); diff --git a/apps/server/test/stranded.test.ts b/apps/server/test/stranded.test.ts new file mode 100644 index 0000000..5274ce0 --- /dev/null +++ b/apps/server/test/stranded.test.ts @@ -0,0 +1,97 @@ +import { + contractBoardResponseSchema, + dispatchResponseSchema, + type Contract, +} from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { setCredits, useTestDatabase } from "./db"; +import { + call, + getState, + insertDispatch, + signUpOwner, + testContract, + useTestApp, + type Guest, +} from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +/** An owner whose Courier has just delivered Earth→Moon; there is no Moon bay. */ +async function landedAtMoon(): Promise { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + await setCredits(database().db, owner.playerId, 5000); + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ payout: 1000 }), + }); + clock.now = record.arrivalAt; + const state = await getState(app, owner); + expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" }); + expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); + return owner; +} + +/** A Moon→Earth job the Courier can fly with no skills. */ +function courierJobHome(contracts: Contract[]): Contract { + const job = contracts.find( + (c) => + c.destinationId === "earth" && + c.massTonnes <= 20 && + c.requiredSkill === null && + c.requiredLongHaulLevel === 0, + ); + if (!job) throw new Error("no Courier job home on the Moon board"); + return job; +} + +describe("no more stranded ships (the original bug)", () => { + it("lets a ship that delivered to the Moon, with no Moon bay, take a Moon→Earth job home", async () => { + const { app, clock } = testApp(); + const owner = await landedAtMoon(); + + const board = contractBoardResponseSchema.parse( + (await call(app, owner, "GET", "/api/locations/moon/board")).json(), + ); + const job = courierJobHome(board.contracts); + const response = await call(app, owner, "POST", "/api/dispatches", { + kind: "contract", + contractId: job.id, + shipId: owner.shipId, + pilotId: owner.avatarId, + }); + expect(response.statusCode).toBe(200); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + + clock.now = dispatch.arrivalAt; + const state = await getState(app, owner); + expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "earth" }); + expect(state.summaries.find((s) => s.dispatchId === dispatch.id)).toMatchObject({ + originId: "moon", + destinationId: "earth", + }); + }); + + it("lets the same ship deadhead Moon→Earth instead", async () => { + const { app, clock } = testApp(); + const owner = await landedAtMoon(); + const response = await call(app, owner, "POST", "/api/dispatches", { + kind: "reposition", + shipId: owner.shipId, + pilotId: owner.avatarId, + destinationId: "earth", + }); + expect(response.statusCode).toBe(200); + const { dispatch } = dispatchResponseSchema.parse(response.json()); + clock.now = dispatch.arrivalAt; + expect((await getState(app, owner)).ships[0]).toMatchObject({ + status: "idle", + locationId: "earth", + }); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8db2d33..c3457dc 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -138,7 +138,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record - [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations. - [x] **Step 10B.3 — Database: boards keyed by location.** Migration `0003_cr002_location_boards`: `board_kind` is now `location` / `tutorial` (old `bay` rows relabelled); unique key (player, kind, location, period, tutorial run) means one board per player, location and period. Test: two locations coexist, a duplicate is rejected. - [x] **Step 10B.4 — Server: board endpoint for any discovered location.** `getContractBoard` is now `GET /api/locations/:locationId/board` (`locationBoard` in `src/game/boards.ts`): any discovered location, no bay needed; `409 location-undiscovered` for undiscovered, `404` for unknown. Step 6.9 tests moved to `location-board.test.ts`. -- [ ] Step 10B.5 — Server: no more stranded ships +- [x] **Step 10B.5 — Server: no more stranded ships.** Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. `stranded.test.ts` covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay. - [ ] Step 10B.6 — Server: ship delivery to a bay - [ ] Step 10B.7 — Client: Contracts screen by location - [ ] Step 10B.8 — Client: deadhead action