From 4536569957dde9fe0c44fff31392c7238a927992 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:17:04 -0400 Subject: [PATCH] Step 6.9: add bay contract board endpoint Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ apps/server/src/game/boards.ts | 57 +++++++++++++++++++++++-- apps/server/src/routes/boards.ts | 6 ++- apps/server/test/bay-board.test.ts | 68 ++++++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 apps/server/test/bay-board.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index ccd71f3..73c6f77 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -307,3 +307,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Unassigning** needs an assigned, idle pilot (`pilot-not-assigned-to-ship`, `pilot-not-idle`) whose ship is at a player bay (`no-bay-at-location`). The pilot then stores the ship's location (decision 0.5.27). - **Hired pilot names** are "First Last" from fixed lists, seeded with `deriveSeed(worldSeed, "pilot-name:")`, where n counts the player's earlier hires. - **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints. + +## Step 6.9 + +- **Discovered locations are read only when a board is first generated** (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5). +- **Older periods' boards are never shown**: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns `contract-expired` instead of "unknown contract". +- **The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10**, once contract dispatch exists. diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index 1ccba3e..1f9ec20 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -3,13 +3,20 @@ import { content } from "@deadhead/content"; import { boardPeriodIndex, boardPeriodMs, + generateContractBoard, generateTutorialBoard, type Contract, type RealTime, } from "@deadhead/sim"; import { and, eq } from "drizzle-orm"; import type { Queryable } from "../db/client"; -import { acceptedContracts, contractBoards, players } from "../db/schema"; +import { + acceptedContracts, + bays, + contractBoards, + discoveredLocations, + players, +} from "../db/schema"; import { gameError } from "../http/errors"; export interface BoardView { @@ -25,7 +32,7 @@ export interface BoardView { export async function getOrCreateBoard( tx: Queryable, key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number }, - generate: () => Contract[], + generate: () => Contract[] | Promise, now: RealTime, ): Promise { const where = and( @@ -39,7 +46,7 @@ export async function getOrCreateBoard( await tx .insert(contractBoards) - .values({ id: randomUUID(), ...key, contracts: generate(), createdAt: now }) + .values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now }) .onConflictDoNothing(); const [stored] = await tx.select().from(contractBoards).where(where); return stored!.contracts; @@ -91,3 +98,47 @@ export async function tutorialBoard( ); return boardView(tx, playerId, contracts, periodIndex); } + +/** + * 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. + */ +export async function bayBoard( + 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."); + + const [player] = await tx.select().from(players).where(eq(players.id, playerId)); + const periodIndex = boardPeriodIndex(now, content.config); + const contracts = await getOrCreateBoard( + tx, + { playerId, kind: "bay", locationId, periodIndex }, + async () => { + const discovered = await tx + .select({ locationId: discoveredLocations.locationId }) + .from(discoveredLocations) + .where(eq(discoveredLocations.playerId, playerId)); + return generateContractBoard( + { + origin, + discoveredIds: new Set(discovered.map((d) => d.locationId)), + periodIndex, + worldSeed: player!.worldSeed, + }, + content, + ); + }, + now, + ); + return boardView(tx, playerId, contracts, periodIndex); +} diff --git a/apps/server/src/routes/boards.ts b/apps/server/src/routes/boards.ts index 267a13c..c1d553b 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 { tutorialBoard } from "../game/boards"; +import { bayBoard, tutorialBoard } from "../game/boards"; import { registerRoute } from "../http/route"; import type { AppContext } from "./context"; import { withPlayer } from "./with-player"; @@ -10,4 +10,8 @@ export function registerBoardRoutes(app: FastifyInstance, ctx: AppContext): void registerRoute(app, routes.getTutorialBoard, ctx.options, async (_input, { playerId }) => withPlayer(ctx, playerId, (tx, now) => tutorialBoard(tx, playerId, now)), ); + + registerRoute(app, routes.getContractBoard, ctx.options, async (input, { playerId }) => + withPlayer(ctx, playerId, (tx, now) => bayBoard(tx, playerId, input.locationId, now)), + ); } diff --git a/apps/server/test/bay-board.test.ts b/apps/server/test/bay-board.test.ts new file mode 100644 index 0000000..84b0211 --- /dev/null +++ b/apps/server/test/bay-board.test.ts @@ -0,0 +1,68 @@ +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/memory-bank/progress.md b/memory-bank/progress.md index 5146cce..c8d8502 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -62,7 +62,7 @@ - [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`. - [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth. - [x] **Step 6.8 — Hire and assign pilots.** `POST /api/pilots`, `/api/pilots/:pilotId/assign`, `/api/pilots/:pilotId/unassign` (`src/game/pilots.ts`): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code `avatar-in-tutorial`. -- [ ] Step 6.9 — Bay contract board endpoint +- [x] **Step 6.9 — Bay contract board endpoint.** `GET /api/bays/:locationId/board` (`bayBoard` in `src/game/boards.ts`): generated from the discovered set on first request in a period and stored; accepted contracts hidden; `409 no-bay-at-location` without a bay. The expired-old-board dispatch check is tested in Step 6.10. - [ ] Step 6.10 — Contract dispatch - [ ] Step 6.11 — Reposition dispatch - [ ] Step 6.12 — Spend skill points