Step 6.1: resolve arrived dispatches on read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 01:02:32 -04:00
parent 7b26d57c4d
commit 906fb275d7
13 changed files with 578 additions and 64 deletions

View File

@ -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.

View File

@ -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;
}

View File

@ -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<typeof schema>;
export type Db = NodePgDatabase<Schema>;
/** The database or an open transaction: anything that can run queries. */
export type Queryable = PgDatabase<NodePgQueryResultHKT, Schema>;
export interface Database {
db: Db;

View File

@ -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<T>(
db: Db,
playerId: string,
fn: (tx: Queryable) => Promise<T>,
): Promise<T> {
return db.transaction(async (tx) => {
await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.playerId, playerId))
.for("update");
return fn(tx);
});
}

View File

@ -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<PlayerState> {
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
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`);

View File

@ -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<number> {
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;
}

View File

@ -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 } };
}

View File

@ -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;
}

View File

@ -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);
}),
);
}

View File

@ -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<void> {
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<string> {
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;
}

178
apps/server/test/game.ts Normal file
View File

@ -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<typeof buildApp>;
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<Guest> {
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<PlayerState> {
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> = {}): 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<DispatchRecord> {
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;
}

View File

@ -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);
});
});

View File

@ -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