Step 10B.6: deliver bought ships to a chosen bay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 15:01:11 -04:00
parent 924151d1a8
commit 61781aa790
21 changed files with 136 additions and 43 deletions

View File

@ -643,3 +643,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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. - **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. - **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.
## Step 10B.6
- **Another player's bay id is `404 not-found` ("You have no such bay")**, the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
- **`SHIPYARD_LOCATION_ID` is gone:** the delivery location is always the chosen bay's.
- **Interim client:** the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.

View File

@ -8,9 +8,6 @@ import { bays, companies, discoveredLocations, players, ships } from "../db/sche
import { gameError } from "../http/errors"; import { gameError } from "../http/errors";
import { shipName } from "./names"; import { shipName } from "./names";
/** Where newly bought ships appear. */
export const SHIPYARD_LOCATION_ID = "earth";
export async function requireCompany(tx: Queryable, playerId: string) { export async function requireCompany(tx: Queryable, playerId: string) {
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId)); const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!company) throw new Error(`Player ${playerId} has no company`); if (!company) throw new Error(`Player ${playerId} has no company`);
@ -84,16 +81,25 @@ export async function leaseBay(
return { id: bay!.id, locationId: bay!.locationId }; return { id: bay!.id, locationId: bay!.locationId };
} }
/** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */ /**
* Buys a ship model, delivered idle and uncrewed to one of the player's bays
* (Steps 6.5, 10B.6; decision 0.5.35).
*/
export async function buyShip( export async function buyShip(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
modelId: string, input: { modelId: string; bayId: string },
now: RealTime, now: RealTime,
): Promise<Ship> { ): Promise<Ship> {
const { modelId } = input;
await requireTutorialOver(tx, playerId, "buying a ship"); await requireTutorialOver(tx, playerId, "buying a ship");
const model = content.shipModel(modelId); const model = content.shipModel(modelId);
if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`); if (!model?.purchasable) throw gameError("not-found", `No ship model "${modelId}" for sale.`);
const [bay] = await tx
.select()
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.id, input.bayId)));
if (!bay) throw gameError("not-found", "You have no such bay.");
await spend(tx, playerId, model.price, `a ${model.name}`); await spend(tx, playerId, model.price, `a ${model.name}`);
const player = await requirePlayer(tx, playerId); const player = await requirePlayer(tx, playerId);
@ -105,7 +111,7 @@ export async function buyShip(
playerId, playerId,
modelId, modelId,
name: shipName(player.worldSeed, owned!.n), name: shipName(player.worldSeed, owned!.n),
locationId: SHIPYARD_LOCATION_ID, locationId: bay.locationId,
createdAt: now, createdAt: now,
}) })
.returning(); .returning();

View File

@ -10,7 +10,7 @@ import { withPlayer } from "./with-player";
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void { export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) => registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({ withPlayer(ctx, playerId, async (tx, now) => ({
ship: await buyShip(tx, playerId, input.modelId, now), ship: await buyShip(tx, playerId, input, now),
})), })),
); );

View File

@ -16,7 +16,7 @@ async function dispatched() {
const { app } = testApp(); const { app } = testApp();
const { db } = database(); const { db } = database();
const guest = await signUp(app); const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000); await setCredits(db, guest.playerId, 10_000);
const contract = testContract(); const contract = testContract();
await putBoard(db, guest.playerId, [contract]); await putBoard(db, guest.playerId, [contract]);

View File

@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema"; import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { bays } from "../src/db/schema";
import { shipName } from "../src/game/names"; import { shipName } from "../src/game/names";
import { setCredits, useTestDatabase } from "./db"; import { setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUp, signUpOwner, useTestApp } from "./game"; import { call, getState, signUp, signUpOwner, useTestApp } from "./game";
const database = useTestDatabase(); const database = useTestDatabase();
@ -12,13 +14,16 @@ const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code; errorResponseSchema.parse(response.json()).error.code;
describe("buy a ship", () => { describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => { it("deducts the price and adds an idle, uncrewed ship at the Earth bay", async () => {
const { app } = testApp(); const { app } = testApp();
const owner = await signUpOwner(app, database().db); const owner = await signUpOwner(app, database().db);
const price = content.shipModel("courier")!.price; const price = content.shipModel("courier")!.price;
await setCredits(database().db, owner.playerId, price + 500); await setCredits(database().db, owner.playerId, price + 500);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" }); const response = await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json()); const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ expect(ship).toMatchObject({
@ -41,7 +46,10 @@ describe("buy a ship", () => {
const price = content.shipModel("freighter")!.price; const price = content.shipModel("freighter")!.price;
await setCredits(database().db, owner.playerId, price - 1); await setCredits(database().db, owner.playerId, price - 1);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "freighter" }); const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("insufficient-funds"); expect(errorCode(response)).toBe("insufficient-funds");
@ -55,7 +63,10 @@ describe("buy a ship", () => {
const guest = await signUp(app); const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000); await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" }); const response = await call(app, guest, "POST", "/api/ships", {
modelId: "courier",
bayId: "any",
});
expect(response.statusCode).toBe(409); expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active"); expect(errorCode(response)).toBe("tutorial-active");
@ -68,19 +79,76 @@ describe("buy a ship", () => {
const { app } = testApp(); const { app } = testApp();
const owner = await signUpOwner(app, database().db); const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000); await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "company-courier" }); const response = await call(app, owner, "POST", "/api/ships", {
modelId: "company-courier",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
it("rejects an unknown model", async () => { it("rejects an unknown model", async () => {
const { app } = testApp(); const { app } = testApp();
const owner = await signUpOwner(app, database().db); const owner = await signUpOwner(app, database().db);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "x-wing" }); const response = await call(app, owner, "POST", "/api/ships", {
modelId: "x-wing",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("not-found"); expect(errorCode(response)).toBe("not-found");
}); });
}); });
describe("ship delivery to a bay (CR-002)", () => {
it("delivers a Freighter bought for the Moon bay to the Moon", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const moonBayId = randomUUID();
await database()
.db.insert(bays)
.values({ id: moonBayId, playerId: owner.playerId, locationId: "moon", createdAt: TEST_NOW });
await setCredits(database().db, owner.playerId, content.shipModel("freighter")!.price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: moonBayId,
});
expect(response.statusCode).toBe(200);
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
locationId: "moon",
status: "idle",
pilotId: null,
});
});
it("rejects a purchase with no bay id as a validation error", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(400);
expect(errorCode(response)).toBe("validation-error");
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
});
it("refuses another player's bay and changes nothing", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
const other = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: other.earthBayId,
});
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("not-found");
const state = await getState(app, owner);
expect(state.company.credits).toBe(1_000_000);
expect(state.ships).toHaveLength(1);
expect((await getState(app, other)).ships).toHaveLength(1);
});
});
describe("ship names", () => { describe("ship names", () => {
it("are stable for a world seed and differ between ships", () => { it("are stable for a world seed and differ between ships", () => {
expect(shipName(42, 0)).toBe(shipName(42, 0)); expect(shipName(42, 0)).toBe(shipName(42, 0));

View File

@ -14,7 +14,7 @@ describe("expired contract cleanup", () => {
it("deletes old boards and accepted contracts and keeps current ones", async () => { it("deletes old boards and accepted contracts and keeps current ones", async () => {
const { db } = database(); const { db } = database();
const guest = await signUp(testApp().app); const guest = await signUp(testApp().app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
const period = boardPeriodMs(content.config); const period = boardPeriodMs(content.config);
const current = boardPeriodIndex(TEST_NOW, content.config); const current = boardPeriodIndex(TEST_NOW, content.config);
const old = current - BOARD_RETENTION_PERIODS - 1; const old = current - BOARD_RETENTION_PERIODS - 1;

View File

@ -14,8 +14,8 @@ describe("concurrency and money safety", () => {
await setCredits(database().db, guest.playerId, price + price / 2); await setCredits(database().db, guest.playerId, price + price / 2);
const responses = await Promise.all([ const responses = await Promise.all([
call(app, guest, "POST", "/api/ships", { modelId: "courier" }), call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
call(app, guest, "POST", "/api/ships", { modelId: "courier" }), call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
]); ]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
@ -33,7 +33,7 @@ describe("concurrency and money safety", () => {
const responses = await Promise.all( const responses = await Promise.all(
Array.from({ length: 8 }, () => Array.from({ length: 8 }, () =>
call(app, guest, "POST", "/api/ships", { modelId: "courier" }), call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
), ),
); );
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3); expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
@ -46,7 +46,7 @@ describe("concurrency and money safety", () => {
const { app } = testApp(); const { app } = testApp();
const { db } = database(); const { db } = database();
const guest = await signUp(app); const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000); await setCredits(db, guest.playerId, 10_000);
const contract = testContract(); const contract = testContract();
await putBoard(db, guest.playerId, [contract]); await putBoard(db, guest.playerId, [contract]);

View File

@ -26,7 +26,7 @@ interface Owner {
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */ /** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
async function owner(): Promise<Owner> { async function owner(): Promise<Owner> {
const guest = await signUp(testApp().app); const guest = await signUp(testApp().app);
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(database().db, guest.playerId, guest.avatarId);
await setCredits(database().db, guest.playerId, 10_000); await setCredits(database().db, guest.playerId, 10_000);
return { guest, shipId }; return { guest, shipId };
} }

View File

@ -76,7 +76,7 @@ export async function seedPlayer(
}); });
if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits); if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits);
if (state === "tutorial") return player; if (state === "tutorial") return player;
return { ...player, shipId: await makeOwner(db, player.playerId, player.avatarId) }; return { ...player, ...(await makeOwner(db, player.playerId, player.avatarId)) };
} }
export async function setCredits(db: Db, playerId: string, credits: number): Promise<void> { export async function setCredits(db: Db, playerId: string, credits: number): Promise<void> {
@ -85,10 +85,15 @@ export async function setCredits(db: Db, playerId: string, credits: number): Pro
/** /**
* Turns a tutorial player into an owner: tutorial over, one Courier at Earth * Turns a tutorial player into an owner: tutorial over, one Courier at Earth
* crewed by the avatar, an Earth bay. Returns the ship id. * crewed by the avatar, an Earth bay. Returns the ship and bay ids.
*/ */
export async function makeOwner(db: Db, playerId: string, avatarId: string): Promise<string> { export async function makeOwner(
db: Db,
playerId: string,
avatarId: string,
): Promise<{ shipId: string; earthBayId: string }> {
const shipId = randomUUID(); const shipId = randomUUID();
const earthBayId = randomUUID();
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(players) .update(players)
@ -108,7 +113,7 @@ export async function makeOwner(db: Db, playerId: string, avatarId: string): Pro
.where(eq(pilots.id, avatarId)); .where(eq(pilots.id, avatarId));
await tx await tx
.insert(bays) .insert(bays)
.values({ id: randomUUID(), playerId, locationId: "earth", createdAt: TEST_NOW }); .values({ id: earthBayId, playerId, locationId: "earth", createdAt: TEST_NOW });
}); });
return shipId; return { shipId, earthBayId };
} }

View File

@ -209,8 +209,7 @@ export async function putBoard(
export async function signUpOwner( export async function signUpOwner(
app: TestApp["app"], app: TestApp["app"],
db: Db, db: Db,
): Promise<Guest & { shipId: string }> { ): Promise<Guest & { shipId: string; earthBayId: string }> {
const guest = await signUp(app); const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId); return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) };
return { ...guest, shipId };
} }

View File

@ -18,7 +18,7 @@ async function ownerWithMoonBay() {
const { app } = testApp(); const { app } = testApp();
const { db } = database(); const { db } = database();
const guest = await signUp(app); const guest = await signUp(app);
const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId: earthShipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 100_000); await setCredits(db, guest.playerId, 100_000);
await db.insert(bays).values({ await db.insert(bays).values({
id: randomUUID(), id: randomUUID(),

View File

@ -11,7 +11,7 @@ const testApp = useTestApp(database);
async function owner() { async function owner() {
const { app } = testApp(); const { app } = testApp();
const guest = await signUp(app); const guest = await signUp(app);
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(database().db, guest.playerId, guest.avatarId);
await setCredits(database().db, guest.playerId, 1000); await setCredits(database().db, guest.playerId, 1000);
return { guest, shipId }; return { guest, shipId };
} }

View File

@ -15,7 +15,7 @@ describe("full player state", () => {
const { app, clock } = testApp(); const { app, clock } = testApp();
const { db } = database(); const { db } = database();
const guest = await signUp(app); const guest = await signUp(app);
const flyingShipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId: flyingShipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 9000); await setCredits(db, guest.playerId, 9000);
// A second ship at the Moon crewed by a hired pilot with some XP and a skill. // A second ship at the Moon crewed by a hired pilot with some XP and a skill.

View File

@ -11,7 +11,7 @@ describe("acknowledging summaries", () => {
const { app, clock } = testApp(); const { app, clock } = testApp();
const { db } = database(); const { db } = database();
const guest = await signUp(app); const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId); const { shipId } = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 1000); await setCredits(db, guest.playerId, 1000);
const record = await insertDispatch(db, { const record = await insertDispatch(db, {
playerId: guest.playerId, playerId: guest.playerId,
@ -47,7 +47,7 @@ describe("acknowledging summaries", () => {
const { app, clock } = testApp(); const { app, clock } = testApp();
const { db } = database(); const { db } = database();
const owner = await signUp(app); const owner = await signUp(app);
const shipId = await makeOwner(db, owner.playerId, owner.avatarId); const { shipId } = await makeOwner(db, owner.playerId, owner.avatarId);
const record = await insertDispatch(db, { const record = await insertDispatch(db, {
playerId: owner.playerId, playerId: owner.playerId,
shipId, shipId,

View File

@ -4,10 +4,11 @@ import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" }; const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
/** Ship models for sale; new ships appear at Earth (Step 9.8). */ /** Ship models for sale, delivered to the first bay until Step 10B.9's bay picker (Step 9.8). */
export function Market() { export function Market() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const buy = useGameAction("buyShip"); const buy = useGameAction("buyShip");
const bay = state.bays[0];
return ( return (
<section className="space-y-3 p-4"> <section className="space-y-3 p-4">
@ -45,8 +46,8 @@ export function Market() {
<button <button
type="button" type="button"
aria-label={`Buy ${model.name}`} aria-label={`Buy ${model.name}`}
disabled={!affordable || buy.isPending} disabled={!affordable || !bay || buy.isPending}
onClick={() => buy.mutate({ modelId: model.id })} onClick={() => bay && buy.mutate({ modelId: model.id, bayId: bay.id })}
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40" className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
> >
{affordable ? "Buy" : "Not enough credits"} {affordable ? "Buy" : "Not enough credits"}

View File

@ -60,7 +60,7 @@ describe("typed API client", () => {
error: { code: "insufficient-funds", message: "Not enough credits." }, error: { code: "insufficient-funds", message: "Not enough credits." },
}); });
const error = await createApiClient({ fetch }) const error = await createApiClient({ fetch })
.call("buyShip", { modelId: "courier" }) .call("buyShip", { modelId: "courier", bayId: "b1" })
.catch((e: unknown) => e); .catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError); expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({ expect(error).toMatchObject({

View File

@ -27,6 +27,7 @@ describe("market screen", () => {
fakeApi({ fakeApi({
getPlayerState: () => getPlayerState: () =>
samplePlayerState({ samplePlayerState({
bays: [{ id: "b-earth", locationId: "earth" }],
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 }, company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
}), }),
}), }),
@ -52,6 +53,7 @@ describe("market screen", () => {
getPlayerState: () => getPlayerState: () =>
samplePlayerState({ samplePlayerState({
tutorial: null, tutorial: null,
bays: [{ id: "b-earth", locationId: "earth" }],
ships: owned, ships: owned,
company: { company: {
id: "c", id: "c",
@ -67,7 +69,7 @@ describe("market screen", () => {
}); });
renderApp(api, "/market"); renderApp(api, "/market");
await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" })); await userEvent.click(await screen.findByRole("button", { name: "Buy Freighter" }));
expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter" }); expect(api.call).toHaveBeenCalledWith("buyShip", { modelId: "freighter", bayId: "b-earth" });
expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth."); expect(await screen.findByRole("status")).toHaveTextContent("Lucky Heron is waiting at Earth.");
const nav = screen.getByRole("navigation", { name: "Main" }); const nav = screen.getByRole("navigation", { name: "Main" });

View File

@ -52,7 +52,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | | `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes |
| `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
| `buyShip` | `POST /api/ships` | `modelId` | `{ ship }` | yes | | `buyShip` | `POST /api/ships` | `modelId`, `bayId` (one of the player's bays; the ship is delivered there) | `{ ship }` | yes |
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes | | `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes | | `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes | | `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |

View File

@ -139,7 +139,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
- [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.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`. - [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`.
- [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. - [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 - [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
- [ ] Step 10B.7 — Client: Contracts screen by location - [ ] Step 10B.7 — Client: Contracts screen by location
- [ ] Step 10B.8 — Client: deadhead action - [ ] Step 10B.8 — Client: deadhead action
- [ ] Step 10B.9 — Client: bays as HQs - [ ] Step 10B.9 — Client: bays as HQs

View File

@ -45,7 +45,8 @@ export const createDispatchRequestSchema = z.discriminatedUnion("kind", [
export type CreateDispatchRequest = z.infer<typeof createDispatchRequestSchema>; export type CreateDispatchRequest = z.infer<typeof createDispatchRequestSchema>;
export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema }); export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
export const buyShipRequestSchema = z.object({ modelId: idSchema }); /** Decision 0.5.35: the ship is delivered to one of the player's bays. */
export const buyShipRequestSchema = z.object({ modelId: idSchema, bayId: idSchema });
export const shipResponseSchema = z.object({ ship: shipSchema }); export const shipResponseSchema = z.object({ ship: shipSchema });
export const leaseBayRequestSchema = z.object({ locationId: idSchema }); export const leaseBayRequestSchema = z.object({ locationId: idSchema });

View File

@ -186,7 +186,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
{ kind: "contract", contractId: "c1" }, { kind: "contract", contractId: "c1" },
], ],
["dispatch response", dispatchResponseSchema, { dispatch }, { dispatch: {} }], ["dispatch response", dispatchResponseSchema, { dispatch }, { dispatch: {} }],
["buy ship request", buyShipRequestSchema, { modelId: "courier" }, { modelId: "" }], [
"buy ship request",
buyShipRequestSchema,
{ modelId: "courier", bayId: "b1" },
{ modelId: "courier" },
],
["ship response", shipResponseSchema, { ship }, {}], ["ship response", shipResponseSchema, { ship }, {}],
["lease bay request", leaseBayRequestSchema, { locationId: "moon" }, { locationId: 3 }], ["lease bay request", leaseBayRequestSchema, { locationId: "moon" }, { locationId: 3 }],
["bay response", bayResponseSchema, { bay }, { bay: null }], ["bay response", bayResponseSchema, { bay }, { bay: null }],