Step 6.3: add tutorial board endpoint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:04:58 -04:00
parent 2a8f788ce8
commit 18f8a5ac86
8 changed files with 252 additions and 7 deletions

View File

@ -266,3 +266,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 6.2 ## Step 6.2
- **No new code was needed**: Step 5.5 built the state reader and Step 6.1 added resolution, so this step is the full test. A pilot's `level` and `skillPointsAvailable` are derived from XP and points spent each time state is read, never stored. - **No new code was needed**: Step 5.5 built the state reader and Step 6.1 added resolution, so this step is the full test. A pilot's `level` and `skillPointsAvailable` are derived from XP and points spent each time state is read, never stored.
## Step 6.3
- **Every game endpoint runs through `withPlayer`**: it takes the player lock and resolves due arrivals before the action, so actions always see current positions, credits and tutorial state.
- **Boards are generated and stored on first request** with `INSERT ... ON CONFLICT DO NOTHING` on the unique (player, kind, location, period) key, then re-read, so concurrent first requests end up with one stored board.
- **Board responses hide accepted contracts** (`accepted_contracts`) and report `periodEndsAt`, when every contract on the board expires.
- **Tutorial board errors:** `409 not-in-tutorial` once the tutorial is over.

View File

@ -6,6 +6,7 @@ import { createDatabase, type Database } from "./db/client";
import { registerErrorHandler } from "./http/errors"; import { registerErrorHandler } from "./http/errors";
import { requirePlayerId } from "./http/session"; import { requirePlayerId } from "./http/session";
import type { AppContext } from "./routes/context"; import type { AppContext } from "./routes/context";
import { registerBoardRoutes } from "./routes/boards";
import { registerPlayerRoutes } from "./routes/player"; import { registerPlayerRoutes } from "./routes/player";
export interface AppDeps { export interface AppDeps {
@ -39,6 +40,7 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta
// Under /api so it is reachable through the same-domain proxy (decision 0.5.20). // Under /api so it is reachable through the same-domain proxy (decision 0.5.20).
app.get("/api/health", async () => ({ status: "ok" })); app.get("/api/health", async () => ({ status: "ok" }));
registerPlayerRoutes(app, ctx); registerPlayerRoutes(app, ctx);
registerBoardRoutes(app, ctx);
return app; return app;
} }

View File

@ -0,0 +1,93 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import {
boardPeriodIndex,
boardPeriodMs,
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 { gameError } from "../http/errors";
export interface BoardView {
contracts: Contract[];
periodEndsAt: RealTime;
}
/**
* Returns the stored board for (player, kind, location, period), generating and
* storing it on the first request (decisions 0.5.5 and 0.5.21). Concurrent
* first requests store one board: the insert ignores conflicts and re-reads.
*/
export async function getOrCreateBoard(
tx: Queryable,
key: { playerId: string; kind: "bay" | "tutorial"; locationId: string; periodIndex: number },
generate: () => Contract[],
now: RealTime,
): Promise<Contract[]> {
const where = and(
eq(contractBoards.playerId, key.playerId),
eq(contractBoards.kind, key.kind),
eq(contractBoards.locationId, key.locationId),
eq(contractBoards.periodIndex, key.periodIndex),
);
const [existing] = await tx.select().from(contractBoards).where(where);
if (existing) return existing.contracts;
await tx
.insert(contractBoards)
.values({ id: randomUUID(), ...key, contracts: generate(), createdAt: now })
.onConflictDoNothing();
const [stored] = await tx.select().from(contractBoards).where(where);
return stored!.contracts;
}
export async function acceptedContractIds(tx: Queryable, playerId: string): Promise<Set<string>> {
const rows = await tx
.select({ contractId: acceptedContracts.contractId })
.from(acceptedContracts)
.where(eq(acceptedContracts.playerId, playerId));
return new Set(rows.map((r) => r.contractId));
}
/** Board contracts minus the ones this player already accepted. */
export async function boardView(
tx: Queryable,
playerId: string,
contracts: Contract[],
periodIndex: number,
): Promise<BoardView> {
const accepted = await acceptedContractIds(tx, playerId);
return {
contracts: contracts.filter((c) => !accepted.has(c.id)),
periodEndsAt: (periodIndex + 1) * boardPeriodMs(content.config),
};
}
/** The tutorial board for the current period at the loaner's location (Step 6.3). */
export async function tutorialBoard(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<BoardView> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive || !player.loanerLocationId) {
throw gameError("not-in-tutorial", "The tutorial is over.");
}
const loanerLocationId = player.loanerLocationId as "earth" | "moon";
const periodIndex = boardPeriodIndex(now, content.config);
const contracts = await getOrCreateBoard(
tx,
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
() =>
generateTutorialBoard(
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed },
content,
),
now,
);
return boardView(tx, playerId, contracts, periodIndex);
}

View File

@ -0,0 +1,13 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { tutorialBoard } from "../game/boards";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Contract boards. */
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)),
);
}

View File

@ -4,13 +4,12 @@ import { routes } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim"; import { realToGame } from "@deadhead/sim";
import { fromNodeHeaders } from "better-auth/node"; import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { withPlayerLock } from "../game/lock";
import { createPlayer } from "../game/new-player"; import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state"; import { loadPlayerState } from "../game/player-state";
import { resolveArrivedDispatches } from "../game/resolve";
import { ApiError } from "../http/errors"; import { ApiError } from "../http/errors";
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";
/** Server time, guest sign-up and player state. */ /** Server time, guest sign-up and player state. */
export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): void { export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): void {
@ -41,9 +40,6 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
// Lazy resolution on read (Step 6.1): apply every arrival before returning state. // Lazy resolution on read (Step 6.1): apply every arrival before returning state.
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) => registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
withPlayerLock(db, playerId, async (tx) => { withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)),
await resolveArrivedDispatches(tx, playerId, clock());
return loadPlayerState(tx, playerId);
}),
); );
} }

