Step 10B.4: serve contract boards for any discovered location

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 14:51:54 -04:00
parent b3d4975111
commit a5803d31f5
10 changed files with 111 additions and 92 deletions

View File

@ -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 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. - **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.

View File

@ -10,13 +10,7 @@ import {
} from "@deadhead/sim"; } from "@deadhead/sim";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client"; import type { Queryable } from "../db/client";
import { import { acceptedContracts, contractBoards, discoveredLocations, players } from "../db/schema";
acceptedContracts,
bays,
contractBoards,
discoveredLocations,
players,
} from "../db/schema";
import { gameError } from "../http/errors"; import { gameError } from "../http/errors";
export interface BoardView { 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 * The contract board (freight market) of any discovered location for the
* (Step 6.9). Generated on the first request in the period from the locations * current period (Step 6.9, CR-002): no bay needed. Generated on the first
* discovered at that moment, then stored, so later discoveries in the same * request in the period from the locations discovered at that moment, then
* period do not change it. * stored, so later discoveries in the same period do not change it.
*/ */
export async function bayBoard( export async function locationBoard(
tx: Queryable, tx: Queryable,
playerId: string, playerId: string,
locationId: string, locationId: string,
now: RealTime, now: RealTime,
): Promise<BoardView> { ): Promise<BoardView> {
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); 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 [player] = await tx.select().from(players).where(eq(players.id, playerId));
const periodIndex = boardPeriodIndex(now, content.config); const periodIndex = boardPeriodIndex(now, content.config);

View File

@ -1,6 +1,6 @@
import { routes } from "@deadhead/schema"; import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { bayBoard, tutorialBoard } from "../game/boards"; import { locationBoard, tutorialBoard } from "../game/boards";
import { registerRoute } from "../http/route"; import { registerRoute } from "../http/route";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import { withPlayer } from "./with-player"; 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 }) => 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)),
); );
} }

View File

@ -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<Guest> {
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);
});
});

View File

@ -89,7 +89,7 @@ describe("contract dispatch", () => {
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id }); expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
expect(state.pilots[0]!.status).toBe("in-flight"); expect(state.pilots[0]!.status).toBe("in-flight");
const board = contractBoardResponseSchema.parse( 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([]); expect(board.contracts).toEqual([]);
}); });

View File

@ -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);
});
});

View File

@ -52,7 +52,7 @@ describe("typed API client", () => {
it("puts path parameters in GET URLs", async () => { it("puts path parameters in GET URLs", async () => {
const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 }); const fetch = mockFetch(200, { contracts: [], periodEndsAt: 7_200_000 });
await createApiClient({ fetch }).call("getContractBoard", { locationId: "moon" }); 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 () => { it("throws the server's error code and message", async () => {

View File

@ -49,7 +49,7 @@ The `routes` catalog is the single source for both server and client. Each entry
| `getServerTime` | `GET /api/time` | — | `ServerTime` | no | | `getServerTime` | `GET /api/time` | — | `ServerTime` | no |
| `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no |
| `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | | `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 | | `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` | `{ ship }` | yes |

View File

@ -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.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.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.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.5 — Server: no more stranded ships
- [ ] Step 10B.6 — Server: ship delivery to a bay - [ ] Step 10B.6 — Server: ship delivery to a bay
- [ ] Step 10B.7 — Client: Contracts screen by location - [ ] Step 10B.7 — Client: Contracts screen by location

View File

@ -60,7 +60,7 @@ export const routes = {
}, },
getContractBoard: { getContractBoard: {
method: "GET", method: "GET",
path: "/api/bays/:locationId/board", path: "/api/locations/:locationId/board",
request: contractBoardRequestSchema, request: contractBoardRequestSchema,
response: contractBoardResponseSchema, response: contractBoardResponseSchema,
auth: true, auth: true,