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:
parent
b3d4975111
commit
a5803d31f5
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue