diff --git a/DECISIONS.md b/DECISIONS.md index ee0654e..90a22c5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/game/fleet.ts b/apps/server/src/game/fleet.ts index ecba064..1a65546 100644 --- a/apps/server/src/game/fleet.ts +++ b/apps/server/src/game/fleet.ts @@ -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 { + 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(); diff --git a/apps/server/src/routes/fleet.ts b/apps/server/src/routes/fleet.ts index 9ae624b..38da9f8 100644 --- a/apps/server/src/routes/fleet.ts +++ b/apps/server/src/routes/fleet.ts @@ -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), })), ); diff --git a/apps/server/test/arrival-job.test.ts b/apps/server/test/arrival-job.test.ts index 1cc7ab3..1a94221 100644 --- a/apps/server/test/arrival-job.test.ts +++ b/apps/server/test/arrival-job.test.ts @@ -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]); diff --git a/apps/server/test/buy-ship.test.ts b/apps/server/test/buy-ship.test.ts index a138f2b..0cbea74 100644 --- a/apps/server/test/buy-ship.test.ts +++ b/apps/server/test/buy-ship.test.ts @@ -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)); diff --git a/apps/server/test/cleanup.test.ts b/apps/server/test/cleanup.test.ts index 1f213fc..3b7b737 100644 --- a/apps/server/test/cleanup.test.ts +++ b/apps/server/test/cleanup.test.ts @@ -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; diff --git a/apps/server/test/concurrency.test.ts b/apps/server/test/concurrency.test.ts index 5bff6fd..1e5c0c6 100644 --- a/apps/server/test/concurrency.test.ts +++ b/apps/server/test/concurrency.test.ts @@ -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]); diff --git a/apps/server/test/contract-dispatch.test.ts b/apps/server/test/contract-dispatch.test.ts index 524090c..69ebc8f 100644 --- a/apps/server/test/contract-dispatch.test.ts +++ b/apps/server/test/contract-dispatch.test.ts @@ -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 { 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 }; } diff --git a/apps/server/test/db.ts b/apps/server/test/db.ts index 25a26e8..e0da036 100644 --- a/apps/server/test/db.ts +++ b/apps/server/test/db.ts @@ -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 { @@ -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 { +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 }; } diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index da63e57..293f601 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -209,8 +209,7 @@ export async function putBoard( export async function signUpOwner( app: TestApp["app"], db: Db, -): Promise { +): Promise { 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)) }; } diff --git a/apps/server/test/pilots.test.ts b/apps/server/test/pilots.test.ts index 17f88c5..ce6170c 100644 --- a/apps/server/test/pilots.test.ts +++ b/apps/server/test/pilots.test.ts @@ -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(), diff --git a/apps/server/test/resolution.test.ts b/apps/server/test/resolution.test.ts index 5da0c57..ea6bb16 100644 --- a/apps/server/test/resolution.test.ts +++ b/apps/server/test/resolution.test.ts @@ -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 }; } diff --git a/apps/server/test/state.test.ts b/apps/server/test/state.test.ts index 242cd29..2d3a6c1 100644 --- a/apps/server/test/state.test.ts +++ b/apps/server/test/state.test.ts @@ -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. diff --git a/apps/server/test/summaries.test.ts b/apps/server/test/summaries.test.ts index a6bbe53..4873d30 100644 --- a/apps/server/test/summaries.test.ts +++ b/apps/server/test/summaries.test.ts @@ -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, diff --git a/apps/web/src/screens/Market.tsx b/apps/web/src/screens/Market.tsx index 5669af5..6cb82d6 100644 --- a/apps/web/src/screens/Market.tsx +++ b/apps/web/src/screens/Market.tsx @@ -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 (
@@ -45,8 +46,8 @@ export function Market() {