# 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::")`, 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 `::`, 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::`, 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:")`. - **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`, 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:")`, 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:")`, 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 (``; `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 ``**, 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. ## Step 10B.10 - **GDD §12 ("Each bay has one contract board") was also corrected**, since it contradicted §10; the CR named only §4 and §10. - **The manual check was also scripted** against a live dev server with the test clock: after the deal, a Courier flies Earth→Moon and then takes a Moon→Earth job from the Moon board with no Moon bay. The user's own manual check on a device is still the review gate. ## CR-003 — Map Art (Phase 10C) - **Change request CR-003 is stored in `memory-bank/change-requests/`** and inserted as Phase 10C, after Phase 10B and before Phase 11. - **Decision numbering:** the CR's 0.5.29–0.5.33 are recorded as **0.5.37 Map art pipeline, 0.5.38 Constant screen size, 0.5.39 Child bodies, 0.5.40 Background, 0.5.41 Fallback**, since 29–36 were already in use. ## Step 10C.1 - **Inventory:** `bg.png`, `body-sun.png`, `body-earth.png`, `body-moon.png`, `body-mars.png`, `body-ceres.png`, `body-jupiter.png`, `ship-courier.png`, `ship-freighter.png`, `ship-bulk-hauler.png`, `ship-company-courier.png`. Every name matched exactly one asset id; nothing was ambiguous. - **The Sun's file is `body-sun.png`**; its asset id stays `sun` as in the CR. - **Missing: `badge-cloud-platform`** (the CR marks it optional). It is in the manifest with `file: null`, Jupiter Cloud Platform keeps `badge: "badge-cloud-platform"` in content, and the map simply draws no badge (fallback, decision 0.5.41). - **The manifest is JSON** (`apps/web/src/map/art/manifest.json`) with a `kind` per asset (`background`, `body`, `badge`, `ship`), so both the app (typed wrapper `manifest.ts`) and the Node optimization script read the same file. - **The Sun is not a location**, so its sprite key lives only in the manifest; the map style will refer to `sun` directly. ## Step 10C.2 - **Output naming:** `public/assets/map-gen/@1x.png` and `@2x.png` for sprites, `.webp` for the background. Missing assets (`file: null`) produce nothing and a warning. - **The build runs the script with `--if-available`:** it regenerates when sharp is installed (output is byte-identical, so git sees no change) and otherwise only checks that the committed output exists. Builds therefore never need sharp, as the CR requires. - **The source art is removed from the build output** by a small Vite plugin. Everything in `public/` is copied into `dist` by default, and the sources (about 22 MB) are only script input. The sources stay where the user put them. - **The committed-output test compares decoded pixels, not bytes**, because PNG compression can differ between Windows and Linux (CI). The "twice is identical" test compares bytes within one machine. The source-transparency test reads all four corners of each source sprite. - **Sizes:** the 2x body sprites are about 0.5–0.6 MB each (lossless PNG of detailed art). That is acceptable for now. If the PWA precache (Step 11.1) is too heavy, palette quantization is the lever. - **sharp's install script is not approved** in pnpm; its prebuilt platform binaries load without it. ## Step 10C.3 - **Angles are degrees clockwise from screen right** (screen y grows down), so −45° is up and to the right. The Moon sits 30 px up-right of Earth; the idle-ship badge sits 20 px down-left (135°) of a body's center; Jupiter's platform badge sits on its edge at −45°. - **Child offsets are keyed by location id with a `parentId`**, so a future moon only needs a config entry (decision 0.5.39). - **The background follows map zoom by 5%** (`backgroundZoomFollow`), the "zooms only slightly" option in Step 10C.5. - **Placeholder colors are in the style too**, so the fallback (decision 0.5.41) draws with the same palette as before. ## Step 10C.4 - **One bundle, loaded asset by asset:** the bundle is registered with `Assets.addBundle` once per page, then each alias is loaded with `Assets.load` under `Promise.allSettled`. `Assets.loadBundle` would reject on the first failure and lose every texture. - **Warnings are only for load failures.** An asset with no source file (`badge-cloud-platform`) is reported by the art script, not at runtime, so a normal page load logs nothing and the "exactly one warning" test holds. - **Textures are cached by PixiJS across visits to the Map**, so leaving and returning does not reload or recreate them (Step 10C.9 relies on this). - **Drawing moved to `MapRenderer`**, which keeps one Sprite or Text per item and redraws only Graphics layers each frame. Sprites of finished flights are destroyed; their textures are shared and kept. - **The loaner's flights use the Company Courier sprite**; an owned ship's flight uses its model's sprite. ## Step 10C.5 - **Background zoom follows `zoom ^ 0.05`, floored at 1**, so at the maximum zoom (40×) the background is about 20% larger and it never shrinks below cover size. With the clamp, that guarantees the viewport is always covered (property-tested over viewports, pans, zooms and two image shapes). - **No background art means no background sprite**: the canvas's solid background color is the placeholder. - **Map height fixed:** the Map section used `100dvh − 7.5rem` and stopped 42 px above the nav, which showed as a dark band under the new art. It now uses the real header and nav heights (4.875rem plus the safe-area inset) and cancels the layout's 5rem scroll padding, so the page does not scroll. - **Screenshots** come from a scratchpad Playwright harness driving local Chrome against a separate dev stack (API 3125, web 5199). The Vite proxy target is now configurable (`API_PROXY_TARGET`, default `http://127.0.0.1:3000`) for that. ## Step 10C.6 - **The child offset angle is measured from the outward direction (Sun → parent on screen)**, not from screen right. With a fixed screen angle the Moon landed on the Sun whenever Earth was on the Sun's opposite side at phone width (seen in the first screenshot). The distance is still a fixed screen offset (decision 0.5.39), and the tests check the exact offset at several zooms and dates. - **Moon distance 46 px** (the style config draft said 30): at the largest sprite scale Earth and the Moon need (32 + 18) / 2 × 1.75 = 43.75 px to clear each other. - **Sprite scale = zoom ^ 0.35, clamped to 0.75–1.75** (new `spriteScale.zoomExponent`): sprites grow a little when zooming in and are about the same size at the default zoom. - **Scene anchors:** a child body's scene `point` is its parent's map point, and flights carry origin and destination ids. `layoutScene` adds the screen offset, so routes to and from the Moon end exactly at the drawn Moon. `locationMapPoint` (map-space Moon offset) is kept but no longer drawn. - **Labels and badges avoid collisions** with a small greedy pass: a label goes below its body, else above it if below hits another body or label; an idle badge takes the first clear diagonal. This handles Earth and the Moon and long names (Jupiter Cloud Platform next to Mars) without special cases. - **Tap targets** come from the same layout as drawing: each is a circle of the larger of the sprite size and 44 px. Ships still win ties. - **The idle badge sits just off the body's edge** (2 px, up-left by default) instead of a fixed distance from the centre, so it clears big bodies like Jupiter. ## Step 10C.7 - **Overlays are decided by a pure function** (`locationOverlays`) that the renderer applies. PixiJS `Text` cannot be measured under jsdom, so the scene test checks this plan (filter, lock, HQ, badge) rather than live display objects. The manual screenshots cover the drawing. - **The padlock is drawn over the body's centre** and the label loses its 🔒 emoji. Undiscovered labels keep the muted color. - **The HQ marker is a small green house** placed beyond the label (below a label that is below the body, above one that is above), so it never sits between label and body. The old green ring around bay bodies is gone. - **Lock arc fix:** a bare `Graphics.arc` in PixiJS continues the current path, which drew long stray lines. The lock now starts its shackle with `moveTo`. ## Step 10C.8 - **Clustering threshold and spacing are both 44 px** (`shipSeparationPx` = the minimum tap size), so after nudging each ship has its own full tap target. Ships are grouped greedily by distance and spread along the normal of the first ship's route, centred on the group's average position. - **Ships keep the sim position on the route line** except when nudged; the route line itself is not moved. - **Highlight rings** (amber, 5 px outside the sprite) go on the selected ship, and on a selected location too, since "selection highlights" are in the CR's code-drawn list. - **Earth–Moon flights stay close to both bodies**: with the Moon at a fixed 46 px from Earth (decision 0.5.39), those routes are always short on screen. That is the intended trade-off. ## Step 10C.9 - **The ticker lifecycle is a small helper (`runWhileVisible`)** tested with a fake ticker and jsdom visibility events. The real `MapCanvas` cannot start PixiJS under jsdom and is mocked in component tests. - **Canvas resolution is capped at 2×.** Measuring in headless Chrome (software WebGL) showed frame time is set by pixel count, not our JavaScript: 58.5 fps at DPR 1 with the CPU throttled 4×, versus 11 fps at DPR 3. The art only has 2× variants, so a 3× canvas cost nine times the pixels of 1× for no visible gain; the cap doubled the rate to 22 fps there. A real phone rasterizes on its GPU, so the user's device check is the real verdict. - **Crowds of more than three ships use a hexagonal grid**, still 44 px apart, after the first busy screenshot showed ten simultaneous departures strung across 400 px of empty space. Up to three ships keep the perpendicular spread from Step 10C.8. - **Per-frame work stays allocation-light:** textures and Text objects are created once, Graphics layers are cleared and redrawn, and labels restyle only when their colour changes (restyling re-renders a text texture). ## Step 10C.10 - **Step 11.1's amendment names the generated folder explicitly** (`public/assets/map-gen/`), since the source art is dropped from the build and must not be precached. - **The "map with undiscovered locations" review shot is the tutorial map**, which shows Mars, Ceres and Jupiter locked; the mid-game shot has Mars discovered and Ceres and Jupiter still locked. - **Screenshot size:** phone shots are full-resolution (DPR 3) PNGs, about 2 MB each; `docs/screenshots/` is about 24 MB. They are kept as-is for the review; trimming or moving them out of the repository is a later cleanup. ## CR-004 — Pilot Duty and Rest (Phase 10D) - **Change request CR-004 is stored in `memory-bank/change-requests/`** and inserted as Phase 10D, after Phase 10C and before Phase 11. - **Decision numbering:** the CR's 0.5.34–0.5.38 are recorded as **0.5.42 Duty clock, 0.5.43 Duty limit, 0.5.44 Finish the job, 0.5.45 Rest, 0.5.46 Rest covers a board refresh**, since 34–41 were already in use. - **Error code:** the CR's `pilot_resting` is `pilot-resting`, matching the API's kebab-case codes. - **Rest length vs. the CR's example:** the CR's rule sets the default rest to one board period (60 real minutes = 60 game hours at the current compression), but its worked example says a pilot "rests about 15 real minutes". The rule is explicit and test-enforced (decision 0.5.46), so it wins: default rest is 60 game hours (one real hour). Shortening rest would mean shortening the board period, which is a balance change for Phase 12. ## Step 10D.1 - **Field names:** `dutyLimitGameHours`, `longHaulDutyBonusGameHours`, `restGameHours`. All in game hours, like other trip times. - **The "rest ≥ board period" rule is a content test, not a schema refinement**, as the CR asks. The schema only requires the three numbers to be positive. ## Step 10D.2 - **Trip duty is measured from the dispatch's real departure and arrival times** (`tripGameHours`), so it is exactly the travel time the player saw, handling time included. - **"Contains a board boundary" is tested as "wakes in a later board period"**: `boardPeriodIndex(restEndsAt(start)) > boardPeriodIndex(start)`, which is what "wakes to a fresh board" means. - **Resting ends exactly at rest-ends-at** (`now < restEndsAt`), the same convention as dispatch arrival. ## Step 10D.3 - **Deadheads can also be refused with `pilot-resting`**: they are flights, and rest blocks flying. The reason is first among the pilot checks in both validators, after the ship checks. - **Validation never looks at duty hours** (decision 0.5.44), so a pilot over the limit who is not resting can always be dispatched. Duty only matters when a trip resolves. - **Resolution owns the duty outcome:** it adds the trip's game hours (none for tutorial runs), and at or over the limit returns duty 0 and rest-ends-at = arrival + rest. The resolution time does not matter, so offline resolution matches live. - **Interim wiring:** until the pilots table has the columns (10D.4–10D.5), the server passes `restEndsAt: null` and duty 0, and the client passes `restEndsAt: null`, so behaviour is unchanged. ## Step 10D.4 - **`restEndsAt` in the API is null unless the pilot is resting now.** The database keeps the last value (which goes stale harmlessly), but clients only see a time when it matters, next to `resting: true`. - **`dutyLimit` is sent with each pilot** so the client does not recompute Long Haul bonuses for display (it still uses the sim for predictions). - **`pilot-resting` needed no new server code:** the API's error codes include every sim validation reason, and 10D.3 added it there. - **Pilot responses depend on the time** (resting or not), so `toApiPilot`, `loadPlayerState` and the pilot actions now take `now` from `withPlayer`. - **Duty hours are a double** (game hours with fractions, as trip times are). Summaries read `restEndsAt` from the stored resolution, so older resolutions without it show null. ## Step 10D.5 - **One code path for both resolutions:** lazy reads and the pg-boss arrival job both call `resolveArrivedDispatches`, so duty and rest are written in one place. `rest_ends_at` is only written when a trip starts mandatory rest, so a voluntary rest is never cut short by an unrelated resolution. - **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot. - **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes. - **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away. ## Step 10D.6 - **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts. - **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows. - **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours. ## Step 10D.7 - **The resting line is computed from the clock each render** ("Resting — 12:05"), so it counts down while the sheet is open, and it is the first problem listed. - **Duty after uses the same sim functions the server uses on arrival** (`tripGameHours` from departure and arrival, `dutyAfterTrip`, `needsRest`), so the warning matches what will happen. - **The deadhead picker also respects rest** (it validates with the pilot's real `restEndsAt`), disabling every destination with "Pilot is resting". ## Step 10D.8 - **The countdown uses the board's own `periodEndsAt`** from the server, and the board query refetches half a second after it, so a pilot who wakes up sees the new board without reloading. - **"Eligible" means the dispatch sheet would allow it:** a ship is stuck when `pairOptions` finds no contract on this board with no problems for that ship and its pilot. Uncrewed ships and resting pilots get no prompt (resting pilots already show their countdown elsewhere). - **One prompt per stuck ship**, naming the ship and pilot, so a location with several idle ships stays clear. - **`Countdown` takes an optional `format`**; the board refresh uses `mm:ss` (`formatClock`) as the CR asks. ## Step 10D.9 - **The summary gives the wake time as the device's local clock time** (like the dispatch sheet's real ETA), since the player reads it on returning; HQ gives a live countdown instead. - **The HQ rest card is always shown after the tutorial**, with "Everyone is ready to fly." when nobody rests, so the player learns the mechanic exists before it first bites. ## Step 10D.10 - **The CR's manual check was scripted** against a live dev server with the test clock (scratchpad `cr004-smoke.mjs`): mandatory rest after the sixth Earth↔Moon trip, `pilot-resting` while resting, and a different board on waking. The user's own check on a device is still the review gate. - **Observed for Phase 12 balance (not changed here):** a board's only guaranteed Courier job is used by the first trip from that location in a period, so later trips in the same period are usually deadheads. Resting, which wakes the pilot to a new board, is now the natural answer, which is what the CR intends; the size of that effect is for the balance pass. ## CR-005 — UI Overhaul (Phase 10E) - **Change request CR-005 is stored in `memory-bank/change-requests/`** and inserted as Phase 10E, after Phase 10D and before Phase 11. Its decisions are plan §0.5.47–0.5.51. - **Requested by the user** ("the UI is very simple, has small text buttons on the bottom of the screen, and isn't very eye catching as a game"). Direction chosen: a "freight terminal HUD" that reuses the CR-003 art, so no new art is needed. ## Step 10E.1 - **Fonts via `@fontsource`, latin subset only, weights 500–700 (Chakra Petch) and 400–600 (Barlow).** Self-hosting keeps the game working offline for the Phase 11 PWA; Google Fonts would not. - **The backdrop is `body::before` (fixed, `z-index: -1`)**, so it never scrolls and screens no longer paint their own background. The layout's `bg-slate-950` was removed so it shows through. ## Step 10E.2 - **No component library.** The kit is a handful of small Tailwind components in `src/ui/`; a dependency like Radix or shadcn would be more than the game needs. - **Icons are hand-drawn inline SVG paths** (`ui/icons.tsx`), not an icon package: 28 icons, no network, `currentColor`, always `aria-hidden`. - **`Sheet` renders the title itself** so every dialog gets the same eyebrow/title/subtitle layout and the `aria-labelledby` wiring cannot be forgotten. - **`Art` uses the CR-003 generated sprites directly** (`/assets/map-gen/@1x|2x.png`) through `ART_BASE_URL`, so screens and map share one art pipeline. ## Step 10E.3 - **Jobs badge during the tutorial is 1 until a run is in flight**: the loaner isn't a ship in state, but "go fly a run" is the one thing the player should do next. - **The desktop rail is 96 px with labels under icons**, not a wide sidebar, so the map and screens keep the room. The wordmark hides below 420 px wide so the credits and clock chips fit on small phones. - **Screenshot stack:** a scratchpad Playwright harness (playwright-core driving local Chrome) seeds a tutorial save and a mid-game save through the API on a separate database (`deadhead_shots`, API 3125, web 5199), as in Step 10C. ## Step 10E.4 - **"Fly a company run" on the tutorial HQ panel is the one copy addition so far**: a link to Jobs, shown only when no run is in flight and no deal is waiting. It adds a path; nothing existing changed (decision 0.5.51 is about not changing existing copy and names). - **`ScreenHeader` is a `div`, not a `header`**: a second `
` was exposed as another `banner` landmark next to the app header. - **Route strip labels are CSS-generated** (`after:content-[attr(data-label)]`) because tests and screen readers already get the route as text; DOM text in a decorative strip would duplicate it. ## Step 10E.5 - **Sheets portal to `document.body`.** The screen column animates in with a `transform`, which makes it the containing block for `position: fixed`; the first dispatch-sheet screenshot showed the backdrop but the sheet stranded inside the column, under the header and nav. Portalling fixes all sheets at once and keeps tests working (they query `screen`). - **Contract cards keep their texts in the same order and grouping** ("Standard (tier 2) · 50 t · …", "Needs …", "expires in …") so the board tests stay unchanged, and they contain no nested list items (tests count `listitem`s). - **Dispatch outlook cells wrap each `dt`/`dd` pair in a `div`** (valid inside `dl`), so text still reads "Trip2h 05m". ## Step 10E.6 - **Market spec bars are decorative (`aria-hidden`)** and relative to the best purchasable model; the numbers stay in `dt`/`dd` text. Range uses `log10(1 + range)` because a linear bar made the Courier's 5M km invisible next to the Bulk Hauler's 1,000M km. - **Idle/In flight pills are new visible text** on Fleet cards; they restate the ship's status, which was implied before by "At …" or a route. ## Step 10E.9 - **Final screenshots are palette-quantized** (sharp `palette: true`, quality 90): 24 shots in 6 MB instead of 18 MB, with no visible banding in the art. - **The 500 KB chunk warning is accepted for now.** The UI kit and screens added about 27 KB to the main chunk; route-level code splitting (like the map's) can take it back under when the PWA work in Phase 11 looks at load size. ## Config change: shorter board period and rest - **`contractBoardPeriodRealMinutes` 60 → 10 and `restGameHours` 60 → 10** (user request, after Phase 10D). At 60× compression a rest is now 10 real minutes, exactly one board period, so decision 0.5.46 still holds and its content test (rest in real time ≥ board period) is unchanged and passing. - **Effects:** boards refresh every 10 real minutes and every contract expires at the end of its period, so offers turn over six times as fast. A rested pilot is back in 10 minutes instead of an hour. This also resolves the earlier note that the CR-004 example expected a short rest. - **Two tests assumed a 60-minute period** and now derive their timing from config instead: the server's `testContract` helper (expiry = end of the board period) and the board-refresh countdown test. No game code changed. ## CR-006 — Boards That Fit Your Fleet (Phase 10F) - **Requested by the user** after CR-005: highlight weight a ship can't carry, and offer more jobs that fit the player's ships and pilot skills than the one guaranteed job per band. Stored in `memory-bank/change-requests/`, inserted as Phase 10F before Phase 11; decisions are plan §0.5.52–0.5.53. ## Step 10F.1 - **"Your ship" means the ships at the board's location**, because those are the ones that can take the job now; with none there, the whole fleet (one of them could deadhead in). Ships in flight have no location, so they only count in the fallback. ## Step 10F.3 - **Freed slots go to the largest flyable band first.** With a Courier and a Freighter, the Bulk Hauler band's spare slot becomes a Freighter-band job, since the biggest ship you own earns the most per trip. With one Courier everything lands in the Courier band. - **A band counts as flyable if some ship can carry its minimum**, and a fit job's mass is capped at the chosen ship's capacity; so a Freighter also gets work from a band whose top it can't carry. - **Fit destinations are checked at the board's start time**, like the contract's own distance. A route at the edge of a ship's range could drift out during the 10-minute period; dispatch validation still decides. - **An uncrewed ship counts with no skills**, as if crewed by a fresh hire, so buying a ship immediately shapes the next board. ## Follow-up after Phase 10F: deadheads in the logbook - **Found while checking deadhead end to end** (user request): a deadhead's "While you were away" entry read "+0 cr +0 XP" in success green. `ResolutionSummary` gains `deadhead` (the dispatch had no contract) and `fuelCost` (paid at departure); the entry now reads "Deadhead · 230 cr fuel" with a flight icon. Tests: `away-summary.test.tsx`, `reposition.test.ts`, `loan.test.ts`. - **The missing favicon (a harmless 404) is left for later**, by the user's choice; Phase 11's PWA icons are the natural place. ## CR-007 — Cargo Goods, Priority Contracts and Shippers (Phase 10G) - **Supplied by the user as "CR-005 / Phase 10E" with decisions 0.5.39–0.5.44**, all of which were already taken. Recorded as CR-007, Phase 10G (steps 10G.1–10G.11), decisions 0.5.54–0.5.59; `too_slow` is `too-slow`. - **Mass: the band wins** (asked; the user chose it). The CR draws mass from each good's range, but boards draw it from ship-size bands, and no Standard good weighs over 60 t, so a Bulk Hauler would lose its unskilled job and CR-006's fit guarantees would break. A contract now picks a good of its class overlapping its band and draws inside both; with no overlap it uses the class's largest-range good and the band's range (a bulk lot). The lightest band has no floor for goods, so sub-5 t goods can appear. - **Class mix kept by keeping the class-level lists** (not asked: the CR requires it twice and tests it). Switching `produces`/`demands` to goods and deriving classes from them would make Earth produce all four classes and move its mix far beyond ±3 points. Locations keep the old lists as `cargoClassMix` for class selection only. - **Tutorial contracts are never Priority**: the CR doesn't say, and the tutorial is about learning the loop, not deadlines. ## Step 10G.5 - **Tutorial goods prefer one whose range holds the tier mass** instead of clamping the tier to a random good, so the CR-001 light/medium/full masses (5, 12, 20 t) and the tutorial's timing tests stay exact. Earth→Moon: Mail or Consumer Goods at 5 t, Packaged Food or Consumer Goods at 12 and 20 t; Moon→Earth: Souvenirs or Optical Glass at 5 t, Optical Glass at 12 and 20 t. ## Step 10G.6 - **A backfill migration instead of a database reset.** The CR allows resetting, but contracts are JSONB, so a custom SQL migration can give old rows the new fields and keep saves. It uses a `pg_temp` helper function (drizzle runs all migrations in one session) and touches only contracts without a `goodId`, so rerunning is a no-op. - **Summaries carry `goodId`, `massTonnes` and `shipper`** for Step 10G.10's "Delivered 6 t Medical Supplies for Meridian Health to the Moon". ## CR-008 — Boards That Feel Fresh (Phase 10H) - **User report: "the jobs aren't refreshing".** Investigated before changing anything: the server stores a new board every period (the user's dev database had one per 10-minute period, all different), and a real browser left on the board refetched one second after the timer hit zero and showed the new cards. The new boards just look the same for an early player: Earth → Moon runs of three goods, with identical pay for the same good and mass. The user picked three fixes from four offered: visible refresh, per-job pay rate, more early goods (not more open slots). ## Step 10H.4 - **Fit jobs choose a good, not a class.** Picking a class first (from `cargoClassMix`) and then a good meant a skilled pilot could be locked into one class whose only route good didn't fit the ship: a Heavy Cargo 1 Courier at the Moon got nothing but 5–20 t "Lunar Titanium" (a 30–120 t good). Fit jobs now pick among all qualified goods on the route that fit the slot; the board's class mix for non-fit jobs is untouched. - **No undersized lots.** When every good of the chosen class is too heavy for a slot, the slot carries a Standard good on the route that fits instead (the CR-007 fallback, extended to mass). Goods too light for a big band still make bulk lots, as the user chose for CR-007; the Earth class-mix test (±3 points) still passes. ## CR-009 — Ship Upgrades (Phase 10I) - **Renumbered on arrival:** the request was titled "CR-006 — Ship Upgrades" for "Phase 10F". CR-006 to CR-008 and Phases 10F–10H already exist, so it is stored as **CR-009** and done as **Phase 10I** (its steps 10F.n are 10I.n). - **Decision numbering:** the CR's 0.5.45–0.5.50 are recorded as **0.5.63 Effective stats, 0.5.64 Where and when, 0.5.65 One level at a time, 0.5.66 Shop time, 0.5.67 Models stay distinct, 0.5.68 Courier to Mars**. - **Error codes:** kebab-case, reusing existing codes that mean the same: `ship_busy` → `ship-not-idle`, `insufficient_funds` → `insufficient-funds`, `tutorial_active` → `tutorial-active`, `too_slow` → `too-slow`. New: `not-at-hq`, `ship-in-shop`, `upgrade-maxed`, `not-upgradable`. - **Fits the current build:** the CR assumes Priority windows and a too-slow reason, which CR-007 added. ## Step 10I.1 - **Courier fuel tanks: +600 % per level** (5 → 35 → 65 → 95 M km). Earth–Mars over the first two game years ranges 82–394 M km (monthly samples), so level 3 reaches Mars only near closest approach and level 2 never does. The Courier's base range (5 M km) and the Step 3.7 range tests are unchanged. - **Freighter fuel tanks: +15 % per level** (650 → 942.5 M km). At the CR's default +20 % a maxed Freighter would reach 1040 M km, past a base Bulk Hauler's 1000 and Jupiter at its farthest, breaking decision 0.5.67. - **Overrides cover the percent as well as the max level** (`modelOverrides`), since the CR allows tuning the Courier's per-level bonus. - **Handling time per tonne stays a game-wide base** (`handlingGameHoursPerTonne`) rather than a ShipModel field: it was a single config value, and upgrades make it per ship through effective stats (10I.2). The "affects a stat that exists" test accepts it as that base; the other four are checked against every ShipModel. - **Reductions are negative percents** (−15 % overhead and handling per level), so one linear rule covers every upgrade. - **Upgrade costs and shop times are config** (`upgradeLevels`, one entry per level), so the max level any upgrade may have is bounded by its length (tested). ## Step 10I.2 - **Effective stats are a ShipModel plus handling time** (`EffectiveStats extends ShipModel`), so every function that took a model (route check, travel time, fuel) takes effective stats unchanged. Only the place that looks a ship up changed: `shipStats(ship)` replaces the plain model lookup in dispatch, deadhead and record code. - **Upgrades ride on `ShipState` as an optional `upgrades` map**; absent means none, so existing callers and tests needed no edits. - **Levels above a model's max are clamped** in `effectiveStats`, so bad data can never break the "models stay distinct" caps. - **Boards fit effective capacity:** CR-006's fleet-fitted boards now see an upgraded Cargo Bay. The Priority window's reference ship stays the base model, so windows are the same for every player. ## Step 10I.3 - **Check order:** tutorial, loaner, in flight, in the shop, at an HQ, maxed, affordable. Situation checks come before price, so the reason shown is the one the player must fix first. - **"At an HQ" is an input (`atHq`)**, not computed from bays in the sim, so the server decides it from the player's bays and the client from state, both with the same rule. - **Success returns everything the server writes**: the new level, its cost, and in-shop-until (now + that level's shop time). - **`ship-in-shop` also blocks deadheads**, since a ship in the shop isn't going anywhere. It sits right after `ship-not-idle` in both validators. - **Costs are rounded to whole credits** like fuel; for current prices all three levels are exact. ## Step 10I.4 - **Ships remember their latest shop job** (`shop_upgrade_id`, `shop_level` with `in_shop_until`) until the player acknowledges it, so the "out of the shop" line (Step 10I.8) knows what finished while they were away without a job table. The API shows it as `shop`, and `inShopUntil` only while the ship is actually in the shop. - **Acknowledging a finished upgrade reuses `acknowledgeSummaries`** with optional `shipIds`, since both are cleared from the same "While you were away" modal. The request needs at least one id overall (so `dispatchIds` may be empty). - **Effective stats are sent per ship** (`stats`), computed by the server with the sim, so every screen can show them without recomputing; the client still uses the sim for predictions. - **Upgrade codes come from the sim's `UPGRADE_FAILURES`**, joining the dispatch and deadhead reasons in the API's error list; the new ones are `not-at-hq`, `ship-in-shop`, `upgrade-maxed` and `not-upgradable`. ## Step 10I.5 - **The upgrade runs in the player's locked transaction** (`withPlayer` locks the company row, Step 6.14), so the funds check and the charge cannot interleave with another purchase: of two simultaneous upgrades the player can afford one of, exactly one succeeds and the balance never goes negative (tested). - **The level is raised at purchase** (decision 0.5.66). Effective stats change at once, but the ship can't fly until in-shop-until, so there is nothing to finish later. - **Server messages for upgrade refusals** live with the other validation texts (`UPGRADE_MESSAGES`); an unknown upgrade id is `404 not-found`. ## Step 10I.6 - **The detail view is a sheet opened from an "Upgrades" button on each Fleet card**, so the card keeps its quick actions (Find work, Deadhead) and the shop is one tap away. - **The client decides what's enabled with the sim's `validateUpgrade`**, using "a bay where the ship is" for at-HQ, exactly as the server does. - **"In the shop" is decided by the clock** (`inShopUntil` vs now), so the countdown and the card's actions return the moment the work is done, without a refetch. - **Handling time reads in game minutes per tonne** (1.20 → 1.02 min/t) and overhead in hours, so small changes stay visible. ## Step 10I.7 - **The client computes effective stats with the sim** (`shipStatsOf`) from the ship's model and upgrade levels, rather than reading the API's `stats`, so predictions run on the exact functions the server uses. `stats` stays in the API for display and other clients. - **"Market ship comparison for owned ships"**: the Market had no owned-ship comparison, so each model card now lists the player's upgraded ships of that model with their effective speed, capacity and range under the base specs. - **Map reachability** keeps listing purchasable models (base stats) and adds the player's upgraded ships that reach only because of their upgrades, e.g. "your Dusty Mule (upgraded)" for a Courier with Fuel Tanks 3 near Mars's closest approach. ## Step 10I.8 - **"Finished since the last visit" means finished and not yet acknowledged**: a ship keeps its shop job after the shop time passes until the player dismisses the modal, so an upgrade that finished while the app was closed is reported once, like a delivery. - **Acknowledging only clears finished jobs**; a ship still in the shop keeps its job even if its id is sent. - **The modal can open for shop news alone** (no deliveries), and the acknowledged count includes cleared upgrades. - **The HQ card only appears while a ship is in the shop**, unlike the always-on pilot rest card: the shop is an occasional action, rest is a rhythm. ## Step 10I.9 - **GDD §8 is left to the designer**, as the CR says; it doesn't mention upgrades yet, so the "confirm it matches" check is flagged for the user rather than written here. - **The manual check was scripted** (`cr009-smoke.mjs`). It found that with 10-minute shop times and 10-minute boards, the heavy job you spot before upgrading always expires before the ship leaves the shop; the script finds a fresh 21–23 t job afterwards. Whether to keep that tension is a Phase 12 call. ## CR-010 — Veteran Pilot and Profit-Share Pay (Phase 10J) - **Renumbered on arrival:** titled "CR-007 … Phase 10G"; CR-007 to CR-009 and Phases 10G–10I already exist, so it is **CR-010**, done as **Phase 10J** (its 10G.n are 10J.n). - **Decisions:** 0.5.12 Pilot pay is replaced in place (profit share). The CR's 0.5.51 and 0.5.52 are **0.5.69 Deduction order** and **0.5.70 Veteran pilot**. Its "CR-001 decision 0.5.24" (the loan) is this plan's §0.5.32. - **Goal and timing target changes** to CR-001's goal 4 and the "hire first pilot" target are applied in their steps (10J.3, 10J.7). ## Step 10J.1 - **The flat fee is removed one step later**, in 10J.2 together with the formula that replaces it, so every commit builds. `pilotProfitShare` is added here. - **The veteran lives with the onboarding content** (`content.veteranPilot`: name, bio, level, skills) since it belongs to the deal. Name and bio are from the CR's example. - **Skill points are checked against the level:** a level-2 pilot has 2 × 2 = 4 points; the veteran spends 1 (Heavy Cargo 1) and has 3 unspent. ## Step 10J.2 - **Rounding:** pilot pay uses `Math.round` to whole credits, the same as payouts and fuel, so the CR's 0.25 × (400 − 150) = 62.5 pays **63**. The loan repayment keeps its own rule (rounded down, decision 0.5.32), computed on what is left after pilot pay (0.5.69). - **The split is exact:** credits added + pilot pay + loan repayment always equal the payout (fuel was charged at dispatch); tested over 500 random cases. - **`Resolution.pilotPay`** is stored with each resolution so summaries can show the share (10J.6); older stored resolutions without it read as 0. ## Step 10J.3 - **New goal id `veteran-contract`** (was `first-hire`), with a new condition kind `veteran-contracts`. A new id keeps any old "first hire" claim from counting as this different goal; `pilots-hired` stays available as a condition kind but no goal uses it now. - **The title is built from content** (`Fly a contract with ${veteranPilot.name}`), so renaming the veteran renames the goal. - **Counting is the server's job**: the sim only compares `veteranContracts` to the goal; which deliveries count (contracts, not deadheads or tutorial runs, flown by the veteran) is decided where they are stored (10J.4). ## Step 10J.4 - **The veteran is a pilots row with `is_veteran`**, otherwise identical to a hire (pay, duty, rest, assignment rules). The flag only identifies them for goal 4 and the bio line. - **Level 2 is stored as XP**: the veteran starts with exactly level 2's threshold (250 XP), so levels and skill points come from the normal rules; one point is recorded as spent on Heavy Cargo 1. - **Created inside `acceptDeal`**, after the deal check, in the player-locked transaction: a refused or repeated accept creates nothing (tested). ## Step 10J.5 - **Name and bio come from `content.veteranPilot`** in both places; the pilot card shows the bio when the API marks the pilot `isVeteran`. - **The modal adds a "Pilot" row** to its "You get / Loan / Repayment" list, in the CR's "…and [name]" style, and its opening line mentions the pilot. ## Step 10J.6 - **The share uses the sim's `pilotShare`** with the trip's predicted fuel, so the sheet's net profit equals what the server will credit (before any loan repayment). - **`ResolutionSummary.pilotPay`** comes from the stored resolution; older resolutions without it show 0, so no share line. ## Step 10J.7 - **CR-001's text is amended in place** where the CR asks (goal 4, the deal, the modal, and the "hire first pilot" target now "buy a second ship"), with a note of what changed, so its history stays readable. - **The manual check was scripted** (`cr010-smoke.mjs`); its numbers confirm the deduction order: (1118 − 230) × 0.25 = 222 to the pilot, then 30 % of the remaining 896 (268, rounded down) to the lender, 628 credited. ## CR-011 — Settings Screen and Company Backup (Phase 10K) - **Renumbered on arrival:** titled "CR-008 … Phase 10H"; CR-008 to CR-010 and Phases 10H–10J already exist, so it is **CR-011**, done as **Phase 10K** (its 10H.n are 10K.n). - **Decisions** 0.5.53–0.5.58 are recorded as **0.5.71–0.5.76**; "account linking" is added to §0.3 (deferred). ## Step 10K.1 - **Reduced motion is stored as an override** (`null` = follow the device), so a player who never touches it keeps tracking their OS setting, as decision 0.5.76 asks. - **Clock default is `locale`** (the device's own 12/24-hour habit) rather than a fixed format. - **Saved data is validated field by field** on load; anything unknown or broken falls back to that field's default instead of discarding everything. - **The store is built by a factory** (`createPreferencesStore(storage)`) so tests can simulate a reload or a throwing storage; the app uses one instance on `localStorage`. ## Step 10K.2 - **Settings is a route (`/settings`) inside the normal layout**, reached only from the header gear, so the seven-tab navigation is unchanged and the back button works. - **Reduced motion is a three-way choice (Device / On / Off)**: "Device" stores no override, so the default keeps following the OS (decision 0.5.76). - **Items appear with the step that makes them work** (rename and new company, backup, version, developer tools), so no placeholder controls ship. ## Step 10K.3 - **The default "device" clock changes how the game clock looks** on 12-hour devices ("2145-07-11 7:18 PM"); before, it was always 24-hour. That is decision 0.5.76's default; players who prefer 24-hour pick it in Settings. - **Countdowns default to real time everywhere, Priority windows included.** CR-007's chip showed game hours and switched to real time on tap; it now shows the chosen countdown format, with both in its tooltip, so one setting governs every span. - **Game-time spans read "4.3 game h"** so they can't be mistaken for real hours next to a real countdown. - **Reduced motion has two sources**: the OS media query (unless the player turned motion back on) and the player's override. The map keeps moving ships but stills its background. - **Pure helpers read preferences directly** (`displayPreferences()`) where they format strings outside React (the dispatch sheet's problem lines); those re-render every second with the clock anyway. ## Fix: header on phones (user report during Phase 10K) - **User report:** "On a phone screen the credits and time line can't be read" (screenshot at about 430 px): the credits chip was drawn over the DEADHEAD wordmark. The wordmark showed from 420 px, and 10K.2's gear plus 10K.3's device-locale clock ("2145-07-11 7:18 PM") made the right side wider than the space left. - **Fix:** the wordmark now shows from 640 px (`sm`), the logo side clips instead of overflowing, the game clock shows only the time below 640 px (the date returns above), chips sit a little closer, and the gear is 32 px on phones. The gear icon is now a cog (it read as a sun). - **Checked** in headless Chrome at 375, 390 and 430 px with a 1,234,567 cr balance and a 12-hour clock: no overlapping header items and no horizontal scroll (`docs/screenshots/header-phone-390.png`). ## Step 10K.4 - **The map now remembers its view per device** (the CR's "saved map camera"), saving at most every 300 ms while panning; "Reset map view" clears it. - **The intro card became the first dismissible tip**: it still shows only before the first tutorial run, but can be closed, and "Show tips again" (or a new company) brings it back. Other hints stay state-driven (they disappear on their own when no longer true). ## Step 10K.5 - **Name rules live in the request schema** (`companyNameSchema`): the API trims, then checks length and characters, so a bad name is a `400 validation-error` before any database work and nothing changes. Allowed punctuation: `. , ' & ! ? -`. - **Default names come from the world seed** (`companyName`, two word lists like ship names), as decision 0.5.75 says; every new company was "Deadhead Freight" before. Existing companies keep their name. ## Step 10K.6 - **One starting-state function** (`createStartingState`: company, start-discovered locations, avatar at Earth) serves guest creation and a new company, so they can't drift apart; the test compares a reset company with a brand-new guest field by field. - **The player row survives** (its id, sign-ins and later its backup code); only its tutorial fields reset, with a world seed guaranteed to differ. - **No job cleanup is needed:** arrival jobs look up the player's due dispatches when they fire, and the deleted ones are simply gone. - **The new company also gets a new default name** (from the new seed); a renamed company's name does not carry over, since everything else starts over too. ## Step 10K.7 - **Client and server share one name rule** (`companyNameSchema` from `@deadhead/schema`), so the inline message is the same check the server will apply. - **The confirmation is case-sensitive**: the typed text must equal the company name exactly. - **A new company clears dismissed tips** so the tutorial intro card greets the restart, as the CR's "Contracts with the intro card" asks. ## Step 10K.8 - **Restoring creates a new guest sign-in for the device** and links it to the player in `player_logins`; session lookup checks the player's own user, then those links. Better Auth's public API can't start a session for an existing user, and this way several devices share a player safely (decision 0.5.73). The device's previous guest company is left behind, as the CR warns. - **Code format:** 20 characters from a 31-character alphabet without 0/O/1/I/L (`23456789ABCDEFGHJKMNPQRSTUVWXYZ`), shown `XXXXX-XXXXX-XXXXX-XXXXX`: about 99 bits, above the 80 the CR asks. Input ignores case, spaces and dashes. - **Hashing:** SHA-256 of the normalized code, with a unique index; the code is random enough that a slow password hash adds nothing, and lookup stays one indexed query. - **Endpoints:** `GET` says whether a code exists; `POST` makes a new one, shown once, and the old one stops working. The server never returns an existing code. - **Errors:** a wrong code is a generic `409 restore-failed` (no hint whether it was close); too many failures are `429 rate-limited`. 409 rather than 401, because the client treats 401 as "signed out". - **Rate limit:** 5 failed attempts per 15 minutes, counted per IP and per device id (the client sends `x-device-id`), in memory per server process, which is enough for the single-instance base game (Phase 13 can move it to Postgres). ## Step 10K.9 - **The code is shown once, when made**, like the server promises: reopening "Back up company" with a code on file offers to generate a new one (with the warning) rather than showing the old code, which the server no longer knows. - **Restore from the start screen never warns** (there's no company yet); in Settings it warns only when the current company is past the tutorial, as the CR asks. - **Success replaces the cached player state with the restored one** and refreshes boards, then goes to HQ; the server has already switched the device's session cookie. - **The device id is a random UUID in local storage**, sent only with restore requests for rate limiting; it isn't tied to the player. ## Step 10K.10 - **Version is the web package's version, bumped to 0.10.0** (it was 0.0.0) to mark the Phase 10 build; **build is the git short hash** at build time ("dev" when built outside git), injected with Vite `define`. Tests use fixed stand-ins. - **"Current screen" in debug info is the last screen before Settings**: the button lives in Settings, so the useful answer is where the player was when they opened it. The layout records it. - **Player state now includes `playerId`** for debug info; it was only returned at sign-up. ## Step 10K.11 - **"Advance" adds to the offset** (`devAdvanceClock`), where the existing clock-offset route sets it outright; the client doesn't know the server's offset, and E2E tests keep using clock-offset unchanged. - **The developer routes are in the catalog** (so the client calls them typed, as the CR's §6 asks) but are only registered outside production, like clock-offset; in production all three return 404 (tested). They require a session so they act on the caller's player. - **The clock is shared:** advancing it moves time for every player on that server, which is fine for development servers. ## Step 10K.12 - **Amendment "12.x"** is recorded on Step 12.3 and applies to all the end-to-end steps (12.3–12.6). - **Step 11.2's offline rule is noted for Phase 11**, where the offline behaviour is built; server-backed settings already fail with the usual error when offline. - **The two-device manual check was scripted** with separate sessions; a real second phone or a private window is the user's check. ## CR-012 — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (Phase 10L) - **Renumbered on arrival:** titled "CR-009 … Phase 10I"; CR-009 to CR-011 and Phases 10I–10K already exist, so it is **CR-012**, done as **Phase 10L** (its 10I.n are 10L.n). - **Decisions:** the CR's 0.5.59–0.5.63 are **0.5.77–0.5.81**; its amended "0.5.49" and "0.5.50" are this plan's **0.5.67** and **0.5.68** (ship upgrades CR). Its "CR-006" is **CR-009**; "CR-008" is **CR-011**. Error code `would-strand`. ## Step 10L.1 - **New ship values** follow the CR's §2 descriptions: Lighter 42,400 cr (40 % of the way from Courier to Freighter), 1.6M km/h, 80 t (80 % of a Freighter), 2 h overhead, 0.012 h/t handling (40 % faster loading), 350 cr/M km; Clipper 61,600 cr (60 %), 6.5M km/h, 30 t (1.5× Courier), 2 h overhead, 600 cr/M km. Ranges (Lighter 10, Clipper 198) come from the measured leg distances; the reach table is enforced in 10L.4. - **"Clipper is the fastest model" includes the loaner** (6.0M km/h), so the Clipper is 6.5M km/h rather than just the fastest for sale. - **Handling time becomes per model where set** (`ShipModel.handlingGameHoursPerTonne`, optional, defaulting to the game-wide value); upgrades scale the model's own base. - **Roles are content** (`role`), one line per model including the loaner, for the Market (10L.7). - **The new sprites are listed in the manifest as pending** (`file: null`) so the "every ship has a sprite in the manifest" test holds; they use the placeholder until art arrives (10L.10). ## Step 10L.2 - **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg. - **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used. ## Step 10L.3 - **The check runs after range and landing, before Priority, skills, expiry and credits** (the CR says after range and landing). The arrival time is the trip's own travel time (cruise, overhead, handling for the contract's mass; empty for a deadhead). - **"Other discovered location" includes the origin**, so a ship can always be allowed to fly back where it came from if that is still in range. - **`discoveredIds` is optional on a contract proposal**; absent means every location counts. Tutorial runs and older callers keep working, and the client's eligibility (which doesn't pass it yet) is lenient until 10L.9; the server's check is the one that counts. - **The rule only looks at arrival plus margin**, as the CR says. A ship may land somewhere it can't leave at once but can within the margin: with today's ranges a Clipper flying Moon→Mars can land on a date when it can't leave Mars right away but can 48 hours later. The exhaustive test checks the margin time, not the arrival. - **Tests use a fixture** (a Moon with factor 0.5 and a "moon-station" sharing its orbit) because no real ship and date strands cleanly enough to also show "passes once another location is discovered". ## Step 10L.4 - **Ranges come from the measured leg distances over 16 game years** (monthly): Earth–Mars 58.9–399.6, Moon–Mars 58.5–400.0, Earth–Ceres 262.7–565.8, Moon–Ceres 262.8–565.9, Mars–Ceres 175.5–662.8, Ceres–Jupiter 378.9–1,216.8, Earth–Jupiter 592.8–964.8, Moon–Jupiter 592.4–965.1, Mars–Jupiter 526.9–1,029.8 M km. - **Freighter 650 → 450.** It must cover Earth→Mars at its farthest (≥ 400), but never Moon→Jupiter (450 × 1.3 = 585 < 592.4) or Mars→Jupiter (450 × 1.15 = 517.5 < 526.9); from Ceres (585) it reaches Jupiter on some dates. 450 also clears Earth→Ceres on most dates. - **Bulk Hauler 1000 → 1250**, so every leg is always in range, including Jupiter→Ceres at its farthest (1,216.8) with a factor of 1. - **Clipper stays at 198**: Earth→Mars is in range on just over 30 % of dates, and Moon→Ceres (198 × 1.3 = 257.4) stays below Ceres's closest approach (262.8). The margins are small, so the Clipper's range shouldn't move without re-running this table. - **Decision 0.5.25 is superseded** (noted in the plan): the Freighter no longer reaches Jupiter from Earth at all. - **No other stats changed.** The existing upgrade-cap tests (0.5.67) and the Courier-to-Mars test (0.5.68) still pass at these values; 10L.5 adds the new caps. ## Step 10L.5 - **Courier tanks 600 → 450 % per level.** Over 16 years Mars comes as close as 58.9M km from Earth, so the old level 2 (65M km) would have reached it, breaking decision 0.5.68. At 450 % level 2 is 50M km (never) and level 3 is 72.5M km, which reaches Mars only in the closest approaches (months 150 and 176–177 of the 16 years sampled). A maxed Courier now reaches Mars rarely, which fits "near closest approach only". - **The Freighter's 15 % tanks override stays**: 450 → 652.5M km maxed, short of the Bulk Hauler (1,250) and of Jupiter from Earth at its farthest (965). Its comment now quotes the new numbers. - **No caps for the new models**: with the default percentages a maxed Clipper holds 43.5 t (Freighter 100), a maxed Courier flies 3.9M km/h (Clipper 6.5M), and a maxed Lighter's range is 16M km (20.8 from the Moon), far below Mars's closest 58.5. - **The Lighter cap is a sim test** because it needs distances; the plain stat inequalities stay in the content test. ## Step 10L.6 - **No schema change was needed** beyond the sim's failure lists: `ERROR_CODES` spreads `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, so `would-strand` arrived with 10L.3 (with its server message and the contract-dispatch discovered ids). - **The server's stranding test uses a real date** (1,420 game days after the test clock's start) on which a base Clipper can hop Moon→Mars but can't leave Mars within the margin, and asserts that with the sim first so a future range change fails loudly rather than silently. ## Step 10L.7 - **A third part, "Other dates", was added** to the CR's "From Earth now … · By hopping …": without it a Clipper on a far-Mars date would show Mars nowhere, though it can fly there directly later. It lists places an HQ reaches directly on some date but not today. - **"By hopping" means reachable only through other locations**, over legs each in range on some date in the 16-year horizon; places an HQ reaches directly never appear there. Hops are not checked for timing (that is the player's job), and undiscovered places are included because the Market describes the ship, not the player's map. - **The horizon starts at the game epoch** and is computed once per model, then cached; only the "now" part depends on the current date. The line is computed at render from the server clock, not ticked, since range changes over game days. - **Origins are the player's HQ locations** (one part each); a player with no bays sees no Reach line. ## Step 10L.8 - **Origins are discovered locations with an HQ or an idle ship**, in content order. In-flight ships don't count (they aren't anywhere yet). A player with neither, as in the tutorial, is measured from Earth, as before. - **Upgraded ships are listed under the origin they are at**, only when their base model can't make the leg from there but their effective stats can ("your Swift Heron (upgraded)"). Before, they were checked from Earth wherever they were. - **Origins with nobody are left out**; if none can reach, the panel says "No ship can reach it from your bases right now." ## Step 10L.9 - **The client now passes discovered ids** to `validateDispatch`, so the sheet agrees with the server (10L.3 left it lenient). - **The stranding line replaces the range line, never adds to it**: it is shown only when the leg itself is in range, matching the sim's check order. - **`wouldStrand` accepts a bare `ShipModel`** (travel time already falls back to the game-wide handling rate), so the sheet's explainer can use it with the stats it already has. ## Step 10L.10 - **The art arrived with the CR**, so both ships use real sprites rather than the placeholder. Both face nose-up on transparent backgrounds, like the existing ships. - **The transparency check tolerates alpha ≤ 2/255** rather than editing the user's file: `ship-lighter.png` has a stray alpha-1 pixel in one corner, which is invisible. The script never writes sources (decision 0.5.37). ## Step 10L.11 - **The Lighter-first / Clipper-first balance check waits for the balance script** (plan Step 12.1, not built yet); the amendment is recorded on Step 12.1. - **The manual check found a hop needs Long Haul**: every Mars→Ceres leg is over 100M km, so it needs Long Haul 1. A fresh veteran (Heavy Cargo 1, 3 unspent points) qualifies once a point goes into Long Haul. This is existing skill gating, not a range problem, but it makes the Clipper's hop to Ceres a mid-game move. - **Clipper fuel is expensive by design** (600 cr/M km): a ~190M km hop costs about 110,000 credits, in line with a Freighter's long runs. The check topped up credits with the developer tool. Payouts for those legs are tuned elsewhere and were not changed here. ## CR-013 — The Veteran's Clipper (Phase 10M) - **Renumbered on arrival:** titled "CR-010 … Phase 10J"; CR-010 to CR-012 and Phases 10J–10L already exist, so it is **CR-013**, done as **Phase 10M** (its 10J.n are 10M.n). - **Decisions:** the CR's 0.5.64 and 0.5.65 are **0.5.82** and **0.5.83**; its amended "0.5.52 Veteran skill" is this plan's **0.5.70**. Its "CR-007" (veteran, steps 10G.n) is **CR-010** (10J.n); its "CR-009" (new ships, 10I.11) is **CR-012** (10L.11). ## Step 10M.1 - **The deal's contents are config** (`deal.avatarShipModelId`, `veteranShipModelId`, `bayLocationId`) so the loan formula and the server's deal both read the same ids; the stored `dealLoanAmount` number is gone. - **The loan is a pure function of content** (`dealLoanAmount`), computed once into the bundle. Tests call it with altered prices to show it follows them. - **The loan grows from 5,000 to 66,600 credits.** At the 30 % repayment share, paying it off takes far more deliveries than before; the "under 4 hours of active play" target is for the balance script (Step 12.1) to check.