Compare commits

..

No commits in common. "0044e355f192a7a9be804a69c257bfbbf8c0e93f" and "7b26d57c4d543ebe5b67c12d8700222a3e82c09b" have entirely different histories.

42 changed files with 89 additions and 3087 deletions

View File

@ -252,91 +252,3 @@ 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.
## 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.
## Step 6.4
- **A tutorial contract must be on one of the player's stored tutorial boards** (found by a JSONB containment query on contract id). Unknown ids return `404 unknown-contract`; ids already in `accepted_contracts` return `409 contract-already-accepted`. A contract from an earlier period is found, and sim then rejects it as `contract-expired`.
- **The loaner's `ShipState`** is built from `players.loaner_location_id` with id `loaner` and the content Courier model. The dispatch row has `ship_id` null.
- **While flying, the avatar's stored location is null**; resolution sets it to the destination, along with the loaner's location.
- **`storeDispatch` is shared by every dispatch kind:** it inserts the dispatch, records the accepted contract, and deducts the record's fuel cost (0 for tutorial runs).
- **Sim validation reasons are returned as `409` with their own code** and a player-facing message from `src/game/messages.ts`.
- **`404 unknown-contract` and `404 unknown-skill`** join `not-found` as the codes that map to 404.
## Step 6.5
- **Ship names** are "Adjective Noun" from fixed word lists, picked by a generator seeded with `deriveSeed(worldSeed, "ship-name:<n>")`, where n is how many ships the player owned before this one. They are stable per world (decision 0.5.14) and are not unique; duplicates are acceptable. Word lists live in the server (`src/game/names.ts`) because they are not tuning numbers.
- **New ships always appear at Earth**, as the plan says, whatever the player's bays.
- **`spend()` checks and deducts credits inside the player lock**, so a failed purchase changes nothing (`409 insufficient-funds`). Unknown models return `404 not-found`.
## Step 6.6
- **A bay lease is a one-time payment** of the location's `bayLeasePrice`, as the plan words it ("one-time lease price"); there is no recurring rent in the base game.
- **Check order:** location exists (`404 not-found`), discovered (`409 location-undiscovered`), no bay there yet (`409 bay-already-leased`), affordable (`409 insufficient-funds`).
## Step 6.7
- **The end-of-tutorial check runs in two places:** at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in `withPlayer`. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger.
- **"Removing the loaner"** means clearing `players.loaner_location_id` and `tutorial_active`; the loaner never had a ship row.
- **The avatar is moved to Earth** as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4).
## Step 6.8
- **New server error code `avatar-in-tutorial`** for assigning the avatar to an owned ship before the tutorial ends (decision 0.5.23).
- **Assignment checks, in order:** pilot and ship belong to the player (`404 not-found`), avatar not in tutorial, pilot idle (`pilot-not-idle`), pilot unassigned (`pilot-already-assigned`), ship idle (`ship-not-idle`), same location (`ship-not-idle-here`), ship uncrewed (`ship-crewed`). The unique `assigned_ship_id` constraint backs up the one-pilot-per-ship rule.
- **Unassigning** needs an assigned, idle pilot (`pilot-not-assigned-to-ship`, `pilot-not-idle`) whose ship is at a player bay (`no-bay-at-location`). The pilot then stores the ship's location (decision 0.5.27).
- **Hired pilot names** are "First Last" from fixed lists, seeded with `deriveSeed(worldSeed, "pilot-name:<n>")`, where n counts the player's earlier hires.
- **`toApiPilot`** (`src/game/mappers.ts`) is shared by the state reader and the pilot endpoints.
## Step 6.9
- **Discovered locations are read only when a board is first generated** (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5).
- **Older periods' boards are never shown**: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns `contract-expired` instead of "unknown contract".
- **The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10**, once contract dispatch exists.
## Step 6.10
- **A contract must be on one of the player's stored bay boards** (any period). Once found, sim validation decides; an older board's contract fails with `contract-expired`.
- **Ships and pilots must belong to the player** (`404 not-found`) before sim validation runs. An in-flight ship's missing location is passed to sim as an empty string; sim reports `ship-not-idle` first anyway.
- **`cannot-land` is tested with a test-only vacuum ship** added by mocking `@deadhead/content` in its own test file, since every base-game ship is atmospheric (decision 0.5.1).
- **Failure tests check that nothing changed:** no dispatch row, no accepted contract, credits untouched.
## Step 6.11
- **Repositions use sim `validateReposition`** with the player's bay locations; their failure reasons map to error codes unchanged (`no-bay-at-destination`, `already-there`, ...). No contract is accepted, and `storeDispatch` charges only the fuel.
- **On arrival** the ship and its pilot are idle at the destination, with no credits or XP (sim resolution gives 0 for repositions).
## Step 6.12
- **Points available come from sim:** `skillPointsAvailable(pilotLevel(xp), pointsSpent)`. Spending adds one level to the skill in the `skills` JSONB and one to `skill_points_spent`.
- **Skill points can be spent any time**, including while the pilot is flying; nothing in the plan restricts it, and skills only matter when the next dispatch is checked.
- **Check order:** pilot belongs to the player (`404 not-found`), skill exists (`404 unknown-skill`), a point is available (`409 no-skill-points`), skill below max (`409 skill-maxed`).
## Step 6.13
- **Acknowledging is by dispatch id and idempotent.** Ids that are unknown, belong to another player, are still in flight or were already acknowledged are skipped silently, and the response counts only those newly acknowledged. A client retry is harmless.
## Step 6.14
- **No new mechanism was needed:** since Step 6.1 every authenticated endpoint runs in `withPlayer`, a transaction that first takes `SELECT ... FOR UPDATE` on the player's company row. Requests for one player run one after another; different players never block each other.
- **Race tests go beyond the plan's double purchase:** eight simultaneous purchases with money for three, the same contract dispatched twice at once, and the same bay leased twice at once. With the row lock removed, three of the four fail, which confirms the tests exercise the lock. The two-purchase race can pass by luck without the lock, which is why the eight-purchase version exists.

View File

@ -1,15 +1,18 @@
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 { registerErrorHandler } from "./http/errors";
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 { requirePlayerId } from "./http/session";
import type { AppContext } from "./routes/context";
import { registerBoardRoutes } from "./routes/boards";
import { registerDispatchRoutes } from "./routes/dispatches";
import { registerFleetRoutes } from "./routes/fleet";
import { registerPlayerRoutes } from "./routes/player";
export interface AppDeps {
clock?: Clock;
@ -19,18 +22,10 @@ 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.
@ -39,12 +34,40 @@ 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" }));
registerPlayerRoutes(app, ctx);
registerBoardRoutes(app, ctx);
registerDispatchRoutes(app, ctx);
registerFleetRoutes(app, 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) };
});
registerRoute(app, routes.getPlayerState, options, async (_input, { playerId }) =>
loadPlayerState(db, playerId),
);
return app;
}

View File

@ -1,15 +1,11 @@
import type { PgDatabase } from "drizzle-orm/pg-core";
import { drizzle, type NodePgDatabase, type NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
import { drizzle, type NodePgDatabase } 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<Schema>;
/** The database or an open transaction: anything that can run queries. */
export type Queryable = PgDatabase<NodePgQueryResultHKT, Schema>;
export type Db = NodePgDatabase<typeof schema>;
export interface Database {
db: Db;

View File

@ -1,144 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import {
boardPeriodIndex,
boardPeriodMs,
generateContractBoard,
generateTutorialBoard,
type Contract,
type RealTime,
} from "@deadhead/sim";
import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
contractBoards,
discoveredLocations,
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[] | Promise<Contract[]>,
now: RealTime,
): Promise<Contract[]> {
const where = and(
eq(contractBoards.playerId, key.playerId),
eq(contractBoards.kind, key.kind),
eq(contractBoards.locationId, key.locationId),
eq(contractBoards.periodIndex, key.periodIndex),
);
const [existing] = await tx.select().from(contractBoards).where(where);
if (existing) return existing.contracts;
await tx
.insert(contractBoards)
.values({ id: randomUUID(), ...key, contracts: await generate(), createdAt: now })
.onConflictDoNothing();
const [stored] = await tx.select().from(contractBoards).where(where);
return stored!.contracts;
}
export async function acceptedContractIds(tx: Queryable, playerId: string): Promise<Set<string>> {
const rows = await tx
.select({ contractId: acceptedContracts.contractId })
.from(acceptedContracts)
.where(eq(acceptedContracts.playerId, playerId));
return new Set(rows.map((r) => r.contractId));
}
/** Board contracts minus the ones this player already accepted. */
export async function boardView(
tx: Queryable,
playerId: string,
contracts: Contract[],
periodIndex: number,
): Promise<BoardView> {
const accepted = await acceptedContractIds(tx, playerId);
return {
contracts: contracts.filter((c) => !accepted.has(c.id)),
periodEndsAt: (periodIndex + 1) * boardPeriodMs(content.config),
};
}
/** The tutorial board for the current period at the loaner's location (Step 6.3). */
export async function tutorialBoard(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<BoardView> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive || !player.loanerLocationId) {
throw gameError("not-in-tutorial", "The tutorial is over.");
}
const loanerLocationId = player.loanerLocationId as "earth" | "moon";
const periodIndex = boardPeriodIndex(now, content.config);
const contracts = await getOrCreateBoard(
tx,
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
() =>
generateTutorialBoard(
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed },
content,
),
now,
);
return boardView(tx, playerId, contracts, periodIndex);
}
/**
* The contract board of one of the player's bays for the current period
* (Step 6.9). Generated on the first request in the period from the locations
* discovered at that moment, then stored, so later discoveries in the same
* period do not change it.
*/
export async function bayBoard(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<BoardView> {
const [bay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
const origin = content.location(locationId);
if (!bay || !origin) throw gameError("no-bay-at-location", "You have no bay there.");
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
const periodIndex = boardPeriodIndex(now, content.config);
const contracts = await getOrCreateBoard(
tx,
{ playerId, kind: "bay", locationId, periodIndex },
async () => {
const discovered = await tx
.select({ locationId: discoveredLocations.locationId })
.from(discoveredLocations)
.where(eq(discoveredLocations.playerId, playerId));
return generateContractBoard(
{
origin,
discoveredIds: new Set(discovered.map((d) => d.locationId)),
periodIndex,
worldSeed: player!.worldSeed,
},
content,
);
},
now,
);
return boardView(tx, playerId, contracts, periodIndex);
}

View File

@ -1,289 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Dispatch } from "@deadhead/schema";
import {
createDispatchRecord,
createRepositionRecord,
loanerModel,
validateDispatch,
validateReposition,
type Contract,
type DispatchRecord,
type PilotState,
type RealTime,
type ShipState,
} from "@deadhead/sim";
import { and, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
acceptedContracts,
bays,
companies,
contractBoards,
dispatches,
pilots,
players,
ships,
} from "../db/schema";
import { gameError } from "../http/errors";
import { acceptedContractIds } from "./boards";
import { requireCompany, requirePlayer } from "./fleet";
import { toApiDispatch } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { requireOwnPilot, requireOwnShip } from "./pilots";
/** The id the tutorial loaner uses in dispatch records (it is not a ship row). */
export const LOANER_SHIP_ID = "loaner";
/** Finds a contract on any of the player's stored boards of a kind. */
export async function findBoardContract(
tx: Queryable,
playerId: string,
kind: "bay" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number } | undefined> {
const [board] = await tx
.select()
.from(contractBoards)
.where(
and(
eq(contractBoards.playerId, playerId),
eq(contractBoards.kind, kind),
sql`${contractBoards.contracts} @> ${JSON.stringify([{ id: contractId }])}::jsonb`,
),
);
const contract = board?.contracts.find((c) => c.id === contractId);
return board && contract ? { contract, periodIndex: board.periodIndex } : undefined;
}
/** Loads a board contract the player may still accept, or throws. */
export async function claimableContract(
tx: Queryable,
playerId: string,
kind: "bay" | "tutorial",
contractId: string,
): Promise<{ contract: Contract; periodIndex: number }> {
const found = await findBoardContract(tx, playerId, kind, contractId);
if (!found) throw gameError("unknown-contract", "No such contract on your boards.");
if ((await acceptedContractIds(tx, playerId)).has(contractId)) {
throw gameError("contract-already-accepted", "That contract has already been taken.");
}
return found;
}
export function pilotState(row: typeof pilots.$inferSelect): PilotState {
return {
id: row.id,
isAvatar: row.isAvatar,
assignedShipId: row.assignedShipId,
inFlight: row.status === "in_flight",
skills: row.skills,
};
}
/** Stores a dispatch, marks its contract accepted, and charges fuel. */
export async function storeDispatch(
tx: Queryable,
args: {
playerId: string;
record: DispatchRecord;
/** Null for the tutorial loaner. */
shipId: string | null;
/** The board period the contract came from; null for a reposition. */
periodIndex: number | null;
now: RealTime;
},
): Promise<Dispatch> {
const { playerId, record, shipId, now } = args;
const [row] = await tx
.insert(dispatches)
.values({
id: record.id,
playerId,
shipId,
pilotId: record.pilotId,
tutorial: record.tutorial,
record,
arrivalAt: record.arrivalAt,
})
.returning();
if (record.contract && args.periodIndex !== null) {
await tx.insert(acceptedContracts).values({
playerId,
contractId: record.contract.id,
periodIndex: args.periodIndex,
dispatchId: record.id,
acceptedAt: now,
});
}
if (record.fuelCost > 0) {
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${record.fuelCost}` })
.where(eq(companies.playerId, playerId));
}
return toApiDispatch(row!);
}
/**
* The avatar flies the company loaner on a tutorial contract (Step 6.4). The
* company pays fuel, and only one tutorial run may be in flight.
*/
export async function dispatchTutorial(
tx: Queryable,
playerId: string,
contractId: string,
now: RealTime,
): Promise<Dispatch> {
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 [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) throw gameError("tutorial-run-in-flight", "Your tutorial run is still flying.");
const { contract, periodIndex } = await claimableContract(tx, playerId, "tutorial", contractId);
const [avatar] = await tx
.select()
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!avatar || !company) throw new Error(`Player ${playerId} is missing its avatar or company`);
const loaner: ShipState = {
id: LOANER_SHIP_ID,
modelId: loanerModel(content).id,
locationId: player.loanerLocationId,
inFlight: false,
};
const pilot = pilotState(avatar);
const check = validateDispatch(
{ ship: loaner, pilot, contract, tutorial: true, credits: company.credits, now },
content,
);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{
id: randomUUID(),
ship: loaner,
pilot,
contract,
tutorial: true,
now,
worldSeed: player.worldSeed,
},
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: null, periodIndex, now });
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, avatar.id));
return dispatch;
}
export function shipState(row: typeof ships.$inferSelect): ShipState {
return {
id: row.id,
modelId: row.modelId,
// An in-flight ship has no location; validation reports it as not idle first.
locationId: row.locationId ?? "",
inFlight: row.status === "in_flight",
};
}
/**
* Dispatches an owned ship and its pilot on a bay-board contract (Step 6.10):
* sim validation, fuel charged, contract accepted, ship and pilot in flight.
*/
export async function dispatchContract(
tx: Queryable,
playerId: string,
input: { contractId: string; shipId: string; pilotId: string },
now: RealTime,
): Promise<Dispatch> {
const { contract, periodIndex } = await claimableContract(tx, playerId, "bay", input.contractId);
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
contract,
tutorial: false,
credits: company.credits,
now,
};
const check = validateDispatch(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, { playerId, record, shipId: ship.id, periodIndex, now });
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}
export async function setInFlight(tx: Queryable, shipId: string, pilotId: string) {
await tx.update(ships).set({ status: "in_flight", locationId: null }).where(eq(ships.id, shipId));
await tx
.update(pilots)
.set({ status: "in_flight", locationId: null })
.where(eq(pilots.id, pilotId));
}
/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */
export async function dispatchReposition(
tx: Queryable,
playerId: string,
input: { shipId: string; pilotId: string; destinationId: string },
now: RealTime,
): Promise<Dispatch> {
const ship = await requireOwnShip(tx, playerId, input.shipId);
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
const player = await requirePlayer(tx, playerId);
const company = await requireCompany(tx, playerId);
const bayRows = await tx
.select({ locationId: bays.locationId })
.from(bays)
.where(eq(bays.playerId, playerId));
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
destinationId: input.destinationId,
bayLocationIds: new Set(bayRows.map((b) => b.locationId)),
credits: company.credits,
now,
};
const check = validateReposition(proposal, content);
if (!check.ok) throw gameError(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createRepositionRecord(
{ id: randomUUID(), ...proposal, worldSeed: player.worldSeed },
content,
);
const dispatch = await storeDispatch(tx, {
playerId,
record,
shipId: ship.id,
periodIndex: null,
now,
});
await setInFlight(tx, ship.id, pilot.id);
return dispatch;
}

View File

@ -1,111 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Bay, Ship } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { and, count, eq, sql } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, companies, discoveredLocations, players, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { shipName } from "./names";
/** Where newly bought ships appear. */
export const SHIPYARD_LOCATION_ID = "earth";
export async function requireCompany(tx: Queryable, playerId: string) {
const [company] = await tx.select().from(companies).where(eq(companies.playerId, playerId));
if (!company) throw new Error(`Player ${playerId} has no company`);
return company;
}
export async function requirePlayer(tx: Queryable, playerId: string) {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player) throw new Error(`Player ${playerId} not found`);
return player;
}
/** Deducts credits, or throws `insufficient-funds` without changing anything. */
export async function spend(tx: Queryable, playerId: string, amount: number, what: string) {
const company = await requireCompany(tx, playerId);
if (company.credits < amount) {
throw gameError("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`);
}
await tx
.update(companies)
.set({ credits: sql`${companies.credits} - ${amount}` })
.where(eq(companies.playerId, playerId));
}
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
export async function leaseBay(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<Bay> {
const location = content.location(locationId);
if (!location) throw gameError("not-found", `No location "${locationId}".`);
const [discovered] = await tx
.select()
.from(discoveredLocations)
.where(
and(
eq(discoveredLocations.playerId, playerId),
eq(discoveredLocations.locationId, locationId),
),
);
if (!discovered) {
throw gameError(
"location-undiscovered",
`Deliver to ${location.name} before leasing a bay there.`,
);
}
const [existing] = await tx
.select()
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
if (existing)
throw gameError("bay-already-leased", `You already have a bay at ${location.name}.`);
await spend(tx, playerId, location.bayLeasePrice, `a bay at ${location.name}`);
const [bay] = await tx
.insert(bays)
.values({ id: randomUUID(), playerId, locationId, createdAt: now })
.returning();
return { id: bay!.id, locationId: bay!.locationId };
}
/** Buys a ship model: it appears idle at Earth with no pilot (Step 6.5). */
export async function buyShip(
tx: Queryable,
playerId: string,
modelId: string,
now: RealTime,
): Promise<Ship> {
const model = content.shipModel(modelId);
if (!model) throw gameError("not-found", `No ship model "${modelId}".`);
await spend(tx, playerId, model.price, `a ${model.name}`);
const player = await requirePlayer(tx, playerId);
const [owned] = await tx.select({ n: count() }).from(ships).where(eq(ships.playerId, playerId));
const [ship] = await tx
.insert(ships)
.values({
id: randomUUID(),
playerId,
modelId,
name: shipName(player.worldSeed, owned!.n),
locationId: SHIPYARD_LOCATION_ID,
createdAt: now,
})
.returning();
return {
id: ship!.id,
modelId: ship!.modelId,
name: ship!.name,
status: "idle",
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
};
}

View File

@ -1,22 +0,0 @@
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

@ -1,7 +1,5 @@
import { content } from "@deadhead/content";
import type { Dispatch, Pilot } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
import type { dispatches, pilots } from "../db/schema";
import type { Dispatch } from "@deadhead/schema";
import type { dispatches } from "../db/schema";
/** Database status values use `in_flight`; the API uses `in-flight`. */
export function toApiStatus<S extends "idle" | "in_flight" | "resolved">(
@ -17,31 +15,3 @@ export function toApiDispatch(row: typeof dispatches.$inferSelect): Dispatch {
resolvedAt: row.resolvedAt,
};
}
/**
* API view of a pilot. An assigned pilot is wherever its ship is
* (`assignedShipLocationId`); an unassigned pilot stores its own location.
*/
export function toApiPilot(
row: typeof pilots.$inferSelect,
assignedShipLocationId: string | null,
): Pilot {
const level = pilotLevel(row.xp, content.config);
return {
id: row.id,
name: row.name,
isAvatar: row.isAvatar,
xp: row.xp,
level,
skills: row.skills,
skillPointsAvailable: skillPointsAvailable(level, row.skillPointsSpent, content.config),
assignedShipId: row.assignedShipId,
status: toApiStatus(row.status),
locationId:
row.status === "in_flight"
? null
: row.assignedShipId
? assignedShipLocationId
: row.locationId,
};
}

View File

@ -1,18 +0,0 @@
import type { DispatchFailure, RepositionFailure } from "@deadhead/sim";
/** Player-facing text for each sim validation reason. */
export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, string> = {
"ship-not-idle": "That ship is already flying.",
"ship-not-at-origin": "That ship is not at the contract's pickup location.",
"pilot-not-idle": "That pilot is already flying.",
"pilot-not-assigned": "That pilot is not assigned to that ship.",
"over-capacity": "The cargo is too heavy for that ship.",
"cannot-land": "That ship cannot land at the destination.",
"out-of-range": "The destination is out of that ship's range right now.",
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
"contract-expired": "This contract has expired.",
"insufficient-credits": "Not enough credits for fuel.",
"already-there": "The ship is already there.",
"no-bay-at-destination": "You have no bay at that destination.",
};

View File

@ -1,86 +0,0 @@
import { createRng, deriveSeed, type Seed } from "@deadhead/sim";
const SHIP_ADJECTIVES = [
"Dusty",
"Rusty",
"Lucky",
"Stubborn",
"Quiet",
"Restless",
"Patient",
"Wandering",
"Crooked",
"Faithful",
"Midnight",
"Copper",
"Iron",
"Silver",
"Old",
"Brave",
];
const SHIP_NOUNS = [
"Mule",
"Ox",
"Heron",
"Comet",
"Lantern",
"Drifter",
"Kettle",
"Anchor",
"Sparrow",
"Badger",
"Tortoise",
"Pilgrim",
"Barge",
"Nomad",
"Hauler",
"Wren",
];
const FIRST_NAMES = [
"Rae",
"Jonah",
"Mira",
"Tobias",
"Ines",
"Kofi",
"Lena",
"Arjun",
"Sasha",
"Dmitri",
"Yuki",
"Nadia",
"Emeka",
"Freya",
"Mateo",
"Priya",
];
const LAST_NAMES = [
"Okafor",
"Lindqvist",
"Moreau",
"Tanaka",
"Reyes",
"Novak",
"Haddad",
"Kowalski",
"Mbeki",
"Castillo",
"Sorensen",
"Iyer",
"Brennan",
"Vasquez",
"Kim",
"Abara",
];
/** The nth ship name in a world (decision 0.5.14): stable for a given seed and n. */
export function shipName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `ship-name:${n}`));
return `${rng.pick(SHIP_ADJECTIVES)} ${rng.pick(SHIP_NOUNS)}`;
}
/** The nth hired pilot's name in a world: stable for a given seed and n. */
export function pilotName(worldSeed: Seed, n: number): string {
const rng = createRng(deriveSeed(worldSeed, `pilot-name:${n}`));
return `${rng.pick(FIRST_NAMES)} ${rng.pick(LAST_NAMES)}`;
}

View File

@ -1,174 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable, type RealTime } from "@deadhead/sim";
import { and, count, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, pilots, ships } from "../db/schema";
import { gameError } from "../http/errors";
import { requirePlayer, spend } from "./fleet";
import { toApiPilot } from "./mappers";
import { VALIDATION_MESSAGES } from "./messages";
import { pilotName } from "./names";
async function hasBayAt(tx: Queryable, playerId: string, locationId: string): Promise<boolean> {
const [bay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, locationId)));
return bay !== undefined;
}
export async function requireOwnPilot(tx: Queryable, playerId: string, pilotId: string) {
const [pilot] = await tx
.select()
.from(pilots)
.where(and(eq(pilots.id, pilotId), eq(pilots.playerId, playerId)));
if (!pilot) throw gameError("not-found", "No such pilot.");
return pilot;
}
export async function requireOwnShip(tx: Queryable, playerId: string, shipId: string) {
const [ship] = await tx
.select()
.from(ships)
.where(and(eq(ships.id, shipId), eq(ships.playerId, playerId)));
if (!ship) throw gameError("not-found", "No such ship.");
return ship;
}
/** Hires a level-0 pilot at one of the player's bays (Step 6.8, decision 0.5.22). */
export async function hirePilot(
tx: Queryable,
playerId: string,
locationId: string,
now: RealTime,
): Promise<Pilot> {
if (!(await hasBayAt(tx, playerId, locationId))) {
throw gameError("no-bay-at-location", "Pilots can only be hired at one of your bays.");
}
await spend(tx, playerId, content.config.pilotHireCost, "a new pilot");
const player = await requirePlayer(tx, playerId);
const [hired] = await tx
.select({ n: count() })
.from(pilots)
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, false)));
const [pilot] = await tx
.insert(pilots)
.values({
id: randomUUID(),
playerId,
name: pilotName(player.worldSeed, hired!.n),
locationId,
createdAt: now,
})
.returning();
return toApiPilot(pilot!, null);
}
/**
* Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same
* location. The avatar is locked to the loaner during the tutorial (decision 0.5.23).
*/
export async function assignPilot(
tx: Queryable,
playerId: string,
pilotId: string,
shipId: string,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
const ship = await requireOwnShip(tx, playerId, shipId);
const player = await requirePlayer(tx, playerId);
if (pilot.isAvatar && player.tutorialActive) {
throw gameError("avatar-in-tutorial", "You fly the company loaner until the tutorial ends.");
}
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
if (pilot.assignedShipId !== null) {
throw gameError(
"pilot-already-assigned",
"That pilot already has a ship. Unassign them first.",
);
}
if (ship.status === "in_flight") {
throw gameError("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]);
}
if (ship.locationId !== pilot.locationId) {
throw gameError("ship-not-idle-here", "The pilot and the ship must be at the same location.");
}
const [crew] = await tx
.select({ id: pilots.id })
.from(pilots)
.where(eq(pilots.assignedShipId, shipId));
if (crew) throw gameError("ship-crewed", "That ship already has a pilot.");
const [updated] = await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, pilotId))
.returning();
return toApiPilot(updated!, ship.locationId);
}
/** Unassigns a pilot from its ship; only allowed where the player has a bay (decision 0.5.2). */
export async function unassignPilot(
tx: Queryable,
playerId: string,
pilotId: string,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
if (pilot.assignedShipId === null) {
throw gameError("pilot-not-assigned-to-ship", "That pilot has no ship.");
}
if (pilot.status === "in_flight") {
throw gameError("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
}
const ship = await requireOwnShip(tx, playerId, pilot.assignedShipId);
if (ship.locationId === null || !(await hasBayAt(tx, playerId, ship.locationId))) {
throw gameError("no-bay-at-location", "Pilots can only leave their ship at one of your bays.");
}
const [updated] = await tx
.update(pilots)
.set({ assignedShipId: null, locationId: ship.locationId })
.where(eq(pilots.id, pilotId))
.returning();
return toApiPilot(updated!, null);
}
/** Spends one available skill point on a skill below its max level (Step 6.12). */
export async function spendSkillPoint(
tx: Queryable,
playerId: string,
pilotId: string,
skillId: string,
): Promise<Pilot> {
const pilot = await requireOwnPilot(tx, playerId, pilotId);
const skill = content.skill(skillId);
if (!skill) throw gameError("unknown-skill", `No skill "${skillId}".`);
const level = pilotLevel(pilot.xp, content.config);
if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) {
throw gameError("no-skill-points", "That pilot has no skill points to spend.");
}
const current = pilot.skills[skillId] ?? 0;
if (current >= skill.maxLevel) {
throw gameError("skill-maxed", `${skill.name} is already at its maximum level.`);
}
const [updated] = await tx
.update(pilots)
.set({
skills: { ...pilot.skills, [skillId]: current + 1 },
skillPointsSpent: pilot.skillPointsSpent + 1,
})
.where(eq(pilots.id, pilotId))
.returning();
const [ship] = updated!.assignedShipId
? await tx.select().from(ships).where(eq(ships.id, updated!.assignedShipId))
: [];
return toApiPilot(updated!, ship?.locationId ?? null);
}

View File

@ -1,7 +1,8 @@
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 { Queryable } from "../db/client";
import type { Db } from "../db/client";
import {
bays,
companies,
@ -11,10 +12,10 @@ import {
players,
ships,
} from "../db/schema";
import { toApiDispatch, toApiPilot, toApiStatus } from "./mappers";
import { toApiDispatch, toApiStatus } from "./mappers";
/** Reads everything the client needs about one player. */
export async function loadPlayerState(db: Queryable, playerId: string): Promise<PlayerState> {
export async function loadPlayerState(db: Db, 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`);
@ -59,9 +60,27 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null,
})),
pilots: pilotRows.map((p) =>
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
),
pilots: pilotRows.map((p) => {
const level = pilotLevel(p.xp, content.config);
const locationId =
p.status === "in_flight"
? null
: p.assignedShipId
? (shipById.get(p.assignedShipId)?.locationId ?? null)
: p.locationId;
return {
id: p.id,
name: p.name,
isAvatar: p.isAvatar,
xp: p.xp,
level,
skills: p.skills,
skillPointsAvailable: skillPointsAvailable(level, p.skillPointsSpent, content.config),
assignedShipId: p.assignedShipId,
status: toApiStatus(p.status),
locationId,
};
}),
bays: bayRows.map((b) => ({ id: b.id, locationId: b.locationId })),
locations: content.locations.map((l) => ({ id: l.id, discovered: discoveredIds.has(l.id) })),
tutorial:

View File

@ -1,97 +0,0 @@
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";
import { maybeEndTutorial } from "./tutorial";
/**
* 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. Ends the tutorial if its last run just landed
* (Step 6.7). 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);
}
}
if (resolvedCount > 0) await maybeEndTutorial(tx, playerId);
return resolvedCount;
}

View File

@ -1,30 +0,0 @@
import type { RealTime } from "@deadhead/sim";
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { dispatches } from "../db/schema";
/**
* Marks "while you were away" summaries as seen (Step 6.13). Only this
* player's resolved, unacknowledged dispatches are touched; other ids are
* ignored. Returns how many were acknowledged.
*/
export async function acknowledgeSummaries(
tx: Queryable,
playerId: string,
dispatchIds: string[],
now: RealTime,
): Promise<number> {
const updated = await tx
.update(dispatches)
.set({ acknowledgedAt: now })
.where(
and(
eq(dispatches.playerId, playerId),
inArray(dispatches.id, dispatchIds),
eq(dispatches.status, "resolved"),
isNull(dispatches.acknowledgedAt),
),
)
.returning({ id: dispatches.id });
return updated.length;
}

View File

@ -1,49 +0,0 @@
import { and, eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { bays, dispatches, pilots, players, ships } from "../db/schema";
/** Where the avatar ends up when the tutorial ends (decision 0.5.4). */
export const TUTORIAL_END_LOCATION_ID = "earth";
/**
* Ends the tutorial once the player owns a ship, has an Earth bay, and has no
* tutorial run in flight (decision 0.5.4): the loaner is removed and the avatar
* becomes a normal, unassigned pilot at Earth. Returns whether it ended now.
*/
export async function maybeEndTutorial(tx: Queryable, playerId: string): Promise<boolean> {
const [player] = await tx.select().from(players).where(eq(players.id, playerId));
if (!player?.tutorialActive) return false;
const [ship] = await tx
.select({ id: ships.id })
.from(ships)
.where(eq(ships.playerId, playerId))
.limit(1);
if (!ship) return false;
const [earthBay] = await tx
.select({ id: bays.id })
.from(bays)
.where(and(eq(bays.playerId, playerId), eq(bays.locationId, TUTORIAL_END_LOCATION_ID)));
if (!earthBay) return false;
const [running] = await tx
.select({ id: dispatches.id })
.from(dispatches)
.where(
and(
eq(dispatches.playerId, playerId),
eq(dispatches.tutorial, true),
eq(dispatches.status, "in_flight"),
),
);
if (running) return false;
await tx
.update(players)
.set({ tutorialActive: false, loanerLocationId: null })
.where(eq(players.id, playerId));
await tx
.update(pilots)
.set({ locationId: TUTORIAL_END_LOCATION_ID, assignedShipId: null, status: "idle" })
.where(and(eq(pilots.playerId, playerId), eq(pilots.isAvatar, true)));
return true;
}

View File

@ -12,17 +12,6 @@ export class ApiError extends Error {
}
}
const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = new Set([
"not-found",
"unknown-contract",
"unknown-skill",
]);
/** A game-rule failure: 404 for unknown things, 409 for everything else. */
export function gameError(code: ErrorCode, message: string): ApiError {
return new ApiError(NOT_FOUND_CODES.has(code) ? 404 : 409, code, message);
}
export function errorBody(code: ErrorCode, message: string): ErrorResponse {
return { error: { code, message } };
}

View File

@ -1,17 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { bayBoard, 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)),
);
registerRoute(app, routes.getContractBoard, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => bayBoard(tx, playerId, input.locationId, now)),
);
}

View File

@ -1,12 +0,0 @@
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

@ -1,22 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { dispatchContract, dispatchReposition, dispatchTutorial } from "../game/dispatch";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Contract, tutorial and reposition dispatches. */
export function registerDispatchRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.createDispatch, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
switch (input.kind) {
case "contract":
return { dispatch: await dispatchContract(tx, playerId, input, now) };
case "tutorial":
return { dispatch: await dispatchTutorial(tx, playerId, input.contractId, now) };
case "reposition":
return { dispatch: await dispatchReposition(tx, playerId, input, now) };
}
}),
);
}

View File

@ -1,46 +0,0 @@
import { routes } from "@deadhead/schema";
import type { FastifyInstance } from "fastify";
import { buyShip, leaseBay } from "../game/fleet";
import { assignPilot, hirePilot, spendSkillPoint, unassignPilot } from "../game/pilots";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
import { withPlayer } from "./with-player";
/** Ships, bays and pilots. */
export function registerFleetRoutes(app: FastifyInstance, ctx: AppContext): void {
registerRoute(app, routes.buyShip, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
ship: await buyShip(tx, playerId, input.modelId, now),
})),
);
registerRoute(app, routes.leaseBay, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
bay: await leaseBay(tx, playerId, input.locationId, now),
})),
);
registerRoute(app, routes.hirePilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
pilot: await hirePilot(tx, playerId, input.locationId, now),
})),
);
registerRoute(app, routes.assignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
pilot: await assignPilot(tx, playerId, input.pilotId, input.shipId),
})),
);
registerRoute(app, routes.unassignPilot, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
pilot: await unassignPilot(tx, playerId, input.pilotId),
})),
);
registerRoute(app, routes.spendSkillPoint, ctx.options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx) => ({
pilot: await spendSkillPoint(tx, playerId, input.pilotId, input.skillId),
})),
);
}

View File

@ -1,52 +0,0 @@
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 { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state";
import { acknowledgeSummaries } from "../game/summaries";
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 {
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 }) =>
withPlayer(ctx, playerId, (tx) => loadPlayerState(tx, playerId)),
);
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => ({
acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now),
})),
);
}

View File

@ -1,24 +0,0 @@
import type { Queryable } from "../db/client";
import { withPlayerLock } from "../game/lock";
import { resolveArrivedDispatches } from "../game/resolve";
import { maybeEndTutorial } from "../game/tutorial";
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), runs `fn`, then ends the
* tutorial if the action completed its conditions (Step 6.7).
*/
export function withPlayer<T>(
ctx: AppContext,
playerId: string,
fn: (tx: Queryable, now: number) => Promise<T>,
): Promise<T> {
const now = ctx.clock();
return withPlayerLock(ctx.db, playerId, async (tx) => {
await resolveArrivedDispatches(tx, playerId, now);
const result = await fn(tx, now);
await maybeEndTutorial(tx, playerId);
return result;
});
}

View File

@ -1,68 +0,0 @@
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { discoveredLocations } from "../src/db/schema";
import { makeOwner, TEST_NOW, useTestDatabase } from "./db";
import { call, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function owner(): Promise<Guest> {
const guest = await signUp(testApp().app);
await makeOwner(database().db, guest.playerId, guest.avatarId);
return guest;
}
async function earthBoard(guest: Guest) {
const response = await call(testApp().app, guest, "GET", "/api/bays/earth/board");
expect(response.statusCode).toBe(200);
return contractBoardResponseSchema.parse(response.json());
}
describe("bay contract board", () => {
it("returns a board of the configured size for an owned bay", async () => {
const guest = await owner();
const { contracts, periodEndsAt } = await earthBoard(guest);
expect(contracts).toHaveLength(content.config.contractBoardSize);
for (const c of contracts) expect(c.originId).toBe("earth");
expect(periodEndsAt).toBeGreaterThan(testApp().clock.now);
});
it("refuses a location where the player has no bay", async () => {
const { app } = testApp();
const guest = await owner();
const response = await call(app, guest, "GET", "/api/bays/moon/board");
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-location");
});
it("returns the same stored board within a period, even after a discovery", async () => {
const { clock } = testApp();
const guest = await owner();
const first = await earthBoard(guest);
await database()
.db.insert(discoveredLocations)
.values(
["mars", "ceres-station", "jupiter-cloud-platform"].map((locationId) => ({
playerId: guest.playerId,
locationId,
discoveredAt: TEST_NOW,
})),
);
clock.advance(boardPeriodMs(content.config) / 2 - 1);
expect((await earthBoard(guest)).contracts).toEqual(first.contracts);
});
it("returns a new board after the period rolls over", async () => {
const { clock } = testApp();
const guest = await owner();
const first = await earthBoard(guest);
clock.now = first.periodEndsAt;
const next = await earthBoard(guest);
expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config));
expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id));
for (const c of next.contracts) expect(c.expiresAt).toBe(next.periodEndsAt);
});
});

View File

@ -1,65 +0,0 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { shipName } from "../src/game/names";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at Earth", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + 500);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: null,
dispatchId: null,
});
expect(ship.name.length).toBeGreaterThan(0);
const state = await getState(app, guest);
expect(state.company.credits).toBe(500);
expect(state.ships.map((s) => s.id)).toEqual([ship.id]);
});
it("returns insufficient-funds and changes nothing without enough credits", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("freighter")!.price;
await setCredits(database().db, guest.playerId, price - 1);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "freighter" });
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
const state = await getState(app, guest);
expect(state.company.credits).toBe(price - 1);
expect(state.ships).toHaveLength(0);
});
it("rejects an unknown model", async () => {
const { app } = testApp();
const guest = await signUp(app);
const response = await call(app, guest, "POST", "/api/ships", { modelId: "x-wing" });
expect(response.statusCode).toBe(404);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("not-found");
});
});
describe("ship names", () => {
it("are stable for a world seed and differ between ships", () => {
expect(shipName(42, 0)).toBe(shipName(42, 0));
const names = new Set(Array.from({ length: 10 }, (_, i) => shipName(42, i)));
expect(names.size).toBeGreaterThan(1);
});
});

View File

@ -1,77 +0,0 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("concurrency and money safety", () => {
it("lets only one of two simultaneous purchases through when only one is affordable", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price + price / 2);
const responses = await Promise.all([
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.ships).toHaveLength(1);
expect(state.company.credits).toBe(price / 2);
});
it("never goes negative under many simultaneous purchases", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.shipModel("courier")!.price;
await setCredits(database().db, guest.playerId, price * 3);
const responses = await Promise.all(
Array.from({ length: 8 }, () =>
call(app, guest, "POST", "/api/ships", { modelId: "courier" }),
),
);
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
const state = await getState(app, guest);
expect(state.ships).toHaveLength(3);
expect(state.company.credits).toBe(0);
});
it("accepts a contract only once when dispatched twice at the same moment", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
const contract = testContract();
await putBoard(db, guest.playerId, [contract]);
const body = { kind: "contract", contractId: contract.id, shipId, pilotId: guest.avatarId };
const responses = await Promise.all([
call(app, guest, "POST", "/api/dispatches", body),
call(app, guest, "POST", "/api/dispatches", body),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.activeDispatches).toHaveLength(1);
expect(state.company.credits).toBe(10_000 - state.activeDispatches[0]!.fuelCost);
});
it("leases one bay when the same lease is sent twice at once", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 100_000);
const responses = await Promise.all([
call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
call(app, guest, "POST", "/api/bays", { locationId: "moon" }),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.bays).toHaveLength(1);
expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice);
});
});

View File

@ -1,69 +0,0 @@
import { randomUUID } from "node:crypto";
import { errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it, vi } from "vitest";
import { dispatches, pilots, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, putBoard, signUp, testContract, useTestApp } from "./game";
// Every base-game ship is atmospheric (decision 0.5.1), so `cannot-land` needs
// a test-only vacuum ship in the content the server sees.
vi.mock("@deadhead/content", async (importOriginal) => {
const original = await importOriginal<typeof import("@deadhead/content")>();
const courier = original.content.shipModel("courier")!;
const shipModels = [
...original.content.shipModels,
{ ...courier, id: "test-vacuum", landingClass: "vacuum" as const },
];
return {
...original,
content: {
...original.content,
shipModels,
shipModel: (id: string) => shipModels.find((s) => s.id === id),
},
};
});
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("contract dispatch landing check", () => {
it("rejects cannot-land and changes nothing", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
// A vacuum-only ship at the Moon, crewed by a pilot, offered a Moon→Earth job.
const shipId = randomUUID();
await db.insert(ships).values({
id: shipId,
playerId: guest.playerId,
modelId: "test-vacuum",
name: "Moon Crawler",
locationId: "moon",
createdAt: TEST_NOW,
});
const pilotId = randomUUID();
await db.insert(pilots).values({
id: pilotId,
playerId: guest.playerId,
name: "Vac Pilot",
assignedShipId: shipId,
createdAt: TEST_NOW,
});
const contract = testContract({ originId: "moon", destinationId: "earth" });
await putBoard(db, guest.playerId, [contract]);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId: contract.id,
shipId,
pilotId,
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("cannot-land");
expect(await db.select().from(dispatches)).toEqual([]);
});
});

View File

@ -1,203 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { boardPeriodMs } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { acceptedContracts, companies, dispatches, pilots, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, putBoard, signUp, testContract, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
interface Owner {
guest: Guest;
shipId: string;
}
/** An owner with a crewed Courier at Earth, an Earth bay and 10,000 credits. */
async function owner(): Promise<Owner> {
const guest = await signUp(testApp().app);
const shipId = await makeOwner(database().db, guest.playerId, guest.avatarId);
await setCredits(database().db, guest.playerId, 10_000);
return { guest, shipId };
}
function dispatch(
o: Owner,
contractId: string,
overrides: { shipId?: string; pilotId?: string } = {},
) {
return call(testApp().app, o.guest, "POST", "/api/dispatches", {
kind: "contract",
contractId,
shipId: overrides.shipId ?? o.shipId,
pilotId: overrides.pilotId ?? o.guest.avatarId,
});
}
/** Asserts the request failed with `code` and changed nothing. */
async function expectRejected(
o: Owner,
response: Awaited<ReturnType<typeof dispatch>>,
code: string,
) {
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe(code);
const { db } = database();
expect(
await db.select().from(dispatches).where(eq(dispatches.playerId, o.guest.playerId)),
).toEqual([]);
expect(await db.select().from(acceptedContracts)).toEqual([]);
const [company] = await db
.select()
.from(companies)
.where(eq(companies.playerId, o.guest.playerId));
expect(company!.credits).toBe(10_000);
}
describe("contract dispatch", () => {
it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => {
const { app, clock } = testApp();
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
const response = await dispatch(o, contract.id);
expect(response.statusCode).toBe(200);
const record = dispatchResponseSchema.parse(response.json()).dispatch;
expect(record).toMatchObject({
status: "in-flight",
shipId: o.shipId,
pilotId: o.guest.avatarId,
departureAt: clock.now,
expectedPayout: contract.payout,
tutorial: false,
});
expect(record.fuelCost).toBeGreaterThan(0);
const state = await getState(app, o.guest);
expect(state.company.credits).toBe(10_000 - record.fuelCost);
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
expect(state.pilots[0]!.status).toBe("in-flight");
const board = contractBoardResponseSchema.parse(
(await call(app, o.guest, "GET", "/api/bays/earth/board")).json(),
);
expect(board.contracts).toEqual([]);
});
it("rejects ship-not-idle", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await database().db.update(ships).set({ status: "in_flight" }).where(eq(ships.id, o.shipId));
await expectRejected(o, await dispatch(o, contract.id), "ship-not-idle");
});
it("rejects ship-not-at-origin", async () => {
const o = await owner();
const contract = testContract({ originId: "moon", destinationId: "earth" });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "ship-not-at-origin");
});
it("rejects pilot-not-idle", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await database()
.db.update(pilots)
.set({ status: "in_flight" })
.where(eq(pilots.id, o.guest.avatarId));
await expectRejected(o, await dispatch(o, contract.id), "pilot-not-idle");
});
it("rejects pilot-not-assigned", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
const otherPilotId = randomUUID();
await database().db.insert(pilots).values({
id: otherPilotId,
playerId: o.guest.playerId,
name: "Spare Pilot",
locationId: "earth",
createdAt: TEST_NOW,
});
await expectRejected(
o,
await dispatch(o, contract.id, { pilotId: otherPilotId }),
"pilot-not-assigned",
);
});
it("rejects over-capacity", async () => {
const o = await owner();
const contract = testContract({ massTonnes: 50 });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "over-capacity");
});
it("rejects out-of-range", async () => {
const o = await owner();
const contract = testContract({ destinationId: "mars" });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "out-of-range");
});
it("rejects cargo-skill-too-low", async () => {
const o = await owner();
const contract = testContract({
cargoTypeId: "heavy",
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
});
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "cargo-skill-too-low");
});
it("rejects long-haul-too-low", async () => {
const o = await owner();
const contract = testContract({ requiredLongHaulLevel: 1 });
await putBoard(database().db, o.guest.playerId, [contract]);
await expectRejected(o, await dispatch(o, contract.id), "long-haul-too-low");
});
it("rejects contract-expired once the board's period has rolled over", async () => {
const { clock } = testApp();
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
// The contract expires at the end of its board period (TEST_NOW is a period start).
expect(contract.expiresAt).toBe(TEST_NOW + boardPeriodMs(content.config));
clock.now = contract.expiresAt;
await expectRejected(o, await dispatch(o, contract.id), "contract-expired");
});
it("rejects insufficient-credits", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
await setCredits(database().db, o.guest.playerId, 0);
const response = await dispatch(o, contract.id);
expect(errorCode(response)).toBe("insufficient-credits");
expect(await database().db.select().from(dispatches)).toEqual([]);
});
it("rejects an unknown contract and a contract taken twice", async () => {
const o = await owner();
const contract = testContract();
await putBoard(database().db, o.guest.playerId, [contract]);
expect((await dispatch(o, "earth:0:99")).statusCode).toBe(404);
expect((await dispatch(o, contract.id)).statusCode).toBe(200);
expect(errorCode(await dispatch(o, contract.id))).toBe("contract-already-accepted");
});
});

View File

@ -1,4 +1,5 @@
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";
@ -74,30 +75,24 @@ export async function seedPlayer(
worldSeed: options.worldSeed ?? 12345,
now: TEST_NOW,
});
if (options.credits !== undefined) await setCredits(db, player.playerId, options.credits);
if (options.credits !== undefined) {
await db
.update(companies)
.set({ credits: options.credits })
.where(eq(companies.playerId, player.playerId));
}
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, playerId));
.where(eq(players.id, player.playerId));
await tx.insert(ships).values({
id: shipId,
playerId,
modelId: "courier",
playerId: player.playerId,
modelId: content.shipModels[0]!.id,
name: "Test Courier",
locationId: "earth",
createdAt: TEST_NOW,
@ -105,10 +100,13 @@ export async function makeOwner(db: Db, playerId: string, avatarId: string): Pro
await tx
.update(pilots)
.set({ assignedShipId: shipId, locationId: null })
.where(eq(pilots.id, avatarId));
await tx
.insert(bays)
.values({ id: randomUUID(), playerId, locationId: "earth", createdAt: TEST_NOW });
.where(eq(pilots.id, player.avatarId));
await tx.insert(bays).values({
id: randomUUID(),
playerId: player.playerId,
locationId: "earth",
createdAt: TEST_NOW,
});
return shipId;
});
return { ...player, shipId };
}

View File

@ -1,108 +0,0 @@
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots, players } from "../src/db/schema";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function buyShipAndEarthBay(guest: Guest) {
const { app } = testApp();
expect((await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).statusCode).toBe(
200,
);
expect((await call(app, guest, "POST", "/api/bays", { locationId: "earth" })).statusCode).toBe(
200,
);
}
describe("end of tutorial", () => {
it("ends when the player owns a ship and an Earth bay, moving the avatar to Earth unassigned", async () => {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
await setCredits(db, guest.playerId, 1_000_000);
// Put the loaner and avatar at the Moon.
await db
.update(players)
.set({ loanerLocationId: "moon" })
.where(eq(players.id, guest.playerId));
await db.update(pilots).set({ locationId: "moon" }).where(eq(pilots.id, guest.avatarId));
await call(app, guest, "POST", "/api/ships", { modelId: "courier" });
expect((await getState(app, guest)).tutorial).not.toBeNull();
await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
const state = await getState(app, guest);
expect(state.tutorial).toBeNull();
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
status: "idle",
locationId: "earth",
assignedShipId: null,
});
const board = await call(app, guest, "GET", "/api/tutorial/board");
expect(board.statusCode).toBe(409);
expect(errorResponseSchema.parse(board.json()).error.code).toBe("not-in-tutorial");
});
it("waits for an in-flight tutorial run to land before ending", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const [contract] = contractBoardResponseSchema.parse(
(await call(app, guest, "GET", "/api/tutorial/board")).json(),
).contracts;
const { dispatch } = dispatchResponseSchema.parse(
(
await call(app, guest, "POST", "/api/dispatches", {
kind: "tutorial",
contractId: contract!.id,
})
).json(),
);
await buyShipAndEarthBay(guest);
expect((await getState(app, guest)).tutorial).toEqual({
loanerLocationId: "earth",
runInFlight: true,
});
clock.now = dispatch.arrivalAt;
const state = await getState(app, guest);
expect(state.tutorial).toBeNull();
// The run still paid out, and the avatar was brought back to Earth.
expect(state.company.credits).toBe(
1_000_000 -
content.shipModel("courier")!.price -
content.location("earth")!.bayLeasePrice +
contract!.payout,
);
expect(state.pilots[0]).toMatchObject({
locationId: "earth",
status: "idle",
assignedShipId: null,
});
});
it("does not end with only a ship, or only an Earth bay", async () => {
const { app } = testApp();
const shipOnly = await signUp(app);
await setCredits(database().db, shipOnly.playerId, 1_000_000);
await call(app, shipOnly, "POST", "/api/ships", { modelId: "courier" });
expect((await getState(app, shipOnly)).tutorial).not.toBeNull();
const bayOnly = await signUp(app);
await setCredits(database().db, bayOnly.playerId, 1_000_000);
await call(app, bayOnly, "POST", "/api/bays", { locationId: "earth" });
expect((await getState(app, bayOnly)).tutorial).not.toBeNull();
});
});

View File

@ -1,192 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { createGuestResponseSchema, playerStateSchema, type PlayerState } from "@deadhead/schema";
import {
boardPeriodIndex,
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 { contractBoards, 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;
}
/** Stores a hand-made Earth bay board for the current period. */
export async function putBoard(db: Db, playerId: string, contracts: Contract[], now = TEST_NOW) {
await db.insert(contractBoards).values({
id: randomUUID(),
playerId,
kind: "bay",
locationId: "earth",
periodIndex: boardPeriodIndex(now, content.config),
contracts,
createdAt: now,
});
}

View File

@ -1,63 +0,0 @@
import { content } from "@deadhead/content";
import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { setCredits, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("lease a bay", () => {
it("leases a bay at Earth and deducts the price", async () => {
const { app } = testApp();
const guest = await signUp(app);
const price = content.location("earth")!.bayLeasePrice;
await setCredits(database().db, guest.playerId, price + 100);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(200);
const { bay } = bayResponseSchema.parse(response.json());
expect(bay.locationId).toBe("earth");
const state = await getState(app, guest);
expect(state.company.credits).toBe(100);
expect(state.bays).toEqual([bay]);
});
it("refuses an undiscovered location", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "mars" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("location-undiscovered");
expect((await getState(app, guest)).company.credits).toBe(1_000_000);
});
it("refuses a second bay at the same location", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 1_000_000);
await call(app, guest, "POST", "/api/bays", { locationId: "moon" });
const response = await call(app, guest, "POST", "/api/bays", { locationId: "moon" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("bay-already-leased");
const state = await getState(app, guest);
expect(state.bays).toHaveLength(1);
expect(state.company.credits).toBe(1_000_000 - content.location("moon")!.bayLeasePrice);
});
it("refuses when the player cannot afford it, and unknown locations", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 0);
expect(errorCode(await call(app, guest, "POST", "/api/bays", { locationId: "earth" }))).toBe(
"insufficient-funds",
);
const unknown = await call(app, guest, "POST", "/api/bays", { locationId: "pluto" });
expect(unknown.statusCode).toBe(404);
});
});

View File

@ -1,172 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { bays, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, insertDispatch, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
/** An owner with a Moon bay, plenty of credits, and an extra uncrewed Courier at the Moon. */
async function ownerWithMoonBay() {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const earthShipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 100_000);
await db.insert(bays).values({
id: randomUUID(),
playerId: guest.playerId,
locationId: "moon",
createdAt: TEST_NOW,
});
const moonShipId = randomUUID();
await db.insert(ships).values({
id: moonShipId,
playerId: guest.playerId,
modelId: "courier",
name: "Moon Hopper",
locationId: "moon",
createdAt: TEST_NOW,
});
return { guest, earthShipId, moonShipId };
}
async function hire(guest: Guest, locationId: string) {
return call(testApp().app, guest, "POST", "/api/pilots", { locationId });
}
describe("hiring pilots", () => {
it("hires a level-0 pilot at the Moon bay and deducts the fee", async () => {
const { app } = testApp();
const { guest } = await ownerWithMoonBay();
const response = await hire(guest, "moon");
expect(response.statusCode).toBe(200);
const { pilot } = pilotResponseSchema.parse(response.json());
expect(pilot).toMatchObject({
isAvatar: false,
level: 0,
xp: 0,
locationId: "moon",
assignedShipId: null,
status: "idle",
});
expect(pilot.name.length).toBeGreaterThan(0);
expect((await getState(app, guest)).company.credits).toBe(
100_000 - content.config.pilotHireCost,
);
});
it("refuses to hire where the player has no bay", async () => {
const { guest } = await ownerWithMoonBay();
const response = await hire(guest, "mars");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("no-bay-at-location");
});
it("refuses to hire without enough credits", async () => {
const { guest } = await ownerWithMoonBay();
await setCredits(database().db, guest.playerId, content.config.pilotHireCost - 1);
expect(errorCode(await hire(guest, "moon"))).toBe("insufficient-funds");
});
});
describe("assigning pilots", () => {
it("assigns a pilot to an idle, uncrewed ship at the same location", async () => {
const { app } = testApp();
const { guest, moonShipId } = await ownerWithMoonBay();
const { pilot } = pilotResponseSchema.parse((await hire(guest, "moon")).json());
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
shipId: moonShipId,
});
expect(response.statusCode).toBe(200);
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
assignedShipId: moonShipId,
locationId: "moon",
});
const state = await getState(app, guest);
expect(state.ships.find((s) => s.id === moonShipId)!.pilotId).toBe(pilot.id);
});
it("refuses a ship at another location", async () => {
const { app } = testApp();
const { guest, moonShipId } = await ownerWithMoonBay();
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
shipId: moonShipId,
});
expect(errorCode(response)).toBe("ship-not-idle-here");
});
it("refuses a crewed ship", async () => {
const { app } = testApp();
const { guest, earthShipId } = await ownerWithMoonBay();
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
shipId: earthShipId,
});
expect(errorCode(response)).toBe("ship-crewed");
});
it("refuses while the ship is in flight", async () => {
const { app, clock } = testApp();
const { guest, earthShipId } = await ownerWithMoonBay();
const { pilot } = pilotResponseSchema.parse((await hire(guest, "earth")).json());
await insertDispatch(database().db, {
playerId: guest.playerId,
shipId: earthShipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
const response = await call(app, guest, "POST", `/api/pilots/${pilot.id}/assign`, {
shipId: earthShipId,
});
expect(errorCode(response)).toBe("ship-not-idle");
});
it("refuses to assign the avatar during the tutorial", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(database().db, guest.playerId, 100_000);
const ship = (await call(app, guest, "POST", "/api/ships", { modelId: "courier" })).json() as {
ship: { id: string };
};
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, {
shipId: ship.ship.id,
});
expect(errorCode(response)).toBe("avatar-in-tutorial");
});
});
describe("unassigning pilots", () => {
it("unassigns at a bay, leaving the pilot at the ship's location", async () => {
const { app } = testApp();
const { guest, earthShipId } = await ownerWithMoonBay();
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`);
expect(response.statusCode).toBe(200);
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
assignedShipId: null,
locationId: "earth",
});
const state = await getState(app, guest);
expect(state.ships.find((s) => s.id === earthShipId)!.pilotId).toBeNull();
});
it("refuses where the player has no bay", async () => {
const { app } = testApp();
const { guest, earthShipId } = await ownerWithMoonBay();
// Park the crewed ship at Mars, where there is no bay.
const { db } = database();
await db.update(ships).set({ locationId: "mars" }).where(eq(ships.id, earthShipId));
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/unassign`);
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("no-bay-at-location");
});
});

View File

@ -1,66 +0,0 @@
import { randomUUID } from "node:crypto";
import { dispatchResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { bays } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function ownerWithMoonBay(withMoonBay = true) {
const { app } = testApp();
const { db } = database();
const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 10_000);
if (withMoonBay) {
await db.insert(bays).values({
id: randomUUID(),
playerId: guest.playerId,
locationId: "moon",
createdAt: TEST_NOW,
});
}
return { guest, shipId };
}
describe("reposition dispatch", () => {
it("flies the ship empty to another bay, costing only fuel", async () => {
const { app, clock } = testApp();
const { guest, shipId } = await ownerWithMoonBay();
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "reposition",
shipId,
pilotId: guest.avatarId,
destinationId: "moon",
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
expect(dispatch).toMatchObject({ contract: null, expectedPayout: 0, destinationId: "moon" });
expect(dispatch.fuelCost).toBeGreaterThan(0);
clock.now = dispatch.arrivalAt;
const state = await getState(app, guest);
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
});
it("refuses a destination without a player bay and changes nothing", async () => {
const { app } = testApp();
const { guest, shipId } = await ownerWithMoonBay(false);
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "reposition",
shipId,
pilotId: guest.avatarId,
destinationId: "moon",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-destination");
const state = await getState(app, guest);
expect(state.company.credits).toBe(10_000);
expect(state.ships[0]!.status).toBe("idle");
});
});

View File

@ -1,150 +0,0 @@
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

@ -1,61 +0,0 @@
import { content } from "@deadhead/content";
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { pilots } from "../src/db/schema";
import { makeOwner, useTestDatabase } from "./db";
import { call, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
/** An owner whose avatar has `xp` XP and the given skills and points spent. */
async function pilotWith(xp: number, skills: Record<string, number> = {}, spent = 0) {
const guest = await signUp(testApp().app);
await makeOwner(database().db, guest.playerId, guest.avatarId);
await database()
.db.update(pilots)
.set({ xp, skills, skillPointsSpent: spent })
.where(eq(pilots.id, guest.avatarId));
return guest;
}
function spend(guest: Guest, skillId: string) {
return call(testApp().app, guest, "POST", `/api/pilots/${guest.avatarId}/skills`, { skillId });
}
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
// Level 1 gives `skillPointsPerLevel` points.
const level1Xp = content.config.pilotLevelXpThresholds[0]!;
describe("spending skill points", () => {
it("raises the skill by one and uses one point", async () => {
const guest = await pilotWith(level1Xp);
const response = await spend(guest, "heavy-cargo");
expect(response.statusCode).toBe(200);
const { pilot } = pilotResponseSchema.parse(response.json());
expect(pilot.skills).toEqual({ "heavy-cargo": 1 });
expect(pilot.skillPointsAvailable).toBe(content.config.skillPointsPerLevel - 1);
});
it("refuses with no points available", async () => {
const guest = await pilotWith(0);
const response = await spend(guest, "heavy-cargo");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("no-skill-points");
});
it("refuses a skill already at max level", async () => {
const guest = await pilotWith(level1Xp, { "long-haul": 5 });
expect(errorCode(await spend(guest, "long-haul"))).toBe("skill-maxed");
});
it("refuses an unknown skill", async () => {
const guest = await pilotWith(level1Xp);
const response = await spend(guest, "juggling");
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("unknown-skill");
});
});

View File

@ -1,97 +0,0 @@
import { randomUUID } from "node:crypto";
import { content } from "@deadhead/content";
import { playerStateSchema } from "@deadhead/schema";
import { pilotLevel } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { pilots, ships } from "../src/db/schema";
import { makeOwner, setCredits, TEST_NOW, useTestDatabase } from "./db";
import { call, insertDispatch, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("full player state", () => {
it("reflects one ship in flight and one idle, and validates against its schema", async () => {
const { app, clock } = testApp();
const { db } = database();
const guest = await signUp(app);
const flyingShipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 9000);
// A second ship at the Moon crewed by a hired pilot with some XP and a skill.
const idleShipId = randomUUID();
const hiredId = randomUUID();
await db.insert(ships).values({
id: idleShipId,
playerId: guest.playerId,
modelId: "freighter",
name: "Slow Ox",
locationId: "moon",
createdAt: TEST_NOW,
});
await db.insert(pilots).values({
id: hiredId,
playerId: guest.playerId,
name: "Rae Okafor",
xp: 260,
skills: { "heavy-cargo": 1 },
skillPointsSpent: 1,
assignedShipId: idleShipId,
createdAt: TEST_NOW,
});
const record = await insertDispatch(db, {
playerId: guest.playerId,
shipId: flyingShipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
const response = await call(app, guest, "GET", "/api/state");
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.company.credits).toBe(9000);
expect(state.tutorial).toBeNull();
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
const flying = state.ships.find((s) => s.id === flyingShipId)!;
const idle = state.ships.find((s) => s.id === idleShipId)!;
expect(flying).toMatchObject({
status: "in-flight",
locationId: null,
dispatchId: record.id,
pilotId: guest.avatarId,
});
expect(idle).toMatchObject({
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: hiredId,
modelId: "freighter",
});
const avatar = state.pilots.find((p) => p.id === guest.avatarId)!;
const hired = state.pilots.find((p) => p.id === hiredId)!;
expect(avatar).toMatchObject({
status: "in-flight",
locationId: null,
assignedShipId: flyingShipId,
});
expect(hired).toMatchObject({
status: "idle",
locationId: "moon",
assignedShipId: idleShipId,
xp: 260,
level: pilotLevel(260, content.config),
skills: { "heavy-cargo": 1 },
});
expect(hired.level).toBe(2);
expect(hired.skillPointsAvailable).toBe(2 * content.config.skillPointsPerLevel - 1);
expect(state.activeDispatches).toEqual([
expect.objectContaining({ id: record.id, status: "in-flight", arrivalAt: record.arrivalAt }),
]);
expect(state.summaries).toEqual([]);
expect(state.locations).toHaveLength(content.locations.length);
});
});

View File

@ -1,67 +0,0 @@
import { acknowledgeSummariesResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { makeOwner, setCredits, useTestDatabase } from "./db";
import { call, getState, insertDispatch, signUp, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("acknowledging summaries", () => {
it("clears unacknowledged summaries while keeping the resolved effects", async () => {
const { app, clock } = testApp();
const { db } = database();
const guest = await signUp(app);
const shipId = await makeOwner(db, guest.playerId, guest.avatarId);
await setCredits(db, guest.playerId, 1000);
const record = await insertDispatch(db, {
playerId: guest.playerId,
shipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
const before = await getState(app, guest);
expect(before.summaries.map((s) => s.dispatchId)).toEqual([record.id]);
const response = await call(app, guest, "POST", "/api/summaries/acknowledge", {
dispatchIds: [record.id, "not-a-real-dispatch"],
});
expect(response.statusCode).toBe(200);
expect(acknowledgeSummariesResponseSchema.parse(response.json())).toEqual({ acknowledged: 1 });
const after = await getState(app, guest);
expect(after.summaries).toEqual([]);
expect(after.company.credits).toBe(before.company.credits);
expect(after.pilots[0]!.xp).toBe(before.pilots[0]!.xp);
expect(after.ships[0]!.locationId).toBe("moon");
// Acknowledging again changes nothing.
const again = await call(app, guest, "POST", "/api/summaries/acknowledge", {
dispatchIds: [record.id],
});
expect(again.json()).toEqual({ acknowledged: 0 });
});
it("does not acknowledge another player's dispatches", async () => {
const { app, clock } = testApp();
const { db } = database();
const owner = await signUp(app);
const shipId = await makeOwner(db, owner.playerId, owner.avatarId);
const record = await insertDispatch(db, {
playerId: owner.playerId,
shipId,
pilotId: owner.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
await getState(app, owner);
const stranger = await signUp(app);
const response = await call(app, stranger, "POST", "/api/summaries/acknowledge", {
dispatchIds: [record.id],
});
expect(response.json()).toEqual({ acknowledged: 0 });
expect((await getState(app, owner)).summaries).toHaveLength(1);
});
});

View File

@ -1,114 +0,0 @@
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");
});
});

View File

@ -1,105 +0,0 @@
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { MS_PER_HOUR, travelTimeGameHours } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { useTestDatabase } from "./db";
import { call, getState, signUp, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
async function tutorialBoard(guest: Guest) {
const response = await call(testApp().app, guest, "GET", "/api/tutorial/board");
return contractBoardResponseSchema.parse(response.json()).contracts;
}
function flyTutorial(guest: Guest, contractId: string) {
return call(testApp().app, guest, "POST", "/api/dispatches", { kind: "tutorial", contractId });
}
describe("tutorial dispatch", () => {
it("dispatches the avatar in the loaner, charging no fuel", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const [contract] = await tutorialBoard(guest);
const response = await flyTutorial(guest, contract!.id);
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
expect(dispatch).toMatchObject({
tutorial: true,
status: "in-flight",
originId: "earth",
destinationId: "moon",
pilotId: guest.avatarId,
fuelCost: 0,
departureAt: clock.now,
expectedPayout: contract!.payout,
});
const travelMs =
(travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) *
MS_PER_HOUR) /
content.config.timeCompression;
expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs));
const state = await getState(app, guest);
expect(state.company.credits).toBe(content.config.startingCredits);
expect(state.tutorial).toEqual({ loanerLocationId: "earth", runInFlight: true });
expect(state.pilots[0]).toMatchObject({ status: "in-flight", locationId: null });
expect(state.activeDispatches.map((d) => d.id)).toEqual([dispatch.id]);
expect((await tutorialBoard(guest)).map((c) => c.id)).not.toContain(contract!.id);
});
it("rejects a second tutorial run while one is in flight", async () => {
const { app } = testApp();
const guest = await signUp(app);
const [first, second] = await tutorialBoard(guest);
await flyTutorial(guest, first!.id);
const response = await flyTutorial(guest, second!.id);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-run-in-flight");
});
it("pays exactly the tutorial share on arrival and moves the loaner and avatar to the Moon", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const [contract] = await tutorialBoard(guest);
const { dispatch } = dispatchResponseSchema.parse(
(await flyTutorial(guest, contract!.id)).json(),
);
clock.now = dispatch.arrivalAt;
const state = await getState(app, guest);
expect(state.company.credits).toBe(content.config.startingCredits + contract!.payout);
expect(state.pilots[0]!.xp).toBeGreaterThan(0);
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.tutorial).toEqual({ loanerLocationId: "moon", runInFlight: false });
const next = await tutorialBoard(guest);
expect(next.length).toBeGreaterThan(0);
for (const c of next) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
});
it("rejects unknown and already-taken contracts", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const [contract] = await tutorialBoard(guest);
const unknown = await flyTutorial(guest, "tutorial:earth:0:999");
expect(unknown.statusCode).toBe(404);
expect(errorResponseSchema.parse(unknown.json()).error.code).toBe("unknown-contract");
const { dispatch } = dispatchResponseSchema.parse(
(await flyTutorial(guest, contract!.id)).json(),
);
clock.now = dispatch.arrivalAt;
const again = await flyTutorial(guest, contract!.id);
expect(errorResponseSchema.parse(again.json()).error.code).toBe("contract-already-accepted");
});
});

View File

@ -376,20 +376,3 @@ Every catalog endpoint is registered with `registerRoute(app, routes.<name>, opt
4. Outside production, validate the result against the route's `response` schema; a mismatch is logged and returned as `500 internal-error`.
Handlers throw `ApiError(status, code, message)` for expected failures. Unknown paths return `404 not-found`.
## Game logic (`apps/server/src/game`)
| Module | Responsibility |
|---|---|
| `lock.ts` | `withPlayerLock`: transaction + `FOR UPDATE` on the player's company row |
| `resolve.ts` | `resolveArrivedDispatches`: exactly-once resolution of due dispatches (lazy on every request; Phase 7 job) |
| `tutorial.ts` | `maybeEndTutorial` (decision 0.5.4) |
| `boards.ts` | Stored tutorial and bay boards per period; accepted contracts hidden |
| `dispatch.ts` | Tutorial, contract and reposition dispatch; `storeDispatch` (record, accepted contract, fuel) |
| `fleet.ts` | Buy ship, lease bay, `spend` credits |
| `pilots.ts` | Hire, assign, unassign, spend skill point |
| `summaries.ts` | Acknowledge "while you were away" entries |
| `player-state.ts`, `mappers.ts` | Database rows to API shapes |
| `names.ts` | Seeded ship and pilot names |
Routes (`apps/server/src/routes`) wrap every authenticated action in `withPlayer`: lock, resolve due arrivals, run the action, then `maybeEndTutorial`. Game-rule failures are `409` with their error code; unknown things are `404`.

View File

@ -51,20 +51,3 @@
- [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/`.
- [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.
- [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).
- [x] **Step 6.4 — Tutorial dispatch.** `POST /api/dispatches` with `kind: "tutorial"` (`src/game/dispatch.ts`): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar.
- [x] **Step 6.5 — Buy a ship.** `POST /api/ships` (`src/game/fleet.ts`, `src/routes/fleet.ts`): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (`src/game/names.ts`); `409 insufficient-funds` changes nothing.
- [x] **Step 6.6 — Lease a bay.** `POST /api/bays` (`leaseBay` in `src/game/fleet.ts`): one-time lease price at a discovered location; `409 location-undiscovered`, `409 bay-already-leased`, `409 insufficient-funds`, `404 not-found`.
- [x] **Step 6.7 — End tutorial.** `maybeEndTutorial` (`src/game/tutorial.ts`) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
- [x] **Step 6.8 — Hire and assign pilots.** `POST /api/pilots`, `/api/pilots/:pilotId/assign`, `/api/pilots/:pilotId/unassign` (`src/game/pilots.ts`): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code `avatar-in-tutorial`.
- [x] **Step 6.9 — Bay contract board endpoint.** `GET /api/bays/:locationId/board` (`bayBoard` in `src/game/boards.ts`): generated from the discovered set on first request in a period and stored; accepted contracts hidden; `409 no-bay-at-location` without a bay. The expired-old-board dispatch check is tested in Step 6.10.
- [x] **Step 6.10 — Contract dispatch.** `POST /api/dispatches` with `kind: "contract"` (`dispatchContract`): sim validation, fuel deducted, contract accepted, ship and pilot in flight. One integration test per sim reason (`cannot-land` via a test-only vacuum ship in mocked content), each checking nothing changed; also covers the expired-old-board case from Step 6.9.
- [x] **Step 6.11 — Reposition dispatch.** `POST /api/dispatches` with `kind: "reposition"` (`dispatchReposition`): sim reposition validation against the player's bays, fuel only, no payout or XP; ship and pilot arrive at the destination.
- [x] **Step 6.12 — Spend skill points.** `POST /api/pilots/:pilotId/skills` (`spendSkillPoint`): +1 skill level, +1 point spent; `409 no-skill-points`, `409 skill-maxed`, `404 unknown-skill`.
- [x] **Step 6.13 — Acknowledge summaries.** `POST /api/summaries/acknowledge` (`src/game/summaries.ts`) sets `acknowledged_at` on the player's resolved, unacknowledged dispatches among the given ids and returns how many; resolved effects are untouched.
- [x] **Step 6.14 — Concurrency and money safety.** Every authenticated endpoint runs in `withPlayer` (transaction + company row lock). Race tests: two and eight simultaneous purchases, a contract dispatched twice at once, a bay leased twice at once; three of them fail when the lock is removed.

View File

@ -27,7 +27,6 @@ export const SERVER_ERROR_CODES = [
"no-skill-points",
"skill-maxed",
"unknown-skill",
"avatar-in-tutorial",
] as const;
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];