From 9dd557f7c027c6c99bf93d680dd9e36db046262d Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 9 Oct 2026 19:38:25 -0400 Subject: [PATCH] Step 14.5: port the game logic to engine commands Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 10 + memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- packages/content/src/data/config.ts | 3 + packages/content/src/schemas.ts | 4 + packages/content/test/schemas.test.ts | 2 + packages/engine/src/core.ts | 14 +- packages/engine/src/game/boards.ts | 144 +++++++ packages/engine/src/game/commands.ts | 118 ++++++ packages/engine/src/game/company.ts | 174 ++++++++ packages/engine/src/game/dispatch.ts | 201 +++++++++ packages/engine/src/game/engine.ts | 155 +++++++ packages/engine/src/game/errors.ts | 25 ++ packages/engine/src/game/fleet.ts | 255 +++++++++++ packages/engine/src/game/messages.ts | 34 ++ packages/engine/src/game/names.ts | 117 +++++ packages/engine/src/game/resolve.ts | 94 +++++ packages/engine/src/game/state.ts | 250 +++++++++++ packages/engine/src/index.ts | 12 + packages/engine/test/catalog.test.ts | 39 ++ .../engine/test/ported/board-keys.test.ts | 18 + packages/engine/test/ported/buy-ship.test.ts | 148 +++++++ packages/engine/test/ported/cleanup.test.ts | 89 ++++ .../engine/test/ported/company-name.test.ts | 45 ++ .../engine/test/ported/concurrency.test.ts | 86 ++++ .../ported/contract-dispatch-landing.test.ts | 69 +++ .../test/ported/contract-dispatch.test.ts | 249 +++++++++++ packages/engine/test/ported/deal.test.ts | 113 +++++ packages/engine/test/ported/dev-tools.test.ts | 164 +++++++ packages/engine/test/ported/duty-rest.test.ts | 156 +++++++ packages/engine/test/ported/goals.test.ts | 134 ++++++ packages/engine/test/ported/guest.test.ts | 73 ++++ .../engine/test/ported/last-arrival.test.ts | 38 ++ packages/engine/test/ported/lease-bay.test.ts | 73 ++++ .../test/ported/lighter-clipper.test.ts | 78 ++++ packages/engine/test/ported/loan.test.ts | 127 ++++++ .../engine/test/ported/location-board.test.ts | 114 +++++ .../engine/test/ported/new-company.test.ts | 73 ++++ packages/engine/test/ported/pilots.test.ts | 179 ++++++++ .../engine/test/ported/reposition.test.ts | 62 +++ .../engine/test/ported/resolution.test.ts | 145 +++++++ packages/engine/test/ported/skills.test.ts | 64 +++ packages/engine/test/ported/state.test.ts | 98 +++++ packages/engine/test/ported/stranded.test.ts | 96 +++++ packages/engine/test/ported/summaries.test.ts | 52 +++ packages/engine/test/ported/time.test.ts | 17 + .../engine/test/ported/tutorial-board.test.ts | 115 +++++ .../test/ported/tutorial-dispatch.test.ts | 109 +++++ .../ported/tutorial-v2-three-runs.test.ts | 36 ++ .../engine/test/ported/tutorial-v2.test.ts | 86 ++++ packages/engine/test/ported/upgrades.test.ts | 185 ++++++++ .../engine/test/ported/validation.test.ts | 61 +++ packages/engine/test/ported/veteran.test.ts | 111 +++++ packages/engine/test/support.ts | 399 ++++++++++++++++++ packages/schema/src/save.ts | 11 + packages/schema/test/save-fixture.ts | 3 + 56 files changed, 5326 insertions(+), 5 deletions(-) create mode 100644 packages/engine/src/game/boards.ts create mode 100644 packages/engine/src/game/commands.ts create mode 100644 packages/engine/src/game/company.ts create mode 100644 packages/engine/src/game/dispatch.ts create mode 100644 packages/engine/src/game/engine.ts create mode 100644 packages/engine/src/game/errors.ts create mode 100644 packages/engine/src/game/fleet.ts create mode 100644 packages/engine/src/game/messages.ts create mode 100644 packages/engine/src/game/names.ts create mode 100644 packages/engine/src/game/resolve.ts create mode 100644 packages/engine/src/game/state.ts create mode 100644 packages/engine/test/catalog.test.ts create mode 100644 packages/engine/test/ported/board-keys.test.ts create mode 100644 packages/engine/test/ported/buy-ship.test.ts create mode 100644 packages/engine/test/ported/cleanup.test.ts create mode 100644 packages/engine/test/ported/company-name.test.ts create mode 100644 packages/engine/test/ported/concurrency.test.ts create mode 100644 packages/engine/test/ported/contract-dispatch-landing.test.ts create mode 100644 packages/engine/test/ported/contract-dispatch.test.ts create mode 100644 packages/engine/test/ported/deal.test.ts create mode 100644 packages/engine/test/ported/dev-tools.test.ts create mode 100644 packages/engine/test/ported/duty-rest.test.ts create mode 100644 packages/engine/test/ported/goals.test.ts create mode 100644 packages/engine/test/ported/guest.test.ts create mode 100644 packages/engine/test/ported/last-arrival.test.ts create mode 100644 packages/engine/test/ported/lease-bay.test.ts create mode 100644 packages/engine/test/ported/lighter-clipper.test.ts create mode 100644 packages/engine/test/ported/loan.test.ts create mode 100644 packages/engine/test/ported/location-board.test.ts create mode 100644 packages/engine/test/ported/new-company.test.ts create mode 100644 packages/engine/test/ported/pilots.test.ts create mode 100644 packages/engine/test/ported/reposition.test.ts create mode 100644 packages/engine/test/ported/resolution.test.ts create mode 100644 packages/engine/test/ported/skills.test.ts create mode 100644 packages/engine/test/ported/state.test.ts create mode 100644 packages/engine/test/ported/stranded.test.ts create mode 100644 packages/engine/test/ported/summaries.test.ts create mode 100644 packages/engine/test/ported/time.test.ts create mode 100644 packages/engine/test/ported/tutorial-board.test.ts create mode 100644 packages/engine/test/ported/tutorial-dispatch.test.ts create mode 100644 packages/engine/test/ported/tutorial-v2-three-runs.test.ts create mode 100644 packages/engine/test/ported/tutorial-v2.test.ts create mode 100644 packages/engine/test/ported/upgrades.test.ts create mode 100644 packages/engine/test/ported/validation.test.ts create mode 100644 packages/engine/test/ported/veteran.test.ts create mode 100644 packages/engine/test/support.ts diff --git a/DECISIONS.md b/DECISIONS.md index 8272f10..40c27f2 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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`. + +## 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. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 589f631..c82290a 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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. +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/.json` in the Capacitor data directory, temp file then rename). ## Database (`apps/server/src/db`) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4b4d348..fefb8e9 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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.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). -- [ ] 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.7 — Switch the client to the engine - [ ] Step 14.8 — Company creation on the device diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index ca74d08..d758d17 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -25,6 +25,9 @@ export const gameConfig: GameConfig = { // veteran, and the Earth bay. The loan is computed from their prices. deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, 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). 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). diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index c5fcd8e..c440997 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -220,6 +220,10 @@ export const gameConfigSchema = z bayLocationId: z.string().min(1), }), 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 * 0.5.84), so long legs aren't worth hundreds of short ones. diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index fb14a42..c15ecd6 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -109,6 +109,8 @@ const validGameConfig = { handlingGameHoursPerTonne: 0.02, upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }], loanRepaymentShare: 0.3, + boardRetentionPeriods: 24, + savedDispatchRetentionRealHours: 168, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, }; diff --git a/packages/engine/src/core.ts b/packages/engine/src/core.ts index a16375b..36392d8 100644 --- a/packages/engine/src/core.ts +++ b/packages/engine/src/core.ts @@ -15,6 +15,13 @@ export const cryptoRandom: RandomSource = { seed: () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0]!, }; +/** A command's new save and its result; `unchanged` skips saving. */ +export interface WriteOutcome { + save: SaveDocument; + result: T; + unchanged?: boolean; +} + /** Thrown when this tab doesn't hold the game (decision 0.5.90). */ export class GameLockedError extends Error { constructor() { @@ -95,16 +102,15 @@ export class EngineCore { * returns the new save, plus a result for the caller. */ write( - fn: ( - save: SaveDocument | null, - now: RealTime, - ) => { save: SaveDocument; result: T } | Promise<{ save: SaveDocument; result: T }>, + fn: (save: SaveDocument | null, now: RealTime) => WriteOutcome | Promise>, ): Promise { return this.#enqueue(async () => { if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError(); const current = await this.#current(); const now = this.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({ ...out.save, lastTimeSeen: Math.max(out.save.lastTimeSeen, now), diff --git a/packages/engine/src/game/boards.ts b/packages/engine/src/game/boards.ts new file mode 100644 index 0000000..abd6b0a --- /dev/null +++ b/packages/engine/src/game/boards.ts @@ -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 { + 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); +} diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts new file mode 100644 index 0000000..8fdb8b0 --- /dev/null +++ b/packages/engine/src/game/commands.ts @@ -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); + }, + }; +} diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts new file mode 100644 index 0000000..342ac77 --- /dev/null +++ b/packages/engine/src/game/company.ts @@ -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 { + return Object.fromEntries( + content.upgrades + .map((u) => [u.id, upgradeMaxLevel(u, modelId)] as const) + .filter(([, level]) => level > 0), + ); +} diff --git a/packages/engine/src/game/dispatch.ts b/packages/engine/src/game/dispatch.ts new file mode 100644 index 0000000..73b7ec1 --- /dev/null +++ b/packages/engine/src/game/dispatch.ts @@ -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; +} diff --git a/packages/engine/src/game/engine.ts b/packages/engine/src/game/engine.ts new file mode 100644 index 0000000..e1097ef --- /dev/null +++ b/packages/engine/src/game/engine.ts @@ -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; +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 = ( + ctx: CommandContext, + input: z.output<(typeof routes)[N]["request"]>, +) => RouteResponse; + +/** 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]: CommandHandler }; + +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; +} + +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(name: N, input: RouteRequest): Promise> { + 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; + } + + async #run(name: CommandName, input: unknown): Promise { + 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 }; diff --git a/packages/engine/src/game/errors.ts b/packages/engine/src/game/errors.ts new file mode 100644 index 0000000..659e619 --- /dev/null +++ b/packages/engine/src/game/errors.ts @@ -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 = new Set([ + "not-found", + "unknown-contract", + "unknown-skill", + "unknown-goal", +]); diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts new file mode 100644 index 0000000..6a40e97 --- /dev/null +++ b/packages/engine/src/game/fleet.ts @@ -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; +} diff --git a/packages/engine/src/game/messages.ts b/packages/engine/src/game/messages.ts new file mode 100644 index 0000000..a7d65c4 --- /dev/null +++ b/packages/engine/src/game/messages.ts @@ -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 = { + "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 = { + "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.", +}; diff --git a/packages/engine/src/game/names.ts b/packages/engine/src/game/names.ts new file mode 100644 index 0000000..72e80b3 --- /dev/null +++ b/packages/engine/src/game/names.ts @@ -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)}`; +} diff --git a/packages/engine/src/game/resolve.ts b/packages/engine/src/game/resolve.ts new file mode 100644 index 0000000..657379d --- /dev/null +++ b/packages/engine/src/game/resolve.ts @@ -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, + ); +} diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts new file mode 100644 index 0000000..2af888f --- /dev/null +++ b/packages/engine/src/game/state.ts @@ -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 { + 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), + }; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 14de891..3d774cd 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -21,3 +21,15 @@ export { type RandomSource, } from "./core"; 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"; diff --git a/packages/engine/test/catalog.test.ts b/packages/engine/test/catalog.test.ts new file mode 100644 index 0000000..e69f0eb --- /dev/null +++ b/packages/engine/test/catalog.test.ts @@ -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; + } +} + +/** 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([]); + }); +}); diff --git a/packages/engine/test/ported/board-keys.test.ts b/packages/engine/test/ported/board-keys.test.ts new file mode 100644 index 0000000..2e6b22d --- /dev/null +++ b/packages/engine/test/ported/board-keys.test.ts @@ -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"]); + }); +}); diff --git a/packages/engine/test/ported/buy-ship.test.ts b/packages/engine/test/ported/buy-ship.test.ts new file mode 100644 index 0000000..5400aa1 --- /dev/null +++ b/packages/engine/test/ported/buy-ship.test.ts @@ -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); + }); +}); diff --git a/packages/engine/test/ported/cleanup.test.ts b/packages/engine/test/ported/cleanup.test.ts new file mode 100644 index 0000000..da40092 --- /dev/null +++ b/packages/engine/test/ported/cleanup.test.ts @@ -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); + }); +}); diff --git a/packages/engine/test/ported/company-name.test.ts b/packages/engine/test/ported/company-name.test.ts new file mode 100644 index 0000000..00f9732 --- /dev/null +++ b/packages/engine/test/ported/company-name.test.ts @@ -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