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:
parent
924151d1a8
commit
61781aa790
|
|
@ -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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ import { bays, companies, discoveredLocations, players, ships } from "../db/sche
|
|||
import { gameError } from "../http/errors";
|
||||
import { shipName } from "./names";
|
||||
|
||||
/** Where newly bought ships appear. */
|
||||
export const SHIPYARD_LOCATION_ID = "earth";
|
||||
|
||||
export async function requireCompany(tx: Queryable, playerId: string) {
|
||||
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
modelId: string,
|
||||
input: { modelId: string; bayId: string },
|
||||
now: RealTime,
|
||||
): Promise<Ship> {
|
||||
const { modelId } = input;
|
||||
await requireTutorialOver(tx, playerId, "buying a ship");
|
||||
const model = content.shipModel(modelId);
|
||||
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}`);
|
||||
|
||||
const player = await requirePlayer(tx, playerId);
|
||||
|
|
@ -105,7 +111,7 @@ export async function buyShip(
|
|||
playerId,
|
||||
modelId,
|
||||
name: shipName(player.worldSeed, owned!.n),
|
||||
locationId: SHIPYARD_LOCATION_ID,
|
||||
locationId: bay.locationId,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { withPlayer } from "./with-player";
|
|||
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
|
||||
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
ship: await buyShip(tx, playerId, input.modelId, now),
|
||||
ship: await buyShip(tx, playerId, input, now),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ async function dispatched() {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
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);
|
||||
const contract = testContract();
|
||||
await putBoard(db, guest.playerId, [contract]);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { content } from "@deadhead/content";
|
||||
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bays } from "../src/db/schema";
|
||||
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";
|
||||
|
||||
const database = useTestDatabase();
|
||||
|
|
@ -12,13 +14,16 @@ const errorCode = (response: { json(): unknown }) =>
|
|||
errorResponseSchema.parse(response.json()).error.code;
|
||||
|
||||
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 owner = await signUpOwner(app, database().db);
|
||||
const price = content.shipModel("courier")!.price;
|
||||
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);
|
||||
const { ship } = shipResponseSchema.parse(response.json());
|
||||
expect(ship).toMatchObject({
|
||||
|
|
@ -41,7 +46,10 @@ describe("buy a ship", () => {
|
|||
const price = content.shipModel("freighter")!.price;
|
||||
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(errorCode(response)).toBe("insufficient-funds");
|
||||
|
||||
|
|
@ -55,7 +63,10 @@ describe("buy a ship", () => {
|
|||
const guest = await signUp(app);
|
||||
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(errorCode(response)).toBe("tutorial-active");
|
||||
|
||||
|
|
@ -68,19 +79,76 @@ describe("buy a ship", () => {
|
|||
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: "company-courier" });
|
||||
const response = await call(app, owner, "POST", "/api/ships", {
|
||||
modelId: "company-courier",
|
||||
bayId: owner.earthBayId,
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an unknown model", async () => {
|
||||
const { app } = testApp();
|
||||
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(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", () => {
|
||||
it("are stable for a world seed and differ between ships", () => {
|
||||
expect(shipName(42, 0)).toBe(shipName(42, 0));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ describe("expired contract cleanup", () => {
|
|||
it("deletes old boards and accepted contracts and keeps current ones", async () => {
|
||||
const { db } = database();
|
||||
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 current = boardPeriodIndex(TEST_NOW, content.config);
|
||||
const old = current - BOARD_RETENTION_PERIODS - 1;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ describe("concurrency and money safety", () => {
|
|||
await setCredits(database().db, guest.playerId, price + price / 2);
|
||||
|
||||
const responses = await Promise.all([
|
||||
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
|
||||
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", bayId: guest.earthBayId }),
|
||||
]);
|
||||
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ describe("concurrency and money safety", () => {
|
|||
|
||||
const responses = await Promise.all(
|
||||
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);
|
||||
|
|
@ -46,7 +46,7 @@ describe("concurrency and money safety", () => {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
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);
|
||||
const contract = testContract();
|
||||
await putBoard(db, guest.playerId, [contract]);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface Owner {
|
|||
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
|
||||
async function owner(): Promise<Owner> {
|
||||
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);
|
||||
return { guest, shipId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export async function seedPlayer(
|
|||
});
|
||||
if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits);
|
||||
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> {
|
||||
|
|
@ -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
|
||||
* 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 earthBayId = randomUUID();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(players)
|
||||
|
|
@ -108,7 +113,7 @@ export async function makeOwner(db: Db, playerId: string, avatarId: string): Pro
|
|||
.where(eq(pilots.id, avatarId));
|
||||
await tx
|
||||
.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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,8 +209,7 @@ export async function putBoard(
|
|||
export async function signUpOwner(
|
||||
app: TestApp["app"],
|
||||
db: Db,
|
||||
): Promise<Guest & { shipId: string }> {
|
||||
): Promise<Guest & { shipId: string; earthBayId: string }> {
|
||||
const guest = await signUp(app);
|
||||
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
|
||||
return { ...guest, shipId };
|
||||
return { ...guest, ...(await makeOwner(db, guest.playerId, guest.avatarId)) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ async function ownerWithMoonBay() {
|
|||
const { app } = testApp();
|
||||
const { db } = database();
|
||||
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 db.insert(bays).values({
|
||||
id: randomUUID(),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const testApp = useTestApp(database);
|
|||
async function owner() {
|
||||
const { app } = testApp();
|
||||
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);
|
||||
return { guest, shipId };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe("full player state", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
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);
|
||||
|
||||
// A second ship at the Moon crewed by a hired pilot with some XP and a skill.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ describe("acknowledging summaries", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
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);
|
||||
const record = await insertDispatch(db, {
|
||||
playerId: guest.playerId,
|
||||
|
|
@ -47,7 +47,7 @@ describe("acknowledging summaries", () => {
|
|||
const { app, clock } = testApp();
|
||||
const { db } = database();
|
||||
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, {
|
||||
playerId: owner.playerId,
|
||||
shipId,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|||
|
||||
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() {
|
||||
const state = useLoadedPlayer();
|
||||
const buy = useGameAction("buyShip");
|
||||
const bay = state.bays[0];
|
||||
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
|
|
@ -45,8 +46,8 @@ export function Market() {
|
|||
<button
|
||||
type="button"
|
||||
aria-label={`Buy ${model.name}`}
|
||||
disabled={!affordable || buy.isPending}
|
||||
onClick={() => buy.mutate({ modelId: model.id })}
|
||||
disabled={!affordable || !bay || buy.isPending}
|
||||
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"
|
||||
>
|
||||
{affordable ? "Buy" : "Not enough credits"}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ describe("typed API client", () => {
|
|||
error: { code: "insufficient-funds", message: "Not enough credits." },
|
||||
});
|
||||
const error = await createApiClient({ fetch })
|
||||
.call("buyShip", { modelId: "courier" })
|
||||
.call("buyShip", { modelId: "courier", bayId: "b1" })
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("market screen", () => {
|
|||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
bays: [{ id: "b-earth", locationId: "earth" }],
|
||||
company: { id: "c", name: "D", credits: courierPrice, loanBalance: 0 },
|
||||
}),
|
||||
}),
|
||||
|
|
@ -52,6 +53,7 @@ describe("market screen", () => {
|
|||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b-earth", locationId: "earth" }],
|
||||
ships: owned,
|
||||
company: {
|
||||
id: "c",
|
||||
|
|
@ -67,7 +69,7 @@ describe("market screen", () => {
|
|||
});
|
||||
renderApp(api, "/market");
|
||||
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.");
|
||||
|
||||
const nav = screen.getByRole("navigation", { name: "Main" });
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
|
||||
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||
|
|
|
|||
|
|
@ -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.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.
|
||||
- [ ] 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.8 — Client: deadhead action
|
||||
- [ ] Step 10B.9 — Client: bays as HQs
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ export const createDispatchRequestSchema = z.discriminatedUnion("kind", [
|
|||
export type CreateDispatchRequest = z.infer<typeof createDispatchRequestSchema>;
|
||||
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 leaseBayRequestSchema = z.object({ locationId: idSchema });
|
||||
|
|
|
|||
|
|
@ -186,7 +186,12 @@ const cases: [string, z.ZodType, unknown, unknown][] = [
|
|||
{ kind: "contract", contractId: "c1" },
|
||||
],
|
||||
["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 }, {}],
|
||||
["lease bay request", leaseBayRequestSchema, { locationId: "moon" }, { locationId: 3 }],
|
||||
["bay response", bayResponseSchema, { bay }, { bay: null }],
|
||||
|
|
|
|||
Loading…
Reference in New Issue