From 906fb275d7decf684cf643c6a17329a146498aa1 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 01:02:32 -0400 Subject: [PATCH] Step 6.1: resolve arrived dispatches on read Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 10 ++ apps/server/src/app.ts | 55 ++------- apps/server/src/db/client.ts | 8 +- apps/server/src/game/lock.ts | 22 ++++ apps/server/src/game/player-state.ts | 4 +- apps/server/src/game/resolve.ts | 94 ++++++++++++++ apps/server/src/http/errors.ts | 5 + apps/server/src/routes/context.ts | 12 ++ apps/server/src/routes/player.ts | 49 ++++++++ apps/server/test/db.ts | 38 +++--- apps/server/test/game.ts | 178 +++++++++++++++++++++++++++ apps/server/test/resolution.test.ts | 150 ++++++++++++++++++++++ memory-bank/progress.md | 17 +++ 13 files changed, 578 insertions(+), 64 deletions(-) create mode 100644 apps/server/src/game/lock.ts create mode 100644 apps/server/src/game/resolve.ts create mode 100644 apps/server/src/routes/context.ts create mode 100644 apps/server/src/routes/player.ts create mode 100644 apps/server/test/game.ts create mode 100644 apps/server/test/resolution.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index f179e7d..eb7b0a7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -252,3 +252,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Requests with no fields reject unexpected ones** (`emptyRequestSchema` is strict), including query parameters. - **Responses are validated everywhere except production.** A violation is logged and returned as `500 internal-error`, which makes tests fail loudly. Production skips the check to save work. - **Fastify's own 4xx errors** (malformed JSON, wrong content type) are reported as `400 validation-error` in the documented format. + +## Step 6.1 + +- **One locking rule for all player changes:** `withPlayerLock(db, playerId, fn)` opens a transaction and takes `SELECT ... FOR UPDATE` on the player's `companies` row before running `fn`. Resolution and every mutating endpoint use it, so one player's changes never interleave (this is also Step 6.14's mechanism). +- **Exactly-once resolution has two guards:** the company lock serializes resolvers, and the status update is conditional (`WHERE status = 'in_flight'`), with effects applied only when it changed a row. +- **`resolvedAt` is the time resolution ran**, not the arrival time; the arrival time is in the record. +- **On resolution:** credits change by the sim resolution's credits; the pilot gains XP and becomes idle; the ship becomes idle at the destination; an unassigned pilot (the tutorial avatar) gets the destination as its stored location; a tutorial run moves the loaner; a discovery is inserted. +- **Game-rule failures return `409`** with their error code (`gameError`), except `not-found` (`404`). +- **Routes live in `src/routes/*`** and share an `AppContext` (db, auth, clock, route options). +- **Test helpers** (`test/game.ts`): `useTestApp` (fresh app plus a movable `TestClock`), `signUp` (a real guest session via `POST /api/guest`), `call`/`getState`, `insertDispatch` (puts a sim-built dispatch in the database). `makeOwner` / `setCredits` (`test/db.ts`) reshape a signed-up player. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index f1bdf7a..2ead9f4 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,18 +1,12 @@ -import { randomInt } from "node:crypto"; -import { content } from "@deadhead/content"; -import { routes } from "@deadhead/schema"; -import { realToGame } from "@deadhead/sim"; -import { fromNodeHeaders } from "better-auth/node"; import Fastify, { type FastifyInstance } from "fastify"; import { createAuth } from "./auth"; import { type Clock, systemClock } from "./clock"; import type { ServerConfig } from "./config"; import { createDatabase, type Database } from "./db/client"; -import { createPlayer } from "./game/new-player"; -import { loadPlayerState } from "./game/player-state"; -import { ApiError, registerErrorHandler } from "./http/errors"; -import { registerRoute, type RouteOptions } from "./http/route"; +import { registerErrorHandler } from "./http/errors"; import { requirePlayerId } from "./http/session"; +import type { AppContext } from "./routes/context"; +import { registerPlayerRoutes } from "./routes/player"; export interface AppDeps { clock?: Clock; @@ -22,10 +16,18 @@ export interface AppDeps { /** Builds the Fastify app without listening, so tests can start it on any port. */ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance { - const clock = deps.clock ?? systemClock; const database = deps.database ?? createDatabase(config.databaseUrl); const { db } = database; const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl }); + const ctx: AppContext = { + db, + auth, + clock: deps.clock ?? systemClock, + options: { + requirePlayer: (request) => requirePlayerId(request, auth, db), + validateResponses: config.env !== "production", + }, + }; const app = Fastify({ // Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise. @@ -34,40 +36,9 @@ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInsta if (!deps.database) app.addHook("onClose", () => database.close()); registerErrorHandler(app); - const options: RouteOptions = { - requirePlayer: (request) => requirePlayerId(request, auth, db), - validateResponses: config.env !== "production", - }; - // Under /api so it is reachable through the same-domain proxy (decision 0.5.20). app.get("/api/health", async () => ({ status: "ok" })); - - registerRoute(app, routes.getServerTime, options, async () => { - const realTime = clock(); - return { realTime, gameTime: realToGame(realTime, content.config) }; - }); - - registerRoute(app, routes.createGuest, options, async (_input, { request, reply }) => { - const signIn = await auth.api.signInAnonymous({ - headers: fromNodeHeaders(request.headers), - returnHeaders: true, - }); - if (!signIn.response) throw new ApiError(500, "internal-error", "Could not create a guest."); - - const { playerId } = await createPlayer(db, { - userId: signIn.response.user.id, - // A 32-bit unsigned seed (decision 0.5.10). - worldSeed: randomInt(0, 2 ** 32), - now: clock(), - }); - // Pass Better Auth's session cookie through to the client. - reply.header("set-cookie", signIn.headers.getSetCookie()); - return { playerId, state: await loadPlayerState(db, playerId) }; - }); - - registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) => - loadPlayerState(db, playerId), - ); + registerPlayerRoutes(app, ctx); return app; } diff --git a/apps/server/src/db/client.ts b/apps/server/src/db/client.ts index c4ecb1f..56b5520 100644 --- a/apps/server/src/db/client.ts +++ b/apps/server/src/db/client.ts @@ -1,11 +1,15 @@ -import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgDatabase } from "drizzle-orm/pg-core"; +import { drizzle, type NodePgDatabase, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; import pg from "pg"; import * as authSchema from "./auth-schema"; import * as gameSchema from "./schema"; const schema = { ...gameSchema, ...authSchema }; +type Schema = typeof schema; -export type Db = NodePgDatabase; +export type Db = NodePgDatabase; +/** The database or an open transaction: anything that can run queries. */ +export type Queryable = PgDatabase; export interface Database { db: Db; diff --git a/apps/server/src/game/lock.ts b/apps/server/src/game/lock.ts new file mode 100644 index 0000000..d2f4a57 --- /dev/null +++ b/apps/server/src/game/lock.ts @@ -0,0 +1,22 @@ +import { eq } from "drizzle-orm"; +import type { Db, Queryable } from "../db/client"; +import { companies } from "../db/schema"; + +/** + * Runs `fn` in a transaction holding a row lock on the player's company, so + * changes to one player's money and fleet never interleave (Step 6.14). + */ +export function withPlayerLock( + db: Db, + playerId: string, + fn: (tx: Queryable) => Promise, +): Promise { + return db.transaction(async (tx) => { + await tx + .select({ id: companies.id }) + .from(companies) + .where(eq(companies.playerId, playerId)) + .for("update"); + return fn(tx); + }); +} diff --git a/apps/server/src/game/player-state.ts b/apps/server/src/game/player-state.ts index 0ae18c6..d0a4fbf 100644 --- a/apps/server/src/game/player-state.ts +++ b/apps/server/src/game/player-state.ts @@ -2,7 +2,7 @@ import { content } from "@deadhead/content"; import type { PlayerState } from "@deadhead/schema"; import { pilotLevel, skillPointsAvailable } from "@deadhead/sim"; import { and, eq, isNull } from "drizzle-orm"; -import type { Db } from "../db/client"; +import type { Queryable } from "../db/client"; import { bays, companies, @@ -15,7 +15,7 @@ import { import { toApiDispatch, toApiStatus } from "./mappers"; /** Reads everything the client needs about one player. */ -export async function loadPlayerState(db: Db, playerId: string): Promise { +export async function loadPlayerState(db: Queryable, playerId: string): Promise { const [player] = await db.select().from(players).where(eq(players.id, playerId)); const [company] = await db.select().from(companies).where(eq(companies.playerId, playerId)); if (!player || !company) throw new Error(`Player ${playerId} not found`); diff --git a/apps/server/src/game/resolve.ts b/apps/server/src/game/resolve.ts new file mode 100644 index 0000000..f9e9471 --- /dev/null +++ b/apps/server/src/game/resolve.ts @@ -0,0 +1,94 @@ +import { content } from "@deadhead/content"; +import { resolveDispatch, type RealTime } from "@deadhead/sim"; +import { and, asc, eq, lte, sql } from "drizzle-orm"; +import type { Queryable } from "../db/client"; +import { companies, discoveredLocations, dispatches, pilots, players, ships } from "../db/schema"; + +/** + * Resolves every in-flight dispatch of a player that has arrived by `now`, in + * arrival order, applying credits, XP, ship/pilot location and discovery. Run + * it inside `withPlayerLock`. Each dispatch moves from in_flight to resolved + * exactly once: the status update is conditional, and effects are applied only + * when it changed a row. Returns how many dispatches were resolved. + */ +export async function resolveArrivedDispatches( + tx: Queryable, + playerId: string, + now: RealTime, +): Promise { + const due = await tx + .select() + .from(dispatches) + .where( + and( + eq(dispatches.playerId, playerId), + eq(dispatches.status, "in_flight"), + lte(dispatches.arrivalAt, now), + ), + ) + .orderBy(asc(dispatches.arrivalAt)); + if (due.length === 0) return 0; + + const discovered = await tx + .select({ locationId: discoveredLocations.locationId }) + .from(discoveredLocations) + .where(eq(discoveredLocations.playerId, playerId)); + const discoveredIds = new Set(discovered.map((d) => d.locationId)); + + let resolvedCount = 0; + for (const dispatch of due) { + const [pilot] = await tx.select().from(pilots).where(eq(pilots.id, dispatch.pilotId)); + if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); + + const result = resolveDispatch( + { record: dispatch.record, pilotIsAvatar: pilot.isAvatar, discoveredIds, now }, + content, + ); + if (result.status !== "resolved") continue; + const { resolution } = result; + + const claimed = await tx + .update(dispatches) + .set({ status: "resolved", resolution, resolvedAt: now }) + .where(and(eq(dispatches.id, dispatch.id), eq(dispatches.status, "in_flight"))) + .returning({ id: dispatches.id }); + if (claimed.length === 0) continue; + resolvedCount++; + + await tx + .update(companies) + .set({ credits: sql`${companies.credits} + ${resolution.credits}` }) + .where(eq(companies.playerId, playerId)); + + await tx + .update(pilots) + .set({ + xp: sql`${pilots.xp} + ${resolution.pilotXp}`, + status: "idle", + // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. + ...(pilot.assignedShipId === null ? { locationId: resolution.shipLocationId } : {}), + }) + .where(eq(pilots.id, pilot.id)); + + if (dispatch.shipId) { + await tx + .update(ships) + .set({ status: "idle", locationId: resolution.shipLocationId }) + .where(eq(ships.id, dispatch.shipId)); + } + if (dispatch.tutorial) { + await tx + .update(players) + .set({ loanerLocationId: resolution.shipLocationId }) + .where(eq(players.id, playerId)); + } + if (resolution.discoversDestination) { + await tx + .insert(discoveredLocations) + .values({ playerId, locationId: dispatch.record.destinationId, discoveredAt: now }) + .onConflictDoNothing(); + discoveredIds.add(dispatch.record.destinationId); + } + } + return resolvedCount; +} diff --git a/apps/server/src/http/errors.ts b/apps/server/src/http/errors.ts index a0c8dcf..b0157c1 100644 --- a/apps/server/src/http/errors.ts +++ b/apps/server/src/http/errors.ts @@ -12,6 +12,11 @@ export class ApiError extends Error { } } +/** A game-rule failure: 404 for unknown things, 409 for everything else. */ +export function gameError(code: ErrorCode, message: string): ApiError { + return new ApiError(code === "not-found" ? 404 : 409, code, message); +} + export function errorBody(code: ErrorCode, message: string): ErrorResponse { return { error: { code, message } }; } diff --git a/apps/server/src/routes/context.ts b/apps/server/src/routes/context.ts new file mode 100644 index 0000000..5f38607 --- /dev/null +++ b/apps/server/src/routes/context.ts @@ -0,0 +1,12 @@ +import type { Auth } from "../auth"; +import type { Clock } from "../clock"; +import type { Db } from "../db/client"; +import type { RouteOptions } from "../http/route"; + +/** What every route module needs. */ +export interface AppContext { + db: Db; + auth: Auth; + clock: Clock; + options: RouteOptions; +} diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts new file mode 100644 index 0000000..88f7898 --- /dev/null +++ b/apps/server/src/routes/player.ts @@ -0,0 +1,49 @@ +import { randomInt } from "node:crypto"; +import { content } from "@deadhead/content"; +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"; + +/** Server time, guest sign-up and player state. */ +export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): void { + const { db, auth, clock, options } = ctx; + + registerRoute(app, routes.getServerTime, options, async () => { + const realTime = clock(); + return { realTime, gameTime: realToGame(realTime, content.config) }; + }); + + registerRoute(app, routes.createGuest, options, async (_input, { request, reply }) => { + const signIn = await auth.api.signInAnonymous({ + headers: fromNodeHeaders(request.headers), + returnHeaders: true, + }); + if (!signIn.response) throw new ApiError(500, "internal-error", "Could not create a guest."); + + const { playerId } = await createPlayer(db, { + userId: signIn.response.user.id, + // A 32-bit unsigned seed (decision 0.5.10). + worldSeed: randomInt(0, 2 ** 32), + now: clock(), + }); + // Pass Better Auth's session cookie through to the client. + reply.header("set-cookie", signIn.headers.getSetCookie()); + return { playerId, state: await loadPlayerState(db, playerId) }; + }); + + // 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); + }), + ); +} diff --git a/apps/server/test/db.ts b/apps/server/test/db.ts index 808560f..25a26e8 100644 --- a/apps/server/test/db.ts +++ b/apps/server/test/db.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { content } from "@deadhead/content"; import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach } from "vitest"; import { createDatabase, type Database, type Db } from "../src/db/client"; @@ -75,24 +74,30 @@ export async function seedPlayer( worldSeed: options.worldSeed ?? 12345, now: TEST_NOW, }); - if (options.credits !== undefined) { - await db - .update(companies) - .set({ credits: options.credits }) - .where(eq(companies.playerId, player.playerId)); - } + if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits); if (state === "tutorial") return player; + return { ...player, shipId: await makeOwner(db, player.playerId, player.avatarId) }; +} +export async function setCredits(db: Db, playerId: string, credits: number): Promise { + await db.update(companies).set({ credits }).where(eq(companies.playerId, playerId)); +} + +/** + * Turns a tutorial player into an owner: tutorial over, one Courier at Earth + * crewed by the avatar, an Earth bay. Returns the ship id. + */ +export async function makeOwner(db: Db, playerId: string, avatarId: string): Promise { const shipId = randomUUID(); await db.transaction(async (tx) => { await tx .update(players) .set({ tutorialActive: false, loanerLocationId: null }) - .where(eq(players.id, player.playerId)); + .where(eq(players.id, playerId)); await tx.insert(ships).values({ id: shipId, - playerId: player.playerId, - modelId: content.shipModels[0]!.id, + playerId, + modelId: "courier", name: "Test Courier", locationId: "earth", createdAt: TEST_NOW, @@ -100,13 +105,10 @@ export async function seedPlayer( await tx .update(pilots) .set({ assignedShipId: shipId, locationId: null }) - .where(eq(pilots.id, player.avatarId)); - await tx.insert(bays).values({ - id: randomUUID(), - playerId: player.playerId, - locationId: "earth", - createdAt: TEST_NOW, - }); + .where(eq(pilots.id, avatarId)); + await tx + .insert(bays) + .values({ id: randomUUID(), playerId, locationId: "earth", createdAt: TEST_NOW }); }); - return { ...player, shipId }; + return shipId; } diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts new file mode 100644 index 0000000..3d0eba0 --- /dev/null +++ b/apps/server/test/game.ts @@ -0,0 +1,178 @@ +import { randomUUID } from "node:crypto"; +import { content } from "@deadhead/content"; +import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema"; +import { + createDispatchRecord, + createRepositionRecord, + type Contract, + type DispatchRecord, +} from "@deadhead/sim"; +import { eq } from "drizzle-orm"; +import { afterEach, beforeEach } from "vitest"; +import { buildApp } from "../src/app"; +import type { Database, Db } from "../src/db/client"; +import { dispatches, pilots, players, ships } from "../src/db/schema"; +import { TEST_NOW } from "./db"; +import { testConfig } from "./helpers"; + +/** A clock tests can move. Starts at `TEST_NOW`. */ +export class TestClock { + now = TEST_NOW; + read = () => this.now; + advance(ms: number) { + this.now += ms; + } +} + +export interface TestApp { + app: ReturnType; + clock: TestClock; +} + +/** A fresh app per test on the shared test database, with a controllable clock. */ +export function useTestApp(database: () => Database): () => TestApp { + let current: TestApp | undefined; + beforeEach(() => { + const clock = new TestClock(); + current = { app: buildApp(testConfig(), { database: database(), clock: clock.read }), clock }; + }); + afterEach(() => current?.app.close()); + return () => current!; +} + +function cookieFrom(setCookie: string | string[] | undefined): string { + const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + return values.map((c) => c.split(";")[0]).join("; "); +} + +export interface Guest { + cookie: string; + playerId: string; + avatarId: string; +} + +/** Signs up a guest through the API (a tutorial player) and returns its session. */ +export async function signUp(app: TestApp["app"]): Promise { + const response = await app.inject({ method: "POST", url: "/api/guest" }); + const body = createGuestResponseSchema.parse(response.json()); + return { + cookie: cookieFrom(response.headers["set-cookie"]), + playerId: body.playerId, + avatarId: body.state.pilots[0]!.id, + }; +} + +/** Calls an endpoint as a guest. */ +export async function call( + app: TestApp["app"], + guest: Guest, + method: "GET" | "POST", + url: string, + payload?: object, +) { + return app.inject({ + method, + url, + headers: { cookie: guest.cookie }, + ...(payload ? { payload } : {}), + }); +} + +export async function getState(app: TestApp["app"], guest: Guest): Promise { + const response = await call(app, guest, "GET", "/api/state"); + if (response.statusCode !== 200) throw new Error(`state failed: ${response.body}`); + return playerStateSchema.parse(response.json()); +} + +export function testContract(overrides: Partial = {}): Contract { + return { + id: `test:${randomUUID()}`, + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 10, + payout: 2000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: TEST_NOW, + expiresAt: TEST_NOW + 3_600_000, + firstContact: false, + ...overrides, + }; +} + +/** + * Puts an in-flight dispatch straight into the database, as if it had been + * dispatched at `departAt`: the ship and pilot are marked in flight. + */ +export async function insertDispatch( + db: Db, + args: { + playerId: string; + shipId: string; + pilotId: string; + departAt: number; + contract?: Contract | null; + destinationId?: string; + }, +): Promise { + const [player] = await db.select().from(players).where(eq(players.id, args.playerId)); + const [ship] = await db.select().from(ships).where(eq(ships.id, args.shipId)); + const [pilot] = await db.select().from(pilots).where(eq(pilots.id, args.pilotId)); + const shipState = { + id: ship!.id, + modelId: ship!.modelId, + locationId: ship!.locationId!, + inFlight: false, + }; + const pilotState = { + id: pilot!.id, + isAvatar: pilot!.isAvatar, + assignedShipId: pilot!.assignedShipId, + inFlight: false, + skills: pilot!.skills, + }; + const id = randomUUID(); + const contract = args.contract === undefined ? testContract() : args.contract; + const record = contract + ? createDispatchRecord( + { + id, + ship: shipState, + pilot: pilotState, + contract, + tutorial: false, + now: args.departAt, + worldSeed: player!.worldSeed, + }, + content, + ) + : createRepositionRecord( + { + id, + ship: shipState, + pilot: pilotState, + destinationId: args.destinationId!, + now: args.departAt, + worldSeed: player!.worldSeed, + }, + content, + ); + + await db.insert(dispatches).values({ + id, + playerId: args.playerId, + shipId: ship!.id, + pilotId: pilot!.id, + tutorial: false, + record, + arrivalAt: record.arrivalAt, + }); + await db + .update(ships) + .set({ status: "in_flight", locationId: null }) + .where(eq(ships.id, ship!.id)); + await db.update(pilots).set({ status: "in_flight" }).where(eq(pilots.id, pilot!.id)); + return record; +} diff --git a/apps/server/test/resolution.test.ts b/apps/server/test/resolution.test.ts new file mode 100644 index 0000000..5da0c57 --- /dev/null +++ b/apps/server/test/resolution.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { withPlayerLock } from "../src/game/lock"; +import { resolveArrivedDispatches } from "../src/game/resolve"; +import { makeOwner, setCredits, useTestDatabase } from "./db"; +import { getState, insertDispatch, signUp, testContract, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +/** An owner (one Courier at Earth, crewed by the avatar) with 1,000 credits. */ +async function owner() { + const { app } = testApp(); + const guest = await signUp(app); + const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId); + await setCredits(database().db, guest.playerId, 1000); + return { guest, shipId }; +} + +describe("lazy resolution on read", () => { + it("applies a past arrival's credits, XP, ship location and discovery", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await owner(); + // A delivery to Mars, which the player has not discovered yet. + const contract = testContract({ destinationId: "mars", payout: 5000 }); + const record = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + contract, + }); + clock.now = record.arrivalAt + 60_000; + + const state = await getState(app, guest); + // The avatar flew it, so no pilot pay is deducted. + expect(state.company.credits).toBe(1000 + 5000); + expect(state.pilots[0]!.xp).toBeGreaterThan(0); + expect(state.pilots[0]!.status).toBe("idle"); + expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "mars", dispatchId: null }); + expect(state.locations.find((l) => l.id === "mars")!.discovered).toBe(true); + expect(state.activeDispatches).toHaveLength(0); + expect(state.summaries).toEqual([ + expect.objectContaining({ + dispatchId: record.id, + credits: 5000, + discoveredLocationId: "mars", + }), + ]); + }); + + it("leaves dispatches that have not arrived yet in flight", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await owner(); + const record = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt - 1; + + const state = await getState(app, guest); + expect(state.company.credits).toBe(1000); + expect(state.ships[0]).toMatchObject({ + status: "in-flight", + locationId: null, + dispatchId: record.id, + }); + expect(state.activeDispatches.map((d) => d.id)).toEqual([record.id]); + }); + + it("does not apply an arrival twice on a second fetch", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await owner(); + const record = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt; + + const first = await getState(app, guest); + const second = await getState(app, guest); + expect(second.company.credits).toBe(first.company.credits); + expect(second.pilots[0]!.xp).toBe(first.pilots[0]!.xp); + expect(first.company.credits).toBe(1000 + 2000); + }); + + it("applies an arrival exactly once under two concurrent fetches", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await owner(); + const record = await insertDispatch(database().db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + clock.now = record.arrivalAt; + + const [a, b] = await Promise.all([getState(app, guest), getState(app, guest)]); + expect(a.company.credits).toBe(3000); + expect(b.company.credits).toBe(3000); + }); + + it("resolves exactly once when two resolvers race outside HTTP", async () => { + const { clock } = testApp(); + const { guest, shipId } = await owner(); + const { db } = database(); + const record = await insertDispatch(db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + const run = () => + withPlayerLock(db, guest.playerId, (tx) => + resolveArrivedDispatches(tx, guest.playerId, record.arrivalAt), + ); + const counts = await Promise.all([run(), run(), run()]); + expect(counts.reduce((a, b) => a + b, 0)).toBe(1); + }); + + it("resolves several arrivals in arrival order", async () => { + const { app, clock } = testApp(); + const { guest, shipId } = await owner(); + const { db } = database(); + // Fly to the Moon, then (already resolved in the DB as of arrival) on to Earth. + const first = await insertDispatch(db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: clock.now, + }); + clock.now = first.arrivalAt; + await getState(app, guest); + const second = await insertDispatch(db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: first.arrivalAt, + contract: testContract({ originId: "moon", destinationId: "earth", payout: 700 }), + }); + clock.now = second.arrivalAt + 1; + const state = await getState(app, guest); + expect(state.company.credits).toBe(1000 + 2000 + 700); + expect(state.ships[0]!.locationId).toBe("earth"); + expect(state.summaries).toHaveLength(2); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 534936a..271d7bf 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -51,3 +51,20 @@ - [x] **Step 5.4 — Test database harness.** `apps/server/test/db.ts`: `useTestDatabase()` (migrate once, truncate all tables before each test) and `seedPlayer(db, "tutorial" | "owner")`. New-player creation lives in `src/game/new-player.ts` (`createPlayer`), shared with guest sign-up. - [x] **Step 5.5 — Guest accounts.** Better Auth with the anonymous plugin (`src/auth.ts`, tables in `src/db/auth-schema.ts`, migration `0001_auth`). `POST /api/guest` creates a guest session and a tutorial player; `GET /api/state` returns player state for the session or `401 unauthorized`. Error handler sends the documented error format. - [x] **Step 5.6 — Request validation from the route catalog.** `registerRoute` (`src/http/route.ts`) validates merged params/query/body before auth, checks sessions for `auth` routes, and checks responses outside production. Malformed JSON and unexpected fields return `400 validation-error`. + +## Phase 6 — Server Game Logic + +- [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/`. +- [ ] Step 6.2 — Get full player state +- [ ] Step 6.3 — Tutorial board endpoint +- [ ] Step 6.4 — Tutorial dispatch +- [ ] Step 6.5 — Buy a ship +- [ ] Step 6.6 — Lease a bay +- [ ] Step 6.7 — End tutorial +- [ ] Step 6.8 — Hire and assign pilots +- [ ] Step 6.9 — Bay contract board endpoint +- [ ] Step 6.10 — Contract dispatch +- [ ] Step 6.11 — Reposition dispatch +- [ ] Step 6.12 — Spend skill points +- [ ] Step 6.13 — Acknowledge summaries +- [ ] Step 6.14 — Concurrency and money safety