deadhead-space-freight-tycoon/DECISIONS.md

671 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).
## Step 7.2
- **One job per dispatch,** `resolve-arrivals` with `{ playerId, dispatchId }`, scheduled for the arrival time. The handler resolves everything due for that player under the player lock, the same code as lazy resolution, so it is a no-op when a request got there first. A job that fires early resolves nothing; the next request or the next due job picks the dispatch up.
- **Jobs are sent after the dispatch transaction commits.** A failed send is logged and the dispatch still succeeds, because lazy resolution on read is the safety net.
- **The app clock is `base clock + offset`** (`createOffsetClock`). The offset is always 0 except through `POST /api/test/clock-offset` (`{ offsetMs }`), which is registered only when `NODE_ENV` is not `production` and is not in the route catalog. Job timers use the base (real) clock: `startAfter = arrivalAt - offset`.
- **The job's handler reads time from the same clock as requests,** so tests run handlers directly with a controlled clock (decision 0.5.18).
## Step 7.3
- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig.
- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`.
- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled.
## Step 8.1
- **Client stack:** Vite 8 + React 19, Tailwind CSS 4 (via `@tailwindcss/vite`, no config file), React Router 8 in declarative mode (`<Routes>`; `BrowserRouter` in `main.tsx`, `MemoryRouter` in tests). Component tests use Vitest with jsdom and Testing Library (`apps/web/vitest.config.ts`).
- **The dev server proxies `/api` to `http://127.0.0.1:3000`**, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
- **Screens are listed once in `src/routes.ts`**, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
- **Header credits and clock are placeholders** until Steps 8.3 and 8.5 supply live values.
- **ESLint gives `apps/web` browser globals.**
## Step 8.2
- **One generic `call(name, input)`** rather than a generated method per route. Types come from the catalog (`RouteRequest` / `RouteResponse`), so a new route needs no client change.
- **Requests:** `:params` are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. `credentials: "include"` sends the session cookie. The base URL defaults to same origin (decision 0.5.20).
- **Errors:** a response failing its schema throws `ApiValidationError`; a non-2xx response throws `ApiError` with the server's code and message (or `internal-error` if the body is not the documented format).
- **ESLint now ignores unused names starting with `_`**, for deliberately unused parameters.
## Step 8.3
- **The clock offset lives in a Zustand store** (`useClock`), as the tech stack prescribes for real-time client state. `serverNow()` is `Date.now() + offset`; the device clock only affects visuals and is never trusted for outcomes.
- **Sync uses the midpoint of the round trip:** offset = server time − (sent + received) / 2.
- **A one-second ticker** (`startClockTicker`) refreshes `now` in the store so components re-render; components read `useClock(s => s.now)`.
- **The header shows in-game time** as `YYYY-MM-DD HH:MM` (UTC), from sim `realToGame`.
## Step 8.4
- **"Is there a session?" is decided by the server:** the client loads player state on start-up; `401 unauthorized` means no session, so it shows the start screen. The session itself is Better Auth's persistent cookie, which the browser keeps across reloads ("persist the session"); the client stores nothing. Expired sessions land on the start screen too.
- **After "Start your company"** the guest response's state is written straight into the query cache and the app navigates to HQ, so there is no extra request.
- **TanStack Query and an API context arrive here** rather than in Step 8.5, because onboarding needs the state query. The header shows live credits from it.
- **Client test helpers** (`apps/web/test/render.tsx`): `fakeApi` (per-route handlers), `samplePlayerState`, and `renderApp` (fresh query cache, memory router).
## Step 8.5
- **Arrival refetch is driven by the ticking server clock** (`useClock`), not by per-dispatch timers: when `now` passes an in-flight dispatch's `arrivalAt`, state is refetched, which makes the server resolve it lazily. Each dispatch triggers one refetch; Phase 7's job covers anything the client misses.
- **`refetchOnWindowFocus: true` is set explicitly** (it is TanStack Query's default) to document the requirement.
- **Tests fake only `Date` and `setInterval`** for the arrival case, leaving `setTimeout` real so TanStack Query's scheduling works; the focus test runs on real timers.
## Step 9.1
- **Tutorial goal cost = cheapest ship model + Earth bay lease**, read from content, so it follows any rebalancing.
- **The player-state query is fresh for 10 seconds** (`staleTime`), so screens that read it do not each refetch on mount. Returning to the app (`refetchOnWindowFocus: "always"`), arrivals and actions still refetch immediately.
- **The arrival watcher runs once, in `App`,** not in every component that reads player state.
- **Shared screen helpers:** `useLoadedPlayer()` (screens render only after state has loaded), `useGameAction(route)` (call an endpoint, then invalidate player state and boards), `Countdown` (live, from the synced clock), and `src/format.ts` (credits, durations, names).
## Step 9.2
- **The summary dialog sits above all screens** (rendered by `App`). Dismissing hides it immediately (local state) and sends `acknowledgeSummaries` for the listed ids; the follow-up state refetch confirms it.
- **Tutorial runs are shown as "Company loaner"**, since the loaner is not in the ships list.
## Step 9.3
- **Trip-time predictions use sim with the content the server uses** (`src/game.ts`: distance at the current server time, `travelTimeGameHours`, converted to real time). They are predictions only (rule 0.1.6).
- **Boards are TanStack queries under the `["board", …]` key**, which every game action invalidates, so a flown contract disappears from the list.
## Step 9.4
- **The distance shown on a contract is the current distance** (sim, at the server clock), which is what fuel and travel time use when dispatching now. The payout was fixed at the distance when the contract was offered (decision 0.5.11).
- **Requirements are shown as text** ("Needs Heavy Cargo 2, Long Haul 1"); whether a particular pilot meets them is shown in the dispatch sheet (Step 9.5).
- **The bay selector is a native `<select>`**, which works well on phones.
## Step 9.5
- **Eligibility in the sheet comes from sim `validateDispatch`**, given the client's copy of state and the synced clock, so the client shows the same reasons the server would return. The server still decides (rule 0.1.6).
- **Pairs are the idle ships at the pickup with their assigned pilots.** Uncrewed ships there are listed as "No pilot assigned"; ships elsewhere are not listed.
- **Net profit = payout − fuel − pilot pay** (no pilot pay when the avatar flies; decision 0.5.12).
- **ETA is shown twice:** in-game (`YYYY-MM-DD HH:MM`) and real local time on the device.
- **The first eligible pair is preselected**; Confirm is disabled when none is eligible.
## Step 9.6
- **Reposition is offered only for idle, crewed ships at one of the player's bays, when another bay exists.** The ship's assigned pilot flies it. Other cases do not show the button, rather than showing a button that always fails.
- **Flight progress comes from sim `dispatchProgress`** with the synced clock, updating every second.
## Step 9.7
- **No "unassign" button yet.** The plan's Pilots-screen actions are hire, assign and spend skill point; the endpoint exists for a later UI. Hired pilots start unassigned, and the avatar is unassigned when the tutorial ends, so assignment always has a path.
- **The avatar shows "Flying the company loaner" during the tutorial** and has no assign control (decision 0.5.23).
- **XP progress** is shown as XP gained since the current level's threshold over the gap to the next threshold; at max level it shows total XP.
## Step 9.8
- **Market lists every content ship model** with its stats straight from `@deadhead/content`; no API call is needed until Buy. After a purchase the player-state refresh adds the ship to Fleet, and a status line says where it was delivered.
## Step 9.9
- **Distance from Earth is live** (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home".
- **Landing requirement "vacuum" is shown as "Any ship"**, since every ship can land at a vacuum-rated location.
- **Lease buttons are disabled when unaffordable** and absent where a bay exists or the location is undiscovered.
## Step 9.10
- **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
- **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one.
- **The placeholder screen is gone;** every route has a real screen.
## Step 10.1
- **PixiJS v8 used directly** (an `Application` created in an effect), not `@pixi/react`: the map is one imperative canvas redrawn each frame from player state and the server clock, which needs no React reconciliation. The effect copes with StrictMode double-mounting.
- **Radial scale is a square root of the distance from the Sun,** normalised so 1,000M km is the map edge. Order is preserved, and Jupiter (~780M km) sits about 2.3× Earth's map radius instead of 5×. Directions from the Sun are unchanged.
- **The Moon is drawn a fixed small offset from Earth** in its true direction; at map scale it would sit on top of Earth.
- **All map logic is plain, testable functions** (projection, camera, scene; hit-testing in Step 10.3). jsdom has no WebGL, so component tests replace `MapCanvas` with a test double (`test/setup.ts`).
- **Camera state (zoom, pan) is a Zustand store**, per the tech stack; zoom is clamped to 0.5–40×, and "Reset view" restores it.
- **The Map screen is lazy-loaded,** so PixiJS (~285 KB) downloads only when the map is opened.
- **Seven tabs:** a Map tab is added after HQ. To fit 375 px, the Contracts tab is labelled "Jobs" and Market "Shop"; screen titles are unchanged.
## Step 10.2
- **Route lines run from the origin's position at departure to the destination's position at arrival**, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim `dispatchProgress`; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan.
- **Idle ships are a count badge per location** rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one.
## Step 10.3
- **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
- **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter.
- **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates.
## CR-001 — Faster Start (Phase 10A)
- **Change request CR-001 is stored in `memory-bank/change-requests/`** and inserted as Phase 10A, before Phase 11.
- **Decision numbering:** the plan's §0.5 already used 21–28, so the CR's new decisions 0.5.21–0.5.26 are recorded as **0.5.29–0.5.34** (Loaner ship, The deal, Handling time, Loan, Scripted tutorial contracts, Onboarding goals). Decisions 0.5.4 and 0.5.16 are replaced, 0.5.3 amended, and 0.5.21 amended (see below).
- **Error code `tutorial-active`** (kebab-case like every other code) implements the CR's `tutorial_active`.
- **Tutorial boards are keyed by run number as well as period and location.** With three contracts per board and accepted contracts hidden, a period-only key would empty the board after three runs and strand the player until the next period. Keying by tutorial runs completed gives every run a fresh board (needed anyway for the run-3 scripted contract); a new period also gives a fresh board, so a contract cannot expire with no replacement.
## Step 10A.1
- **Company Courier:** 6M km/h (twice the Courier), 0.3 h overhead (Courier 2 h), 20 t, same range, fuel and landing as the Courier, `purchasable: false`. Its `price` is kept only because the schema requires one; it is never sold.
- **`tutorialLoanerShipModelId`** is a new config field naming the loaner model; the CR's field list did not include it, but the sim needs to know which model the loaner is (it used "first Courier archetype" before).
- **Tutorial mass tiers 5 / 12 / 20 t** and **handling 0.02 game h per tonne**: a full 20 t Company Courier run Earth→Moon is about 0.76 game hours (~46 real seconds), a light one about 0.46 h (~28 s).
- **Starting credits 500** (a little over two Courier fuel loads from Earth at ~230 each). The replacement Step 2.5 test checks that the light tier's tutorial pay over the pre-deal runs, plus starting credits, covers three Courier Earth→Moon fuel bills.
- **`dealLoanAmount` 5,000** = Courier price + Earth bay lease, and a test keeps it equal to that sum.
- **Goal conditions are data** (a discriminated union: `own-ship-deliveries`, `skill-points-spent`, `ships-owned`, `pilots-hired`, `locations-discovered`, `loan-paid-off`), so Step 10A.5's evaluator needs no per-goal code. Rewards are first guesses (300–2,500).
- **`content.purchasableShipModels`** is what the Market lists; buying a non-purchasable model returns `404 not-found`, like an unknown model.
## Step 10A.2
- **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
- **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record.
## Step 10A.3
- **The tutorial board has no randomness:** location, run number and period fully determine it, so the world-seed input was dropped. Contract ids are `tutorial:<location>:<run>:<period>:<slot>` (run is 1-based), unique per run and period.
- **Slot order is light, medium, full;** a scripted contract keyed to the upcoming run (`runsCompleted + 1`) replaces the medium slot, keeps the medium mass, uses its own cargo, and pays `round(normal payout × tutorial share × pay multiplier)`.
- **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message.
- **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it.
- **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.
## Step 10A.4
- **The lender's cut is rounded down to whole credits:** repayment = `min(balance, floor(credits × share))`, or 0 when the balance is 0 or the credits after pilot pay are not positive. Credits are whole numbers everywhere, and rounding down favours the player by under one credit.
- **`Resolution.credits` is now the net amount** (after pilot pay and repayment); `loanRepayment` is reported separately for the server to apply and the summary to show.
- **Temporary:** the server resolves with `loanBalance: 0` until the balance column exists (Steps 10A.6 and 10A.9).
## Step 10A.5
- **Goals are evaluated against a small `GoalProgress` summary** (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage.
- **"Pay off your loan" requires the deal to have been accepted**, so a player who never had a loan does not complete it by default.
- **The evaluator takes the goal list as an argument** (not part of `SimContent`), since only goal code needs it.
## Step 10A.6
- **The repayment is stored in each dispatch's `resolution` JSON** (`loanRepayment`), next to the other applied effects, rather than as a separate column.
- **`purchasable` lives only in content.** Ship models are not stored in the database; the Market lists `content.purchasableShipModels`, and the server refuses non-purchasable models (Step 10A.1).
- **Tutorial boards are keyed by run** (`contract_boards.tutorial_run`, 0 for bay boards) as well as period and location (see CR-001 notes).
- **New state fields:** `company.loanBalance`; `tutorial.runsCompleted` and `tutorial.dealAvailable` (computed by `isDealAvailable`: tutorial on, deal not yet taken, runs ≥ `tutorialRunsBeforeDeal`, loaner at Earth, no run in flight); `goals` as `{ id, status }`, empty until Step 10A.10 fills it; summaries gain `loanRepayment` and `scriptedId`.
- **New routes:** `POST /api/deal/accept` returns the whole new `PlayerState`, since the deal changes almost everything; `POST /api/loan/repay` and `POST /api/goals/:goalId/claim` return `{ company }`.
- **Error codes:** `tutorial-active` (purchases during the tutorial), `deal-not-available`, `unknown-goal` (404), `goal-not-ready`, `goal-already-claimed`.
## Step 10A.7
- **The run counter increments when a tutorial dispatch resolves** (in the same locked resolution pass), not when it is dispatched, so "runs completed" means runs that landed.
- **Purchase blocking happens first** in buy-ship, lease-bay and hire-pilot (`requireTutorialOver`), so a tutorial player gets `tutorial-active` before any other check and nothing changes.
- **The Step 6.7 tests were removed one step early.** They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
- **Tests for purchases, races and pilot assignment now start from an owner** (`signUpOwner`), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
- **`tutorialRunsBeforeDeal = 3` is tested by mocking content** in its own file, like the earlier `cannot-land` test.
## Step 10A.8
- **The deal hands over a content `courier` model at Earth and an Earth bay.** Constants `DEAL_SHIP_MODEL_ID` and `DEAL_BAY_LOCATION_ID` are server code, since they describe the story beat ("his old Courier and his Earth bay"), not tuning.
- **The endpoint returns the full new `PlayerState`** so the client can switch straight to the post-tutorial UI.
- **Exactly once:** the player lock serializes accepts, and `isDealAvailable` requires `deal_accepted` to be false, so a second or simultaneous accept fails with `deal-not-available` and changes nothing.
- **The old automatic tutorial end (`maybeEndTutorial`, Step 6.7) is deleted;** accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced).
## Step 10A.9
- **Within one resolution pass the balance carries forward:** each arrival repays against what the previous one left, so several arrivals resolving together never repay more than the balance.
- **Repaying is checked against the amount actually taken:** `payment = min(amount, balance)`; if that exceeds credits the request fails with `insufficient-funds` and nothing changes. With a zero balance, a repay request succeeds and moves nothing.
- **The repay endpoint returns `{ company }`** (credits and balance) and runs inside `withPlayer` like every money change.
## Step 10A.10
- **"Own-ship deliveries" = resolved, non-tutorial dispatches that carried a contract** (repositions store a null contract and do not count).
- **Goal status is computed fresh from current state on every read and every claim;** nothing is cached, so a claim checks the same condition the player saw.
- **Claims during the tutorial fail with `goal-not-ready`,** since goals do not exist until the tutorial is over.
## Step 10A.11
- **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice.
- **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute).
- **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content.
## Step 10A.12
- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`.
## Step 10A.13
- **The deal dialog waits for the delivery summary:** the fourth run's landing also produces a "while you were away" entry, so the deal appears once that is dismissed, instead of two dialogs stacking.
- **The accept response is the new player state,** written straight into the query cache before navigating to HQ, so HQ shows the ship, bay and loan without another request.
- **The dialog has a single button** ("Shake on it"); there is no decline (CR-001 §7).
## Step 10A.14
- **During the tutorial HQ shows run progress toward the deal** ("Company runs: n of 4") in place of the old savings goal, so HQ still points at the next milestone.
- **The loan card shows while a balance remains** and disappears at zero; the "Pay off your loan" goal (Step 10A.15) marks the moment.
- **Paid-off progress is measured against `dealLoanAmount`.**
- **The UI blocks bad pay-down amounts before sending** (not a whole positive number, above the balance, above credits) with a short reason; the server still enforces the same rules.
## Step 10A.15
- **HQ shows the next three unclaimed goals in content order,** whether ready or not, so the player always sees what comes next; claimed goals drop out and the following goal takes their place.
- **Goal titles and rewards come from content;** the API only sends each goal's id and status.
## Step 10A.16
- **The map was already reachable during the tutorial** (its tab exists from the first screen); this step adds the lock and reachability.
- **Locked locations are labelled "🔒 Name"** on the canvas (they were dimmed already), replacing the earlier "Name?".
- **Reachability uses sim `checkRoute` (range and landing) from Earth at the current server time,** over `content.purchasableShipModels`, so it changes as planets move. When nothing can reach it the panel says so.
## Step 10A.17
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`). _Correction:_ the intermittent onboarding failure this was meant to fix was really a race in the test (it checked the intro card before the tutorial board had loaded); that is fixed in the playtest fixes below. The longer wait is kept as harmless headroom.
## Step 10A.18
- **Plan amendments are appended to each affected step** as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
- **Known issue found in the regression (not fixed in this phase):** after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.
## Playtest fixes after Phase 10A
- **"4 of 4" with no deal:** the user's account played through code being changed mid-phase (the dev server reloads), so its first tutorial run was flown before the run counter existed and the count ran one behind; after the fourth counted run the loaner was at the Moon. On finished code the deal appears after run 4. HQ now explains the remaining step when enough runs are done but the loaner is not at Earth (`tutorialHint`).
- **The dispatch sheet looked "stuck" on the first job:** it did change per job, but showed only "Earth → Moon" and the first failing check, so every over-heavy job looked identical. The sheet now shows the job's cargo, tier, mass and required skills, and lists every problem with numbers ("Too heavy: 228 t (holds 20 t)", "Needs Expensive Cargo 2 (pilot has 0)", range, Long Haul, fuel). Eligibility is still decided by sim `validateDispatch`.
- **Still open:** most post-deal boards have no contract a Courier can carry (see Step 10A.18).
## Contract size bands (playtest fix, user's choice)
- **Bay contract mass comes from bands, one per ship size** (`contractMassBands` in GameConfig, replacing `contractMassTonnes`): 3 contracts of 5–20 t (Courier), 3 of 21–100 t (Freighter), 2 of 101–400 t (Bulk Hauler). The counts must add up to `contractBoardSize` (schema check). Chosen by the user over shrinking the range or fitting boards to the player's fleet.
- **The first contract of each band is a guaranteed job:** Standard cargo (no skill) to an already-discovered location, never first contact. A new pilot has no skill points (tutorial XP does not reach level 1), so without this a Courier-sized job could still need a skill. The other slots are drawn as before. First contact now applies to 5 of 8 slots, about 6% of contracts, inside the 5–15% the Step 3.9 test expects.
- **Boards list contracts band by band** (small to large), so a new player sees Courier-sized work first.
- **Existing stored boards are unchanged** until their period ends (at most an hour); new boards use the bands.
## CR-002 — Open Markets, Bays as HQs (Phase 10B)
- **Change request CR-002 is stored in `memory-bank/change-requests/`** and inserted as Phase 10B, before Phase 11.
- **Decision numbering:** the CR's new 0.5.27 (Ship delivery) and 0.5.28 (Undiscovered locations) are recorded as **0.5.35** and **0.5.36**, since 27–34 were already in use. Decision 0.5.5 is replaced and the glossary gains "Contract board" and "Deadhead".
- **Error code:** the CR's `location_undiscovered` is the existing `location-undiscovered`.
## Step 10B.1
- **The board generator's input is `location`** (the freight market's location). Sim never knew about bays for boards; the old field name `origin` was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (`board:<location>:<period>`) are unchanged.
## Step 10B.2
- **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part.
- **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason.
- **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario.
## Step 10B.3
- **The board key was already per location:** (player, kind, location, period, tutorial run) since CR-001. This step renames the `board_kind` value `bay` to `location`, so the stored data says what it is.
- **The migration relabels existing `bay` rows as `location`** in one `UPDATE` while the column is temporarily text. That keeps the user's development database usable without a reset; it is a one-line relabel, not a data migration of board contents.
## Step 10B.4
- **The route keeps its name, `getContractBoard`, with a new path `/api/locations/:locationId/board`.** The client builds requests from the catalog, so it follows automatically.
- **Check order:** unknown location `404 not-found`, then undiscovered `409 location-undiscovered` (decision 0.5.36). Bays are not consulted.
- **Discovery by a delivery shows up on the same request:** the endpoint runs inside `withPlayer`, which resolves due arrivals first.
## Step 10B.5
- **No server code changed beyond a comment:** contract dispatch already validated only the ship, pilot, contract and credits, and the contract lookup searches every stored location board. The step is the regression test for the stranded-ship bug.
- **The test picks the Moon→Earth Courier job from the real generated board** (destination Earth, 20 t or less, no skill), relying on the guaranteed first slot of the smallest mass band, not a hand-placed contract.
## Step 10B.6
- **Another player's bay id is `404 not-found` ("You have no such bay")**, the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
- **`SHIPYARD_LOCATION_ID` is gone:** the delivery location is always the chosen bay's.
- **Interim client:** the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.
## Step 10B.7
- **`Ship.lastArrivalAt` is never null:** the latest resolved arrival of that ship, else its delivery time (`created_at`). A newly bought ship counts as having just arrived at its bay, which is what the default-location rule wants.
- **The selector is a wrapping row of location buttons (`aria-pressed`), not a `<select>`**, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
- **The URL is the source of truth for the selection** (`?location=`, replacing `?bay=`), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
- **A hint shows when no idle ship is at the selected location**, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.
## Step 10B.8
- **The picker runs `validateReposition` per destination**, so the client and server agree on what is possible. Fuel is `tripFuelCost` and time is `tripRealMs` with zero cargo, the same inputs the sim's reposition record uses.
- **The action shows on every idle ship, crewed or not.** An uncrewed ship's options are all disabled with "No pilot assigned", which tells the player what to fix rather than hiding the action.
- **Disabled destinations still show fuel and time**, so the player can see how far off they are.
## Step 10B.9
- **The delivery picker is a dialog with radio buttons and a "Buy for N cr" confirm**, so buying always takes two taps, even with one bay (preselected). The confirm is disabled until a bay is chosen.
- **"Bay owned" now reads "Your HQ is here"**, and the map panel says "Your HQ is here." or "No HQ here."; the player-facing word for a bay is HQ, while the action keeps "Lease bay" as in the CR.
- **The success message names the delivery location** ("… is waiting at Moon.") instead of always Earth.