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
|
||||
|
||||
- **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 { requirePlayerId } from "./http/session";
|
||||
import type { AppContext } from "./routes/context";
|
||||
import { registerBoardRoutes } from "./routes/boards";
|
||||
import { registerPlayerRoutes } from "./routes/player";
|
||||
|
||||
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).
|
||||
app.get("/api/health", async () => ({ status: "ok" }));
|
||||
registerPlayerRoutes(app, ctx);
|
||||
registerBoardRoutes(app, ctx);
|
||||
|
||||
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 { fromNodeHeaders } from "better-auth/node";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { withPlayerLock } from "../game/lock";
|
||||
import { createPlayer } from "../game/new-player";
|
||||
import { loadPlayerState } from "../game/player-state";
|
||||
import { resolveArrivedDispatches } from "../game/resolve";
|
||||
import { ApiError } from "../http/errors";
|
||||
import { registerRoute } from "../http/route";
|
||||
import type { AppContext } from "./context";
|
||||
import { withPlayer } from "./with-player";
|
||||
|
||||
/** Server time, guest sign-up and player state. */
|
||||
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.
|
||||
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
|
||||
withPlayerLock(db, playerId, async (tx) => {
|
||||
await resolveArrivedDispatches(tx, playerId, clock());
|
||||
return loadPlayerState(tx, playerId);
|
||||
}),
|
||||
withPlayer(ctx, playerId, (tx) => 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.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.5 — Buy a ship
|
||||
- [ ] Step 6.6 — Lease a bay
|
||||
|
|
|
|||
Loading…
Reference in New Issue