View File

@ -0,0 +1,20 @@
import type { Queryable } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import type { AppContext } from "./context";
/**
* Runs a game action for a player: takes the player lock, applies any arrivals
* that are due (so the action sees current state), then runs `fn`.
*/
export function withPlayer<T>(
ctx: AppContext,
playerId: string,
fn: (tx: Queryable, now: number) => Promise<T>,
): Promise<T> {
const now = ctx.clock();
return withPlayerLock(ctx.db, playerId, async (tx) => {
await resolveArrivedDispatches(tx, playerId, now);
return fn(tx, now);
});
}

View File

@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, createDispatchRecord, type Contract } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import type { Db } from "../src/db/client";
import { acceptedContracts, contractBoards, dispatches, players } from "../src/db/schema";
import { makeOwner, useTestDatabase } from "./db";
import { call, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function board(guest: Guest) {
const response = await call(testApp().app, guest, "GET", "/api/tutorial/board");
expect(response.statusCode).toBe(200);
return contractBoardResponseSchema.parse(response.json());
}
/** Records a contract as accepted, with the tutorial dispatch it would create. */
async function accept(db: Db, guest: Guest, contract: Contract, now: number) {
const id = randomUUID();
const record = createDispatchRecord(
{
id,
ship: { id: "loaner", modelId: "courier", locationId: contract.originId, inFlight: false },
pilot: {
id: guest.avatarId,
isAvatar: true,
assignedShipId: null,
inFlight: false,
skills: {},
},
contract,
tutorial: true,
now,
worldSeed: 1,
},
content,
);
await db.insert(dispatches).values({
id,
playerId: guest.playerId,
pilotId: guest.avatarId,
tutorial: true,
record,
arrivalAt: record.arrivalAt,
});
await db.insert(acceptedContracts).values({
playerId: guest.playerId,
contractId: contract.id,
periodIndex: 0,
dispatchId: id,
acceptedAt: now,
});
}
describe("tutorial board endpoint", () => {
it("gives a tutorial player at Earth a full board of Earth→Moon contracts", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const { contracts, periodEndsAt } = await board(guest);
expect(contracts).toHaveLength(content.config.tutorialBoardSize);
for (const c of contracts) {
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
expect(c.cargoTypeId).toBe("standard");
}
expect(periodEndsAt).toBeGreaterThan(clock.now);
expect(periodEndsAt - clock.now).toBeLessThanOrEqual(boardPeriodMs(content.config));
});
it("offers Moon→Earth when the loaner is at the Moon", async () => {
const { app } = testApp();
const guest = await signUp(app);
await database()
.db.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
for (const c of (await board(guest)).contracts) {
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
}
});
it("stores the board: repeated requests in a period return the same contracts", async () => {
const { clock } = testApp();
const guest = await signUp(testApp().app);
const first = await board(guest);
clock.advance(60_000);
expect((await board(guest)).contracts).toEqual(first.contracts);
expect(await database().db.select().from(contractBoards)).toHaveLength(1);
});
it("hides contracts the player has already accepted", async () => {
const { clock } = testApp();
const guest = await signUp(testApp().app);
const before = await board(guest);
await accept(database().db, guest, before.contracts[0]!, clock.now);
const after = await board(guest);
expect(after.contracts).toHaveLength(before.contracts.length - 1);
expect(after.contracts.map((c) => c.id)).not.toContain(before.contracts[0]!.id);
});
it("refuses players who are past the tutorial", async () => {
const { app } = testApp();
const guest = await signUp(app);
await makeOwner(database().db, guest.playerId, guest.avatarId);
const response = await call(app, guest, "GET", "/api/tutorial/board");
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-in-tutorial");
});
});

View File

@ -56,7 +56,7 @@
- [x] **Step 6.1 — Lazy resolution on read.** `resolveArrivedDispatches` (`src/game/resolve.ts`) resolves arrived dispatches in arrival order inside `withPlayerLock` (`src/game/lock.ts`, a transaction holding the company row lock); a conditional status update makes each resolve exactly once. `GET /api/state` runs it before reading. Routes moved to `src/routes/`. - [x] **Step 6.1 — Lazy resolution on read.** `resolveArrivedDispatches` (`src/game/resolve.ts`) resolves arrived dispatches in arrival order inside `withPlayerLock` (`src/game/lock.ts`, a transaction holding the company row lock); a conditional status update makes each resolve exactly once. `GET /api/state` runs it before reading. Routes moved to `src/routes/`.
- [x] **Step 6.2 — Get full player state.** Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity. - [x] **Step 6.2 — Get full player state.** Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity.
- [ ] Step 6.3 — Tutorial board endpoint - [x] **Step 6.3 — Tutorial board endpoint.** `GET /api/tutorial/board` (`src/routes/boards.ts`, `src/game/boards.ts`): stored per (period, loaner location) on first request, accepted contracts hidden, `409 not-in-tutorial` after the tutorial. Every game route now runs through `withPlayer` (lock + lazy resolution).
- [ ] Step 6.4 — Tutorial dispatch - [ ] Step 6.4 — Tutorial dispatch
- [ ] Step 6.5 — Buy a ship - [ ] Step 6.5 — Buy a ship
- [ ] Step 6.6 — Lease a bay - [ ] Step 6.6 — Lease a bay