deadhead-space-freight-tycoon/DECISIONS.md

35 KiB
Raw Blame History

Decisions

Implementation decisions not spelled out in memory-bank/implementation-plan.md. Design decisions from plan §0.5 are logged here as they are implemented.

Step 1.1

  • Repo lives on an NTFS drive (C:\Projects\...). The original location (G:) is exFAT, which cannot hold symlinks, so pnpm could not link workspace packages. Do not move the repo back to an exFAT drive.
  • TypeScript is pinned to ~6.0. TypeScript 7 is the latest release, but typescript-eslint (needed in Step 1.2) only supports <6.1. Upgrade once typescript-eslint supports 7.
  • Packages export TypeScript source directly (main/types point at src/index.ts). There is no build step for internal packages; Vite, Vitest and the server's TS runner consume the source. The base config uses moduleResolution: "Bundler" and noEmit.
  • Node 24 is the required runtime (engines + .nvmrc).
  • Line endings are LF everywhere (.gitattributes), overriding the machine's global core.autocrlf, to match Prettier and the Linux CI runner.

Step 1.2

  • ESLint 10 flat config with typescript-eslint recommended (not type-aware, to keep lint fast) and eslint-config-prettier. pnpm lint runs ESLint then prettier --check.
  • Purity rule for packages/sim and packages/content: no-restricted-properties bans Math.random and Date.now; no-restricted-syntax bans new Date() with no arguments (new Date(x) is allowed).
  • Prettier skips memory-bank/ so tooling never rewrites the design docs.

Step 1.3

  • Vitest projects: the root vitest.config.ts treats each apps/* and packages/* folder as a project, so pnpm test runs every package's tests. Packages can add their own vitest.config.ts when they need a different environment.
  • Tests live in each package's test/ folder and are included in that package's tsconfig, so pnpm typecheck covers them.

Step 1.4

  • CI runs on Gitea Actions (.gitea/workflows/ci.yml): install with a frozen lockfile, then typecheck, lint and test.
  • .pnpm-store/ is ignored by Prettier, ESLint and git. On the Gitea runner, pnpm puts its store inside the workspace, and prettier --check failed on the store's files (CI run #1).

Step 1.5

  • One Postgres container, two databases. docker-compose.yml runs a single postgres:18 server on port 5432 (user/password deadhead). The default database deadhead is for development; docker/postgres/init/01-create-test-db.sql creates deadhead_test the first time the data volume is created. To recreate both from scratch, run docker compose down -v.
  • Env var names in .env.example: DATABASE_URL, TEST_DATABASE_URL, BETTER_AUTH_SECRET and BETTER_AUTH_URL (Better Auth's own names), PORT, and GAME_EPOCH_REAL_START plus GAME_EPOCH_EPHEMERIS_DATE (decision 0.5.9). The game-clock speed is not an env var; it belongs in GameConfig (rule 0.1.5).
  • Epoch values in .env.example are placeholders. The design docs do not fix an ephemeris date yet.

Step 2.1

  • Ids are lowercase kebab-case strings (earth, bulk-hauler, long-haul).
  • One landing-class enum (vacuum, atmospheric, hardened) serves both a location's landing requirement and a ship's landing class. hardened is not used in the base game.
  • Location body is an enum of the five astronomy bodies (earth, moon, mars, ceres, jupiter) so the sim can map each to a position source in Step 3.3.
  • Epoch fields are ISO 8601 UTC strings (epoch.realStart, epoch.ephemerisDate), not Date objects, so content stays plain data.
  • tutorialBoardSize is a GameConfig field in addition to contractBoardSize, because decision 0.5.21 sets the tutorial board's size separately.
  • XP per delivery inputs are base, perMillionKm and perCargoTier. Step 3.16 decides how they combine.
  • pilotLevelXpThresholds lists the total XP needed for level 1, 2, 3, ...; it and the Long Haul cut-offs must be strictly increasing.
  • tierPayMultiplier is one number per cargo type. Step 3.8 decides how it scales with tier.
  • Imports omit the .ts extension (moduleResolution: "Bundler").

Step 2.2

  • Location ids: earth, moon, mars, ceres-station, jupiter-cloud-platform.
  • Cargo flows follow GDD §9: Earth and Mars produce standard and high-value goods and demand ore (heavy) and fuel (dangerous); the Moon produces standard and heavy; Ceres produces heavy; Jupiter produces dangerous (fuel). The outposts import high-value equipment.
  • Launch multipliers: Earth 3, Jupiter Cloud Platform 2, Mars 1.5, Moon 0.5, Ceres 0.3.
  • Bay lease prices are first guesses (Earth 5,000 up to Jupiter 15,000). Step 2.5 tunes the Earth price against the tutorial.
  • The cargo-id check uses a placeholder list of the four cargo ids planned in Step 2.4 until the cargo data exists.

Step 2.3

  • Ranges (million km): Courier 5 (Moon only; Mars is ~55 at its closest), Freighter 650 (covers Ceres at its farthest, ~600, and Jupiter near its closest, ~590, per decision 0.5.25), Bulk Hauler 1,000 (Jupiter at its farthest, ~970). Step 3.7 checks these against real positions.
  • Speeds and overheads aim at GDD §6 durations at compression 60: Courier 3M km/h with 2 h overhead (Earth→Moon ≈ 2 real minutes, mostly overhead), Freighter 2M km/h with 4 h (Earth→Mars ≈ 1–2 real hours), Bulk Hauler 1.2M km/h with 8 h.
  • Prices (30,000 / 150,000 / 600,000), capacities (20 / 100 / 400 t), fuel costs (20 / 40 / 80 per million km) and hull ratings (1 / 2 / 3) are first guesses; Step 2.5 tunes the Courier price.

Step 2.4

  • Ids: cargo standard, heavy, high-value, dangerous; skills long-haul, heavy-cargo, expensive-cargo, dangerous-cargo.
  • Base pay (credits per tonne per million km): Standard 10, Heavy 14, High-Value 18, Dangerous 20. Tier multipliers: 1, 1.2, 1.25, 1.3. First guesses, to be tuned in Phase 12.
  • Cargo and skills share one data file (src/data/cargo.ts), since every cargo skill exists to gate a cargo type.

Step 2.5

  • The game epoch lives in GameConfig, not in env vars. Step 2.1 puts it in GameConfig, and rule 0.1.5 keeps game numbers in packages/content. The GAME_EPOCH_* variables added to .env.example in Step 1.5 are removed; the placeholder values (real start 2026-01-01, in-game 2100-01-01) are unchanged.
  • Money values scaled up. With the Step 2.2–2.4 rates a tutorial run paid ~24 credits. Pay rates and fuel costs are ×10, and prices are set so a Courier (4,000) plus an Earth bay (1,000) takes ~21 runs at ~240 credits each. Freighter 100,000, Bulk Hauler 400,000; leases Moon 800, Mars 20,000, Ceres 40,000, Jupiter 80,000.
  • Placeholder tutorial payout in the test: Standard pay × 0.384M km × average mass (midpoint of the contract minimum and Courier capacity, 12.5 t) × tutorial share (0.5).
  • Starting credits are 0. The company pays fuel during the tutorial, and the ~21-run target already assumes the player starts with nothing.
  • Other defaults: board size 8, tutorial board 40 (more than the ~30 runs that fit in a 60-minute period), first contact 10% at ×2 pay, contract mass 5–400 t, Long Haul cut-offs 100/250/400/600/800M km, ten pilot levels at 2 skill points each (enough to max all four skills), hire cost 2,000, pilot pay 100 per delivery.
  • Known balance issue for Phase 12: pay is linear in distance, but every trip pays a fixed overhead, so short Earth–Moon runs earn far less per real hour than long runs (a Freighter on Mars earns roughly 50× a Courier on the Moon).

Step 2.6

  • Lookups return undefined for an unknown id ("not found" without throwing). Callers decide whether a missing id is an error.
  • The package also exports each data list and schema individually, alongside the content bundle, so tests and the server can validate data with the schemas.

Step 3.1

  • Times are Unix-millisecond numbers. A real time is a wall-clock instant; a game time is the instant of the in-game date, so new Date(gameTime) gives the in-game date and can be passed straight to astronomy-engine.
  • Every sim function takes the content it needs as arguments (here the GameConfig), rather than importing the default bundle, so tests can pass custom fixtures.

Step 3.2

  • Seeds are 32-bit unsigned integers. The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step.
  • createRng returns an object with internal state. It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere.

Step 3.3

  • Positions are heliocentric, in the J2000 mean ecliptic plane (HelioVector rotated by Rotation_EQJ_ECL), keeping x and y and dropping the small out-of-plane z.
  • The Moon is Earth plus 0.384M km on the +x axis, as the plan specifies; its real orbit is ignored.
  • Ceres uses a circular orbit (astronomy-engine has no Ceres): radius 2.7675 AU (~414M km), period 1,680.5 days, mean longitude ~160° at J2000. The angle is approximate, which is fine for gameplay.
  • Test date is 2100-03-01, close to the placeholder game epoch.

Step 3.4

  • Route distance is the straight line between the two positions at one game time. Orbital transfer paths are not modelled.

Step 3.5

  • Travel time takes the distance as a number, not two locations and a time. The caller measures the distance at departure, so the function cannot accidentally use a mid-flight distance.

Step 3.7

  • Landing is checked before range, so a ship that can neither land nor reach reports cannot-land.
  • The route check looks only at the destination's landing requirement. A ship is always already sitting at its origin, so it can take off from there.
  • Range tests find each planet's closest and farthest point from Earth by sampling every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest).

Step 3.8

  • Tier scaling is compounding: pay is multiplied by tierPayMultiplier ** tier. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate.
  • Payouts are rounded to whole credits when the contract is offered.
  • Contracts refer to content by id (originId, destinationId, cargoTypeId), so they can be stored and sent over the API as plain data. requiredSkill is { skillId, level }, or null for Standard.

Step 3.9

  • Board seed: deriveSeed(worldSeed, "board:<bayLocationId>:<periodIndex>"), with one generator per board.
  • Board periods count from the Unix epoch: period index = floor(real ms ÷ period length). Contracts are offered at the start of their period, and their payout and Long Haul level use the distance at that moment, so a board generated later in the period is identical (decision 0.5.5).
  • Contract ids are <origin>:<periodIndex>:<slot>, unique within a player's world. The server may add its own database ids.
  • First contact is rolled only when an undiscovered location exists. If no other location is discovered, every contract is first contact. Destinations are picked uniformly from the eligible list.
  • Mass is a whole number of tonnes drawn uniformly from the configured range. Tier is drawn uniformly from 1 to the required skill's max level.
  • Sim functions take a SimContent object (locations, cargoTypes, skills, config); the content bundle satisfies it, and tests can pass fixtures.

Step 3.10

  • Tutorial contract mass is capped at the loaner's capacity: a whole number from the contract minimum (5 t) up to the smaller of the contract maximum and the Courier's 20 t. Otherwise most tutorial contracts would be too heavy to fly.
  • The loaner is the content's Courier, found by archetype courier. SimContent now includes shipModels.
  • Tutorial payout = round(normal payout × tutorial share), where the normal payout is the regular rounded payout for the same contract. The contract stores this reduced amount, so resolution credits contract.payout directly.
  • Tutorial board seed is tutorial:<loanerLocation>:<periodIndex>, giving one Earth→Moon and one Moon→Earth board per period (decision 0.5.21). Contract ids use the same prefix.

Step 3.11

  • Pilot skills are a { skillId: level } map; a missing skill counts as level 0.
  • The Long Haul skill id is the constant long-haul in sim, because the rule (decision 0.5.7) is specifically about that skill.
  • The cargo skill is checked before Long Haul when reporting a failure reason.

Step 3.12

  • Minimal state shapes: ShipState { id, modelId, locationId, inFlight } and PilotState { id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these.
  • Tutorial dispatch: the caller passes the loaner as the ship (a ShipState built from the player's tutorial object, decision 0.5.26) and sets tutorial: true. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that.
  • Checks run in the plan's order, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits.
  • Fuel is charged in whole credits (tripFuelCost rounds), using the distance at departure (now).

Step 3.13

  • Extra record fields: tutorial (resolution pays the tutorial share and skips pilot pay) and distanceMillionKm (the distance at departure, used for XP), on top of the plan's fields.
  • The caller supplies the dispatch id; the seed is deriveSeed(worldSeed, "dispatch:<id>").
  • Arrival = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds.
  • fuelCost is what the player pays: 0 for tutorial runs.
  • expectedPayout is the contract payout before pilot pay; resolution deducts pilot pay.

Step 3.14

  • A reposition needs a player bay at the destination, checked against bayLocationIds. Bays exist only at discovered locations, so this also guarantees the destination is discovered.
  • Reposition check order: ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition.
  • Contract and reposition records share one builder; a reposition has contract: null and expectedPayout: 0.
  • "Zero XP" is enforced at resolution (Step 3.17), because the record has no XP field. That step's tests cover it.

Step 3.15

  • Progress is linear in real time. Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line.
  • A zero-length flight reports progress 1 instead of dividing by zero.

Step 3.16

  • Delivery XP = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
  • Pilot level is the number of pilotLevelXpThresholds the total XP has reached (0 below the first threshold), so levels cap at the list length (10).
  • Skill points available = level × points per level − points spent, floored at 0.

Step 3.17

  • Resolution credits = contract payout − pilot pay. Fuel is not included, because it is charged when the ship departs (the record's fuelCost). Credits can be negative if a payout is below pilot pay; the server applies the number as given.
  • Tutorial runs grant XP to the avatar like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay.
  • Repositions resolve to 0 credits and 0 XP and never discover anything, since they only fly to bays.
  • The caller passes pilotIsAvatar and the current discovered set, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally.

Step 3.18

  • Explicit public API. packages/sim/src/index.ts lists every export. Internal helpers (findLocation, findShipModel, standardCargo, buildContract, skillLevel) are not exported; tests that need one import it from its module.
  • Coverage uses @vitest/coverage-v8, configured in the root vitest.config.ts to measure only packages/sim/src with a 90% line threshold. pnpm coverage runs the whole suite with coverage, and CI runs it in place of pnpm test, so a coverage drop fails CI.
  • The Step 1.3 smoke test is removed now that the sim package has real tests.

Step 4.1

  • @deadhead/schema depends on @deadhead/sim so error codes are built from sim's DISPATCH_FAILURES and REPOSITION_FAILURES, which replace the earlier type-only unions. A sim reason cannot exist without a matching API error code.
  • The API carries only per-player data. Location details, ship models, cargo and config come from @deadhead/content; the API sends ids plus per-player flags (LocationState is just { id, discovered }).
  • Times are Unix-ms numbers in the API too, matching sim, so the client can pass them straight to sim functions.
  • Player state: tutorial is { loanerLocationId, runInFlight }, or null once the tutorial is over (decision 0.5.26). In-flight dispatches, including the tutorial run, are in activeDispatches; ships point to theirs through dispatchId. Unacknowledged summaries are in summaries.
  • Ship and pilot locationId is null while in flight. The client gets in-flight positions from the dispatch.
  • One dispatch request, three kinds: contract (contract + ship + pilot), tutorial (contract only; the avatar flies the loaner), reposition (ship + pilot + destination).
  • Create-guest returns the new player's id and full state. The session itself travels as a cookie (decision 0.5.20).
  • Compile-time tests check that the schema Contract and sim Contract are mutually assignable, and that a sim DispatchRecord fits the API Dispatch.

Step 4.2

  • One request schema per route covers path parameters and body together (e.g. assignPilot takes pilotId from /api/pilots/:pilotId/assign and shipId from the body). The server merges the two before validating; a test checks that every :param is a field of the request schema.
  • Each route has an auth flag. Only getServerTime and createGuest work without a session.
  • REST-style paths: collections are created with POST (/api/ships, /api/bays, /api/pilots, /api/dispatches), and pilot actions are sub-resources (/api/pilots/:pilotId/assign).
  • The catalog is an as const object that satisfies Record<string, RouteDef>, so the server and client get exact request and response types per route name (RouteRequest<"buyShip">).

Step 5.1

  • The server runs with tsx (pnpm --filter @deadhead/server dev / start). Workspace packages ship TypeScript source with extensionless imports, which Node's built-in type stripping cannot resolve. There is no build step yet.
  • Config comes from env vars validated with Zod in loadConfig, which reports every problem at once. NODE_ENV, PORT and LOG_LEVEL have defaults (development, 3000, info). The entry point loads the repo-root .env with process.loadEnvFile when the file exists; deployments set real env vars.
  • The health endpoint is GET /api/health, under /api so it is reachable through the same-domain proxy (decision 0.5.20). It is infrastructure, so it is not in the route catalog.
  • buildApp(config) does not listen, so tests start it on port 0. Logs are pino JSON; tests use LOG_LEVEL=silent.

Step 5.2

  • The injectable clock arrives now, not in Step 7.2. buildApp(config, { clock }) takes a Clock (() => RealTime) and defaults to Date.now. Every endpoint will read "now" through it (decision 0.5.18). Step 7.2 still adds the test-only clock-offset endpoint.
  • Game time uses the default content.config, via sim realToGame.

Step 5.3

  • Every game table references players.id. Players and companies are one-to-one; companies holds credits and is the row that money-changing endpoints lock (Step 6.14).
  • Times are bigint Unix ms (Drizzle mode: "number"), like sim and the API. World seeds are bigint because unsigned 32-bit values overflow integer.
  • Status enums use in_flight in the database, as the plan words it; the API uses in-flight, so the server maps between them.
  • Dispatches store the full sim DispatchRecord as JSONB, plus the columns queries need (status, arrival_at, resolved_at, acknowledged_at, tutorial, ship_id, pilot_id). The applied Resolution is kept as JSONB for the summary. A tutorial dispatch has ship_id null, because the loaner is not a ship row.
  • Pilot skills are a JSONB { skillId: level } map, plus a skill_points_spent counter. Level is derived from XP, not stored.
  • Contract boards store their contracts as JSONB, keyed by player, kind (bay / tutorial), location (the bay or the loaner's location) and period.
  • Auth tables are not here yet; Better Auth's tables and the link from players to users arrive in Step 5.5.
  • Migrations are generated SQL (drizzle-kit generate) committed in apps/server/drizzle/, which Prettier ignores. They are applied with the drizzle-orm migrator (db:migrate, or runMigrations(url) in tests).
  • Server test files run one at a time (apps/server/vitest.config.ts) because integration tests share one test database.
  • CI runs a postgres:18 service container and points TEST_DATABASE_URL at it; locally the default is the compose database deadhead_test.

Step 5.4

  • The harness truncates every table in the public schema before each test, rather than wrapping tests in rolled-back transactions. It is simpler, and it works for code that opens its own transactions. Because test files run one at a time, this is safe.
  • createPlayer is real game code (src/game/new-player.ts), used both by the harness and, from Step 5.5, by guest sign-up. A new player gets starting credits, the starting-discovered locations, the avatar, and tutorial mode with the loaner at Earth.
  • The avatar is named "Captain" and the company "Deadhead Freight" for now; there is no naming step in the base game. Hired pilots get generated names (Step 6.8).
  • During the tutorial the avatar is unassigned and stores its location (decision 0.5.27). The tutorial dispatch code keeps it equal to the loaner's location.
  • Seed states: tutorial (a brand-new player) and owner (tutorial over, one Courier at Earth crewed by the avatar, an Earth bay). Later steps add states as their tests need them.

Step 5.5

  • Better Auth's tables are generated by its CLI (@better-auth/cli generate with the anonymous plugin) into src/db/auth-schema.ts and migrated by drizzle-kit (0001_auth). They keep Better Auth's own conventions (Postgres timestamp columns, singular table names) rather than ours.
  • players.user_id links each player to one Better Auth user (unique, cascade on delete).
  • Guest creation uses Better Auth's server API, not its HTTP routes. POST /api/guest calls auth.api.signInAnonymous, forwards the Set-Cookie header, and creates the player in the same request. /api/auth/* is not mounted, since nothing in the base game needs it yet (upgrading guests to full accounts is not in scope).
  • World seeds come from crypto.randomInt(0, 2³²).
  • Better Auth uses real time for session expiry, not the injectable game clock. Auth is not game logic, so this does not affect deterministic tests.
  • Errors: an ApiError(status, code, message) thrown anywhere becomes { error: { code, message } }. Unknown routes return 404 not-found, and unexpected exceptions are logged and return 500 internal-error.
  • GET /api/state is implemented now without lazy resolution. It reads ships, pilots (level and skill points derived with sim), bays, all locations with their discovered flag, tutorial state, in-flight dispatches and unacknowledged summaries. Step 6.1 adds resolution, and Step 6.2 its fuller tests.
  • buildApp takes an optional database. Tests pass the harness's connection; otherwise the app opens its own and closes it on shutdown.

Step 5.6

  • The catalog is wired in with a small registerRoute helper, not fastify-type-provider-zod. The catalog merges path parameters with the body into one request schema, which the type provider's separate params/body/query slots do not model.
  • Request validation runs before the session check, so a malformed request always gets 400 validation-error, even without a session.
  • Requests with no fields reject unexpected ones (emptyRequestSchema is strict), including query parameters.
  • Responses are validated everywhere except production. A violation is logged and returned as 500 internal-error, which makes tests fail loudly. Production skips the check to save work.
  • Fastify's own 4xx errors (malformed JSON, wrong content type) are reported as 400 validation-error in the documented format.

Step 6.1

  • One locking rule for all player changes: withPlayerLock(db, playerId, fn) opens a transaction and takes SELECT ... FOR UPDATE on the player's companies row before running fn. Resolution and every mutating endpoint use it, so one player's changes never interleave (this is also Step 6.14's mechanism).
  • Exactly-once resolution has two guards: the company lock serializes resolvers, and the status update is conditional (WHERE status = 'in_flight'), with effects applied only when it changed a row.
  • resolvedAt is the time resolution ran, not the arrival time; the arrival time is in the record.
  • On resolution: credits change by the sim resolution's credits; the pilot gains XP and becomes idle; the ship becomes idle at the destination; an unassigned pilot (the tutorial avatar) gets the destination as its stored location; a tutorial run moves the loaner; a discovery is inserted.
  • Game-rule failures return 409 with their error code (gameError), except not-found (404).
  • Routes live in src/routes/* and share an AppContext (db, auth, clock, route options).
  • Test helpers (test/game.ts): useTestApp (fresh app plus a movable TestClock), signUp (a real guest session via POST /api/guest), call/getState, insertDispatch (puts a sim-built dispatch in the database). makeOwner / setCredits (test/db.ts) reshape a signed-up player.

Step 6.2

  • No new code was needed: Step 5.5 built the state reader and Step 6.1 added resolution, so this step is the full test. A pilot's level and skillPointsAvailable are derived from XP and points spent each time state is read, never stored.

Step 6.3

  • Every game endpoint runs through withPlayer: it takes the player lock and resolves due arrivals before the action, so actions always see current positions, credits and tutorial state.
  • Boards are generated and stored on first request with INSERT ... ON CONFLICT DO NOTHING on the unique (player, kind, location, period) key, then re-read, so concurrent first requests end up with one stored board.
  • Board responses hide accepted contracts (accepted_contracts) and report periodEndsAt, when every contract on the board expires.
  • Tutorial board errors: 409 not-in-tutorial once the tutorial is over.

Step 6.4

  • A tutorial contract must be on one of the player's stored tutorial boards (found by a JSONB containment query on contract id). Unknown ids return 404 unknown-contract; ids already in accepted_contracts return 409 contract-already-accepted. A contract from an earlier period is found, and sim then rejects it as contract-expired.
  • The loaner's ShipState is built from players.loaner_location_id with id loaner and the content Courier model. The dispatch row has ship_id null.
  • While flying, the avatar's stored location is null; resolution sets it to the destination, along with the loaner's location.
  • storeDispatch is shared by every dispatch kind: it inserts the dispatch, records the accepted contract, and deducts the record's fuel cost (0 for tutorial runs).
  • Sim validation reasons are returned as 409 with their own code and a player-facing message from src/game/messages.ts.
  • 404 unknown-contract and 404 unknown-skill join not-found as the codes that map to 404.

Step 6.5

  • Ship names are "Adjective Noun" from fixed word lists, picked by a generator seeded with deriveSeed(worldSeed, "ship-name:<n>"), where n is how many ships the player owned before this one. They are stable per world (decision 0.5.14) and are not unique; duplicates are acceptable. Word lists live in the server (src/game/names.ts) because they are not tuning numbers.
  • New ships always appear at Earth, as the plan says, whatever the player's bays.
  • spend() checks and deducts credits inside the player lock, so a failed purchase changes nothing (409 insufficient-funds). Unknown models return 404 not-found.

Step 6.6

  • A bay lease is a one-time payment of the location's bayLeasePrice, as the plan words it ("one-time lease price"); there is no recurring rent in the base game.
  • Check order: location exists (404 not-found), discovered (409 location-undiscovered), no bay there yet (409 bay-already-leased), affordable (409 insufficient-funds).

Step 6.7

  • The end-of-tutorial check runs in two places: at the end of every resolution pass that resolved something (which also covers Phase 7's arrival job), and after every action in withPlayer. Buying the ship, leasing the Earth bay, or the last tutorial run landing can each be the trigger.
  • "Removing the loaner" means clearing players.loaner_location_id and tutorial_active; the loaner never had a ship row.
  • The avatar is moved to Earth as an idle, unassigned pilot, wherever the loaner was (decision 0.5.4).

Step 6.8

  • New server error code avatar-in-tutorial for assigning the avatar to an owned ship before the tutorial ends (decision 0.5.23).
  • Assignment checks, in order: pilot and ship belong to the player (404 not-found), avatar not in tutorial, pilot idle (pilot-not-idle), pilot unassigned (pilot-already-assigned), ship idle (ship-not-idle), same location (ship-not-idle-here), ship uncrewed (ship-crewed). The unique assigned_ship_id constraint backs up the one-pilot-per-ship rule.
  • Unassigning needs an assigned, idle pilot (pilot-not-assigned-to-ship, pilot-not-idle) whose ship is at a player bay (no-bay-at-location). The pilot then stores the ship's location (decision 0.5.27).
  • Hired pilot names are "First Last" from fixed lists, seeded with deriveSeed(worldSeed, "pilot-name:<n>"), where n counts the player's earlier hires.
  • toApiPilot (src/game/mappers.ts) is shared by the state reader and the pilot endpoints.

Step 6.9

  • Discovered locations are read only when a board is first generated (inside the generator callback), so a discovery later in the period does not change the stored board (decision 0.5.5).
  • Older periods' boards are never shown: the endpoint always uses the current period index. Stored older boards stay in the database until Phase 7's cleanup, so that dispatching from one returns contract-expired instead of "unknown contract".
  • The plan's "dispatching from an old board fails as expired" check is tested in Step 6.10, once contract dispatch exists.

Step 6.10

  • A contract must be on one of the player's stored bay boards (any period). Once found, sim validation decides; an older board's contract fails with contract-expired.
  • Ships and pilots must belong to the player (404 not-found) before sim validation runs. An in-flight ship's missing location is passed to sim as an empty string; sim reports ship-not-idle first anyway.
  • cannot-land is tested with a test-only vacuum ship added by mocking @deadhead/content in its own test file, since every base-game ship is atmospheric (decision 0.5.1).
  • Failure tests check that nothing changed: no dispatch row, no accepted contract, credits untouched.

Step 6.11

  • Repositions use sim validateReposition with the player's bay locations; their failure reasons map to error codes unchanged (no-bay-at-destination, already-there, ...). No contract is accepted, and storeDispatch charges only the fuel.
  • On arrival the ship and its pilot are idle at the destination, with no credits or XP (sim resolution gives 0 for repositions).

Step 6.12

  • Points available come from sim: skillPointsAvailable(pilotLevel(xp), pointsSpent). Spending adds one level to the skill in the skills JSONB and one to skill_points_spent.
  • Skill points can be spent any time, including while the pilot is flying; nothing in the plan restricts it, and skills only matter when the next dispatch is checked.
  • Check order: pilot belongs to the player (404 not-found), skill exists (404 unknown-skill), a point is available (409 no-skill-points), skill below max (409 skill-maxed).

Step 6.13

  • Acknowledging is by dispatch id and idempotent. Ids that are unknown, belong to another player, are still in flight or were already acknowledged are skipped silently, and the response counts only those newly acknowledged. A client retry is harmless.

Step 6.14

  • No new mechanism was needed: since Step 6.1 every authenticated endpoint runs in withPlayer, a transaction that first takes SELECT ... FOR UPDATE on the player's company row. Requests for one player run one after another; different players never block each other.
  • Race tests go beyond the plan's double purchase: eight simultaneous purchases with money for three, the same contract dispatched twice at once, and the same bay leased twice at once. With the row lock removed, three of the four fail, which confirms the tests exercise the lock. The two-purchase race can pass by luck without the lock, which is why the eight-purchase version exists.

Step 7.1

  • Jobs sit behind a small JobQueue interface (start, stop, send). Production uses pg-boss (v12) in the same Postgres database, in its own pgboss schema; most tests pass a recording fake, so they don't start pg-boss, and call job handlers directly (decision 0.5.18). Only the queue test uses real pg-boss.
  • WORKERS_ENABLED (default true) controls whether this process runs workers and cron schedules. Queues are always created and pg-boss always starts, so an API-only process can still enqueue arrival jobs. Tests set it to false.
  • The queue starts in Fastify's onReady hook and stops (gracefully) in onClose, before the database pool closes.
  • The server listens on 0.0.0.0 (IPv4). Clients that resolve localhost to IPv6 ::1 first (PowerShell, Node 17+ fetch) cannot reach it by that name; use 127.0.0.1. To revisit when the client dev proxy is set up (Phase 8).