Step 6.1: resolve arrived dispatches on read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b26d57c4d
commit
906fb275d7
10
DECISIONS.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue