Step 14.5: port the game logic to engine commands
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 19:38:25 -04:00
parent fcc3047af8
commit 9dd557f7c0
56 changed files with 5326 additions and 5 deletions

View File

@ -1368,3 +1368,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The tab lock gates writes, not reads**: a tab that lost the game can still show what it had, but every command is refused with `GameLockedError` until it takes the game back. After taking over, a tab drops its cached save and reloads from storage, so it continues from the other tab's last save. - **The tab lock gates writes, not reads**: a tab that lost the game can still show what it had, but every command is refused with `GameLockedError` until it takes the game back. After taking over, a tab drops its cached save and reloads from storage, so it continues from the other tab's last save.
- **Where Web Locks don't exist** (native apps have one window; very old browsers) the tab simply holds the game. - **Where Web Locks don't exist** (native apps have one window; very old browsers) the tab simply holds the game.
- **The race test uses a minimal purchase command** here; 14.5 repeats it with the real `buyShip`. - **The race test uses a minimal purchase command** here; 14.5 repeats it with the real `buyShip`.
## Step 14.5
- **Same commands, same schemas, same codes**: the engine runs the route catalog's endpoints by name with their request and response schemas, and refuses with the server's error codes and messages, so the client (14.7) only swaps its transport. "No company yet" answers `unauthorized`, which the client already reads as "show the start screen".
- **Every command resolves due arrivals first**, as the server's `withPlayer` did, and a command that changes nothing (most `getPlayerState` calls) doesn't write the save.
- **Pruning needed two small save additions**: lifetime `stats` (contract and Priority deliveries) and each ship's `lastArrivalAt`. Goals and the Contracts screen read these instead of counting old dispatches, so pruning seen trips older than a week (and boards older than 24 periods, as the server's cleanup job did) never changes progress. Unseen summaries and in-flight trips are never pruned. Version 1 of the save isn't released, so it changed in place.
- **Creating a company when one exists returns the existing one** instead of a second company; one save per device (decision 0.5.89).
- **Backup-code endpoints aren't ported**: they need a server. Their tests are dropped with them; export/import replaces them in 14.10.
- **Tests were ported, not rewritten**: the helpers keep the server tests' names and shapes (`call(app, guest, "POST", "/api/ships", …)` routes the old path to its command and answers with the old status code), so most files moved with import changes and database writes turned into save edits. Tests of things that no longer exist were dropped, each listed in `progress.md`. A coverage test checks every command has at least one engine test.
- **The server stays until 14.11**, so both suites run until then.

View File

@ -80,6 +80,8 @@ The `routes` catalog is the single source for both server and client. Each entry
One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the database tables below without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the database tables below without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.
Storage (`packages/engine/src/storage.ts`): `SaveStorage` keeps the save in slot `main` and the previous three in `backup-1..3` (rotated on every save), falling back to the newest valid backup if the main save is unreadable. Backends store text per slot, all-or-nothing: `MemoryBackend` (tests), `IndexedDbBackend` (web, database `deadhead`, store `saves`), `FilesystemBackend` (native, `deadhead/<slot>.json` in the Capacitor data directory, temp file then rename). Storage (`packages/engine/src/storage.ts`): `SaveStorage` keeps the save in slot `main` and the previous three in `backup-1..3` (rotated on every save), falling back to the newest valid backup if the main save is unreadable. Backends store text per slot, all-or-nothing: `MemoryBackend` (tests), `IndexedDbBackend` (web, database `deadhead`, store `saves`), `FilesystemBackend` (native, `deadhead/<slot>.json` in the Capacitor data directory, temp file then rename).
## Database (`apps/server/src/db`) ## Database (`apps/server/src/db`)

View File

@ -345,7 +345,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
- [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported. - [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported.
- [x] **Step 14.3 — Storage layer.** `packages/engine/src/storage.ts`: `SaveStorage` (load, save with rotation, list backups, load a backup, clear) over a `SaveBackend` that stores text in slots `main`, `backup-1..3`, each write all-or-nothing. Saving moves the previous main save to backup 1 (and 1→2, 2→3), so the last three saves are kept. Loading: no main save → empty; an unreadable or invalid one → the newest backup that validates, reported as `restored-from-backup` with the problem; nothing valid → `unreadable`. Backends: `MemoryBackend`, `IndexedDbBackend` (one object store, handed an `IDBFactory`), `FilesystemBackend` (one JSON file per slot in the app data directory, written to a `.tmp` file then renamed; handed the Capacitor Filesystem plugin through a small interface). Tests (`packages/engine/test/storage.test.ts`): one suite run on all three (IndexedDB via `fake-indexeddb`, the filesystem via a fake plugin): empty, round trip, exactly three backups newest first, corrupted main save falls back past a broken backup to the next valid one, unreadable, clear; and a native write interrupted mid-file or before the rename leaves the previous save loading. - [x] **Step 14.3 — Storage layer.** `packages/engine/src/storage.ts`: `SaveStorage` (load, save with rotation, list backups, load a backup, clear) over a `SaveBackend` that stores text in slots `main`, `backup-1..3`, each write all-or-nothing. Saving moves the previous main save to backup 1 (and 1→2, 2→3), so the last three saves are kept. Loading: no main save → empty; an unreadable or invalid one → the newest backup that validates, reported as `restored-from-backup` with the problem; nothing valid → `unreadable`. Backends: `MemoryBackend`, `IndexedDbBackend` (one object store, handed an `IDBFactory`), `FilesystemBackend` (one JSON file per slot in the app data directory, written to a `.tmp` file then renamed; handed the Capacitor Filesystem plugin through a small interface). Tests (`packages/engine/test/storage.test.ts`): one suite run on all three (IndexedDB via `fake-indexeddb`, the filesystem via a fake plugin): empty, round trip, exactly three backups newest first, corrupted main save falls back past a broken backup to the next valid one, unreadable, clear; and a native write interrupted mid-file or before the rename leaves the previous save loading.
- [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7). - [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7).
- [ ] Step 14.5 — Port the game logic - [x] **Step 14.5 — Port the game logic.** `packages/engine/src/game/`: every server endpoint is an engine command with the same request and response schemas (`GameEngine.call(name, input)`: input checked against the route catalog, due arrivals resolved first as the server's `withPlayer` did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: `state` (player state, ships, pilots, goals), `company` (starting save, deal, loan, rename, goals), `boards`, `dispatch`, `fleet` (bays, ships, pilots, skills, upgrades, summaries), `resolve` (arrivals, pruning), `commands` (the table), plus the server's names and messages. Refusals are `CommandError`s with the server's codes; no company yet answers `unauthorized`, as before. Developer commands only with `devTools`; the backup-code endpoints (`getBackupCode`, `createBackupCode`, `restoreCompany`) are not ported (replaced in 14.10). Save additions: `stats` (contract and Priority deliveries, for goals) and each ship's `lastArrivalAt`, so pruning never changes progress; config `boardRetentionPeriods` (24, from the server's cleanup job) and `savedDispatchRetentionRealHours` (168). Tests: 33 server test files ported to `packages/engine/test/ported/` over `test/support.ts` (the server helpers' names and shapes, with `call` routing old endpoint paths to commands and answering with the server's status codes, and `edit`/`readSave`/`addShip`/`addPilot` in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). `test/catalog.test.ts` checks that every command has an engine test.
- [ ] Step 14.6 — Resolution loop - [ ] Step 14.6 — Resolution loop
- [ ] Step 14.7 — Switch the client to the engine - [ ] Step 14.7 — Switch the client to the engine
- [ ] Step 14.8 — Company creation on the device - [ ] Step 14.8 — Company creation on the device

View File

@ -25,6 +25,9 @@ export const gameConfig: GameConfig = {
// veteran, and the Earth bay. The loan is computed from their prices. // veteran, and the Earth bay. The loan is computed from their prices.
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
contractBoardPeriodRealMinutes: 10, contractBoardPeriodRealMinutes: 10,
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168,
// Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84). // Pay and fuel scale gently with distance past an Earth–Moon leg (decision 0.5.84).
economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 }, economicDistance: { referenceMillionKm: 0.384, exponent: 0.65 },
// A ship must be able to fly out of its destination two game days after arriving (CR-012). // A ship must be able to fly out of its destination two game days after arriving (CR-012).

View File

@ -220,6 +220,10 @@ export const gameConfigSchema = z
bayLocationId: z.string().min(1), bayLocationId: z.string().min(1),
}), }),
contractBoardPeriodRealMinutes: z.number().positive(), contractBoardPeriodRealMinutes: z.number().positive(),
/** Past board periods whose boards and accepted contracts are kept (Step 7.3). */
boardRetentionPeriods: positiveInt,
/** Resolved, seen dispatches older than this are pruned from the save (CR-014). */
savedDispatchRetentionRealHours: z.number().positive(),
/** /**
* Pay and fuel use `reference × (distance / reference)^exponent` (decision * Pay and fuel use `reference × (distance / reference)^exponent` (decision
* 0.5.84), so long legs aren't worth hundreds of short ones. * 0.5.84), so long legs aren't worth hundreds of short ones.

View File

@ -109,6 +109,8 @@ const validGameConfig = {
handlingGameHoursPerTonne: 0.02, handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }], upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],
loanRepaymentShare: 0.3, loanRepaymentShare: 0.3,
boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168,
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
}; };

View File

@ -15,6 +15,13 @@ export const cryptoRandom: RandomSource = {
seed: () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0]!, seed: () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0]!,
}; };
/** A command's new save and its result; `unchanged` skips saving. */
export interface WriteOutcome<T> {
save: SaveDocument;
result: T;
unchanged?: boolean;
}
/** Thrown when this tab doesn't hold the game (decision 0.5.90). */ /** Thrown when this tab doesn't hold the game (decision 0.5.90). */
export class GameLockedError extends Error { export class GameLockedError extends Error {
constructor() { constructor() {
@ -95,16 +102,15 @@ export class EngineCore {
* returns the new save, plus a result for the caller. * returns the new save, plus a result for the caller.
*/ */
write<T>( write<T>(
fn: ( fn: (save: SaveDocument | null, now: RealTime) => WriteOutcome<T> | Promise<WriteOutcome<T>>,
save: SaveDocument | null,
now: RealTime,
) => { save: SaveDocument; result: T } | Promise<{ save: SaveDocument; result: T }>,
): Promise<T> { ): Promise<T> {
return this.#enqueue(async () => { return this.#enqueue(async () => {
if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError(); if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError();
const current = await this.#current(); const current = await this.#current();
const now = this.now(); const now = this.now();
const out = await fn(current ? structuredClone(current) : null, now); const out = await fn(current ? structuredClone(current) : null, now);
// Nothing changed: no save (reads like getPlayerState run often).
if (out.unchanged) return out.result;
const next = saveDocumentSchema.parse({ const next = saveDocumentSchema.parse({
...out.save, ...out.save,
lastTimeSeen: Math.max(out.save.lastTimeSeen, now), lastTimeSeen: Math.max(out.save.lastTimeSeen, now),

View File

@ -0,0 +1,144 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import {
boardPeriodIndex,
boardPeriodMs,
generateContractBoard,
generateTutorialBoard,
type Contract,
type FleetMember,
type RealTime,
} from "@deadhead/sim";
import type { RandomSource } from "../core";
import { fail } from "./errors";
export interface BoardView {
contracts: Contract[];
periodEndsAt: RealTime;
}
interface BoardKey {
kind: "location" | "tutorial";
locationId: string;
periodIndex: number;
/** Tutorial boards: runs completed (CR-001). Location boards: 0. */
tutorialRun?: number;
}
/**
* The stored board for (kind, location, period[, run]), generated and stored
* on the first request (decisions 0.5.5 and 0.5.21), so later changes in the
* same period never change it.
*/
export function getOrCreateBoard(
save: SaveDocument,
key: BoardKey,
generate: () => Contract[],
random: RandomSource,
now: RealTime,
): Contract[] {
const run = key.tutorialRun ?? 0;
const existing = save.contractBoards.find(
(b) =>
b.kind === key.kind &&
b.locationId === key.locationId &&
b.periodIndex === key.periodIndex &&
b.tutorialRun === run,
);
if (existing) return existing.contracts;
const contracts = generate();
save.contractBoards.push({
id: random.id(),
kind: key.kind,
locationId: key.locationId,
periodIndex: key.periodIndex,
tutorialRun: run,
contracts,
createdAt: now,
});
return contracts;
}
export function acceptedContractIds(save: SaveDocument): Set<string> {
return new Set(save.acceptedContracts.map((a) => a.contractId));
}
/** Board contracts minus the ones already accepted. */
function boardView(save: SaveDocument, contracts: Contract[], periodIndex: number): BoardView {
const accepted = acceptedContractIds(save);
return {
contracts: contracts.filter((c) => !accepted.has(c.id)),
periodEndsAt: (periodIndex + 1) * boardPeriodMs(content.config),
};
}
/** The tutorial board for the current run and period at the loaner's location (Steps 6.3, 10A.7). */
export function tutorialBoard(save: SaveDocument, random: RandomSource, now: RealTime): BoardView {
const { player } = save;
if (!player.tutorialActive || !player.loanerLocationId) {
throw fail("not-in-tutorial", "The tutorial is over.");
}
const loanerLocationId = player.loanerLocationId as "earth" | "moon";
const periodIndex = boardPeriodIndex(now, content.config);
const runsCompleted = player.tutorialRunsCompleted;
// One board per run (and per period), so every run gets three fresh contracts.
const contracts = getOrCreateBoard(
save,
{ kind: "tutorial", locationId: loanerLocationId, periodIndex, tutorialRun: runsCompleted },
() => generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }, content),
random,
now,
);
return boardView(save, contracts, periodIndex);
}
/**
* The player's ships with their pilots' skills and upgrades, in a stable order
* so the same fleet always gives the same board (decision 0.5.52, CR-009).
*/
export function playerFleet(save: SaveDocument): FleetMember[] {
return [...save.ships]
.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.map((ship) => ({
modelId: ship.modelId,
skills: save.pilots.find((p) => p.assignedShipId === ship.id)?.skills ?? {},
upgrades: ship.upgrades,
}));
}
/**
* Any discovered location's board for the current period (Step 6.9, CR-002):
* no bay needed, generated from the discoveries and fleet of that moment (CR-006).
*/
export function locationBoard(
save: SaveDocument,
locationId: string,
random: RandomSource,
now: RealTime,
): BoardView {
const origin = content.location(locationId);
if (!origin) throw fail("not-found", `No location "${locationId}".`);
const discovered = new Set(save.discoveredLocations.map((d) => d.locationId));
if (!discovered.has(locationId)) {
throw fail("location-undiscovered", `Deliver to ${origin.name} to open its market.`);
}
const periodIndex = boardPeriodIndex(now, content.config);
const contracts = getOrCreateBoard(
save,
{ kind: "location", locationId, periodIndex },
() =>
generateContractBoard(
{
location: origin,
discoveredIds: discovered,
periodIndex,
worldSeed: save.player.worldSeed,
fleet: playerFleet(save),
},
content,
),
random,
now,
);
return boardView(save, contracts, periodIndex);
}

View File

@ -0,0 +1,118 @@
import { content } from "@deadhead/content";
import { tutorialBoard, locationBoard } from "./boards";
import { acceptDeal, claimGoal, maxedUpgrades, renameCompany, repayLoan } from "./company";
import { dispatchContract, dispatchReposition, dispatchTutorial } from "./dispatch";
import type { CommandTable } from "./engine";
import { fail } from "./errors";
import {
acknowledge,
assignPilot,
buyShip,
hirePilot,
leaseBay,
restPilot,
spendSkillPoint,
startUpgrade,
unassignPilot,
} from "./fleet";
import { companyView, goalStates, playerState, requirePilot, requireShip } from "./state";
/** Every command that works on an existing company's save (the server's endpoints). */
export function commandTable(): CommandTable {
return {
getPlayerState: ({ save, now }) => playerState(save, now),
getTutorialBoard: ({ save, random, now }) => tutorialBoard(save, random, now),
getContractBoard: ({ save, random, now }, input) =>
locationBoard(save, input.locationId, random, now),
createDispatch: ({ save, random, now }, input) => {
switch (input.kind) {
case "contract":
return { dispatch: dispatchContract(save, input, random, now) };
case "tutorial":
return { dispatch: dispatchTutorial(save, input.contractId, random, now) };
case "reposition":
return { dispatch: dispatchReposition(save, input, random, now) };
}
},
buyShip: ({ save, random, now }, input) => ({ ship: buyShip(save, input, random, now) }),
startUpgrade: ({ save, now }, input) => ({ ship: startUpgrade(save, input, now) }),
leaseBay: ({ save, random, now }, input) => ({
bay: leaseBay(save, input.locationId, random, now),
}),
hirePilot: ({ save, random, now }, input) => ({
pilot: hirePilot(save, input.locationId, random, now),
}),
assignPilot: ({ save, now }, input) => ({ pilot: assignPilot(save, input, now) }),
unassignPilot: ({ save, now }, input) => ({ pilot: unassignPilot(save, input.pilotId, now) }),
restPilot: ({ save, now }, input) => ({ pilot: restPilot(save, input.pilotId, now) }),
spendSkillPoint: ({ save, now }, input) => ({ pilot: spendSkillPoint(save, input, now) }),
acknowledgeSummaries: ({ save, now }, input) => ({
acknowledged: acknowledge(save, input, now),
}),
acceptDeal: ({ save, random, now }) => {
acceptDeal(save, random, now);
return playerState(save, now);
},
renameCompany: ({ save }, input) => ({ company: renameCompany(save, input.name) }),
repayLoan: ({ save }, input) => ({ company: repayLoan(save, input.amount) }),
claimGoal: ({ save, now }, input) => ({
company: claimGoal(
save,
input.goalId,
goalStates(save).find((g) => g.id === input.goalId)?.status,
now,
),
}),
// Developer tools (CR-011): only when the engine has devTools on.
devAddCredits: ({ save }, input) => {
save.company.credits += input.amount;
return { company: companyView(save) };
},
devPilot: ({ save, now }, input) => {
const pilot = requirePilot(save, input.pilotId);
if (input.action === "level-up") {
const next = content.config.pilotLevelXpThresholds.find((xp) => xp > pilot.xp);
if (next !== undefined) pilot.xp = next;
} else {
Object.assign(pilot, { dutyHours: 0, restEndsAt: null });
}
return playerState(save, now);
},
devShip: ({ save, now }, input) => {
const ship = requireShip(save, input.shipId);
switch (input.action) {
case "max-upgrades":
ship.upgrades = maxedUpgrades(ship.modelId);
break;
case "reset-upgrades":
ship.upgrades = {};
break;
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;
break;
case "move":
if (ship.status !== "idle")
throw fail("ship-not-idle", "Only an idle ship can be moved.");
if (!save.discoveredLocations.some((d) => d.locationId === input.locationId)) {
throw fail("location-undiscovered", "Ships can only be moved to discovered places.");
}
ship.locationId = input.locationId;
break;
}
return playerState(save, now);
},
devDiscoverAll: ({ save, now }) => {
const known = new Set(save.discoveredLocations.map((d) => d.locationId));
for (const l of content.locations) {
if (!known.has(l.id))
save.discoveredLocations.push({ locationId: l.id, discoveredAt: now });
}
return playerState(save, now);
},
};
}

View File

@ -0,0 +1,174 @@
import { content, upgradeMaxLevel } from "@deadhead/content";
import { CURRENT_SAVE_VERSION, type Company, type SaveDocument } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import type { RandomSource } from "../core";
import { fail } from "./errors";
import { companyName, shipName } from "./names";
import { companyView, isDealAvailable } from "./state";
export const AVATAR_NAME = "Captain";
/**
* A new company's save (CR-001 tutorial state, decision 0.5.74): starting
* credits, the starting locations discovered, the avatar at Earth beside the
* loaner, and a fresh world seed. Used for "Start your company" and
* "Start a new company".
*/
export function startingSave(
random: RandomSource,
now: RealTime,
options: { saveId?: string; avoidSeed?: number } = {},
): SaveDocument {
// A 32-bit seed (decision 0.5.10); a new company never reuses the old one.
let worldSeed = random.seed();
while (worldSeed === options.avoidSeed) worldSeed = random.seed();
return {
saveVersion: CURRENT_SAVE_VERSION,
saveId: options.saveId ?? random.id(),
lastTimeSeen: now,
player: {
worldSeed,
tutorialActive: true,
loanerLocationId: "earth",
tutorialRunsCompleted: 0,
dealAccepted: false,
createdAt: now,
},
company: {
id: random.id(),
name: companyName(worldSeed),
credits: content.config.startingCredits,
loanBalance: 0,
},
ships: [],
pilots: [
{
id: random.id(),
name: AVATAR_NAME,
isAvatar: true,
isVeteran: false,
xp: 0,
skills: {},
skillPointsSpent: 0,
assignedShipId: null,
locationId: "earth",
status: "idle",
dutyHours: 0,
restEndsAt: null,
createdAt: now,
},
],
bays: [],
discoveredLocations: content.locations
.filter((l) => l.startsDiscovered)
.map((l) => ({ locationId: l.id, discoveredAt: now })),
contractBoards: [],
dispatches: [],
acceptedContracts: [],
claimedGoals: [],
stats: { contractDeliveries: 0, priorityDeliveries: 0 },
};
}
/** Renames the company (decision 0.5.75); the name arrives validated and trimmed. */
export function renameCompany(save: SaveDocument, name: string): Company {
save.company.name = name;
return companyView(save);
}
/**
* Pays the loan down by up to `amount` (decision 0.5.32): only the balance is
* taken, and paying more than the player has fails with no change.
*/
export function repayLoan(save: SaveDocument, amount: number): Company {
const payment = Math.min(amount, save.company.loanBalance);
if (payment > save.company.credits) {
throw fail("insufficient-funds", `You only have ${save.company.credits} credits.`);
}
save.company.credits -= payment;
save.company.loanBalance -= payment;
return companyView(save);
}
/**
* Accepts the deal (Steps 10A.8, 10M.3): the loaner goes back to the company;
* the player gets the Courier crewed by the avatar, the Clipper crewed by the
* veteran, the Earth bay and the loan; the tutorial ends.
*/
export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTime): void {
if (!isDealAvailable(save)) {
throw fail("deal-not-available", "There is no deal on the table right now.");
}
const { avatarShipModelId, veteranShipModelId, bayLocationId } = content.config.deal;
const owned = save.ships.length;
const [courierId, clipperId] = [random.id(), random.id()];
for (const [i, [id, modelId]] of (
[
[courierId, avatarShipModelId],
[clipperId, veteranShipModelId],
] as const
).entries()) {
save.ships.push({
id,
modelId,
name: shipName(save.player.worldSeed, owned + i),
status: "idle",
locationId: bayLocationId,
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
fromDeal: true,
createdAt: now,
lastArrivalAt: now,
});
}
save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now });
const avatar = save.pilots.find((p) => p.isAvatar)!;
Object.assign(avatar, { assignedShipId: courierId, locationId: null, status: "idle" });
// Halvorsen's veteran (decisions 0.5.70, 0.5.82), crewing his Clipper, at
// their level with their skills bought and the rest unspent.
const veteran = content.veteranPilot;
save.pilots.push({
id: random.id(),
name: veteran.name,
isAvatar: false,
isVeteran: true,
xp: veteran.level === 0 ? 0 : content.config.pilotLevelXpThresholds[veteran.level - 1]!,
skills: { ...veteran.skills },
skillPointsSpent: Object.values(veteran.skills).reduce((a, b) => a + b, 0),
assignedShipId: clipperId,
locationId: null,
status: "idle",
dutyHours: 0,
restEndsAt: null,
createdAt: now,
});
save.company.loanBalance = content.dealLoanAmount;
Object.assign(save.player, { tutorialActive: false, loanerLocationId: null, dealAccepted: true });
}
/** Claims a completed goal's reward, once. */
export function claimGoal(
save: SaveDocument,
goalId: string,
status: string | undefined,
now: RealTime,
): Company {
const goal = content.onboardingGoal(goalId);
if (!goal) throw fail("unknown-goal", `No goal "${goalId}".`);
if (status === "claimed") throw fail("goal-already-claimed", "You already claimed that reward.");
if (status !== "ready") throw fail("goal-not-ready", `Not done yet: ${goal.title}.`);
save.claimedGoals.push({ goalId, claimedAt: now });
save.company.credits += goal.reward;
return companyView(save);
}
/** Every upgrade at its cap for a model (developer tool). */
export function maxedUpgrades(modelId: string): Record<string, number> {
return Object.fromEntries(
content.upgrades
.map((u) => [u.id, upgradeMaxLevel(u, modelId)] as const)
.filter(([, level]) => level > 0),
);
}

View File

@ -0,0 +1,201 @@
import { content } from "@deadhead/content";
import type { Dispatch, SaveDocument } from "@deadhead/schema";
import {
createDispatchRecord,
createRepositionRecord,
loanerModel,
validateDispatch,
validateReposition,
type Contract,
type DispatchRecord,
type RealTime,
type ShipState,
} from "@deadhead/sim";
import type { RandomSource } from "../core";
import { acceptedContractIds } from "./boards";
import { fail } from "./errors";
import { VALIDATION_MESSAGES } from "./messages";
import {
discoveredIds,
LOANER_SHIP_ID,
pilotState,
requirePilot,
requireShip,
shipState,
toApiDispatch,
} from "./state";
/** A contract on one of the stored boards of a kind that may still be taken, or a refusal. */
function claimableContract(
save: SaveDocument,
kind: "location" | "tutorial",
contractId: string,
): { contract: Contract; periodIndex: number } {
for (const board of save.contractBoards) {
if (board.kind !== kind) continue;
const contract = board.contracts.find((c) => c.id === contractId);
if (!contract) continue;
if (acceptedContractIds(save).has(contractId)) {
throw fail("contract-already-accepted", "That contract has already been taken.");
}
return { contract, periodIndex: board.periodIndex };
}
throw fail("unknown-contract", "No such contract on your boards.");
}
/** Stores a dispatch, marks its contract accepted, and charges fuel. */
function storeDispatch(
save: SaveDocument,
args: {
record: DispatchRecord;
shipId: string | null;
periodIndex: number | null;
now: RealTime;
},
): Dispatch {
const { record, shipId, now } = args;
const dispatch: SaveDocument["dispatches"][number] = {
id: record.id,
shipId,
pilotId: record.pilotId,
tutorial: record.tutorial,
record,
arrivalAt: record.arrivalAt,
status: "in-flight",
resolution: null,
resolvedAt: null,
acknowledgedAt: null,
};
save.dispatches.push(dispatch);
if (record.contract && args.periodIndex !== null) {
save.acceptedContracts.push({
contractId: record.contract.id,
periodIndex: args.periodIndex,
dispatchId: record.id,
acceptedAt: now,
});
}
save.company.credits -= record.fuelCost;
return toApiDispatch(dispatch);
}
function setInFlight(save: SaveDocument, shipId: string, pilotId: string): void {
Object.assign(requireShip(save, shipId), { status: "in-flight", locationId: null });
Object.assign(requirePilot(save, pilotId), { status: "in-flight", locationId: null });
}
/**
* 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 function dispatchTutorial(
save: SaveDocument,
contractId: string,
random: RandomSource,
now: RealTime,
): Dispatch {
const { player } = save;
if (!player.tutorialActive || !player.loanerLocationId) {
throw fail("not-in-tutorial", "The tutorial is over.");
}
if (save.dispatches.some((d) => d.tutorial && d.status === "in-flight")) {
throw fail("tutorial-run-in-flight", "Your tutorial run is still flying.");
}
const { contract, periodIndex } = claimableContract(save, "tutorial", contractId);
const avatar = save.pilots.find((p) => p.isAvatar);
if (!avatar) throw new Error("The company has no avatar");
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: save.company.credits, now },
content,
);
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{
id: random.id(),
ship: loaner,
pilot,
contract,
tutorial: true,
now,
worldSeed: player.worldSeed,
},
content,
);
const dispatch = storeDispatch(save, { record, shipId: null, periodIndex, now });
Object.assign(avatar, { status: "in-flight", locationId: null });
return dispatch;
}
/**
* Dispatches an owned ship and its pilot on a location-board contract, from
* wherever the ship is (Steps 6.10, 10B.5): validation, fuel charged,
* contract accepted, ship and pilot in flight.
*/
export function dispatchContract(
save: SaveDocument,
input: { contractId: string; shipId: string; pilotId: string },
random: RandomSource,
now: RealTime,
): Dispatch {
const { contract, periodIndex } = claimableContract(save, "location", input.contractId);
const ship = requireShip(save, input.shipId);
const pilot = requirePilot(save, input.pilotId);
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
contract,
// The no-strand rule counts only discovered locations (decision 0.5.79).
discoveredIds: discoveredIds(save),
tutorial: false,
credits: save.company.credits,
now,
};
const check = validateDispatch(proposal, content);
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
content,
);
const dispatch = storeDispatch(save, { record, shipId: ship.id, periodIndex, now });
setInFlight(save, ship.id, pilot.id);
return dispatch;
}
/** Deadhead: a ship and its pilot fly empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
export function dispatchReposition(
save: SaveDocument,
input: { shipId: string; pilotId: string; destinationId: string },
random: RandomSource,
now: RealTime,
): Dispatch {
const ship = requireShip(save, input.shipId);
const pilot = requirePilot(save, input.pilotId);
const proposal = {
ship: shipState(ship),
pilot: pilotState(pilot),
destinationId: input.destinationId,
discoveredIds: discoveredIds(save),
credits: save.company.credits,
now,
};
const check = validateReposition(proposal, content);
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createRepositionRecord(
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
content,
);
const dispatch = storeDispatch(save, { record, shipId: ship.id, periodIndex: null, now });
setInFlight(save, ship.id, pilot.id);
return dispatch;
}

View File

@ -0,0 +1,155 @@
import { content } from "@deadhead/content";
import {
routes,
type RouteName,
type RouteRequest,
type RouteResponse,
type SaveDocument,
} from "@deadhead/schema";
import { realToGame, type RealTime } from "@deadhead/sim";
import type { z } from "zod";
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
import { commandTable } from "./commands";
import { startingSave } from "./company";
import { CommandError, fail } from "./errors";
import { pruneSave, resolveArrivals } from "./resolve";
import { playerState } from "./state";
/** The commands the engine runs: the server's route catalog, minus what needs a server. */
export const SERVER_ONLY_ROUTES = ["getBackupCode", "createBackupCode", "restoreCompany"] as const;
export type CommandName = Exclude<RouteName, (typeof SERVER_ONLY_ROUTES)[number]>;
export const DEV_COMMANDS: readonly CommandName[] = [
"devAdvanceClock",
"devAddCredits",
"devPilot",
"devShip",
"devDiscoverAll",
];
/** What a command gets: a copy of the save (with due arrivals already applied). */
export interface CommandContext {
save: SaveDocument;
now: RealTime;
random: RandomSource;
}
export type CommandHandler<N extends CommandName> = (
ctx: CommandContext,
input: z.output<(typeof routes)[N]["request"]>,
) => RouteResponse<N>;
/** Commands that run before a company exists, or touch the clock rather than the save. */
type SpecialCommand = "getServerTime" | "createGuest" | "newCompany" | "devAdvanceClock";
export type CommandTable = { [N in Exclude<CommandName, SpecialCommand>]: CommandHandler<N> };
export interface GameEngineOptions extends CoreDeps {
clock: CoreDeps["clock"] & { advance?(ms: number): void; offsetMs?: number };
/** Developer commands (non-production builds only, CR-011). */
devTools?: boolean;
/** Check every response against its schema (tests and development). Default true. */
validateResponses?: boolean;
/** Replaces command handlers (tests only). */
overrides?: Partial<CommandTable>;
}
function describeIssues(error: z.ZodError): string {
return error.issues
.map((issue) => (issue.path.length ? `${issue.path.join(".")}: ` : "") + issue.message)
.join("; ");
}
const noCompany = () => fail("unauthorized", "There is no company on this device yet.");
/**
* The game on the device (CR-014): every former server endpoint as a command
* with the same request and response schemas. Each command first applies any
* arrivals that are due (as the server's lazy resolution did), runs on a copy
* of the save through the single-writer queue, and is saved only if it changed
* something.
*/
export class GameEngine {
readonly core: EngineCore;
readonly #table: CommandTable;
constructor(private readonly options: GameEngineOptions) {
this.core = new EngineCore(options);
this.#table = { ...commandTable(), ...options.overrides } as CommandTable;
}
now(): RealTime {
return this.core.now();
}
/** Runs one command; refusals throw `CommandError` with the server's codes. */
async call<N extends CommandName>(name: N, input: RouteRequest<N>): Promise<RouteResponse<N>> {
const route = routes[name];
if (!route || (SERVER_ONLY_ROUTES as readonly string[]).includes(name)) {
throw fail("not-found", `No command "${name}".`);
}
if (DEV_COMMANDS.includes(name) && !this.options.devTools) {
throw fail("not-found", `No command "${name}".`);
}
const parsed = route.request.safeParse(input);
if (!parsed.success) throw fail("validation-error", describeIssues(parsed.error));
const result = await this.#run(name, parsed.data);
if (this.options.validateResponses ?? true) {
const checked = route.response.safeParse(result);
if (!checked.success) {
throw new Error(
`Response for ${name} violates its schema: ${describeIssues(checked.error)}`,
);
}
}
return result as RouteResponse<N>;
}
async #run(name: CommandName, input: unknown): Promise<unknown> {
const { random } = this.core;
switch (name) {
case "getServerTime": {
const realTime = this.core.now();
return { realTime, gameTime: realToGame(realTime, content.config) };
}
case "devAdvanceClock": {
const clock = this.options.clock;
if (!clock.advance) throw fail("not-found", "This clock can't be moved.");
clock.advance((input as { advanceMs: number }).advanceMs);
return { offsetMs: clock.offsetMs ?? 0, realTime: this.core.now() };
}
case "createGuest":
// "Start your company": a new save when there is none; otherwise the existing one.
return this.core.write((save, now) => {
const next = save ?? startingSave(random, now);
resolveArrivals(next, now);
return {
save: next,
result: { playerId: next.saveId, state: playerState(next, now) },
unchanged: save !== null,
};
});
case "newCompany":
// "Start a new company" (decision 0.5.74): the starting state with a new world seed.
return this.core.write((save, now) => {
if (!save) throw noCompany();
const next = startingSave(random, now, {
saveId: save.saveId,
avoidSeed: save.player.worldSeed,
});
return { save: next, result: playerState(next, now) };
});
default: {
const handler = this.#table[name] as (ctx: CommandContext, input: unknown) => unknown;
return this.core.write((save, now) => {
if (!save) throw noCompany();
const before = JSON.stringify(save);
resolveArrivals(save, now);
const result = handler({ save, now, random }, input);
pruneSave(save, now);
return { save, result, unchanged: JSON.stringify(save) === before };
});
}
}
}
}
export { CommandError };

View File

@ -0,0 +1,25 @@
import type { ErrorCode } from "@deadhead/schema";
/**
* A refused command (CR-014): the same codes and messages the server sent, so
* the client and the ported tests read them as before.
*/
export class CommandError extends Error {
constructor(
readonly code: ErrorCode,
message: string,
) {
super(message);
this.name = "CommandError";
}
}
export const fail = (code: ErrorCode, message: string) => new CommandError(code, message);
/** Codes the server answered with 404 rather than 409 (for HTTP-shaped tests and the client). */
export const NOT_FOUND_CODES: ReadonlySet<ErrorCode> = new Set([
"not-found",
"unknown-contract",
"unknown-skill",
"unknown-goal",
]);

View File

@ -0,0 +1,255 @@
import { content } from "@deadhead/content";
import type { Bay, Pilot, SaveDocument, Ship } from "@deadhead/schema";
import {
isResting,
pilotLevel,
restEndsAt,
skillPointsAvailable,
validateUpgrade,
type RealTime,
} from "@deadhead/sim";
import type { RandomSource } from "../core";
import { fail } from "./errors";
import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
import { pilotName, shipName } from "./names";
import {
hasBayAt,
requirePilot,
requireShip,
requireTutorialOver,
shipState,
spend,
toApiPilot,
toApiShip,
} from "./state";
/** Leases a bay at a discovered location for its one-time price (Step 6.6). */
export function leaseBay(
save: SaveDocument,
locationId: string,
random: RandomSource,
now: RealTime,
): Bay {
requireTutorialOver(save, "leasing a bay");
const location = content.location(locationId);
if (!location) throw fail("not-found", `No location "${locationId}".`);
if (!save.discoveredLocations.some((d) => d.locationId === locationId)) {
throw fail("location-undiscovered", `Deliver to ${location.name} before leasing a bay there.`);
}
if (hasBayAt(save, locationId)) {
throw fail("bay-already-leased", `You already have a bay at ${location.name}.`);
}
spend(save, location.bayLeasePrice, `a bay at ${location.name}`);
const bay = { id: random.id(), locationId, createdAt: now };
save.bays.push(bay);
return { id: bay.id, locationId };
}
/** Buys a ship model, delivered idle and uncrewed to one of the bays (Steps 6.5, 10B.6; decision 0.5.35). */
export function buyShip(
save: SaveDocument,
input: { modelId: string; bayId: string },
random: RandomSource,
now: RealTime,
): Ship {
requireTutorialOver(save, "buying a ship");
const model = content.shipModel(input.modelId);
if (!model?.purchasable) throw fail("not-found", `No ship model "${input.modelId}" for sale.`);
const bay = save.bays.find((b) => b.id === input.bayId);
if (!bay) throw fail("not-found", "You have no such bay.");
spend(save, model.price, `a ${model.name}`);
const ship: SaveDocument["ships"][number] = {
id: random.id(),
modelId: model.id,
name: shipName(save.player.worldSeed, save.ships.length),
status: "idle",
locationId: bay.locationId,
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
fromDeal: false,
createdAt: now,
lastArrivalAt: now,
};
save.ships.push(ship);
return toApiShip(save, ship, now);
}
/** Hires a level-0 pilot at one of the bays (Step 6.8, decision 0.5.22). */
export function hirePilot(
save: SaveDocument,
locationId: string,
random: RandomSource,
now: RealTime,
): Pilot {
requireTutorialOver(save, "hiring pilots");
if (!hasBayAt(save, locationId)) {
throw fail("no-bay-at-location", "Pilots can only be hired at one of your bays.");
}
spend(save, content.config.pilotHireCost, "a new pilot");
const hired = save.pilots.filter((p) => !p.isAvatar).length;
const pilot: SaveDocument["pilots"][number] = {
id: random.id(),
name: pilotName(save.player.worldSeed, hired),
isAvatar: false,
isVeteran: false,
xp: 0,
skills: {},
skillPointsSpent: 0,
assignedShipId: null,
locationId,
status: "idle",
dutyHours: 0,
restEndsAt: null,
createdAt: now,
};
save.pilots.push(pilot);
return toApiPilot(save, pilot, now);
}
/**
* Assigns an idle, unassigned pilot to an idle, uncrewed ship at the same
* location. The avatar flies the loaner during the tutorial (decision 0.5.23).
*/
export function assignPilot(
save: SaveDocument,
input: { pilotId: string; shipId: string },
now: RealTime,
): Pilot {
const pilot = requirePilot(save, input.pilotId);
const ship = requireShip(save, input.shipId);
if (pilot.isAvatar && save.player.tutorialActive) {
throw fail("avatar-in-tutorial", "You fly the company loaner until the tutorial ends.");
}
if (pilot.status === "in-flight")
throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
if (pilot.assignedShipId !== null) {
throw fail("pilot-already-assigned", "That pilot already has a ship. Unassign them first.");
}
if (ship.status === "in-flight")
throw fail("ship-not-idle", VALIDATION_MESSAGES["ship-not-idle"]);
if (ship.locationId !== pilot.locationId) {
throw fail("ship-not-idle-here", "The pilot and the ship must be at the same location.");
}
if (save.pilots.some((p) => p.assignedShipId === ship.id)) {
throw fail("ship-crewed", "That ship already has a pilot.");
}
Object.assign(pilot, { assignedShipId: ship.id, locationId: null });
return toApiPilot(save, pilot, now);
}
/** Unassigns a pilot from its ship, only where the player has a bay (decision 0.5.2). */
export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime): Pilot {
const pilot = requirePilot(save, pilotId);
if (pilot.assignedShipId === null)
throw fail("pilot-not-assigned-to-ship", "That pilot has no ship.");
if (pilot.status === "in-flight")
throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
const ship = requireShip(save, pilot.assignedShipId);
if (ship.locationId === null || !hasBayAt(save, ship.locationId)) {
throw fail("no-bay-at-location", "Pilots can only leave their ship at one of your bays.");
}
Object.assign(pilot, { assignedShipId: null, locationId: ship.locationId });
return toApiPilot(save, pilot, now);
}
/** Rests an idle pilot now, at any duty level (CR-004): duty resets and they can't fly until it ends. */
export function restPilot(save: SaveDocument, pilotId: string, now: RealTime): Pilot {
requireTutorialOver(save, "resting a pilot");
const pilot = requirePilot(save, pilotId);
if (pilot.status === "in-flight")
throw fail("pilot-not-idle", VALIDATION_MESSAGES["pilot-not-idle"]);
if (isResting(pilot.restEndsAt, now))
throw fail("pilot-resting", "That pilot is already resting.");
Object.assign(pilot, { dutyHours: 0, restEndsAt: restEndsAt(now, content.config) });
return toApiPilot(save, pilot, now);
}
/** Spends one available skill point on a skill below its max level (Step 6.12). */
export function spendSkillPoint(
save: SaveDocument,
input: { pilotId: string; skillId: string },
now: RealTime,
): Pilot {
const pilot = requirePilot(save, input.pilotId);
const skill = content.skill(input.skillId);
if (!skill) throw fail("unknown-skill", `No skill "${input.skillId}".`);
const level = pilotLevel(pilot.xp, content.config);
if (skillPointsAvailable(level, pilot.skillPointsSpent, content.config) <= 0) {
throw fail("no-skill-points", "That pilot has no skill points to spend.");
}
const current = pilot.skills[skill.id] ?? 0;
if (current >= skill.maxLevel)
throw fail("skill-maxed", `${skill.name} is already at its maximum level.`);
pilot.skills = { ...pilot.skills, [skill.id]: current + 1 };
pilot.skillPointsSpent += 1;
return toApiPilot(save, pilot, now);
}
/**
* Starts the next level of an upgrade on a ship (CR-009): validate, charge,
* raise the level now, and keep the ship in the shop until the work is done.
*/
export function startUpgrade(
save: SaveDocument,
input: { shipId: string; upgradeId: string },
now: RealTime,
): Ship {
const upgrade = content.upgrade(input.upgradeId);
if (!upgrade) throw fail("not-found", `No upgrade "${input.upgradeId}".`);
const ship = requireShip(save, input.shipId);
const check = validateUpgrade(
{
ship: shipState(ship),
upgrade,
atHq: ship.locationId !== null && hasBayAt(save, ship.locationId),
tutorialActive: save.player.tutorialActive,
credits: save.company.credits,
now,
},
content,
);
if (!check.ok) throw fail(check.reason, UPGRADE_MESSAGES[check.reason]);
save.company.credits -= check.cost;
ship.upgrades = { ...ship.upgrades, [upgrade.id]: check.level };
Object.assign(ship, {
inShopUntil: check.shopUntil,
shopUpgradeId: upgrade.id,
shopLevel: check.level,
});
return toApiShip(save, ship, now);
}
/**
* Marks "while you were away" summaries as seen (Step 6.13): only resolved,
* unseen dispatches. Clears finished upgrade jobs the player has now seen
* (CR-009); ships still in the shop keep theirs. Returns how many changed.
*/
export function acknowledge(
save: SaveDocument,
input: { dispatchIds: string[]; shipIds?: string[] },
now: RealTime,
): number {
const ids = new Set(input.dispatchIds);
let count = 0;
for (const d of save.dispatches) {
if (ids.has(d.id) && d.status === "resolved" && d.acknowledgedAt === null) {
d.acknowledgedAt = now;
count++;
}
}
const shipIds = new Set(input.shipIds ?? []);
for (const ship of save.ships) {
if (
shipIds.has(ship.id) &&
ship.shopUpgradeId !== null &&
ship.inShopUntil !== null &&
ship.inShopUntil <= now
) {
Object.assign(ship, { shopUpgradeId: null, shopLevel: null });
count++;
}
}
return count;
}

View File

@ -0,0 +1,34 @@
import type { DispatchFailure, RepositionFailure, UpgradeFailure } 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.",
"ship-in-shop": "That ship is in the upgrade shop until its work is done.",
"pilot-resting": "That pilot is resting and cannot fly until their rest ends.",
"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.",
"would-strand":
"That ship couldn't fly anywhere else from the destination, so it would be stranded there.",
"too-slow": "That ship can't make this Priority delivery's window.",
"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.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};
/** Player-facing text for each upgrade refusal (CR-009). */
export const UPGRADE_MESSAGES: Record<UpgradeFailure, string> = {
"tutorial-active": "Finish the company runs before upgrading ships.",
"not-upgradable": "The company loaner can't be upgraded.",
"ship-not-idle": "That ship is flying; upgrade it once it lands at an HQ.",
"ship-in-shop": "That ship is already in the shop.",
"not-at-hq": "Upgrades are installed at an HQ; bring the ship to one of your bays.",
"upgrade-maxed": "That upgrade is already at its highest level.",
"insufficient-funds": "Not enough credits for that upgrade.",
};

View File

@ -0,0 +1,117 @@
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)}`;
}
const COMPANY_FIRST = [
"Halcyon",
"Meridian",
"Lodestar",
"Northstar",
"Bluewater",
"Redline",
"Ironclad",
"Kestrel",
"Copperhead",
"Longhaul",
"Brightwater",
"Stonebridge",
];
const COMPANY_LAST = [
"Freight",
"Haulage",
"Cargo",
"Logistics",
"Shipping",
"Transit",
"Lines",
"Carriers",
];
/** A company's default name, stable for its world seed (decision 0.5.75). */
export function companyName(worldSeed: Seed): string {
const rng = createRng(deriveSeed(worldSeed, "company-name"));
return `${rng.pick(COMPANY_FIRST)} ${rng.pick(COMPANY_LAST)}`;
}

View File

@ -0,0 +1,94 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { boardPeriodIndex, resolveDispatch, type RealTime } from "@deadhead/sim";
import { discoveredIds } from "./state";
/**
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
* order (Steps 6.1, 7.2): credits (net of pilot pay and loan repayment), XP,
* duty and rest, ship and pilot locations, tutorial progress and discovery.
* A resolved dispatch is never resolved again, so running this twice in a
* row changes nothing the second time. Returns how many were resolved.
*/
export function resolveArrivals(save: SaveDocument, now: RealTime): number {
const due = save.dispatches
.filter((d) => d.status === "in-flight" && d.arrivalAt <= now)
.sort((a, b) => a.arrivalAt - b.arrivalAt);
if (due.length === 0) return 0;
const discovered = discoveredIds(save);
let resolved = 0;
for (const dispatch of due) {
const pilot = save.pilots.find((p) => p.id === dispatch.pilotId);
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const result = resolveDispatch(
{
record: dispatch.record,
pilotIsAvatar: pilot.isAvatar,
discoveredIds: discovered,
// Each arrival repays against the balance left by the previous one.
loanBalance: save.company.loanBalance,
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
},
content,
);
if (result.status !== "resolved") continue;
const r = result.resolution;
resolved++;
Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now });
save.company.credits += r.credits;
save.company.loanBalance -= r.loanRepayment;
pilot.xp += r.pilotXp;
pilot.status = "idle";
// Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45).
pilot.dutyHours = r.pilotDutyHours;
if (r.pilotRestEndsAt !== null) pilot.restEndsAt = r.pilotRestEndsAt;
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId;
const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined;
if (ship) {
ship.status = "idle";
ship.locationId = r.shipLocationId;
ship.lastArrivalAt = Math.max(ship.lastArrivalAt, dispatch.arrivalAt);
}
if (dispatch.tutorial) {
save.player.loanerLocationId = r.shipLocationId;
save.player.tutorialRunsCompleted++;
} else if (dispatch.record.contract) {
save.stats.contractDeliveries++;
if (dispatch.record.contract.priority) save.stats.priorityDeliveries++;
}
if (r.discoversDestination && !discovered.has(dispatch.record.destinationId)) {
save.discoveredLocations.push({
locationId: dispatch.record.destinationId,
discoveredAt: now,
});
discovered.add(dispatch.record.destinationId);
}
}
return resolved;
}
/**
* Keeps the save small (CR-014 §2, Step 7.3): drops boards and accepted
* contracts older than the board retention, and resolved dispatches the player
* has seen that are older than the dispatch retention. In-flight dispatches,
* unseen summaries and the goal counters are never touched.
*/
export function pruneSave(save: SaveDocument, now: RealTime): void {
const cutoffPeriod = boardPeriodIndex(now, content.config) - content.config.boardRetentionPeriods;
save.contractBoards = save.contractBoards.filter((b) => b.periodIndex >= cutoffPeriod);
save.acceptedContracts = save.acceptedContracts.filter((a) => a.periodIndex >= cutoffPeriod);
const cutoff = now - content.config.savedDispatchRetentionRealHours * 3_600_000;
save.dispatches = save.dispatches.filter(
(d) =>
d.status === "in-flight" ||
d.acknowledgedAt === null ||
(d.resolvedAt ?? d.arrivalAt) >= cutoff,
);
}

View File

@ -0,0 +1,250 @@
import { content } from "@deadhead/content";
import type {
Company,
Dispatch,
GoalState,
Pilot,
PlayerState,
SaveDocument,
Ship,
} from "@deadhead/schema";
import {
completedGoals,
dutyLimit,
effectiveStats,
isInShop,
isResting,
pilotLevel,
skillPointsAvailable,
type GoalProgress,
type PilotState,
type RealTime,
type ShipState,
} from "@deadhead/sim";
import { fail } from "./errors";
type SavedShip = SaveDocument["ships"][number];
type SavedPilot = SaveDocument["pilots"][number];
type SavedDispatch = SaveDocument["dispatches"][number];
/** The id the tutorial loaner uses in dispatch records (it is not an owned ship). */
export const LOANER_SHIP_ID = "loaner";
const START_LOCATION_IDS = new Set(
content.locations.filter((l) => l.startsDiscovered).map((l) => l.id),
);
export function companyView(save: SaveDocument): Company {
const { id, name, credits, loanBalance } = save.company;
return { id, name, credits, loanBalance };
}
export function requireShip(save: SaveDocument, shipId: string): SavedShip {
const ship = save.ships.find((s) => s.id === shipId);
if (!ship) throw fail("not-found", "No such ship.");
return ship;
}
export function requirePilot(save: SaveDocument, pilotId: string): SavedPilot {
const pilot = save.pilots.find((p) => p.id === pilotId);
if (!pilot) throw fail("not-found", "No such pilot.");
return pilot;
}
export function hasBayAt(save: SaveDocument, locationId: string): boolean {
return save.bays.some((b) => b.locationId === locationId);
}
export function discoveredIds(save: SaveDocument): Set<string> {
return new Set(save.discoveredLocations.map((d) => d.locationId));
}
/** Purchases are blocked while the tutorial runs (decision 0.5.16, CR-001). */
export function requireTutorialOver(save: SaveDocument, what: string): void {
if (save.player.tutorialActive) {
throw fail("tutorial-active", `Finish the company runs before ${what}.`);
}
}
/** Deducts credits, or refuses with `insufficient-funds` without changing anything. */
export function spend(save: SaveDocument, amount: number, what: string): void {
if (save.company.credits < amount) {
throw fail("insufficient-funds", `Not enough credits for ${what} (${amount} needed).`);
}
save.company.credits -= amount;
}
export function shipState(ship: SavedShip): ShipState {
return {
id: ship.id,
modelId: ship.modelId,
// An in-flight ship has no location; validation reports it as not idle first.
locationId: ship.locationId ?? "",
inFlight: ship.status === "in-flight",
upgrades: ship.upgrades,
inShopUntil: ship.inShopUntil,
};
}
export function pilotState(pilot: SavedPilot): PilotState {
return {
id: pilot.id,
isAvatar: pilot.isAvatar,
assignedShipId: pilot.assignedShipId,
inFlight: pilot.status === "in-flight",
skills: pilot.skills,
restEndsAt: pilot.restEndsAt,
};
}
export function toApiDispatch(dispatch: SavedDispatch): Dispatch {
return { ...dispatch.record, status: dispatch.status, resolvedAt: dispatch.resolvedAt };
}
/** An assigned pilot is wherever its ship is; an unassigned one stores its own location. */
export function toApiPilot(save: SaveDocument, pilot: SavedPilot, now: RealTime): Pilot {
const resting = isResting(pilot.restEndsAt, now);
const level = pilotLevel(pilot.xp, content.config);
const shipLocation = pilot.assignedShipId
? (save.ships.find((s) => s.id === pilot.assignedShipId)?.locationId ?? null)
: null;
return {
id: pilot.id,
name: pilot.name,
isAvatar: pilot.isAvatar,
isVeteran: pilot.isVeteran,
xp: pilot.xp,
level,
skills: pilot.skills,
skillPointsAvailable: skillPointsAvailable(level, pilot.skillPointsSpent, content.config),
assignedShipId: pilot.assignedShipId,
status: pilot.status,
locationId:
pilot.status === "in-flight" ? null : pilot.assignedShipId ? shipLocation : pilot.locationId,
dutyHours: pilot.dutyHours,
dutyLimit: dutyLimit(pilot.skills, content.config),
resting,
restEndsAt: resting ? pilot.restEndsAt : null,
};
}
/** A ship with its upgrades and effective stats (CR-009). */
export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): Ship {
const model = content.shipModel(ship.modelId);
if (!model) throw new Error(`Ship ${ship.id} has unknown model ${ship.modelId}`);
const stats = effectiveStats(model, ship.upgrades, content);
const active = save.dispatches.find((d) => d.shipId === ship.id && d.status === "in-flight");
return {
id: ship.id,
modelId: ship.modelId,
name: ship.name,
status: ship.status,
locationId: ship.status === "in-flight" ? null : ship.locationId,
dispatchId: active?.id ?? null,
pilotId: save.pilots.find((p) => p.assignedShipId === ship.id)?.id ?? null,
lastArrivalAt: ship.lastArrivalAt,
upgrades: ship.upgrades,
stats: {
cruiseSpeedKmPerGameHour: stats.cruiseSpeedKmPerGameHour,
cargoCapacityTonnes: stats.cargoCapacityTonnes,
fuelRangeMillionKm: stats.fuelRangeMillionKm,
overheadGameHours: stats.overheadGameHours,
handlingGameHoursPerTonne: stats.handlingGameHoursPerTonne,
},
inShopUntil: isInShop(ship, now) ? ship.inShopUntil : null,
shop:
ship.shopUpgradeId && ship.shopLevel !== null && ship.inShopUntil !== null
? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil }
: null,
};
}
/** The deal can be accepted now (decision 0.5.30). */
export function isDealAvailable(save: SaveDocument): boolean {
const { player } = save;
const runInFlight = save.dispatches.some((d) => d.tutorial && d.status === "in-flight");
return (
player.tutorialActive &&
!player.dealAccepted &&
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
player.loanerLocationId === "earth" &&
!runInFlight
);
}
/** The facts goals are checked against (Step 10A.10). */
export function goalProgress(save: SaveDocument): GoalProgress {
return {
ownShipDeliveries: save.stats.contractDeliveries,
skillPointsSpent: save.pilots.reduce((sum, p) => sum + p.skillPointsSpent, 0),
shipsOwned: save.ships.length,
marketShipsBought: save.ships.filter((s) => !s.fromDeal).length,
pilotsHired: save.pilots.filter((p) => !p.isAvatar).length,
priorityDeliveries: save.stats.priorityDeliveries,
locationsDiscovered: save.discoveredLocations.filter(
(d) => !START_LOCATION_IDS.has(d.locationId),
).length,
dealAccepted: save.player.dealAccepted,
loanBalance: save.company.loanBalance,
};
}
/** Every goal's status in content order; none while the tutorial runs (decision 0.5.34). */
export function goalStates(save: SaveDocument): GoalState[] {
if (save.player.tutorialActive) return [];
const complete = new Set(completedGoals(content.onboardingGoals, goalProgress(save)));
const claimed = new Set(save.claimedGoals.map((g) => g.goalId));
return content.onboardingGoals.map((goal) => ({
id: goal.id,
status: claimed.has(goal.id) ? "claimed" : complete.has(goal.id) ? "ready" : "incomplete",
}));
}
/** Everything the client needs (Step 8.5's state). */
export function playerState(save: SaveDocument, now: RealTime): PlayerState {
const active = save.dispatches.filter((d) => d.status === "in-flight");
const discovered = discoveredIds(save);
const tutorialRun = active.some((d) => d.tutorial);
return {
playerId: save.saveId,
company: companyView(save),
ships: save.ships.map((s) => toApiShip(save, s, now)),
pilots: save.pilots.map((p) => toApiPilot(save, p, now)),
bays: save.bays.map((b) => ({ id: b.id, locationId: b.locationId })),
locations: content.locations.map((l) => ({ id: l.id, discovered: discovered.has(l.id) })),
tutorial:
save.player.tutorialActive && save.player.loanerLocationId
? {
loanerLocationId: save.player.loanerLocationId as "earth" | "moon",
runInFlight: tutorialRun,
runsCompleted: save.player.tutorialRunsCompleted,
dealAvailable: isDealAvailable(save),
}
: null,
activeDispatches: active.map(toApiDispatch),
summaries: save.dispatches
.filter((d) => d.status === "resolved" && d.acknowledgedAt === null)
.map((d) => ({
dispatchId: d.id,
shipId: d.record.shipId,
pilotId: d.pilotId,
originId: d.record.originId,
destinationId: d.record.destinationId,
tutorial: d.tutorial,
deadhead: d.record.contract === null,
fuelCost: d.record.fuelCost,
goodId: d.record.contract?.goodId ?? null,
massTonnes: d.record.contract?.massTonnes ?? null,
shipper: d.record.contract?.shipper ?? null,
credits: d.resolution?.credits ?? 0,
loanRepayment: d.resolution?.loanRepayment ?? 0,
pilotPay: d.resolution?.pilotPay ?? 0,
pilotXp: d.resolution?.pilotXp ?? 0,
discoveredLocationId: d.resolution?.discoversDestination ? d.record.destinationId : null,
scriptedId: d.record.contract?.scriptedId ?? null,
restEndsAt: d.resolution?.pilotRestEndsAt ?? null,
resolvedAt: d.resolvedAt ?? d.arrivalAt,
})),
goals: goalStates(save),
};
}

View File

@ -21,3 +21,15 @@ export {
type RandomSource, type RandomSource,
} from "./core"; } from "./core";
export { GAME_LOCK, TabLock, type LockManagerLike } from "./lock"; export { GAME_LOCK, TabLock, type LockManagerLike } from "./lock";
export { type WriteOutcome } from "./core";
export {
DEV_COMMANDS,
GameEngine,
SERVER_ONLY_ROUTES,
type CommandName,
type GameEngineOptions,
} from "./game/engine";
export { CommandError, NOT_FOUND_CODES } from "./game/errors";
export { pruneSave, resolveArrivals } from "./game/resolve";
export { playerState } from "./game/state";
export { startingSave } from "./game/company";

View File

@ -0,0 +1,39 @@
import { routes, type RouteName } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { SERVER_ONLY_ROUTES } from "../src/index";
declare global {
interface ImportMeta {
glob(
pattern: string,
options: { query: "?raw"; import: "default"; eager: true },
): Record<string, string>;
}
}
/** Every engine test file's source (Vite's glob, read at test time). */
const sources = Object.entries(
import.meta.glob("./**/*.test.ts", { query: "?raw", import: "default", eager: true }),
).filter(([path]) => !path.endsWith("catalog.test.ts"));
/** A test uses a command if it names it, or calls its old endpoint path. */
function usedBy(name: RouteName, source: string): boolean {
if (new RegExp(`["'\`]${name}["'\`]`).test(source)) return true;
const pieces = routes[name].path.split(/:\w+/).filter((p) => p.length > 1);
return pieces.every((piece) => source.includes(piece)) && source.includes(routes[name].method);
}
describe("command catalog coverage (Step 14.5)", () => {
const commands = (Object.keys(routes) as RouteName[]).filter(
(name) => !(SERVER_ONLY_ROUTES as readonly string[]).includes(name),
);
it("finds the engine test files", () => {
expect(sources.length).toBeGreaterThan(20);
});
it.each(commands)("%s has at least one engine test", (name) => {
const files = sources.filter(([, source]) => usedBy(name, source)).map(([path]) => path);
expect(files, `no engine test uses ${name}`).not.toEqual([]);
});
});

View File

@ -0,0 +1,18 @@
// Ported from apps/server/test (CR-014 Step 14.5): the database's one-board-per-key
// constraint is now the engine storing one board per location and period.
import { describe, expect, it } from "vitest";
import { useTestApp, call, readSave, signUpOwner } from "../support";
const testApp = useTestApp();
describe("contract boards keyed by location (CR-002)", () => {
it("keeps one stored board per location and period", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await call(app, owner, "GET", "/api/locations/earth/board");
await call(app, owner, "GET", "/api/locations/moon/board");
await call(app, owner, "GET", "/api/locations/moon/board");
const boards = (await readSave(app)).contractBoards;
expect(boards.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
});
});

View File

@ -0,0 +1,148 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { shipName } from "../../src/game/names";
import {
TEST_NOW,
useTestApp,
signUp,
call,
getState,
edit,
setCredits,
signUpOwner,
} from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("buy a ship", () => {
it("deducts the price and adds an idle, uncrewed ship at the Earth bay", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const price = content.shipModel("courier")!.price;
await setCredits(testApp().app, price + 500);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
});
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, owner);
expect(state.company.credits).toBe(500);
expect(state.ships.map((s) => s.id)).toEqual([owner.shipId, ship.id]);
});
it("returns insufficient-funds and changes nothing without enough credits", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const price = content.shipModel("freighter")!.price;
await setCredits(testApp().app, price - 1);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("insufficient-funds");
const state = await getState(app, owner);
expect(state.company.credits).toBe(price - 1);
expect(state.ships).toHaveLength(1);
});
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, guest, "POST", "/api/ships", {
modelId: "courier",
bayId: "any",
});
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest);
expect(state.company.credits).toBe(1_000_000);
expect(state.ships).toHaveLength(0);
});
it("refuses to sell the company loaner", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "company-courier",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404);
});
it("rejects an unknown model", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "x-wing",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(404);
expect(errorCode(response)).toBe("not-found");
});
});
describe("ship delivery to a bay (CR-002)", () => {
it("delivers a Freighter bought for the Moon bay to the Moon", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const moonBayId = randomUUID();
await edit(app, (save) => {
save.bays.push({ id: moonBayId, locationId: "moon", createdAt: TEST_NOW });
});
await setCredits(testApp().app, content.shipModel("freighter")!.price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: moonBayId,
});
expect(response.statusCode).toBe(200);
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
locationId: "moon",
status: "idle",
pilotId: null,
});
});
it("rejects a purchase with no bay id as a validation error", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, owner, "POST", "/api/ships", { modelId: "courier" });
expect(response.statusCode).toBe(400);
expect(errorCode(response)).toBe("validation-error");
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
});
// "Refuses another player's bay" no longer applies: one company per save (CR-014).
});
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

@ -0,0 +1,89 @@
// Ported from apps/server/test (CR-014 Step 14.5): the hourly cleanup job is
// now pruning on every save (Step 7.3, CR-014 §2).
import { content } from "@deadhead/content";
import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { pruneSave } from "../../src/index";
import {
TEST_NOW,
useTestApp,
signUp,
call,
getState,
edit,
readSave,
testContract,
insertDispatch,
putBoard,
makeOwner,
} from "../support";
const testApp = useTestApp();
const RETENTION = content.config.boardRetentionPeriods;
const HOUR = 3_600_000;
describe("pruning old boards and trips on save", () => {
it("drops old boards and accepted contracts and keeps current ones", async () => {
const { app } = testApp();
const guest = await signUp(app);
const { shipId } = await makeOwner(app, guest.avatarId);
const period = boardPeriodMs(content.config);
const current = boardPeriodIndex(TEST_NOW, content.config);
const old = current - RETENTION - 1;
await putBoard(app, [testContract()], TEST_NOW - (current - old) * period);
await putBoard(app, [testContract()], TEST_NOW);
const record = await insertDispatch(app, {
shipId,
pilotId: guest.avatarId,
departAt: TEST_NOW,
});
await edit(app, (save) => {
save.acceptedContracts.push(
{ contractId: "old", periodIndex: old, dispatchId: record.id, acceptedAt: TEST_NOW },
{ contractId: "new", periodIndex: current, dispatchId: record.id, acceptedAt: TEST_NOW },
);
});
const save = await readSave(app);
pruneSave(save, TEST_NOW);
expect(save.contractBoards.map((b) => b.periodIndex)).toEqual([current]);
expect(save.acceptedContracts.map((a) => a.contractId)).toEqual(["new"]);
});
it("keeps boards inside the retention window", async () => {
const { app } = testApp();
await signUp(app);
await putBoard(app, [testContract()], TEST_NOW - RETENTION * boardPeriodMs(content.config));
const save = await readSave(app);
pruneSave(save, TEST_NOW);
expect(save.contractBoards).toHaveLength(1);
});
it("drops seen trips after the retention, keeps unseen ones and in-flight ones, and keeps goal progress", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const { shipId } = await makeOwner(app, guest.avatarId);
const record = await insertDispatch(app, {
shipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
const landed = await getState(app);
expect(landed.summaries).toHaveLength(1);
const progressBefore = landed.goals;
// Unseen: kept however old.
clock.now += content.config.savedDispatchRetentionRealHours * HOUR + HOUR;
await getState(app);
expect((await readSave(app)).dispatches).toHaveLength(1);
// Seen and old: gone on the next save, with goal progress unchanged.
await call(app, guest, "POST", "/api/summaries/acknowledge", { dispatchIds: [record.id] });
clock.now += content.config.savedDispatchRetentionRealHours * HOUR + HOUR;
await call(app, guest, "POST", "/api/test/add-credits", { amount: 1 });
expect((await readSave(app)).dispatches).toEqual([]);
expect((await readSave(app)).stats.contractDeliveries).toBe(1);
expect((await getState(app)).goals).toEqual(progressBefore);
});
});

View File

@ -0,0 +1,45 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { companyName } from "../../src/game/names";
import { useTestApp, signUp, call, getState } from "../support";
const testApp = useTestApp();
const rename = async (name: string) => {
const guest = await signUp(testApp().app);
const before = (await getState(testApp().app, guest)).company.name;
const response = await call(testApp().app, guest, "POST", "/api/company/name", { name });
return { guest, before, response };
};
describe("company name (Step 10K.5)", () => {
it("starts with a name made from the world seed", async () => {
const guest = await signUp(testApp().app);
const state = await getState(testApp().app, guest);
expect(state.company.name.length).toBeGreaterThanOrEqual(3);
expect(companyName(42)).toBe(companyName(42));
const names = new Set(Array.from({ length: 30 }, (_, i) => companyName(i)));
expect(names.size).toBeGreaterThan(1);
});
it("saves a valid name, trimmed", async () => {
const { guest, response } = await rename(" Rosa & Sons Haulage! ");
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company.name).toBe("Rosa & Sons Haulage!");
expect((await getState(testApp().app, guest)).company.name).toBe("Rosa & Sons Haulage!");
});
it.each([
["too short", "Ab"],
["too long", "A".repeat(33)],
["blank after trimming", " "],
["disallowed characters", "Freight <script>"],
["emoji", "Space 🚀 Co"],
])("rejects a name that is %s, changing nothing", async (_why, name) => {
const { guest, before, response } = await rename(name);
expect(response.statusCode).toBe(400);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
expect((await getState(testApp().app, guest)).company.name).toBe(before);
});
});

View File

@ -0,0 +1,86 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
setCredits,
testContract,
putBoard,
makeOwner,
signUpOwner,
} from "../support";
const testApp = useTestApp();
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 signUpOwner(app);
const price = content.shipModel("courier")!.price;
await setCredits(testApp().app, price + price / 2);
const responses = await Promise.all([
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
// The owner already had one ship.
expect(state.ships).toHaveLength(2);
expect(state.company.credits).toBe(price / 2);
});
it("never goes negative under many simultaneous purchases", async () => {
const { app } = testApp();
const guest = await signUpOwner(app);
const price = content.shipModel("courier")!.price;
await setCredits(testApp().app, price * 3);
const responses = await Promise.all(
Array.from({ length: 8 }, () =>
call(app, guest, "POST", "/api/ships", { modelId: "courier", bayId: guest.earthBayId }),
),
);
expect(responses.filter((r) => r.statusCode === 200)).toHaveLength(3);
const state = await getState(app, guest);
expect(state.ships).toHaveLength(1 + 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 guest = await signUp(app);
const { shipId } = await makeOwner(app, guest.avatarId);
await setCredits(app, 10_000);
const contract = testContract();
await putBoard(app, [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 signUpOwner(app);
await setCredits(testApp().app, 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.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
expect(state.company.credits).toBe(100_000 - content.location("moon")!.bayLeasePrice);
});
});

View File

@ -0,0 +1,69 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it, vi } from "vitest";
import {
useTestApp,
signUp,
call,
readSave,
setCredits,
testContract,
putBoard,
makeOwner,
addShip,
addPilot,
} from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
// 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 testApp = useTestApp();
describe("contract dispatch landing check", () => {
it("rejects cannot-land and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await makeOwner(app, guest.avatarId);
await setCredits(app, 10_000);
// A vacuum-only ship at the Moon, crewed by a pilot, offered a Moon→Earth job.
const shipId = randomUUID();
await addShip(app, {
id: shipId,
modelId: "test-vacuum",
name: "Moon Crawler",
locationId: "moon",
});
const pilotId = randomUUID();
await addPilot(app, { id: pilotId, name: "Vac Pilot", assignedShipId: shipId });
const contract = testContract({ originId: "moon", destinationId: "earth" });
await putBoard(app, [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 readSave(app)).dispatches).toEqual([]);
});
});

View File

@ -0,0 +1,249 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import {
contractBoardResponseSchema,
dispatchResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { boardPeriodMs, contractTripGameHours } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
TEST_NOW,
useTestApp,
signUp,
call,
getState,
edit,
readSave,
setCredits,
testContract,
putBoard,
makeOwner,
addPilot,
type Guest,
} from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
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(testApp().app, guest.avatarId);
await setCredits(testApp().app, 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 save = await readSave(testApp().app);
expect(save.dispatches).toEqual([]);
expect(save.acceptedContracts).toEqual([]);
expect(save.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(testApp().app, [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/locations/earth/board")).json(),
);
expect(board.contracts).toEqual([]);
});
it("rejects ship-not-idle", async () => {
const o = await owner();
const contract = testContract();
await putBoard(testApp().app, [contract]);
await edit(testApp().app, (save) => {
save.ships.find((s) => s.id === o.shipId)!.status = "in-flight";
});
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(testApp().app, [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(testApp().app, [contract]);
await edit(testApp().app, (save) => {
save.pilots.find((p) => p.id === o.guest.avatarId)!.status = "in-flight";
});
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(testApp().app, [contract]);
const otherPilotId = randomUUID();
await addPilot(testApp().app, { id: otherPilotId, name: "Spare Pilot", locationId: "earth" });
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(testApp().app, [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(testApp().app, [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",
goodId: "machine-parts",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 2,
requiredSkill: { skillId: "heavy-cargo", level: 2 },
});
await putBoard(testApp().app, [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(testApp().app, [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(testApp().app, [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(testApp().app, [contract]);
await setCredits(testApp().app, 0);
const response = await dispatch(o, contract.id);
expect(errorCode(response)).toBe("insufficient-credits");
expect((await readSave(testApp().app)).dispatches).toEqual([]);
});
it("rejects an unknown contract and a contract taken twice", async () => {
const o = await owner();
const contract = testContract();
await putBoard(testApp().app, [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");
});
});
describe("Priority contracts (CR-007)", () => {
const courier = content.shipModel("courier")!;
/** A Priority Earth→Moon mail job whose window is the Courier's trip plus `slackHours`. */
async function priorityJob(o: Owner, id: string, slackHours: number) {
const { clock } = testApp();
const base = testContract({ id, goodId: "mail", massTonnes: 4, priority: true });
const trip = contractTripGameHours(courier, base, clock.now, content);
const contract = { ...base, deliveryWindowGameHours: trip + slackHours };
await putBoard(testApp().app, [contract], clock.now);
return contract;
}
it("refuses a ship too slow for the window with too-slow and changes nothing", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", -0.25);
await expectRejected(o, await dispatch(o, job.id), "too-slow");
});
it("dispatches a ship fast enough for the window", async () => {
const o = await owner();
const job = await priorityJob(o, "earth:1:0", 0.25);
const response = await dispatch(o, job.id);
expect(response.statusCode).toBe(200);
const { dispatch: stored } = dispatchResponseSchema.parse(response.json());
expect(stored.contract).toMatchObject({ priority: true, goodId: "mail" });
});
it("serves generated boards whose contracts all name a known good and a shipper", async () => {
const o = await owner();
const response = await call(testApp().app, o.guest, "GET", "/api/locations/earth/board");
const { contracts } = contractBoardResponseSchema.parse(response.json());
expect(contracts.length).toBeGreaterThan(0);
for (const c of contracts) {
const good = content.good(c.goodId);
expect(good?.name).toBeTruthy();
expect(good!.cargoClassId).toBe(c.cargoTypeId);
expect(content.location("earth")!.shippers).toContain(c.shipper);
}
});
});

View File

@ -0,0 +1,113 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { useTestApp, signUp, call, getState, edit, readSave, type Guest } from "../support";
const testApp = useTestApp();
/** Fast-forwards a new guest to "four runs done, loaner back at Earth". */
async function readyForDeal(_guest: Guest) {
await edit(testApp().app, (save) => {
save.player.tutorialRunsCompleted = content.config.tutorialRunsBeforeDeal;
save.player.loanerLocationId = "earth";
});
}
const accept = (guest: Guest) => call(testApp().app, guest, "POST", "/api/deal/accept");
/** Everything the deal touches, read straight from the save. */
async function snapshot(_guest: Guest) {
const save = await readSave(testApp().app);
return {
player: save.player,
company: save.company,
ships: save.ships,
bays: save.bays,
pilots: save.pilots,
};
}
describe("accepting the deal", () => {
it("hands over a crewed Courier and Clipper at Earth, an Earth bay and the loan, and ends the tutorial", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await getState(testApp().app, guest)).tutorial?.dealAvailable).toBe(true);
const response = await accept(guest);
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.tutorial).toBeNull();
// CR-013: the Courier for the avatar and the Clipper for the veteran.
expect(state.ships).toHaveLength(2);
const [ship, clipper] = state.ships;
expect(ship).toMatchObject({
modelId: "courier",
status: "idle",
locationId: "earth",
pilotId: guest.avatarId,
});
const veteran = state.pilots.find((p) => p.isVeteran)!;
expect(clipper).toMatchObject({
modelId: "clipper",
status: "idle",
locationId: "earth",
pilotId: veteran.id,
});
expect(veteran).toMatchObject({ assignedShipId: clipper!.id, locationId: "earth" });
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
assignedShipId: ship!.id,
locationId: "earth",
});
expect(state.company.loanBalance).toBe(content.dealLoanAmount);
const after = await snapshot(guest);
expect(after.player).toMatchObject({
tutorialActive: false,
dealAccepted: true,
loanerLocationId: null,
});
});
it("fails with no changes when the deal is not available", async () => {
const guest = await signUp(testApp().app);
const before = await snapshot(guest);
const response = await accept(guest);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("fails while the loaner is at the Moon", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
await edit(testApp().app, (save) => {
save.player.loanerLocationId = "moon";
});
expect((await accept(guest)).statusCode).toBe(409);
});
it("fails the second time", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
expect((await accept(guest)).statusCode).toBe(200);
const before = await snapshot(guest);
const again = await accept(guest);
expect(errorResponseSchema.parse(again.json()).error.code).toBe("deal-not-available");
expect(await snapshot(guest)).toEqual(before);
});
it("produces exactly one deal from two simultaneous accepts", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
const responses = await Promise.all([accept(guest), accept(guest)]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const after = await snapshot(guest);
expect(after.ships).toHaveLength(2);
expect(after.bays).toHaveLength(1);
expect(after.company!.loanBalance).toBe(content.dealLoanAmount);
});
});

View File

@ -0,0 +1,164 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content, upgradeMaxLevel } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema, playerStateSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
newTestApp,
useTestApp,
signUp,
call,
getState,
edit,
testContract,
insertDispatch,
signUpOwner,
} from "../support";
const testApp = useTestApp();
describe("developer tools (Step 10K.11)", () => {
it("advances the server clock", async () => {
const guest = await signUp(testApp().app);
const before = (
(await call(testApp().app, guest, "GET", "/api/time")).json() as { realTime: number }
).realTime;
const response = await call(testApp().app, guest, "POST", "/api/test/advance-clock", {
advanceMs: 600_000,
});
expect(response.statusCode).toBe(200);
const after = (
(await call(testApp().app, guest, "GET", "/api/time")).json() as { realTime: number }
).realTime;
expect(after - before).toBeGreaterThanOrEqual(600_000);
});
it("adds credits", async () => {
const guest = await signUp(testApp().app);
const start = (await getState(testApp().app, guest)).company.credits;
const response = await call(testApp().app, guest, "POST", "/api/test/add-credits", {
amount: 10_000,
});
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(start + 10_000);
});
it("lands every flight when advanced to the last arrival", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
const flight = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(1);
await call(app, owner, "POST", "/api/test/advance-clock", {
advanceMs: flight.arrivalAt - clock.now + 1000,
});
expect((await getState(app, owner)).activeDispatches).toHaveLength(0);
});
it("does not exist in production", async () => {
// A production build: developer commands are off.
const { app } = newTestApp({ devTools: false });
await signUp(app);
for (const [url, payload] of [
["/api/test/advance-clock", { advanceMs: 1000 }],
["/api/test/add-credits", { amount: 10_000 }],
["/api/test/pilot", { pilotId: "p", action: "level-up" }],
["/api/test/ship", { shipId: "s", action: "max-upgrades" }],
["/api/test/discover-all", {}],
] as const) {
const response = await call(app, null, "POST", url, payload);
expect(response.statusCode, url).toBe(404);
}
});
it("levels a pilot up to the next level's XP, and clears duty and rest", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await edit(app, (save) => {
Object.assign(
save.pilots.find((p) => p.id === owner.avatarId)!,
{
xp: 40,
dutyHours: 9,
restEndsAt: clock.now + 3_600_000,
},
);
});
const pilotAfter = async (action: string) => {
const response = await call(app, owner, "POST", "/api/test/pilot", {
pilotId: owner.avatarId,
action,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).pilots.find((p) => p.id === owner.avatarId)!;
};
const levelled = await pilotAfter("level-up");
expect(levelled.xp).toBe(content.config.pilotLevelXpThresholds[0]);
expect(levelled.level).toBe(1);
expect(levelled.skillPointsAvailable).toBe(content.config.skillPointsPerLevel);
expect((await pilotAfter("level-up")).level).toBe(2);
const rested = await pilotAfter("clear-rest");
expect(rested).toMatchObject({ dutyHours: 0, resting: false, restEndsAt: null });
});
it("maxes and resets a ship's upgrades, and finishes shop work", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
const ship = async (input: Record<string, unknown>) => {
const response = await call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
...input,
});
expect(response.statusCode).toBe(200);
return playerStateSchema.parse(response.json()).ships.find((s) => s.id === owner.shipId)!;
};
const maxed = await ship({ action: "max-upgrades" });
for (const upgrade of content.upgrades) {
expect(maxed.upgrades[upgrade.id], upgrade.id).toBe(upgradeMaxLevel(upgrade, "courier"));
}
expect(maxed.stats.fuelRangeMillionKm).toBeGreaterThan(
content.shipModel("courier")!.fuelRangeMillionKm,
);
expect((await ship({ action: "reset-upgrades" })).upgrades).toEqual({});
await edit(app, (save) => {
Object.assign(
save.ships.find((s) => s.id === owner.shipId)!,
{
inShopUntil: clock.now + 600_000,
shopUpgradeId: "engines",
shopLevel: 1,
},
);
});
expect((await ship({ action: "finish-shop" })).inShopUntil).toBeNull();
});
it("moves an idle ship to a discovered place only", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const move = (locationId: string) =>
call(app, owner, "POST", "/api/test/ship", {
shipId: owner.shipId,
action: "move",
locationId,
});
const moved = await move("moon");
expect(moved.statusCode).toBe(200);
expect(playerStateSchema.parse(moved.json()).ships[0]!.locationId).toBe("moon");
const refused = await move("mars");
expect(refused.statusCode).toBe(409);
expect(errorResponseSchema.parse(refused.json()).error.code).toBe("location-undiscovered");
});
it("discovers every location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const response = await call(app, owner, "POST", "/api/test/discover-all", {});
const { locations } = playerStateSchema.parse(response.json());
expect(locations.every((l) => l.discovered)).toBe(true);
expect(locations).toHaveLength(content.locations.length);
});
});

View File

@ -0,0 +1,156 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
pilotResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import { restEndsAt } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
setCredits,
testContract,
insertDispatch,
putBoard,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const avatar = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** Flies the owner's Courier Earth↔Moon until a trip ends in mandatory rest. */
async function chainUntilRest(owner: Guest & { shipId: string }) {
const { clock } = testApp();
let at = "earth";
for (let trip = 0; trip < 20; trip++) {
const to = at === "earth" ? "moon" : "earth";
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: at, destinationId: to, expiresAt: FAR_FUTURE }),
});
clock.now = record.arrivalAt;
const state = await getState(testApp().app, owner);
at = to;
if (avatar(state).resting) return { state, record, at };
expect(avatar(state).dutyHours).toBeGreaterThan(0);
}
throw new Error("never needed rest");
}
describe("pilot duty and rest on the server (Step 10D.5)", () => {
it("rests a pilot after chained runs, refuses them until rest ends, then dispatches", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 50_000);
const { state, record, at } = await chainUntilRest(owner);
const rested = avatar(state);
expect(rested).toMatchObject({ dutyHours: 0, resting: true });
// Rest starts at the arrival time (decision 0.5.45).
expect(rested.restEndsAt).toBe(restEndsAt(record.arrivalAt, content.config));
expect(state.summaries.at(-1)!.restEndsAt).toBe(rested.restEndsAt);
const job = testContract({
originId: at,
destinationId: at === "earth" ? "moon" : "earth",
expiresAt: FAR_FUTURE,
});
await putBoard(testApp().app, [job], clock.now, at);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("pilot-resting");
clock.now = rested.restEndsAt!;
const accepted = await dispatch();
expect(accepted.statusCode).toBe(200);
dispatchResponseSchema.parse(accepted.json());
});
it("rests an idle pilot by hand, and refuses resting one who is flying or already resting", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
const response = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(response.statusCode).toBe(200);
expect(pilotResponseSchema.parse(response.json()).pilot).toMatchObject({
dutyHours: 0,
resting: true,
restEndsAt: restEndsAt(clock.now, content.config),
});
const again = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("pilot-resting");
const other = await signUpOwner(app);
await insertDispatch(testApp().app, {
shipId: other.shipId,
pilotId: other.avatarId,
departAt: clock.now,
});
const flying = await call(app, other, "POST", `/api/pilots/${other.avatarId}/rest`, {});
expect(flying.statusCode).toBe(409);
expect(errorCode(flying)).toBe("pilot-not-idle");
});
it("lets a resting pilot leave the ship at a bay and a fresh pilot fly it at once", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 50_000);
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/rest`, {});
const unassign = await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
expect(unassign.statusCode).toBe(200);
const hired = pilotResponseSchema.parse(
(await call(app, owner, "POST", "/api/pilots", { locationId: "earth" })).json(),
).pilot;
const assign = await call(app, owner, "POST", `/api/pilots/${hired.id}/assign`, {
shipId: owner.shipId,
});
expect(assign.statusCode).toBe(200);
const job = testContract({ expiresAt: FAR_FUTURE });
await putBoard(testApp().app, [job], clock.now);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: hired.id,
});
expect(response.statusCode).toBe(200);
});
it("does not rest the avatar during the tutorial", async () => {
const guest = await signUp(testApp().app);
const response = await call(
testApp().app,
guest,
"POST",
`/api/pilots/${guest.avatarId}/rest`,
{},
);
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
});
});

View File

@ -0,0 +1,134 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
edit,
setCredits,
testContract,
insertDispatch,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
const claim = (guest: Guest, goalId: string) =>
call(testApp().app, guest, "POST", `/api/goals/${goalId}/claim`);
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
/** An owner who has accepted the deal (so "pay off your loan" can apply). */
async function dealOwner() {
const owner = await signUpOwner(testApp().app);
await edit(testApp().app, (save) => {
save.player.dealAccepted = true;
});
return owner;
}
const status = async (guest: Guest, goalId: string) =>
(await getState(testApp().app, guest)).goals.find((g) => g.id === goalId)?.status;
describe("onboarding goals", () => {
it("are absent from state during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect((await getState(testApp().app, guest)).goals).toEqual([]);
});
it("are listed in content order once the tutorial is over", async () => {
const owner = await dealOwner();
const goals = (await getState(testApp().app, owner)).goals;
expect(goals.map((g) => g.id)).toEqual(content.onboardingGoals.map((g) => g.id));
// Paid-off loan: the test owner has no balance.
expect(goals.find((g) => g.id === "loan-paid-off")?.status).toBe("ready");
expect(goals.find((g) => g.id === "priority-contract")?.status).toBe("incomplete");
});
it("becomes ready when its condition is met: a delivery with the player's own ship", async () => {
const { clock } = testApp();
const owner = await dealOwner();
expect(await status(owner, "first-own-delivery")).toBe("incomplete");
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
});
clock.now = record.arrivalAt;
expect(await status(owner, "first-own-delivery")).toBe("ready");
});
it("counts a ship bought from the Market, not the deal's (CR-013)", async () => {
const owner = await dealOwner();
expect(await status(owner, "market-ship")).toBe("incomplete");
await setCredits(testApp().app, content.shipModel("lighter")!.price);
const response = await call(testApp().app, owner, "POST", "/api/ships", {
modelId: "lighter",
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
expect(await status(owner, "market-ship")).toBe("ready");
});
it("counts a Priority delivery, not a Standard one (CR-013)", async () => {
const { clock } = testApp();
const owner = await dealOwner();
const fly = async (priority: boolean) => {
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ priority, deliveryWindowGameHours: priority ? 1000 : null }),
});
clock.now = record.arrivalAt;
};
await fly(false);
expect(await status(owner, "priority-contract")).toBe("incomplete");
await fly(true);
expect(await status(owner, "priority-contract")).toBe("ready");
});
it("counts skill points spent", async () => {
const owner = await dealOwner();
await edit(testApp().app, (save) => {
save.pilots.find((p) => p.id === owner.avatarId)!.skillPointsSpent = 1;
});
expect(await status(owner, "first-skill-point")).toBe("ready");
});
it("claiming a completed goal adds its reward once", async () => {
const owner = await dealOwner();
await setCredits(testApp().app, 100);
const reward = content.onboardingGoal("loan-paid-off")!.reward;
const response = await claim(owner, "loan-paid-off");
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company.credits).toBe(100 + reward);
expect(await status(owner, "loan-paid-off")).toBe("claimed");
const again = await claim(owner, "loan-paid-off");
expect(again.statusCode).toBe(409);
expect(errorCode(again)).toBe("goal-already-claimed");
expect((await getState(testApp().app, owner)).company.credits).toBe(100 + reward);
});
it("refuses an incomplete goal and an unknown one", async () => {
const owner = await dealOwner();
await setCredits(testApp().app, 100);
const response = await claim(owner, "priority-contract");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("goal-not-ready");
expect((await claim(owner, "win-the-game")).statusCode).toBe(404);
expect((await getState(testApp().app, owner)).company.credits).toBe(100);
});
it("refuses claims during the tutorial", async () => {
const guest = await signUp(testApp().app);
expect(errorCode(await claim(guest, "loan-paid-off"))).toBe("goal-not-ready");
});
});

View File

@ -0,0 +1,73 @@
// Ported from apps/server/test (CR-014 Step 14.5): guest sign-up is now
// "Start your company" on the device; session and cookie tests no longer apply.
import { content } from "@deadhead/content";
import {
createGuestResponseSchema,
errorResponseSchema,
playerStateSchema,
} from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { newTestApp, useTestApp, call } from "../support";
const testApp = useTestApp();
describe("starting a company (was guest accounts)", () => {
it("creates a new tutorial company", async () => {
const response = await call(testApp().app, null, "POST", "/api/guest");
expect(response.statusCode).toBe(200);
const body = createGuestResponseSchema.parse(response.json());
expect(body.state.company.credits).toBe(content.config.startingCredits);
expect(body.state.tutorial).toMatchObject({ loanerLocationId: "earth", runInFlight: false });
});
it("returns the new company's state afterwards", async () => {
await call(testApp().app, null, "POST", "/api/guest");
const response = await call(testApp().app, null, "GET", "/api/state");
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
expect(state.company.credits).toBe(content.config.startingCredits);
expect(
state.locations
.filter((l) => l.discovered)
.map((l) => l.id)
.sort(),
).toEqual(["earth", "moon"]);
expect(state.pilots).toHaveLength(1);
expect(state.pilots[0]).toMatchObject({
isAvatar: true,
level: 0,
xp: 0,
locationId: "earth",
status: "idle",
});
expect(state.ships).toHaveLength(0);
expect(state.tutorial).not.toBeNull();
});
it("gives each device its own company", async () => {
const a = createGuestResponseSchema.parse(
(await call(newTestApp().app, null, "POST", "/api/guest")).json(),
);
const b = createGuestResponseSchema.parse(
(await call(newTestApp().app, null, "POST", "/api/guest")).json(),
);
expect(a.playerId).not.toBe(b.playerId);
});
it("keeps the same company if started again", async () => {
const first = createGuestResponseSchema.parse(
(await call(testApp().app, null, "POST", "/api/guest")).json(),
);
const again = createGuestResponseSchema.parse(
(await call(testApp().app, null, "POST", "/api/guest")).json(),
);
expect(again.playerId).toBe(first.playerId);
});
it("has no state before a company exists", async () => {
const response = await call(testApp().app, null, "GET", "/api/state");
expect(response.statusCode).toBe(401);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("unauthorized");
});
});

View File

@ -0,0 +1,38 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { describe, expect, it } from "vitest";
import {
TEST_NOW,
useTestApp,
getState,
testContract,
insertDispatch,
signUpOwner,
} from "../support";
const testApp = useTestApp();
describe("ship lastArrivalAt (CR-002)", () => {
it("is the delivery time before a flight and the latest arrival after flights", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(TEST_NOW);
const out = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = out.arrivalAt;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(out.arrivalAt);
const back = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: "moon", destinationId: "earth" }),
});
clock.now = back.arrivalAt + 1000;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(back.arrivalAt);
});
});

View File

@ -0,0 +1,73 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { bayResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { useTestApp, signUp, call, getState, setCredits, signUpOwner } from "../support";
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
describe("lease a bay", () => {
it("leases a bay at the Moon and deducts the price", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const price = content.location("moon")!.bayLeasePrice;
await setCredits(testApp().app, price + 100);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
expect(response.statusCode).toBe(200);
const { bay } = bayResponseSchema.parse(response.json());
expect(bay.locationId).toBe("moon");
const state = await getState(app, owner);
expect(state.company.credits).toBe(100);
expect(state.bays.map((b) => b.locationId).sort()).toEqual(["earth", "moon"]);
});
it("refuses an undiscovered location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "mars" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("location-undiscovered");
expect((await getState(app, owner)).company.credits).toBe(1_000_000);
});
it("refuses a second bay at the same location", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, owner, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("bay-already-leased");
const state = await getState(app, owner);
expect(state.bays).toHaveLength(1);
expect(state.company.credits).toBe(1_000_000);
});
it("refuses when the player cannot afford it, and unknown locations", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 0);
expect(errorCode(await call(app, owner, "POST", "/api/bays", { locationId: "moon" }))).toBe(
"insufficient-funds",
);
const unknown = await call(app, owner, "POST", "/api/bays", { locationId: "pluto" });
expect(unknown.statusCode).toBe(404);
});
it("returns tutorial-active during the tutorial and changes nothing", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, guest, "POST", "/api/bays", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("tutorial-active");
const state = await getState(app, guest);
expect(state.bays).toEqual([]);
expect(state.company.credits).toBe(1_000_000);
});
});

View File

@ -0,0 +1,78 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, shipResponseSchema } from "@deadhead/schema";
import { checkRoute, effectiveStats, gameToReal, realToGame, wouldStrand } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { TEST_NOW, useTestApp, call, getState, edit, setCredits, signUpOwner } from "../support";
const testApp = useTestApp();
describe("Lighter and Clipper (Step 10L.6)", () => {
for (const modelId of ["lighter", "clipper"]) {
it(`buys a ${modelId} at the chosen HQ`, async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const price = content.shipModel(modelId)!.price;
await setCredits(testApp().app, price);
const response = await call(app, owner, "POST", "/api/ships", {
modelId,
bayId: owner.earthBayId,
});
expect(response.statusCode).toBe(200);
const { ship } = shipResponseSchema.parse(response.json());
expect(ship).toMatchObject({ modelId, status: "idle", locationId: "earth" });
expect((await getState(app, owner)).company.credits).toBe(0);
});
}
it("refuses a deadhead that would strand the ship, changing nothing", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 100_000);
// A Clipper at the Moon, 1,420 game days in: Mars is in range from the Moon
// (198 × 1.3), but from Mars (198 × 1.15) nothing is when it gets there.
const departure = realToGame(TEST_NOW, content.config) + 1420 * 24 * 3_600_000;
clock.now = gameToReal(departure, content.config);
await edit(app, (save) => {
Object.assign(
save.ships.find((s) => s.id === owner.shipId)!,
{
modelId: "clipper",
locationId: "moon",
},
);
save.discoveredLocations.push({ locationId: "mars", discoveredAt: TEST_NOW });
});
// The scenario as the sim sees it.
const clipper = effectiveStats(content.shipModel("clipper")!, {}, content);
const [moon, mars] = [content.location("moon")!, content.location("mars")!];
expect(checkRoute(clipper, moon, mars, realToGame(clock.now, content.config)).ok).toBe(true);
expect(
wouldStrand(
clipper,
moon,
mars,
0,
realToGame(clock.now, content.config),
undefined,
content,
),
).toBe(true);
const before = await getState(app, owner);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("would-strand");
const after = await getState(app, owner);
expect(after.company.credits).toBe(before.company.credits);
expect(after.ships).toEqual(before.ships);
expect(after.activeDispatches).toEqual([]);
});
});

View File

@ -0,0 +1,127 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { companyResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
useTestApp,
call,
getState,
edit,
setCredits,
testContract,
insertDispatch,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
async function setLoan(_guest: Guest, loanBalance: number) {
await edit(testApp().app, (save) => {
save.company.loanBalance = loanBalance;
});
}
const share = content.config.loanRepaymentShare;
describe("loan repayment on delivery", () => {
it("adds the net to credits, lowers the balance by the repayment, and records it", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 1000);
await setLoan(owner, 5000);
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
// The avatar flew it, so there is no pilot pay.
const repayment = Math.floor(2000 * share);
expect(state.company).toMatchObject({
credits: 1000 + 2000 - repayment,
loanBalance: 5000 - repayment,
});
expect(state.summaries[0]).toMatchObject({
deadhead: false,
credits: 2000 - repayment,
loanRepayment: repayment,
});
});
it("repays only what is owed when the balance is small", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 0);
await setLoan(owner, 50);
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 1950, loanBalance: 0 });
});
it("puts the full amount into credits when there is no balance", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 0);
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 2000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.company).toMatchObject({ credits: 2000, loanBalance: 0 });
expect(state.summaries[0]!.loanRepayment).toBe(0);
});
});
describe("paying down the loan", () => {
const repay = (guest: Guest, amount: number) =>
call(testApp().app, guest, "POST", "/api/loan/repay", { amount });
it("moves the amount from credits to the loan", async () => {
const owner = await signUpOwner(testApp().app);
await setCredits(testApp().app, 3000);
await setLoan(owner, 5000);
const response = await repay(owner, 1200);
expect(response.statusCode).toBe(200);
expect(companyResponseSchema.parse(response.json()).company).toMatchObject({
credits: 1800,
loanBalance: 3800,
});
});
it("takes only the balance when asked to repay more", async () => {
const owner = await signUpOwner(testApp().app);
await setCredits(testApp().app, 3000);
await setLoan(owner, 400);
const { company } = companyResponseSchema.parse((await repay(owner, 2500)).json());
expect(company).toMatchObject({ credits: 2600, loanBalance: 0 });
});
it("fails with no changes when repaying more than the player has", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 300);
await setLoan(owner, 5000);
const response = await repay(owner, 1000);
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("insufficient-funds");
expect((await getState(app, owner)).company).toMatchObject({ credits: 300, loanBalance: 5000 });
});
it("rejects a non-positive amount", async () => {
const owner = await signUpOwner(testApp().app);
expect((await repay(owner, 0)).statusCode).toBe(400);
});
});

View File

@ -0,0 +1,114 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, validateDispatch } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
TEST_NOW,
useTestApp,
call,
edit,
testContract,
insertDispatch,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
async function board(guest: Guest, locationId: string) {
const response = await call(testApp().app, guest, "GET", `/api/locations/${locationId}/board`);
expect(response.statusCode).toBe(200);
return contractBoardResponseSchema.parse(response.json());
}
describe("location contract boards", () => {
it("returns the Moon's board for a player with no Moon bay", async () => {
const owner = await signUpOwner(testApp().app);
const { contracts } = await board(owner, "moon");
expect(contracts).toHaveLength(content.config.contractBoardSize);
for (const c of contracts) expect(c.originId).toBe("moon");
});
it("returns the Earth board of a player with an Earth bay too", async () => {
const owner = await signUpOwner(testApp().app);
const { contracts, periodEndsAt } = await board(owner, "earth");
expect(contracts).toHaveLength(content.config.contractBoardSize);
expect(periodEndsAt).toBeGreaterThan(testApp().clock.now);
});
it("returns the same stored board within a period, even after a discovery", async () => {
const { clock } = testApp();
const owner = await signUpOwner(testApp().app);
const first = await board(owner, "moon");
await edit(testApp().app, (save) => {
save.discoveredLocations.push({ locationId: "mars", discoveredAt: TEST_NOW });
});
clock.advance(boardPeriodMs(content.config) / 2 - 1);
expect((await board(owner, "moon")).contracts).toEqual(first.contracts);
});
it("returns a new board after the period rolls over", async () => {
const { clock } = testApp();
const owner = await signUpOwner(testApp().app);
const first = await board(owner, "earth");
clock.now = first.periodEndsAt;
const next = await board(owner, "earth");
expect(next.periodEndsAt).toBe(first.periodEndsAt + boardPeriodMs(content.config));
expect(next.contracts.map((c) => c.id)).not.toEqual(first.contracts.map((c) => c.id));
});
it("refuses Mars before it is discovered, and opens it after a delivery discovers it", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
const before = await call(app, owner, "GET", "/api/locations/mars/board");
expect(before.statusCode).toBe(409);
expect(errorResponseSchema.parse(before.json()).error.code).toBe("location-undiscovered");
// A delivery to Mars (placed directly; the Courier's range is not the point here).
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ destinationId: "mars" }),
});
clock.now = record.arrivalAt;
const { contracts } = await board(owner, "mars");
for (const c of contracts) expect(c.originId).toBe("mars");
});
it("returns not-found for an unknown location", async () => {
const owner = await signUpOwner(testApp().app);
const response = await call(testApp().app, owner, "GET", "/api/locations/pluto/board");
expect(response.statusCode).toBe(404);
});
it("fills a lone Courier's Earth board with jobs it can fly (CR-006)", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
const { contracts } = await board(owner, "earth");
const flyable = contracts.filter(
(contract) =>
validateDispatch(
{
ship: { id: owner.shipId, modelId: "courier", locationId: "earth", inFlight: false },
pilot: {
id: owner.avatarId,
isAvatar: true,
assignedShipId: owner.shipId,
inFlight: false,
skills: {},
restEndsAt: null,
},
contract,
tutorial: false,
credits: 1_000_000,
now: clock.now,
},
content,
).ok,
);
expect(contracts).toHaveLength(content.config.contractBoardSize);
expect(flyable.length).toBeGreaterThanOrEqual(5);
});
});

View File

@ -0,0 +1,73 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { playerStateSchema, type PlayerState } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
newTestApp,
useTestApp,
signUp,
call,
getState,
readSave,
setCredits,
testContract,
insertDispatch,
signUpOwner,
} from "../support";
const testApp = useTestApp();
/** The parts of a state that must match a brand-new guest's (ids differ). */
const shape = (s: PlayerState) => ({
credits: s.company.credits,
loanBalance: s.company.loanBalance,
ships: s.ships.length,
bays: s.bays.length,
pilots: s.pilots.map((p) => ({
isAvatar: p.isAvatar,
locationId: p.locationId,
level: p.level,
assignedShipId: p.assignedShipId,
})),
discovered: s.locations.filter((l) => l.discovered).map((l) => l.id),
tutorial: s.tutorial,
activeDispatches: s.activeDispatches.length,
summaries: s.summaries.length,
goals: s.goals,
});
describe("start a new company (Step 10K.6)", () => {
it("turns a mid-game company back into a fresh guest start, with a new world seed", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 50_000);
await call(app, owner, "POST", "/api/bays", { locationId: "moon" });
await call(app, owner, "POST", `/api/ships/${owner.shipId}/upgrades`, { upgradeId: "engines" });
const flight = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
const before = await readSave(app);
const response = await call(app, owner, "POST", "/api/company/new", {});
expect(response.statusCode).toBe(200);
const fresh = playerStateSchema.parse(response.json());
// A brand-new company on another device.
const other = newTestApp();
const guest = await signUp(other.app);
expect(shape(fresh)).toEqual(shape(await getState(other.app, guest)));
expect(fresh.tutorial).toMatchObject({ loanerLocationId: "earth", runsCompleted: 0 });
expect(fresh.company.credits).toBe(content.config.startingCredits);
expect(shape(fresh).discovered.sort()).toEqual(["earth", "moon"]);
const after = await readSave(app);
expect(after.player.worldSeed).not.toBe(before.player.worldSeed);
expect(after.saveId).toBe(before.saveId);
// The old flight is gone: nothing lands when its arrival time passes.
clock.now = flight.arrivalAt + 1000;
expect(shape(await getState(app, owner))).toEqual(shape(fresh));
});
});

View File

@ -0,0 +1,179 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
TEST_NOW,
useTestApp,
signUp,
call,
getState,
edit,
setCredits,
insertDispatch,
makeOwner,
addShip,
type Guest,
} from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
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 guest = await signUp(app);
const { shipId: earthShipId } = await makeOwner(testApp().app, guest.avatarId);
await setCredits(testApp().app, 100_000);
await edit(app, (save) => {
save.bays.push({ id: randomUUID(), locationId: "moon", createdAt: TEST_NOW });
});
const moonShipId = randomUUID();
await addShip(app, {
id: moonShipId,
modelId: "courier",
name: "Moon Hopper",
locationId: "moon",
});
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(testApp().app, 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(testApp().app, {
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);
// Ships cannot be bought during the tutorial, so put one at Earth directly.
const shipId = randomUUID();
await addShip(app, {
id: shipId,
modelId: "courier",
name: "Gift Courier",
locationId: "earth",
});
const response = await call(app, guest, "POST", `/api/pilots/${guest.avatarId}/assign`, {
shipId,
});
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.
await edit(app, (save) => {
save.ships.find((s) => s.id === earthShipId)!.locationId = "mars";
});
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

@ -0,0 +1,62 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { dispatchResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { useTestApp, call, getState, setCredits, signUpOwner } from "../support";
const testApp = useTestApp();
/** An owner with only the Earth bay, a crewed Courier at Earth and 10,000 credits. */
async function owner() {
const guest = await signUpOwner(testApp().app);
await setCredits(testApp().app, 10_000);
return guest;
}
describe("deadhead dispatch", () => {
it("flies the ship empty to a discovered location without a bay, costing only fuel", async () => {
const { app, clock } = testApp();
const guest = await owner();
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "reposition",
shipId: guest.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.bays.map((b) => b.locationId)).toEqual(["earth"]);
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);
// The logbook marks it as a deadhead with its fuel (no payout to show).
expect(state.summaries).toHaveLength(1);
expect(state.summaries[0]).toMatchObject({
deadhead: true,
fuelCost: dispatch.fuelCost,
credits: 0,
pilotXp: 0,
});
});
it("refuses an undiscovered destination and changes nothing", async () => {
const { app } = testApp();
const guest = await owner();
const response = await call(app, guest, "POST", "/api/dispatches", {
kind: "reposition",
shipId: guest.shipId,
pilotId: guest.avatarId,
destinationId: "mars",
});
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("location-undiscovered");
const state = await getState(app, guest);
expect(state.company.credits).toBe(10_000);
expect(state.ships[0]!.status).toBe("idle");
});
});

View File

@ -0,0 +1,145 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { describe, expect, it } from "vitest";
import { resolveArrivals } from "../../src/index";
import {
useTestApp,
signUp,
getState,
readSave,
setCredits,
testContract,
insertDispatch,
makeOwner,
} from "../support";
const testApp = useTestApp();
/** 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(testApp().app, guest.avatarId);
await setCredits(testApp().app, 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(testApp().app, {
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(testApp().app, {
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(testApp().app, {
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(testApp().app, {
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 resolution runs again on the same save", async () => {
const { clock } = testApp();
const { guest, shipId } = await owner();
const record = await insertDispatch(testApp().app, {
shipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
const save = await readSave(testApp().app);
const counts = [1, 2, 3].map(() => resolveArrivals(save, record.arrivalAt));
expect(counts).toEqual([1, 0, 0]);
});
it("resolves several arrivals in arrival order", async () => {
const { app, clock } = testApp();
const { guest, shipId } = await owner();
// Fly to the Moon, then (already resolved in the DB as of arrival) on to Earth.
const first = await insertDispatch(testApp().app, {
shipId,
pilotId: guest.avatarId,
departAt: clock.now,
});
clock.now = first.arrivalAt;
await getState(app, guest);
const second = await insertDispatch(testApp().app, {
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

@ -0,0 +1,64 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema, pilotResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { useTestApp, signUp, call, edit, makeOwner, type Guest } from "../support";
const testApp = useTestApp();
/** 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(testApp().app, guest.avatarId);
await edit(testApp().app, (save) => {
Object.assign(
save.pilots.find((p) => p.id === guest.avatarId)!,
{
xp,
skills,
skillPointsSpent: spent,
},
);
});
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

@ -0,0 +1,98 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { playerStateSchema } from "@deadhead/schema";
import { pilotLevel } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
setCredits,
insertDispatch,
makeOwner,
addShip,
addPilot,
} from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
describe("full player state", () => {
it("reflects one ship in flight and one idle, and validates against its schema", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const { shipId: flyingShipId } = await makeOwner(app, guest.avatarId);
await setCredits(app, 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 addShip(app, {
id: idleShipId,
modelId: "freighter",
name: "Slow Ox",
locationId: "moon",
});
await addPilot(app, {
id: hiredId,
name: "Rae Okafor",
xp: 260,
skills: { "heavy-cargo": 1 },
skillPointsSpent: 1,
assignedShipId: idleShipId,
});
const record = await insertDispatch(app, {
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

@ -0,0 +1,96 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import {
contractBoardResponseSchema,
dispatchResponseSchema,
type Contract,
} from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
useTestApp,
call,
getState,
setCredits,
testContract,
insertDispatch,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
/** An owner whose Courier has just delivered Earth→Moon; there is no Moon bay. */
async function landedAtMoon(): Promise<Guest & { shipId: string }> {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 5000);
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ payout: 1000 }),
});
clock.now = record.arrivalAt;
const state = await getState(app, owner);
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
return owner;
}
/** A Moon→Earth job the Courier can fly with no skills. */
function courierJobHome(contracts: Contract[]): Contract {
const job = contracts.find(
(c) =>
c.destinationId === "earth" &&
c.massTonnes <= 20 &&
c.requiredSkill === null &&
c.requiredLongHaulLevel === 0,
);
if (!job) throw new Error("no Courier job home on the Moon board");
return job;
}
describe("no more stranded ships (the original bug)", () => {
it("lets a ship that delivered to the Moon, with no Moon bay, take a Moon→Earth job home", async () => {
const { app, clock } = testApp();
const owner = await landedAtMoon();
const board = contractBoardResponseSchema.parse(
(await call(app, owner, "GET", "/api/locations/moon/board")).json(),
);
const job = courierJobHome(board.contracts);
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
const state = await getState(app, owner);
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "earth" });
expect(state.summaries.find((s) => s.dispatchId === dispatch.id)).toMatchObject({
originId: "moon",
destinationId: "earth",
});
});
it("lets the same ship deadhead Moon→Earth instead", async () => {
const { app, clock } = testApp();
const owner = await landedAtMoon();
const response = await call(app, owner, "POST", "/api/dispatches", {
kind: "reposition",
shipId: owner.shipId,
pilotId: owner.avatarId,
destinationId: "earth",
});
expect(response.statusCode).toBe(200);
const { dispatch } = dispatchResponseSchema.parse(response.json());
clock.now = dispatch.arrivalAt;
expect((await getState(app, owner)).ships[0]).toMatchObject({
status: "idle",
locationId: "earth",
});
});
});

View File

@ -0,0 +1,52 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { acknowledgeSummariesResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
setCredits,
insertDispatch,
makeOwner,
} from "../support";
const testApp = useTestApp();
describe("acknowledging summaries", () => {
it("clears unacknowledged summaries while keeping the resolved effects", async () => {
const { app, clock } = testApp();
const guest = await signUp(app);
const { shipId } = await makeOwner(testApp().app, guest.avatarId);
await setCredits(testApp().app, 1000);
const record = await insertDispatch(testApp().app, {
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 });
});
// "Does not acknowledge another player's dispatches" no longer applies: one company per save (CR-014).
});

View File

@ -0,0 +1,17 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { serverTimeSchema } from "@deadhead/schema";
import { realToGame } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { useTestApp } from "../support";
const testApp = useTestApp();
describe("game time command (was the server time endpoint)", () => {
it("returns the engine clock's real time and the matching game time", async () => {
const fixed = Date.parse("2100-06-01T12:00:00Z");
testApp().clock.now = fixed;
const body = serverTimeSchema.parse(await testApp().app.call("getServerTime", {}));
expect(body).toEqual({ realTime: fixed, gameTime: realToGame(fixed, content.config) });
});
});

View File

@ -0,0 +1,115 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { contractBoardResponseSchema, errorResponseSchema } from "@deadhead/schema";
import { boardPeriodMs, createDispatchRecord, type Contract } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { useTestApp, signUp, call, edit, readSave, makeOwner, type Guest } from "../support";
const randomUUID = () => globalThis.crypto.randomUUID();
const testApp = useTestApp();
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(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: {},
restEndsAt: null,
},
contract,
tutorial: true,
now,
worldSeed: 1,
},
content,
);
await edit(testApp().app, (save) => {
save.dispatches.push({
id,
shipId: null,
pilotId: guest.avatarId,
tutorial: true,
record,
arrivalAt: record.arrivalAt,
status: "in-flight",
resolution: null,
resolvedAt: null,
acknowledgedAt: null,
});
save.acceptedContracts.push({
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 edit(app, (save) => {
save.player.loanerLocationId = "moon";
});
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 readSave(testApp().app)).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(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(testApp().app, 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

@ -0,0 +1,109 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
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 { useTestApp, signUp, call, getState, type Guest } from "../support";
const testApp = useTestApp();
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("company-courier")!,
dispatch.distanceMillionKm,
contract!.massTonnes,
content.config,
) *
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).toMatchObject({ 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).toMatchObject({ 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

@ -0,0 +1,36 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { describe, expect, it, vi } from "vitest";
import { useTestApp, signUp, getState, flyTutorialRun } from "../support";
// The deal after three runs instead of four: the loaner is then at the Moon.
vi.mock("@deadhead/content", async (importOriginal) => {
const original = await importOriginal<typeof import("@deadhead/content")>();
return {
...original,
content: {
...original.content,
config: { ...original.content.config, tutorialRunsBeforeDeal: 3 },
},
};
});
const testApp = useTestApp();
describe("deal availability with tutorialRunsBeforeDeal = 3", () => {
it("waits until a fourth run returns the loaner to Earth", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
expect((await getState(testApp().app, guest)).tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: false,
});
const state = await flyTutorialRun(testApp(), guest);
expect(state.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
dealAvailable: true,
});
});
});

View File

@ -0,0 +1,86 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import { errorResponseSchema } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
setCredits,
tutorialBoard,
flyTutorialRun,
} from "../support";
const testApp = useTestApp();
describe("tutorial flow v2", () => {
it("starts a guest with the starting cushion, the Company Courier loaner at Earth and no runs", async () => {
const { app } = testApp();
const guest = await signUp(app);
const state = await getState(app, guest);
expect(state.company).toMatchObject({
credits: content.config.startingCredits,
loanBalance: 0,
});
expect(state.tutorial).toEqual({
loanerLocationId: "earth",
runInFlight: false,
runsCompleted: 0,
dealAvailable: false,
});
expect(state.pilots[0]).toMatchObject({ isAvatar: true, locationId: "earth" });
expect(state.goals).toEqual([]);
});
it("counts each resolved run and gives every run a fresh three-contract board", async () => {
const guest = await signUp(testApp().app);
const first = await tutorialBoard(testApp().app, guest);
expect(first).toHaveLength(3);
const state = await flyTutorialRun(testApp(), guest);
expect(state.tutorial).toMatchObject({ runsCompleted: 1, loanerLocationId: "moon" });
const second = await tutorialBoard(testApp().app, guest);
expect(second).toHaveLength(3);
for (const c of second) expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
expect(second.map((c) => c.id)).not.toEqual(first.map((c) => c.id));
});
it("offers Unmarked crates on the third run's board", async () => {
const guest = await signUp(testApp().app);
await flyTutorialRun(testApp(), guest);
await flyTutorialRun(testApp(), guest);
const third = await tutorialBoard(testApp().app, guest);
expect(third.find((c) => c.scriptedId === "unmarked-crates")).toBeDefined();
});
it("makes the deal available after four runs ending at Earth, not after three", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
const afterThree = await getState(testApp().app, guest);
expect(afterThree.tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: false,
});
const afterFour = await flyTutorialRun(testApp(), guest);
expect(afterFour.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
runInFlight: false,
dealAvailable: true,
});
});
it("blocks hiring pilots during the tutorial", async () => {
const { app } = testApp();
const guest = await signUp(app);
await setCredits(testApp().app, 1_000_000);
const response = await call(app, guest, "POST", "/api/pilots", { locationId: "earth" });
expect(response.statusCode).toBe(409);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("tutorial-active");
expect((await getState(app, guest)).pilots).toHaveLength(1);
});
});

View File

@ -0,0 +1,185 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
shipResponseSchema,
type PlayerState,
} from "@deadhead/schema";
import {
effectiveStats,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
upgradeCost,
} from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
useTestApp,
call,
getState,
setCredits,
testContract,
insertDispatch,
putBoard,
signUpOwner,
type Guest,
} from "../support";
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const freighterModel = content.shipModel("freighter")!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** An owner whose avatar now crews a newly bought Freighter at the Earth HQ. */
async function ownerWithFreighter() {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, freighterModel.price + 50_000);
const bought = await call(app, owner, "POST", "/api/ships", {
modelId: "freighter",
bayId: owner.earthBayId,
});
const freighter = shipResponseSchema.parse(bought.json()).ship;
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/unassign`, {});
await call(app, owner, "POST", `/api/pilots/${owner.avatarId}/assign`, {
shipId: freighter.id,
});
return { owner, freighter };
}
const upgrade = (owner: Guest, shipId: string, upgradeId: string) =>
call(testApp().app, owner, "POST", `/api/ships/${shipId}/upgrades`, { upgradeId });
const shipIn = (state: PlayerState, id: string) => state.ships.find((s) => s.id === id)!;
describe("starting an upgrade (Step 10I.5)", () => {
it("charges, raises the level, keeps the ship in the shop, then flies it faster", async () => {
const { app, clock } = testApp();
const { owner, freighter } = await ownerWithFreighter();
const before = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, freighter.id, "engines");
expect(response.statusCode).toBe(200);
const until = clock.now + 10 * 60_000;
expect(shipResponseSchema.parse(response.json()).ship).toMatchObject({
upgrades: { engines: 1 },
inShopUntil: until,
shop: { upgradeId: "engines", level: 1, until },
});
const state = await getState(app, owner);
expect(state.company.credits).toBe(before - upgradeCost(freighterModel, 1, content.config));
expect(shipIn(state, freighter.id).stats.cruiseSpeedKmPerGameHour).toBeCloseTo(
freighterModel.cruiseSpeedKmPerGameHour * 1.1,
);
const job = testContract({ massTonnes: 60, expiresAt: FAR_FUTURE });
await putBoard(testApp().app, [job], clock.now);
const dispatch = () =>
call(app, owner, "POST", "/api/dispatches", {
kind: "contract",
contractId: job.id,
shipId: freighter.id,
pilotId: owner.avatarId,
});
const refused = await dispatch();
expect(refused.statusCode).toBe(409);
expect(errorCode(refused)).toBe("ship-in-shop");
clock.now = until;
const flown = await dispatch();
expect(flown.statusCode).toBe(200);
const { dispatch: trip } = dispatchResponseSchema.parse(flown.json());
const distance = routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(until, content.config),
);
const hours = travelTimeGameHours(
effectiveStats(freighterModel, { engines: 1 }, content),
distance,
job.massTonnes,
content.config,
);
expect(trip.arrivalAt - trip.departureAt).toBeCloseTo(
(hours * MS_PER_HOUR) / content.config.timeCompression,
-1,
);
});
it("refuses away from an HQ with not-at-hq", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(testApp().app, 50_000);
// Fly the Courier to the Moon, where the player has no bay.
const record = await insertDispatch(testApp().app, {
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = record.arrivalAt;
const credits = (await getState(app, owner)).company.credits;
const response = await upgrade(owner, owner.shipId, "cargo-bay");
expect(response.statusCode).toBe(409);
expect(errorCode(response)).toBe("not-at-hq");
expect((await getState(app, owner)).company.credits).toBe(credits);
});
it("refuses an unknown upgrade with not-found", async () => {
const owner = await signUpOwner(testApp().app);
expect((await upgrade(owner, owner.shipId, "warp-drive")).statusCode).toBe(404);
});
it("makes exactly one of two simultaneous upgrades the player can afford only one of", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const courier = content.shipModel("courier")!;
await setCredits(testApp().app, courier.price);
const second = shipResponseSchema.parse(
(
await call(app, owner, "POST", "/api/ships", {
modelId: "courier",
bayId: owner.earthBayId,
})
).json(),
).ship;
const cost = upgradeCost(courier, 1, content.config);
await setCredits(testApp().app, cost + cost / 2);
const responses = await Promise.all([
upgrade(owner, owner.shipId, "engines"),
upgrade(owner, second.id, "engines"),
]);
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, owner);
expect(state.company.credits).toBe(cost / 2);
expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1);
});
it("keeps the finished job until acknowledged, and only clears finished ones", async () => {
const { app, clock } = testApp();
const { owner, freighter } = await ownerWithFreighter();
await upgrade(owner, freighter.id, "cargo-bay");
const ack = () =>
call(app, owner, "POST", "/api/summaries/acknowledge", {
dispatchIds: [],
shipIds: [freighter.id],
});
// Still in the shop: acknowledging does nothing.
expect((await ack()).json()).toEqual({ acknowledged: 0 });
const until = shipIn(await getState(app, owner), freighter.id).shop!.until;
clock.now = until;
expect(shipIn(await getState(app, owner), freighter.id)).toMatchObject({
inShopUntil: null,
shop: { upgradeId: "cargo-bay", level: 1, until },
});
expect((await ack()).json()).toEqual({ acknowledged: 1 });
expect(shipIn(await getState(app, owner), freighter.id).shop).toBeNull();
});
});

View File

@ -0,0 +1,61 @@
// Ported from apps/server/test (CR-014 Step 14.5): request and response checks
// from the route catalog, now on engine commands. (Malformed JSON bodies and
// HTTP 404s for unknown URLs no longer exist.)
import { errorResponseSchema, type RouteResponse } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { GameEngine, MemoryBackend, SaveStorage } from "../../src/index";
import { useTestApp, signUp, call } from "../support";
const testApp = useTestApp();
const expectValidationError = (response: { statusCode: number; json(): unknown }) => {
expect(response.statusCode).toBe(400);
expect(errorResponseSchema.parse(response.json()).error.code).toBe("validation-error");
};
describe("request validation from the command catalog", () => {
it("rejects unexpected fields on a command that takes none", async () => {
expectValidationError(
await call(testApp().app, null, "POST", "/api/guest", { surprise: true }),
);
});
it("rejects unexpected parameters before anything else", async () => {
expectValidationError(await call(testApp().app, null, "GET", "/api/state?evil=1"));
});
it("still answers valid requests", async () => {
expect((await call(testApp().app, null, "GET", "/api/time")).statusCode).toBe(200);
});
it("refuses an unknown command with not-found", async () => {
await expect(testApp().app.call("nope" as never, {} as never)).rejects.toMatchObject({
code: "not-found",
});
});
});
describe("response validation", () => {
const engine = (validateResponses: boolean) =>
new GameEngine({
storage: new SaveStorage(new MemoryBackend()),
clock: { now: () => 0 },
validateResponses,
// A handler that breaks the state response schema.
overrides: {
getPlayerState: () => ({ playerId: 42 }) as unknown as RouteResponse<"getPlayerState">,
},
});
it("fails a response that violates its schema when checking is on", async () => {
const app = engine(true);
await signUp(app);
await expect(app.call("getPlayerState", {})).rejects.toThrow(/violates its schema/);
});
it("does not check responses when checking is off (production)", async () => {
const app = engine(false);
await signUp(app);
await expect(app.call("getPlayerState", {})).resolves.toEqual({ playerId: 42 });
});
});

View File

@ -0,0 +1,111 @@
// Ported from apps/server/test (CR-014 Step 14.5): now runs on the engine.
import { content } from "@deadhead/content";
import {
dispatchResponseSchema,
errorResponseSchema,
playerStateSchema,
type PlayerState,
} from "@deadhead/schema";
import { pilotShare } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
useTestApp,
signUp,
call,
getState,
setCredits,
testContract,
putBoard,
flyTutorialRun,
type Guest,
} from "../support";
const testApp = useTestApp();
const errorCode = (response: { json(): unknown }) =>
errorResponseSchema.parse(response.json()).error.code;
const veteranOf = (state: PlayerState) => state.pilots.find((p) => p.isVeteran)!;
const avatarOf = (state: PlayerState) => state.pilots.find((p) => p.isAvatar)!;
const FAR_FUTURE = Date.parse("2200-01-01T00:00:00Z");
/** A guest who flies the tutorial runs and accepts the deal. */
async function dealt(): Promise<{ guest: Guest; state: PlayerState }> {
const guest = await signUp(testApp().app);
for (let run = 0; run < content.config.tutorialRunsBeforeDeal; run++) {
await flyTutorialRun(testApp(), guest);
}
const response = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(response.statusCode).toBe(200);
return { guest, state: playerStateSchema.parse(response.json()) };
}
describe("the veteran comes with the deal (Step 10J.4)", () => {
it("adds the veteran crewing the Clipper at Earth, level 2 with Expensive Cargo 1 (CR-013)", async () => {
const { state } = await dealt();
expect(state.pilots).toHaveLength(2);
const [courier, clipper] = state.ships;
expect(clipper!.modelId).toBe("clipper");
expect(avatarOf(state)).toMatchObject({ assignedShipId: courier!.id, locationId: "earth" });
const spentPoints = 1;
expect(veteranOf(state)).toMatchObject({
name: content.veteranPilot.name,
isAvatar: false,
isVeteran: true,
level: 2,
skills: { "expensive-cargo": 1 },
skillPointsAvailable: 2 * content.config.skillPointsPerLevel - spentPoints,
assignedShipId: clipper!.id,
locationId: "earth",
status: "idle",
});
});
it("creates no veteran on a refused or repeated accept", async () => {
const guest = await signUp(testApp().app);
const early = await call(testApp().app, guest, "POST", "/api/deal/accept", {});
expect(errorCode(early)).toBe("deal-not-available");
expect((await getState(testApp().app, guest)).pilots).toHaveLength(1);
const { guest: owner } = await dealt();
const again = await call(testApp().app, owner, "POST", "/api/deal/accept", {});
expect(again.statusCode).toBe(409);
const pilots = (await getState(testApp().app, owner)).pilots;
expect(pilots.filter((p) => p.isVeteran)).toHaveLength(1);
});
it("dispatches the Courier and the Clipper at once, paying the veteran a profit share", async () => {
const { app, clock } = testApp();
const { guest, state } = await dealt();
const [courier, clipper] = state.ships;
const veteran = veteranOf(state);
await setCredits(testApp().app, 10_000);
const forCourier = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
const forClipper = testContract({ payout: 2000, expiresAt: FAR_FUTURE });
await putBoard(testApp().app, [forCourier, forClipper], clock.now);
const send = (contractId: string, shipId: string, pilotId: string) =>
call(app, guest, "POST", "/api/dispatches", {
kind: "contract",
contractId,
shipId,
pilotId,
});
const avatarRun = await send(forCourier.id, courier!.id, guest.avatarId);
const veteranRun = await send(forClipper.id, clipper!.id, veteran.id);
expect(avatarRun.statusCode).toBe(200);
expect(veteranRun.statusCode).toBe(200);
expect((await getState(app, guest)).activeDispatches).toHaveLength(2);
const { dispatch } = dispatchResponseSchema.parse(veteranRun.json());
clock.now = Math.max(
dispatch.arrivalAt,
dispatchResponseSchema.parse(avatarRun.json()).dispatch.arrivalAt,
);
const after = await getState(app, guest);
const summary = after.summaries.find((s) => s.dispatchId === dispatch.id)!;
const pay = pilotShare(forClipper.payout, dispatch.fuelCost, content.config);
expect(pay).toBeGreaterThan(0);
expect(summary.pilotPay).toBe(pay);
expect(summary.credits + summary.loanRepayment).toBe(forClipper.payout - pay);
});
});

View File

@ -0,0 +1,399 @@
import { content } from "@deadhead/content";
import {
createGuestResponseSchema,
playerStateSchema,
routes,
type Contract,
type PlayerState,
type SaveDocument,
} from "@deadhead/schema";
import {
boardPeriodIndex,
boardPeriodMs,
createDispatchRecord,
createRepositionRecord,
type DispatchRecord,
} from "@deadhead/sim";
const randomUUID = () => globalThis.crypto.randomUUID();
import { beforeEach } from "vitest";
import {
CommandError,
GameEngine,
MemoryBackend,
NOT_FOUND_CODES,
SaveStorage,
type CommandName,
} from "../src/index";
/**
* Helpers for the ported server tests (CR-014 Step 14.5): the same names and
* shapes as the server's test helpers, over an engine with in-memory storage
* and a clock the test sets. `call` routes an HTTP-style request to its
* command and answers with the status the server would have sent.
*/
export const TEST_NOW = Date.parse("2100-01-01T00:00:00Z");
/** A clock tests can move. Starts at `TEST_NOW`. */
export class TestClock {
now = TEST_NOW;
advance(ms: number) {
this.now += ms;
}
}
export interface TestApp {
app: GameEngine;
clock: TestClock;
storage: SaveStorage;
backend: MemoryBackend;
}
export function newTestApp(options: { devTools?: boolean } = {}): TestApp {
const clock = new TestClock();
const backend = new MemoryBackend();
const storage = new SaveStorage(backend);
const app = new GameEngine({
storage,
clock: {
now: () => clock.now,
advance: (ms) => clock.advance(ms),
get offsetMs() {
return clock.now - TEST_NOW;
},
},
devTools: options.devTools ?? true,
});
return { app, clock, storage, backend };
}
/** A fresh engine and storage per test. */
export function useTestApp(options: { devTools?: boolean } = {}): () => TestApp {
let current: TestApp | undefined;
beforeEach(() => {
current = newTestApp(options);
});
return () => current!;
}
export interface Guest {
playerId: string;
avatarId: string;
}
/** "Start your company" (the old guest sign-up). */
export async function signUp(app: GameEngine): Promise<Guest> {
const body = createGuestResponseSchema.parse(await app.call("createGuest", {}));
return { playerId: body.playerId, avatarId: body.state.pilots[0]!.id };
}
function routeFor(
method: string,
url: string,
): { name: CommandName; params: Record<string, string> } {
const path = url.split("?")[0]!;
for (const [name, route] of Object.entries(routes)) {
if (route.method !== method) continue;
const pattern = new RegExp(`^${route.path.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`);
const match = pattern.exec(path);
if (match) return { name: name as CommandName, params: { ...match.groups } };
}
throw new Error(`No command for ${method} ${url}`);
}
export interface CallResult {
statusCode: number;
body: string;
json(): unknown;
}
/** Calls a command the way the server test called an endpoint. */
export async function call(
app: GameEngine,
_guest: Guest | null,
method: "GET" | "POST",
url: string,
payload?: object,
): Promise<CallResult> {
const { name, params } = routeFor(method, url);
const query = Object.fromEntries(new URL(url, "http://x").searchParams);
const input = { ...query, ...(payload ?? {}), ...params };
const reply = (statusCode: number, data: unknown): CallResult => {
const body = JSON.stringify(data);
return { statusCode, body, json: () => JSON.parse(body) };
};
try {
return reply(200, await app.call(name, input as never));
} catch (error) {
if (!(error instanceof CommandError)) throw error;
const status =
error.code === "validation-error"
? 400
: error.code === "unauthorized"
? 401
: NOT_FOUND_CODES.has(error.code)
? 404
: 409;
return reply(status, { error: { code: error.code, message: error.message } });
}
}
export async function getState(app: GameEngine, _guest?: Guest): Promise<PlayerState> {
return playerStateSchema.parse(await app.call("getPlayerState", {}));
}
/** Changes the save directly (what the server tests did with database updates). */
export function edit<T = void>(app: GameEngine, fn: (save: SaveDocument) => T): Promise<T> {
return app.core.write((save) => {
if (!save) throw new Error("No company to edit");
const result = fn(save);
return { save, result };
});
}
/** Reads the save directly. */
export function readSave(app: GameEngine): Promise<SaveDocument> {
return app.core.read((save) => {
if (!save) throw new Error("No company");
return structuredClone(save);
});
}
export function setCredits(app: GameEngine, credits: number): Promise<void> {
return edit(app, (save) => {
save.company.credits = credits;
});
}
export function testContract(overrides: Partial<Contract> = {}): Contract {
return {
id: `test:${randomUUID()}`,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: TEST_NOW,
// Contracts expire at the end of their board period (TEST_NOW is a period start).
expiresAt: TEST_NOW + boardPeriodMs(content.config),
firstContact: false,
...overrides,
};
}
/**
* Puts an in-flight dispatch straight into the save, as if it had been
* dispatched at `departAt`: the ship and pilot are marked in flight.
*/
export function insertDispatch(
app: GameEngine,
args: {
shipId: string;
pilotId: string;
departAt: number;
contract?: Contract | null;
destinationId?: string;
},
): Promise<DispatchRecord> {
return edit(app, (save) => {
const ship = save.ships.find((s) => s.id === args.shipId)!;
const pilot = save.pilots.find((p) => p.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,
restEndsAt: null,
};
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: save.player.worldSeed,
},
content,
)
: createRepositionRecord(
{
id,
ship: shipState,
pilot: pilotState,
destinationId: args.destinationId!,
now: args.departAt,
worldSeed: save.player.worldSeed,
},
content,
);
save.dispatches.push({
id,
shipId: ship.id,
pilotId: pilot.id,
tutorial: false,
record,
arrivalAt: record.arrivalAt,
status: "in-flight",
resolution: null,
resolvedAt: null,
acknowledgedAt: null,
});
Object.assign(ship, { status: "in-flight", locationId: null });
pilot.status = "in-flight";
return record;
});
}
/** Stores a hand-made location board (Earth by default) for the period containing `now`. */
export function putBoard(
app: GameEngine,
contracts: Contract[],
now = TEST_NOW,
locationId = "earth",
): Promise<void> {
return edit(app, (save) => {
save.contractBoards.push({
id: randomUUID(),
kind: "location",
locationId,
periodIndex: boardPeriodIndex(now, content.config),
tutorialRun: 0,
contracts,
createdAt: now,
});
});
}
/**
* Turns a tutorial company into an owner: tutorial over, one Courier at Earth
* crewed by the avatar, an Earth bay. Returns the ship and bay ids.
*/
export function makeOwner(
app: GameEngine,
avatarId: string,
): Promise<{ shipId: string; earthBayId: string }> {
const shipId = randomUUID();
const earthBayId = randomUUID();
return edit(app, (save) => {
Object.assign(save.player, { tutorialActive: false, loanerLocationId: null });
save.ships.push({
id: shipId,
modelId: "courier",
name: "Test Courier",
status: "idle",
locationId: "earth",
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
// Stands in for the deal's Courier (not a Market purchase, CR-013).
fromDeal: true,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
});
Object.assign(
save.pilots.find((p) => p.id === avatarId)!,
{
assignedShipId: shipId,
locationId: null,
},
);
save.bays.push({ id: earthBayId, locationId: "earth", createdAt: TEST_NOW });
return { shipId, earthBayId };
});
}
/** A new company turned into an owner (see `makeOwner`). */
export async function signUpOwner(
app: GameEngine,
): Promise<Guest & { shipId: string; earthBayId: string }> {
const guest = await signUp(app);
return { ...guest, ...(await makeOwner(app, guest.avatarId)) };
}
/** The tutorial board the player sees now. */
export async function tutorialBoard(app: GameEngine, _guest?: Guest): Promise<Contract[]> {
return (await app.call("getTutorialBoard", {})).contracts;
}
/** Flies one tutorial run (the given slot, light by default) and lets it land. */
export async function flyTutorialRun(
testApp: TestApp,
_guest?: Guest,
slot = 0,
): Promise<PlayerState> {
const { app, clock } = testApp;
const contract = (await tutorialBoard(app))[slot]!;
const { dispatch } = await app.call("createDispatch", {
kind: "tutorial",
contractId: contract.id,
});
clock.now = dispatch.arrivalAt;
return getState(app);
}
type SavedShip = SaveDocument["ships"][number];
type SavedPilot = SaveDocument["pilots"][number];
/** Adds a ship to the save (idle, uncrewed, no upgrades unless given). */
export function addShip(
app: GameEngine,
ship: Partial<SavedShip> & Pick<SavedShip, "id" | "modelId" | "name">,
): Promise<void> {
return edit(app, (save) => {
save.ships.push({
status: "idle",
locationId: "earth",
upgrades: {},
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
fromDeal: false,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
...ship,
});
});
}
/** Adds a pilot to the save (idle, level 0, unassigned unless given). */
export function addPilot(
app: GameEngine,
pilot: Partial<SavedPilot> & Pick<SavedPilot, "id" | "name">,
): Promise<void> {
return edit(app, (save) => {
save.pilots.push({
isAvatar: false,
isVeteran: false,
xp: 0,
skills: {},
skillPointsSpent: 0,
assignedShipId: null,
locationId: null,
status: "idle",
dutyHours: 0,
restEndsAt: null,
createdAt: TEST_NOW,
...pilot,
});
});
}

View File

@ -57,6 +57,8 @@ export const savedShipSchema = z.object({
/** Handed over in the deal rather than bought (CR-013). */ /** Handed over in the deal rather than bought (CR-013). */
fromDeal: z.boolean(), fromDeal: z.boolean(),
createdAt: timeSchema, createdAt: timeSchema,
/** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */
lastArrivalAt: timeSchema,
}); });
export const savedPilotSchema = z.object({ export const savedPilotSchema = z.object({
@ -140,6 +142,15 @@ export const saveDocumentSchema = z.object({
}), }),
), ),
claimedGoals: z.array(z.object({ goalId: idSchema, claimedAt: timeSchema })), claimedGoals: z.array(z.object({ goalId: idSchema, claimedAt: timeSchema })),
/**
* Lifetime counts for goals, kept apart from `dispatches` so old resolved
* dispatches can be pruned (CR-014 §2) without undoing progress.
*/
stats: z.object({
/** Contracts (not tutorial runs or deadheads) delivered. */
contractDeliveries: z.number().int().nonnegative(),
priorityDeliveries: z.number().int().nonnegative(),
}),
}); });
export type SaveDocument = z.infer<typeof saveDocumentSchema>; export type SaveDocument = z.infer<typeof saveDocumentSchema>;

View File

@ -69,6 +69,7 @@ export function fullSave(): SaveDocument {
shopLevel: null, shopLevel: null,
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T + 200_000,
}, },
{ {
id: "s2", id: "s2",
@ -82,6 +83,7 @@ export function fullSave(): SaveDocument {
shopLevel: 1, shopLevel: 1,
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T,
}, },
], ],
pilots: [ pilots: [
@ -171,5 +173,6 @@ export function fullSave(): SaveDocument {
{ contractId: sampleContract.id, periodIndex: 1, dispatchId: "d1", acceptedAt: T }, { contractId: sampleContract.id, periodIndex: 1, dispatchId: "d1", acceptedAt: T },
], ],
claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }], claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }],
stats: { contractDeliveries: 1, priorityDeliveries: 0 },
}; };
} }