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:
parent
a5803d31f5
commit
924151d1a8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue