Step 6.3: add tutorial board endpoint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a8f788ce8
commit
18f8a5ac86
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue