diff --git a/DECISIONS.md b/DECISIONS.md index 8e58b8c..30f763e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 2ead9f4..5a0c071 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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; } diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts new file mode 100644 index 0000000..1ccba3e --- /dev/null +++ b/apps/server/src/game/boards.ts @@ -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 { + 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> { + 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 { + 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 { + 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); +} diff --git a/apps/server/src/routes/boards.ts b/apps/server/src/routes/boards.ts new file mode 100644 index 0000000..267a13c --- /dev/null +++ b/apps/server/src/routes/boards.ts @@ -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)), + ); +} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 88f7898..ade26d5 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -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)), ); } diff --git a/apps/server/src/routes/with-player.ts b/apps/server/src/routes/with-player.ts new file mode 100644 index 0000000..e96bbdd --- /dev/null +++ b/apps/server/src/routes/with-player.ts @@ -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( + ctx: AppContext, + playerId: string, + fn: (tx: Queryable, now: number) => Promise, +): Promise { + const now = ctx.clock(); + return withPlayerLock(ctx.db, playerId, async (tx) => { + await resolveArrivedDispatches(tx, playerId, now); + return fn(tx, now); + }); +} diff --git a/apps/server/test/tutorial-board.test.ts b/apps/server/test/tutorial-board.test.ts new file mode 100644 index 0000000..b4e4b0c --- /dev/null +++ b/apps/server/test/tutorial-board.test.ts @@ -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"); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index fd30990..623b708 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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