Step 6.9: add bay contract board endpoint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f31d036f08
commit
4536569957
|
|
@ -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:<n>")`, 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.
|
||||
|
|
|
|||
|
|
@ -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<Contract[]>,
|
||||
now: RealTime,
|
||||
): Promise<Contract[]> {
|
||||
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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue