Step 10B.5: prove ships no longer strand without a bay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 14:55:51 -04:00
parent a5803d31f5
commit 924151d1a8
5 changed files with 119 additions and 27 deletions

View File

@ -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.

View File

@ -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(

View File

@ -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",
});

View File

@ -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<Guest & { shipId: string }> {
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",
});
});
});

View File

@ -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