diff --git a/DECISIONS.md b/DECISIONS.md index 0cb065b..ef44ae6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -632,3 +632,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The board key was already per location:** (player, kind, location, period, tutorial run) since CR-001. This step renames the `board_kind` value `bay` to `location`, so the stored data says what it is. - **The migration relabels existing `bay` rows as `location`** in one `UPDATE` while the column is temporarily text. That keeps the user's development database usable without a reset; it is a one-line relabel, not a data migration of board contents. + +## Step 10B.4 + +- **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. diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index 06302e1..7cb1f16 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -10,13 +10,7 @@ import { } from "@deadhead/sim"; import { and, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; -import { - acceptedContracts, - bays, - contractBoards, - discoveredLocations, - players, -} from "../db/schema"; +import { acceptedContracts, contractBoards, discoveredLocations, players } from "../db/schema"; import { gameError } from "../http/errors"; export interface BoardView { @@ -118,23 +112,31 @@ export async function tutorialBoard( } /** - * The contract board of one of the player's bays for the current period - * (Step 6.9). Generated on the first request in the period from the locations - * discovered at that moment, then stored, so later discoveries in the same - * period do not change it. + * The contract board (freight market) of any discovered location for the + * current period (Step 6.9, CR-002): no bay needed. Generated on the first + * request in the period from the locations discovered at that moment, then + * stored, so later discoveries in the same period do not change it. */ -export async function bayBoard( +export async function locationBoard( tx: Queryable, playerId: string, locationId: string, now: RealTime, ): Promise { - const [bay] = await tx - .select({ id: bays.id }) - .from(bays) - .where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId))); const origin = content.location(locationId); - if (!bay || !origin) throw gameError("no-bay-at-location", "You have no bay there."); + if (!origin) throw gameError("not-found", `No location "${locationId}".`); + const [known] = await tx + .select({ locationId: discoveredLocations.locationId }) + .from(discoveredLocations) + .where( + and( + eq(discoveredLocations.playerId, playerId), + eq(discoveredLocations.locationId, locationId), + ), + ); + if (!known) { + throw gameError("location-undiscovered", `Deliver to ${origin.name} to open its market.`); + } const [player] = await tx.select().from(players).where(eq(players.id, playerId)); const periodIndex = boardPeriodIndex(now, content.config); diff --git a/apps/server/src/routes/boards.ts b/apps/server/src/routes/boards.ts index c1d553b..a423317 100644 --- a/apps/server/src/routes/boards.ts +++ b/apps/server/src/routes/boards.ts @@ -1,6 +1,6 @@ import { routes } from "@deadhead/schema"; import type { FastifyInstance } from "fastify"; -import { bayBoard, tutorialBoard } from "../game/boards"; +import { locationBoard, tutorialBoard } from "../game/boards"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -12,6 +12,6 @@ export function registerBoardRoutes(app: FastifyInstance, ctx: AppContext): void ); registerRoute(app, routes.getContractBoard, ctx.options, async (input, { playerId }) => - withPlayer(ctx, playerId, (tx, now) => bayBoard(tx, playerId, input.locationId, now)), + withPlayer(ctx, playerId, (tx, now) => locationBoard(tx, playerId, input.locationId, now)), ); } diff --git a/apps/server/test/bay-board.test.ts b/apps/server/test/bay-board.test.ts deleted file mode 100644 index 84b0211..0000000 --- a/apps/server/test/bay-board.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { content } from "@deadhead/content"; -import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema"; -import { boardPeriodMs } from "@deadhead/sim"; -import { describe, expect, it } from "vitest"; -import { discoveredLocations } from "../src/db/schema"; -import { makeOwner, TEST_NOW, useTestDatabase } from "./db"; -import { call, signUp, useTestApp, type Guest } from "./game"; - -const database = useTestDatabase(); -const testApp = useTestApp(database); - -async function owner(): Promise { - const guest = await signUp(testApp().app); - await makeOwner(database().db, guest.playerId, guest.avatarId); - return guest; -} - -async function earthBoard(guest: Guest) { - const response = await call(testApp().app, guest, "GET", "/api/bays/earth/board"); - expect(response.statusCode).toBe(200); - return contractBoardResponseSchema.parse(response.json()); -} - -describe("bay contract board", () => { - it("returns a board of the configured size for an owned bay", async () => { - const guest = await owner(); - const { contracts, periodEndsAt } = await earthBoard(guest); - expect(contracts).toHaveLength(content.config.contractBoardSize); - for (const c of contracts) expect(c.originId).toBe("earth"); - expect(periodEndsAt).toBeGreaterThan(testApp().clock.now); - }); - - it("refuses a location where the player has no bay", async () => { - const { app } = testApp(); - const guest = await owner(); - const response = await call(app, guest, "GET", "/api/bays/moon/board"); - expect(response.statusCode).toBe(409); - expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-location"); - }); - - it("returns the same stored board within a period, even after a discovery", async () => { - const { clock } = testApp(); - const guest = await owner(); - const first = await earthBoard(guest); - await database() - .db.insert(discoveredLocations) - .values( - ["mars", "ceres-station", "jupiter-cloud-platform"].map((locationId) => ({ - playerId: guest.playerId, - locationId, - discoveredAt: TEST_NOW, - })), - ); - clock.advance(boardPeriodMs(content.config) / 2 - 1); - expect((await earthBoard(guest)).contracts).toEqual(first.contracts); - }); - - it("returns a new board after the period rolls over", async () => { - const { clock } = testApp(); - const guest = await owner(); - const first = await earthBoard(guest); - clock.now = first.periodEndsAt; - const next = await earthBoard(guest); - expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config)); - expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id)); - for (const c of next.contracts) expect(c.expiresAt).toBe(next.periodEndsAt); - }); -}); diff --git a/apps/server/test/contract-dispatch.test.ts b/apps/server/test/contract-dispatch.test.ts index 6491921..524090c 100644 --- a/apps/server/test/contract-dispatch.test.ts +++ b/apps/server/test/contract-dispatch.test.ts @@ -89,7 +89,7 @@ describe("contract dispatch", () => { expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id }); expect(state.pilots[0]!.status).toBe("in-flight"); const board = contractBoardResponseSchema.parse( - (await call(app, o.guest, "GET", "/api/bays/earth/board")).json(), + (await call(app, o.guest, "GET", "/api/locations/earth/board")).json(), ); expect(board.contracts).toEqual([]); }); diff --git a/apps/server/test/location-board.test.ts b/apps/server/test/location-board.test.ts new file mode 100644 index 0000000..c45b5f1 --- /dev/null +++ b/apps/server/test/location-board.test.ts @@ -0,0 +1,79 @@ +import { content } from "@deadhead/content"; +import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema"; +import { boardPeriodMs } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { discoveredLocations } from "../src/db/schema"; +import { TEST_NOW, useTestDatabase } from "./db"; +import { call, insertDispatch, signUpOwner, testContract, useTestApp, type Guest } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +async function board(guest: Guest, locationId: string) { + const response = await call(testApp().app, guest, "GET", `/api/locations/${locationId}/board`); + expect(response.statusCode).toBe(200); + return contractBoardResponseSchema.parse(response.json()); +} + +describe("location contract boards", () => { + it("returns the Moon's board for a player with no Moon bay", async () => { + const owner = await signUpOwner(testApp().app, database().db); + const { contracts } = await board(owner, "moon"); + expect(contracts).toHaveLength(content.config.contractBoardSize); + for (const c of contracts) expect(c.originId).toBe("moon"); + }); + + it("returns the Earth board of a player with an Earth bay too", async () => { + const owner = await signUpOwner(testApp().app, database().db); + const { contracts, periodEndsAt } = await board(owner, "earth"); + expect(contracts).toHaveLength(content.config.contractBoardSize); + expect(periodEndsAt).toBeGreaterThan(testApp().clock.now); + }); + + it("returns the same stored board within a period, even after a discovery", async () => { + const { clock } = testApp(); + const owner = await signUpOwner(testApp().app, database().db); + const first = await board(owner, "moon"); + await database() + .db.insert(discoveredLocations) + .values({ playerId: owner.playerId, locationId: "mars", discoveredAt: TEST_NOW }); + clock.advance(boardPeriodMs(content.config) / 2 - 1); + expect((await board(owner, "moon")).contracts).toEqual(first.contracts); + }); + + it("returns a new board after the period rolls over", async () => { + const { clock } = testApp(); + const owner = await signUpOwner(testApp().app, database().db); + const first = await board(owner, "earth"); + clock.now = first.periodEndsAt; + const next = await board(owner, "earth"); + expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config)); + expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id)); + }); + + it("refuses Mars before it is discovered, and opens it after a delivery discovers it", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app, database().db); + const before = await call(app, owner, "GET", "/api/locations/mars/board"); + expect(before.statusCode).toBe(409); + expect(errorResponseSchema.parse(before.json()).error.code).toBe("location-undiscovered"); + + // A delivery to Mars (placed directly; the Courier's range is not the point here). + const record = await insertDispatch(database().db, { + playerId: owner.playerId, + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: clock.now, + contract: testContract({ destinationId: "mars" }), + }); + clock.now = record.arrivalAt; + const { contracts } = await board(owner, "mars"); + for (const c of contracts) expect(c.originId).toBe("mars"); + }); + + it("returns not-found for an unknown location", async () => { + const owner = await signUpOwner(testApp().app, database().db); + const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board"); + expect(response.statusCode).toBe(404); + }); +}); diff --git a/apps/web/test/api-client.test.ts b/apps/web/test/api-client.test.ts index 511180d..c93eeb6 100644 --- a/apps/web/test/api-client.test.ts +++ b/apps/web/test/api-client.test.ts @@ -52,7 +52,7 @@ describe("typed API client", () => { it("puts path parameters in GET URLs", async () => { const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 }); await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" }); - expect(fetch.mock.calls[0]![0]).toBe("/api/bays/moon/board"); + expect(fetch.mock.calls[0]![0]).toBe("/api/locations/moon/board"); }); it("throws the server's error code and message", async () => { diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 2367c35..2688eb1 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -49,7 +49,7 @@ The `routes` catalog is the single source for both server and client. Each entry | `getServerTime` | `GET /api/time` | — | `ServerTime` | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no | | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | -| `getContractBoard` | `GET /api/bays/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | +| `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 | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e34eeb4..8db2d33 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -137,7 +137,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record - [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added. - [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. -- [ ] Step 10B.4 — Server: board endpoint for any discovered location +- [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 - [ ] Step 10B.6 — Server: ship delivery to a bay - [ ] Step 10B.7 — Client: Contracts screen by location diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 42522e6..2a62499 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -60,7 +60,7 @@ export const routes = { }, getContractBoard: { method: "GET", - path: "/api/bays/:locationId/board", + path: "/api/locations/:locationId/board", request: contractBoardRequestSchema, response: contractBoardResponseSchema, auth: true,