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).
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue