Step 6.9: add bay contract board endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:17:04 -04:00
parent f31d036f08
commit 4536569957
5 changed files with 134 additions and 5 deletions

View File

@ -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). - **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:<n>")`, where n counts the player's earlier hires. - **Hired pilot names** are "First Last" from fixed lists, seeded with `deriveSeed(worldSeed, "pilot-name:<n>")`, where n counts the player's earlier hires.
- **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints. - **`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.

View File

@ -3,13 +3,20 @@ import { content } from "@deadhead/content";
import { import {
boardPeriodIndex, boardPeriodIndex,
boardPeriodMs, boardPeriodMs,
generateContractBoard,
generateTutorialBoard, generateTutorialBoard,
type Contract, type Contract,
type RealTime, type RealTime,
} 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 { acceptedContracts, contractBoards, players } from "../db/schema"; import {
acceptedContracts,
bays,
contractBoards,
discoveredLocations,
players,
} from "../db/schema";
import { gameError } from "../http/errors"; import { gameError } from "../http/errors";
export interface BoardView { export interface BoardView {
@ -25,7 +32,7 @@ export interface BoardView {
export async function getOrCreateBoard( export async function getOrCreateBoard(
tx: Queryable, tx: Queryable,
key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number }, key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number },
generate: () => Contract[], generate: () => Contract[] | Promise<Contract[]>,
now: RealTime, now: RealTime,
): Promise<Contract[]> { ): Promise<Contract[]> {
const where = and( const where = and(
@ -39,7 +46,7 @@ export async function getOrCreateBoard(
await tx await tx
.insert(contractBoards) .insert(contractBoards)
.values({ id: randomUUID(), ...key, contracts: generate(), createdAt: now }) .values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now })
.onConflictDoNothing(); .onConflictDoNothing();
const [stored] = await tx.select().from(contractBoards).where(where); const [stored] = await tx.select().from(contractBoards).where(where);
return stored!.contracts; return stored!.contracts;
@ -91,3 +98,47 @@ export async function tutorialBoard(
); );
return boardView(tx, playerId, contracts, periodIndex); 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<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);
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);
}

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 { tutorialBoard } from "../game/boards"; import { bayBoard, 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";
@ -10,4 +10,8 @@ export function registerBoardRoutes(app: FastifyInstance, ctx: AppContext): void
registerRoute(app, routes.getTutorialBoard, ctx.options, async (_input, { playerId }) => registerRoute(app, routes.getTutorialBoard, ctx.options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => tutorialBoard(tx, playerId, now)), 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)),
);
} }

View File

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

@ -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.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.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`. - [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.10 — Contract dispatch
- [ ] Step 6.11 — Reposition dispatch - [ ] Step 6.11 — Reposition dispatch
- [ ] Step 6.12 — Spend skill points - [ ] Step 6.12 — Spend skill points