deadhead-space-freight-tycoon/DECISIONS.md

224 KiB
Raw Blame History

Decisions

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

Step 1.1

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

Step 1.2

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

Step 1.3

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

Step 1.4

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

Step 1.5

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

Step 2.1

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

Step 2.2

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

Step 2.3

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

Step 2.4

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

Step 2.5

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

Step 2.6

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

Step 3.1

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

Step 3.2

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

Step 3.3

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

Step 3.4

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

Step 3.5

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

Step 3.7

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

Step 3.8

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

Step 3.9

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

Step 3.10

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

Step 3.11

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

Step 3.12

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

Step 3.13

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

Step 3.14

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

Step 3.15

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

Step 3.16

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

Step 3.17

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

Step 3.18

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

Step 4.1

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

Step 4.2

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

Step 5.1

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

Step 5.2

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

Step 5.3

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

Step 5.4

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

Step 5.5

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

Step 5.6

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

Step 6.1

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

Step 6.2

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

Step 6.3

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

Step 6.4

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

Step 6.5

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

Step 6.6

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

Step 6.7

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

Step 6.8

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

Step 6.9

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

Step 6.10

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

Step 6.11

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

Step 6.12

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

Step 6.13

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

Step 6.14

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

Step 7.1

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

Step 7.2

  • One job per dispatch, resolve-arrivals with { playerId, dispatchId }, scheduled for the arrival time. The handler resolves everything due for that player under the player lock, the same code as lazy resolution, so it is a no-op when a request got there first. A job that fires early resolves nothing; the next request or the next due job picks the dispatch up.
  • Jobs are sent after the dispatch transaction commits. A failed send is logged and the dispatch still succeeds, because lazy resolution on read is the safety net.
  • The app clock is base clock + offset (createOffsetClock). The offset is always 0 except through POST /api/test/clock-offset ({ offsetMs }), which is registered only when NODE_ENV is not production and is not in the route catalog. Job timers use the base (real) clock: startAfter = arrivalAt - offset.
  • The job's handler reads time from the same clock as requests, so tests run handlers directly with a controlled clock (decision 0.5.18).

Step 7.3

  • Retention window: 24 board periods (one day at the default one-hour period), as the server constant BOARD_RETENTION_PERIODS. It is operational housekeeping, not game tuning, so it is not in GameConfig.
  • Cleanup deletes boards and accepted-contract rows whose period is older than the window, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns unknown-contract instead of contract-expired.
  • The job runs hourly on the hour (UTC cron 0 * * * *) through pg-boss's scheduler, only in processes with workers enabled.

Step 8.1

  • Client stack: Vite 8 + React 19, Tailwind CSS 4 (via @tailwindcss/vite, no config file), React Router 8 in declarative mode (<Routes>; BrowserRouter in main.tsx, MemoryRouter in tests). Component tests use Vitest with jsdom and Testing Library (apps/web/vitest.config.ts).
  • The dev server proxies /api to http://127.0.0.1:3000, which keeps the client and API on one origin in development (decision 0.5.20) and sidesteps the server listening only on IPv4 (Step 7.1).
  • Screens are listed once in src/routes.ts, shared by the router and the bottom navigation. The Locations tab is labelled "Places" to fit six tabs at 375px.
  • Header credits and clock are placeholders until Steps 8.3 and 8.5 supply live values.
  • ESLint gives apps/web browser globals.

Step 8.2

  • One generic call(name, input) rather than a generated method per route. Types come from the catalog (RouteRequest / RouteResponse), so a new route needs no client change.
  • Requests: :params are filled from the input; remaining fields go in the query string for GET and the JSON body for POST. credentials: "include" sends the session cookie. The base URL defaults to same origin (decision 0.5.20).
  • Errors: a response failing its schema throws ApiValidationError; a non-2xx response throws ApiError with the server's code and message (or internal-error if the body is not the documented format).
  • ESLint now ignores unused names starting with _, for deliberately unused parameters.

Step 8.3

  • The clock offset lives in a Zustand store (useClock), as the tech stack prescribes for real-time client state. serverNow() is Date.now() + offset; the device clock only affects visuals and is never trusted for outcomes.
  • Sync uses the midpoint of the round trip: offset = server time − (sent + received) / 2.
  • A one-second ticker (startClockTicker) refreshes now in the store so components re-render; components read useClock(s => s.now).
  • The header shows in-game time as YYYY-MM-DD HH:MM (UTC), from sim realToGame.

Step 8.4

  • "Is there a session?" is decided by the server: the client loads player state on start-up; 401 unauthorized means no session, so it shows the start screen. The session itself is Better Auth's persistent cookie, which the browser keeps across reloads ("persist the session"); the client stores nothing. Expired sessions land on the start screen too.
  • After "Start your company" the guest response's state is written straight into the query cache and the app navigates to HQ, so there is no extra request.
  • TanStack Query and an API context arrive here rather than in Step 8.5, because onboarding needs the state query. The header shows live credits from it.
  • Client test helpers (apps/web/test/render.tsx): fakeApi (per-route handlers), samplePlayerState, and renderApp (fresh query cache, memory router).

Step 8.5

  • Arrival refetch is driven by the ticking server clock (useClock), not by per-dispatch timers: when now passes an in-flight dispatch's arrivalAt, state is refetched, which makes the server resolve it lazily. Each dispatch triggers one refetch; Phase 7's job covers anything the client misses.
  • refetchOnWindowFocus: true is set explicitly (it is TanStack Query's default) to document the requirement.
  • Tests fake only Date and setInterval for the arrival case, leaving setTimeout real so TanStack Query's scheduling works; the focus test runs on real timers.

Step 9.1

  • Tutorial goal cost = cheapest ship model + Earth bay lease, read from content, so it follows any rebalancing.
  • The player-state query is fresh for 10 seconds (staleTime), so screens that read it do not each refetch on mount. Returning to the app (refetchOnWindowFocus: "always"), arrivals and actions still refetch immediately.
  • The arrival watcher runs once, in App, not in every component that reads player state.
  • Shared screen helpers: useLoadedPlayer() (screens render only after state has loaded), useGameAction(route) (call an endpoint, then invalidate player state and boards), Countdown (live, from the synced clock), and src/format.ts (credits, durations, names).

Step 9.2

  • The summary dialog sits above all screens (rendered by App). Dismissing hides it immediately (local state) and sends acknowledgeSummaries for the listed ids; the follow-up state refetch confirms it.
  • Tutorial runs are shown as "Company loaner", since the loaner is not in the ships list.

Step 9.3

  • Trip-time predictions use sim with the content the server uses (src/game.ts: distance at the current server time, travelTimeGameHours, converted to real time). They are predictions only (rule 0.1.6).
  • Boards are TanStack queries under the ["board", …] key, which every game action invalidates, so a flown contract disappears from the list.

Step 9.4

  • The distance shown on a contract is the current distance (sim, at the server clock), which is what fuel and travel time use when dispatching now. The payout was fixed at the distance when the contract was offered (decision 0.5.11).
  • Requirements are shown as text ("Needs Heavy Cargo 2, Long Haul 1"); whether a particular pilot meets them is shown in the dispatch sheet (Step 9.5).
  • The bay selector is a native <select>, which works well on phones.

Step 9.5

  • Eligibility in the sheet comes from sim validateDispatch, given the client's copy of state and the synced clock, so the client shows the same reasons the server would return. The server still decides (rule 0.1.6).
  • Pairs are the idle ships at the pickup with their assigned pilots. Uncrewed ships there are listed as "No pilot assigned"; ships elsewhere are not listed.
  • Net profit = payout − fuel − pilot pay (no pilot pay when the avatar flies; decision 0.5.12).
  • ETA is shown twice: in-game (YYYY-MM-DD HH:MM) and real local time on the device.
  • The first eligible pair is preselected; Confirm is disabled when none is eligible.

Step 9.6

  • Reposition is offered only for idle, crewed ships at one of the player's bays, when another bay exists. The ship's assigned pilot flies it. Other cases do not show the button, rather than showing a button that always fails.
  • Flight progress comes from sim dispatchProgress with the synced clock, updating every second.

Step 9.7

  • No "unassign" button yet. The plan's Pilots-screen actions are hire, assign and spend skill point; the endpoint exists for a later UI. Hired pilots start unassigned, and the avatar is unassigned when the tutorial ends, so assignment always has a path.
  • The avatar shows "Flying the company loaner" during the tutorial and has no assign control (decision 0.5.23).
  • XP progress is shown as XP gained since the current level's threshold over the gap to the next threshold; at max level it shows total XP.

Step 9.8

  • Market lists every content ship model with its stats straight from @deadhead/content; no API call is needed until Buy. After a purchase the player-state refresh adds the ship to Fleet, and a status line says where it was delivered.

Step 9.9

  • Distance from Earth is live (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home".
  • Landing requirement "vacuum" is shown as "Any ship", since every ship can land at a vacuum-rated location.
  • Lease buttons are disabled when unaffordable and absent where a bay exists or the location is undiscovered.

Step 9.10

  • Two levels of network error: if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
  • Market and Locations have no empty state: they list static content, which always exists. Their error handling is the app-level one.
  • The placeholder screen is gone; every route has a real screen.

Step 10.1

  • PixiJS v8 used directly (an Application created in an effect), not @pixi/react: the map is one imperative canvas redrawn each frame from player state and the server clock, which needs no React reconciliation. The effect copes with StrictMode double-mounting.
  • Radial scale is a square root of the distance from the Sun, normalised so 1,000M km is the map edge. Order is preserved, and Jupiter (~780M km) sits about 2.3× Earth's map radius instead of 5×. Directions from the Sun are unchanged.
  • The Moon is drawn a fixed small offset from Earth in its true direction; at map scale it would sit on top of Earth.
  • All map logic is plain, testable functions (projection, camera, scene; hit-testing in Step 10.3). jsdom has no WebGL, so component tests replace MapCanvas with a test double (test/setup.ts).
  • Camera state (zoom, pan) is a Zustand store, per the tech stack; zoom is clamped to 0.5–40×, and "Reset view" restores it.
  • The Map screen is lazy-loaded, so PixiJS (~285 KB) downloads only when the map is opened.
  • Seven tabs: a Map tab is added after HQ. To fit 375 px, the Contracts tab is labelled "Jobs" and Market "Shop"; screen titles are unchanged.

Step 10.2

  • Route lines run from the origin's position at departure to the destination's position at arrival, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim dispatchProgress; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan.
  • Idle ships are a count badge per location rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one.

Step 10.3

  • Taps are detected in the canvas's own pointer handling: one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
  • Panels are React components over the canvas, so they work like the rest of the UI. The location panel's board link uses /contracts?bay=<id>; the Contracts screen now preselects the bay from that parameter.
  • Component tests "tap" through the canvas test double, which renders one button per tappable item from the real buildScene and calls the same onSelect. Hit-testing itself is unit-tested with screen coordinates.

CR-001 — Faster Start (Phase 10A)

  • Change request CR-001 is stored in memory-bank/change-requests/ and inserted as Phase 10A, before Phase 11.
  • Decision numbering: the plan's §0.5 already used 21–28, so the CR's new decisions 0.5.21–0.5.26 are recorded as 0.5.29–0.5.34 (Loaner ship, The deal, Handling time, Loan, Scripted tutorial contracts, Onboarding goals). Decisions 0.5.4 and 0.5.16 are replaced, 0.5.3 amended, and 0.5.21 amended (see below).
  • Error code tutorial-active (kebab-case like every other code) implements the CR's tutorial_active.
  • Tutorial boards are keyed by run number as well as period and location. With three contracts per board and accepted contracts hidden, a period-only key would empty the board after three runs and strand the player until the next period. Keying by tutorial runs completed gives every run a fresh board (needed anyway for the run-3 scripted contract); a new period also gives a fresh board, so a contract cannot expire with no replacement.

Step 10A.1

  • Company Courier: 6M km/h (twice the Courier), 0.3 h overhead (Courier 2 h), 20 t, same range, fuel and landing as the Courier, purchasable: false. Its price is kept only because the schema requires one; it is never sold.
  • tutorialLoanerShipModelId is a new config field naming the loaner model; the CR's field list did not include it, but the sim needs to know which model the loaner is (it used "first Courier archetype" before).
  • Tutorial mass tiers 5 / 12 / 20 t and handling 0.02 game h per tonne: a full 20 t Company Courier run Earth→Moon is about 0.76 game hours (~46 real seconds), a light one about 0.46 h (~28 s).
  • Starting credits 500 (a little over two Courier fuel loads from Earth at ~230 each). The replacement Step 2.5 test checks that the light tier's tutorial pay over the pre-deal runs, plus starting credits, covers three Courier Earth→Moon fuel bills.
  • dealLoanAmount 5,000 = Courier price + Earth bay lease, and a test keeps it equal to that sum.
  • Goal conditions are data (a discriminated union: own-ship-deliveries, skill-points-spent, ships-owned, pilots-hired, locations-discovered, loan-paid-off), so Step 10A.5's evaluator needs no per-goal code. Rewards are first guesses (300–2,500).
  • content.purchasableShipModels is what the Market lists; buying a non-purchasable model returns 404 not-found, like an unknown model.

Step 10A.2

  • travelTimeGameHours takes mass and config as arguments, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
  • Client trip-time predictions pass the contract's mass, so the dispatch sheet and tutorial board match what the server will record.

Step 10A.3

  • The tutorial board has no randomness: location, run number and period fully determine it, so the world-seed input was dropped. Contract ids are tutorial:<location>:<run>:<period>:<slot> (run is 1-based), unique per run and period.
  • Slot order is light, medium, full; a scripted contract keyed to the upcoming run (runsCompleted + 1) replaces the medium slot, keeps the medium mass, uses its own cargo, and pays round(normal payout × tutorial share × pay multiplier).
  • Contracts carry an optional scriptedId (sim Contract and API contractSchema) so the client can style scripted contracts and look up their flavor message.
  • SimContent includes scriptedTutorialContracts; the content bundle already provides it.
  • Temporary: the server passes runsCompleted: 0 until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.

Step 10A.4

  • The lender's cut is rounded down to whole credits: repayment = min(balance, floor(credits × share)), or 0 when the balance is 0 or the credits after pilot pay are not positive. Credits are whole numbers everywhere, and rounding down favours the player by under one credit.
  • Resolution.credits is now the net amount (after pilot pay and repayment); loanRepayment is reported separately for the server to apply and the summary to show.
  • Temporary: the server resolves with loanBalance: 0 until the balance column exists (Steps 10A.6 and 10A.9).

Step 10A.5

  • Goals are evaluated against a small GoalProgress summary (own-ship deliveries, skill points spent, ships owned, pilots hired, extra locations discovered, deal accepted, loan balance) rather than full player state. The server builds it from the database, and the evaluator stays independent of storage.
  • "Pay off your loan" requires the deal to have been accepted, so a player who never had a loan does not complete it by default.
  • The evaluator takes the goal list as an argument (not part of SimContent), since only goal code needs it.

Step 10A.6

  • The repayment is stored in each dispatch's resolution JSON (loanRepayment), next to the other applied effects, rather than as a separate column.
  • purchasable lives only in content. Ship models are not stored in the database; the Market lists content.purchasableShipModels, and the server refuses non-purchasable models (Step 10A.1).
  • Tutorial boards are keyed by run (contract_boards.tutorial_run, 0 for bay boards) as well as period and location (see CR-001 notes).
  • New state fields: company.loanBalance; tutorial.runsCompleted and tutorial.dealAvailable (computed by isDealAvailable: tutorial on, deal not yet taken, runs ≥ tutorialRunsBeforeDeal, loaner at Earth, no run in flight); goals as { id, status }, empty until Step 10A.10 fills it; summaries gain loanRepayment and scriptedId.
  • New routes: POST /api/deal/accept returns the whole new PlayerState, since the deal changes almost everything; POST /api/loan/repay and POST /api/goals/:goalId/claim return { company }.
  • Error codes: tutorial-active (purchases during the tutorial), deal-not-available, unknown-goal (404), goal-not-ready, goal-already-claimed.

Step 10A.7

  • The run counter increments when a tutorial dispatch resolves (in the same locked resolution pass), not when it is dispatched, so "runs completed" means runs that landed.
  • Purchase blocking happens first in buy-ship, lease-bay and hire-pilot (requireTutorialOver), so a tutorial player gets tutorial-active before any other check and nothing changes.
  • The Step 6.7 tests were removed one step early. They bought a ship and leased an Earth bay during the tutorial, which this step forbids, so they could no longer run. CR-001 §6 replaces Step 6.7 with Step 10A.8, which also removes the old automatic rule.
  • Tests for purchases, races and pilot assignment now start from an owner (signUpOwner), since only owners can buy. The "avatar can't be assigned during the tutorial" test puts a ship in the database directly.
  • tutorialRunsBeforeDeal = 3 is tested by mocking content in its own file, like the earlier cannot-land test.

Step 10A.8

  • The deal hands over a content courier model at Earth and an Earth bay. Constants DEAL_SHIP_MODEL_ID and DEAL_BAY_LOCATION_ID are server code, since they describe the story beat ("his old Courier and his Earth bay"), not tuning.
  • The endpoint returns the full new PlayerState so the client can switch straight to the post-tutorial UI.
  • Exactly once: the player lock serializes accepts, and isDealAvailable requires deal_accepted to be false, so a second or simultaneous accept fails with deal-not-available and changes nothing.
  • The old automatic tutorial end (maybeEndTutorial, Step 6.7) is deleted; accepting the deal is now the only way the tutorial ends (decision 0.5.4 as replaced).

Step 10A.9

  • Within one resolution pass the balance carries forward: each arrival repays against what the previous one left, so several arrivals resolving together never repay more than the balance.
  • Repaying is checked against the amount actually taken: payment = min(amount, balance); if that exceeds credits the request fails with insufficient-funds and nothing changes. With a zero balance, a repay request succeeds and moves nothing.
  • The repay endpoint returns { company } (credits and balance) and runs inside withPlayer like every money change.

Step 10A.10

  • "Own-ship deliveries" = resolved, non-tutorial dispatches that carried a contract (repositions store a null contract and do not count).
  • Goal status is computed fresh from current state on every read and every claim; nothing is cached, so a claim checks the same condition the player saw.
  • Claims during the tutorial fail with goal-not-ready, since goals do not exist until the tutorial is over.

Step 10A.11

  • The intro card and "Suggested" highlight show only before the first run (runsCompleted === 0); after that the board is a plain choice.
  • The suggested contract is the board's first slot, which the v2 generator always fills with the light tier (fastest, most XP per minute).
  • Story names live in client copy: "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content.

Step 10A.12

  • XP shown on a tutorial contract is sim deliveryXp at the current distance, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
  • Scripted contracts show their content name ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's scriptedId.

Step 10A.13

  • The deal dialog waits for the delivery summary: the fourth run's landing also produces a "while you were away" entry, so the deal appears once that is dismissed, instead of two dialogs stacking.
  • The accept response is the new player state, written straight into the query cache before navigating to HQ, so HQ shows the ship, bay and loan without another request.
  • The dialog has a single button ("Shake on it"); there is no decline (CR-001 §7).

Step 10A.14

  • During the tutorial HQ shows run progress toward the deal ("Company runs: n of 4") in place of the old savings goal, so HQ still points at the next milestone.
  • The loan card shows while a balance remains and disappears at zero; the "Pay off your loan" goal (Step 10A.15) marks the moment.
  • Paid-off progress is measured against dealLoanAmount.
  • The UI blocks bad pay-down amounts before sending (not a whole positive number, above the balance, above credits) with a short reason; the server still enforces the same rules.

Step 10A.15

  • HQ shows the next three unclaimed goals in content order, whether ready or not, so the player always sees what comes next; claimed goals drop out and the following goal takes their place.
  • Goal titles and rewards come from content; the API only sends each goal's id and status.

Step 10A.16

  • The map was already reachable during the tutorial (its tab exists from the first screen); this step adds the lock and reachability.
  • Locked locations are labelled "🔒 Name" on the canvas (they were dimmed already), replacing the earlier "Name?".
  • Reachability uses sim checkRoute (range and landing) from Earth at the current server time, over content.purchasableShipModels, so it changes as planets move. When nothing can reach it the panel says so.

Step 10A.17

  • The summary lists every goal currently ready to claim, not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
  • "Claim on HQ" acknowledges the deliveries and navigates to /#goals.
  • Web tests wait up to 3 s for elements (asyncUtilTimeout). Correction: the intermittent onboarding failure this was meant to fix was really a race in the test (it checked the intro card before the tutorial board had loaded); that is fixed in the playtest fixes below. The longer wait is kept as harmless headroom.

Step 10A.18

  • Plan amendments are appended to each affected step as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
  • Known issue found in the regression (not fixed in this phase): after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.

Playtest fixes after Phase 10A

  • "4 of 4" with no deal: the user's account played through code being changed mid-phase (the dev server reloads), so its first tutorial run was flown before the run counter existed and the count ran one behind; after the fourth counted run the loaner was at the Moon. On finished code the deal appears after run 4. HQ now explains the remaining step when enough runs are done but the loaner is not at Earth (tutorialHint).
  • The dispatch sheet looked "stuck" on the first job: it did change per job, but showed only "Earth → Moon" and the first failing check, so every over-heavy job looked identical. The sheet now shows the job's cargo, tier, mass and required skills, and lists every problem with numbers ("Too heavy: 228 t (holds 20 t)", "Needs Expensive Cargo 2 (pilot has 0)", range, Long Haul, fuel). Eligibility is still decided by sim validateDispatch.
  • Still open: most post-deal boards have no contract a Courier can carry (see Step 10A.18).

Contract size bands (playtest fix, user's choice)

  • Bay contract mass comes from bands, one per ship size (contractMassBands in GameConfig, replacing contractMassTonnes): 3 contracts of 5–20 t (Courier), 3 of 21–100 t (Freighter), 2 of 101–400 t (Bulk Hauler). The counts must add up to contractBoardSize (schema check). Chosen by the user over shrinking the range or fitting boards to the player's fleet.
  • The first contract of each band is a guaranteed job: Standard cargo (no skill) to an already-discovered location, never first contact. A new pilot has no skill points (tutorial XP does not reach level 1), so without this a Courier-sized job could still need a skill. The other slots are drawn as before. First contact now applies to 5 of 8 slots, about 6% of contracts, inside the 5–15% the Step 3.9 test expects.
  • Boards list contracts band by band (small to large), so a new player sees Courier-sized work first.
  • Existing stored boards are unchanged until their period ends (at most an hour); new boards use the bands.

CR-002 — Open Markets, Bays as HQs (Phase 10B)

  • Change request CR-002 is stored in memory-bank/change-requests/ and inserted as Phase 10B, before Phase 11.
  • Decision numbering: the CR's new 0.5.27 (Ship delivery) and 0.5.28 (Undiscovered locations) are recorded as 0.5.35 and 0.5.36, since 27–34 were already in use. Decision 0.5.5 is replaced and the glossary gains "Contract board" and "Deadhead".
  • Error code: the CR's location_undiscovered is the existing location-undiscovered.

Step 10B.1

  • The board generator's input is location (the freight market's location). Sim never knew about bays for boards; the old field name origin was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (board:<location>:<period>) are unchanged.

Step 10B.2

  • Deadhead checks, in order: ship idle, pilot idle, pilot assigned to the ship, destination not the current location (already-there), destination discovered (location-undiscovered), landing, range, fuel credits. Bays play no part.
  • no-bay-at-destination is gone from sim reasons and API error codes; location-undiscovered (already a server code) is now also a sim reason.
  • The server change landed in this step (it must pass discoveredIds to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario.

Step 10B.3

  • The board key was already per location: (player, kind, location, period, tutorial run) since CR-001. This step renames the board_kind value bay to location, so the stored data says what it is.
  • The migration relabels existing bay rows as location in one UPDATE while the column is temporarily text. That keeps the user's development database usable without a reset; it is a one-line relabel, not a data migration of board contents.

Step 10B.4

  • The route keeps its name, getContractBoard, with a new path /api/locations/:locationId/board. The client builds requests from the catalog, so it follows automatically.
  • Check order: unknown location 404 not-found, then undiscovered 409 location-undiscovered (decision 0.5.36). Bays are not consulted.
  • Discovery by a delivery shows up on the same request: the endpoint runs inside withPlayer, which resolves due arrivals first.

Step 10B.5

  • No server code changed beyond a comment: contract dispatch already validated only the ship, pilot, contract and credits, and the contract lookup searches every stored location board. The step is the regression test for the stranded-ship bug.
  • The test picks the Moon→Earth Courier job from the real generated board (destination Earth, 20 t or less, no skill), relying on the guaranteed first slot of the smallest mass band, not a hand-placed contract.

Step 10B.6

  • Another player's bay id is 404 not-found ("You have no such bay"), the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
  • SHIPYARD_LOCATION_ID is gone: the delivery location is always the chosen bay's.
  • Interim client: the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.

Step 10B.7

  • Ship.lastArrivalAt is never null: the latest resolved arrival of that ship, else its delivery time (created_at). A newly bought ship counts as having just arrived at its bay, which is what the default-location rule wants.
  • The selector is a wrapping row of location buttons (aria-pressed), not a <select>, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
  • The URL is the source of truth for the selection (?location=, replacing ?bay=), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
  • A hint shows when no idle ship is at the selected location, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.

Step 10B.8

  • The picker runs validateReposition per destination, so the client and server agree on what is possible. Fuel is tripFuelCost and time is tripRealMs with zero cargo, the same inputs the sim's reposition record uses.
  • The action shows on every idle ship, crewed or not. An uncrewed ship's options are all disabled with "No pilot assigned", which tells the player what to fix rather than hiding the action.
  • Disabled destinations still show fuel and time, so the player can see how far off they are.

Step 10B.9

  • The delivery picker is a dialog with radio buttons and a "Buy for N cr" confirm, so buying always takes two taps, even with one bay (preselected). The confirm is disabled until a bay is chosen.
  • "Bay owned" now reads "Your HQ is here", and the map panel says "Your HQ is here." or "No HQ here."; the player-facing word for a bay is HQ, while the action keeps "Lease bay" as in the CR.
  • The success message names the delivery location ("… is waiting at Moon.") instead of always Earth.

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/<id>@1x.png and <id>@2x.png for sprites, <id>.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/<sprite>@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 <header> 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 listitems).
  • 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.

Step 10M.2

  • Goal ids changed with their meaning: second-ship → market-ship, veteran-contract → priority-contract. A claim recorded under an old id no longer matches any goal; the CR allows resetting databases, and a stale claim only hides nothing.
  • "Bought from the Market" is a stored flag (ships.from_deal) rather than "ships owned minus two", so it stays right if the deal's contents change. The migration marks each existing player's first ship as the deal's (it always is: the loaner isn't a row).
  • The old veteran-contracts condition is removed, not kept unused. ships-owned and pilots-hired stay as general-purpose conditions.
  • A Priority delivery counts by the contract snapshot (record.contract.priority), for any pilot, excluding tutorial runs (which are never Priority anyway).

Step 10M.3

  • The veteran is stored like any assigned pilot (location null, following the Clipper), so state shows them at Earth through the ship, as it does the avatar.
  • The Clipper is named with the next seeded ship name, after the Courier, the same way bought ships are.
  • The old rest-and-swap test is replaced by the CR's "both ships can be dispatched immediately" (both in flight at once); the profit-share check moved onto the veteran's Clipper run.

Step 10M.4

  • "Twelve years" lives in the bio, not the sentence: the CR's example sentence ("who's flown that Clipper for twelve years") is split so the sentence uses only names (veteran, ships, bay) and the bio carries the history, which also shows on the pilot card.
  • The loan shown is content.dealLoanAmount; before the deal the player has no loan in state, so "matching state" is tested as the offer equalling the balance the after-deal state reports.

Step 10M.5

  • Open balance issue, not changed here (needs a design call): right after the deal a fresh player has ~890 cr, but fuelling both ships from Earth to the Moon costs ~921 cr (Courier 0.384 × 200 × 3 = 230; Clipper 0.384 × 600 × 3 = 691). So "dispatch the Courier and the Clipper at the same time" isn't possible on a fresh account without first earning more. The scripted check used a 1,000 cr developer top-up. Candidate fixes: more cash with the deal, a lower Clipper fuel rate, or a smaller Earth launch multiplier.
  • Short Clipper hops can lose money: in the check, a Priority Mail job to the Moon paid 373 cr against 691 cr of fuel (pilot share 0). Another run (Medical Supplies) paid 1,083. The Clipper's 600 cr/M km fuel with Earth's ×3 launch cost makes Earth→Moon marginal; its payoff is long, fast legs. Left for the balance pass (Step 12.1).
  • The balance-script checks wait for the script (Step 12.1), where the amended targets and the Lighter-first / Freighter-first policies are recorded.

Earth launch cost 3 → 2.1 (after Phase 10M, user decision)

  • Why: after the deal a fresh player couldn't fuel the Courier and the Clipper from Earth at once (~921 cr against ~890). At 2.1 the pair costs ~645 cr (Courier 161, Clipper 484), and the scripted fresh-account check now flies both together with no top-up.
  • 2.1, not 2: Jupiter Cloud Platform is 2, and Earth must stay the most expensive launch (GDD §launch cost, locations.test.ts). Only Earth's value changed.
  • Effects: every departure from Earth costs 30 % less fuel; payouts don't use the multiplier, so the difference is profit, and the deal loan is paid off sooner. Moon launches (0.5) stay 4.2× cheaper than Earth. The Step 3.x fuel hand calculation now uses 2.1.

Step 11.1

  • Installability is tested with Chrome's own check: Lighthouse removed its PWA category in version 12, so the planned "Lighthouse PWA audit" can't run. The Playwright test calls the DevTools Page.getInstallabilityErrors, which is what that audit reported, and expects an empty list.
  • Browser tests run against vite preview of the real build, with no API server; installability and caching don't need one. They live in apps/web/e2e/*.e2e.ts (not picked up by Vitest) and Phase 12's end-to-end tests can reuse the setup.
  • Icons are generated placeholders (the Courier sprite on #080d1d), with the maskable icon keeping the ship inside the 80 % safe zone. Replace by putting a logo through scripts/icons.mjs.
  • Precache is ~6.7 MB, mostly the @2x map art the CR-003 amendment asks for. Only woff2 fonts are cached (every supporting browser uses them before woff).
  • Auto-update: a new build's service worker takes over on the next load without a prompt; there is no player data in the cache to lose.

Step 11.2

  • "Offline" means no reply, not an error reply. A fetch that rejects (no network, server unreachable) switches to the cached state; a 4xx/5xx from the server is shown as an error as before, because the server is there.
  • The last player state is cached in local storage, schema-checked on read (a build with a different shape ignores it) and cleared on sign-out. It's a convenience cache: if storage is blocked, an offline start shows "Could not reach the server" as before.
  • Actions are disabled per button (needsServer), not app-wide: navigation, closing sheets, reading details, the map and display/help settings keep working. Buttons that only open an action sheet (Dispatch, Deadhead, Buy, Rename, Back up, Restore, New company) are disabled too, so the player isn't led into a form they can't submit. Viewing a ship's upgrades stays open (it's informational; its Install button is disabled).
  • The away summary and the deal wait for the connection: both exist to send something to the server, and a modal that can't be dismissed would lock the app offline.
  • The browser test fakes the API with Playwright route interception against the real production build and service worker, so it needs no server or database; the end-to-end tests against a real server are Phase 12's.

Step 12.1 — balance simulation and the economy

  • Why the economy changed: with pay linear in mass × distance and fuel paid up front, a Mars run paid ~340 Moon runs for the same cargo but needed ~164,000 cr of fuel; nothing could be discovered for hours, and with cheaper fuel one Mars delivery bought everything at once. The user chose a gentler pay curve (option 1).
  • Economic distance (decision 0.5.84): pay and fuel use 0.384 × (d / 0.384)^0.65. A Moon leg is unchanged, so the tutorial and early numbers are too; Mars at 130M km counts as about 17.5M km. fuelCost and tripFuelCost now take the config. Range, travel time, Long Haul requirements and XP still use the real distance.
  • A first contact the fleet can fly (decision 0.5.85): first-contact jobs used to come only from open slots (10 % chance, a random undiscovered destination, any size), so most went to places nothing could reach or were too heavy; discovery ranged from 33 minutes to never. Now one fit slot per board is a first contact built with the fit rules (a crew that can reach it with its Long Haul, cargo it can carry and is skilled for). Once everything reachable is discovered, the slot is an ordinary fit job.
  • Tuning (searched with the script over 7 seeds): exponent 0.65; Clipper fuel 600 → 450 cr/M km (still above the Freighter's 400, so "high" per CR-012); loan repayment share 0.3 → 0.25. A neighbourhood of values passes (exponent 0.63–0.7, repayment 0.2–0.3 with Clipper fuel 450), so the choice isn't knife-edge.
  • Targets are checked on the median of 7 world seeds: one world's job luck moves the first purchase by tens of minutes (it lands right after the first big delivery). Invariants must hold in every world. The timing targets apply to the Lighter-first player (the plan's "buy the cheapest ship"); Freighter-first can't buy a 100,000 cr ship in 45–75 minutes with numbers that also keep Lighter-first in that window, so it is used for the four-ship comparison only.
  • The simulated player's choices (not specified by the plan): best credits-per-minute contract after fuel and pilot pay; deadhead only to a place with work for that ship; rest a pilot with duty hours when there's nothing worth flying; skill points to Long Haul for ships with range past the first cut-off, alternating with the cargo skill that suits the hold (Heavy for 60 t and up, Expensive below); Market ships in policy order with a pilot hired at Earth, keeping 5,000 cr back for fuel; upgrades (cargo bay, then engines, on the most-used ship at an HQ) only once the planned ships are bought and only from 10 % of cash; goals claimed when ready; no extra bays.
  • Invariant definitions: "soft-lock" = after the deal, nothing in flight and no affordable fuel for any contract on a board where a crewed ship is; "idle with nowhere to go" = a crewed, rested ship with no contract and no deadhead it could take with unlimited credits (being short of cash is the soft-lock check's job); "first session" = the first two real hours.

Step 12.2

  • The Step 2.5 economy check moved to the sim's tests: it needs measured numbers from the balance script, and packages/content can't depend on packages/sim. It now checks what the simulated player actually earned in every seeded run instead of a "lowest-paying contract" assumption.
  • Decision 0.5.85 got its own board test here (it was only covered indirectly by the balance targets in 12.1).

Step 12.3

  • The end-to-end tests drive the real UI for the steps the plan names (start, tutorial board, deal, dispatch sheet, logbook, Locations, Pilots) and use the API, with the browser's own session, to fast-forward the clock and read state. CR-011 allows the developer routes for this.
  • They share the server unit tests' database (TEST_DATABASE_URL); each test signs up its own guest, so nothing needs resetting. Don't run them at the same time as the server tests, which truncate it.
  • The test's expected state follows CR-013: two ships after the deal, not the one the plan's 12.3 text lists.

Step 12.4

  • "Close the page" is a real page close; the return is a fresh page in the same browser session, as when reopening the installed app.

Step 12.5

  • The Mars window is computed, not waited for: the test finds the next date the Clipper can reach Mars and fly on, and fast-forwards to it.

Step 12.6

  • A Lighter at the Moon, not the Courier: with fleet-fit boards (CR-006) a Courier never sees a Heavy job it could carry, and a Lighter at the Moon sees one on about one board in eight, so the test waits for one board period at a time.
  • The new pilot is levelled by real deliveries (the server's own XP), rests included, rather than by editing XP.

Base pay +50 % (after Phase 12, user decision)

  • All four cargo classes pay 50 % more per tonne per M km (Standard 150, Heavy 210, High-Value 270, Dangerous 300). Fuel is unchanged, so every job is more profitable; the tutorial share rises too (a new player has ~2,950 cr at the deal instead of ~2,130).
  • Chosen with the balance simulator: +50 % is the largest raise that keeps every Step 12.1 target (first Market ship 60 min, discovery 28, loan paid off in 127, both purchase orders reach four ships at 127). Doubling would put the first Market ship at ~28 minutes, below the 45–75 window.

Phase 13 — hosting choices (decision 0.5.86)

  • Asked at the start of the phase, as the plan says: a home server running Docker Compose at deadhead.belowland.com; a Caddy container in the stack for HTTPS and routing; nightly backups to a mounted folder or NAS share.

Step 13.1

  • The server image runs the TypeScript with tsx, exactly as pnpm start does, rather than adding a bundling step; tsx moved from the server's dev dependencies to its dependencies so the production install keeps it. The image is ~590 MB: pnpm deploy --legacy brings along some packages that better-auth's optional peers pull in. The non-legacy deploy mode would need the lockfile regenerated with injected workspace packages, which wasn't worth it for a home server.
  • Migrations run on every start (migrate-cli, then main): they are idempotent, so a new image migrates the database before serving.
  • Postgres is not published outside the stack; the server reaches it on the compose network. It gets its own named volume (deadhead_postgres-data).
  • The stack's settings live in deploy/.env (git-ignored), with the public address as one setting (PUBLIC_URL) used for Better Auth, so sign-in accepts requests from the public site.
  • deploy/check.sh is the step's test, run locally (it needs Docker); CI doesn't run it, because the Gitea runner's Docker access isn't set up for building images. It uses a throwaway project name and generated secrets and removes its containers and volumes, so it never touches a real deployment on the same machine.

Step 13.2

  • Caddy is the one front door (decision 0.5.20): the same origin serves the app and /api, so cookies and Better Auth's origin check need no cross-site setup. HTTPS is automatic for a domain SITE_ADDRESS; :80 gives plain HTTP for LAN testing (the app can't be installed to a home screen without HTTPS).
  • The image builds from the committed map art and doesn't run the map-art script: the source art is excluded from the build context (it is large and never served).
  • APP_BUILD overrides the git commit in the build info, because image builds have no .git; the README's update command passes the checkout's short hash.
  • Caching: only Vite's content-hashed bundles and fonts are marked immutable. The generated map art keeps its plain names, so it isn't; the service worker precache versions it anyway. The shell, service worker, its registration script and the manifest are no-cache, so a new deploy is picked up on the next visit (auto-update, Step 11.1).
  • The 13.2 manual check is scripted locally (guest sign-up and a tutorial run through Caddy, with the browser's Origin header); loading the real site on desktop and a phone is left to the user, since it needs the home server, DNS and port forwarding.

Step 13.3

  • Backups run in the stack as a backup service on the same Postgres image (so pg_dump matches the server version), with a small shell scheduler rather than cron: one process, logs in docker compose logs backup, nothing to install on the host.
  • Off-server storage is a host folder (BACKUP_DIR), as chosen: a NAS share mounted on the server. The README warns that an unmounted share would put dumps on the server's own disk.
  • Retention is age-based: dumps older than BACKUP_KEEP_DAYS (default 14) are deleted only after a successful dump (the script stops at the first error), so a run of failed backups never removes the last good ones.
  • Dumps use pg_dump's custom format (compressed, restorable with pg_restore --clean --if-exists --no-owner), written under a .partial name and renamed when complete.
  • restore.sh defaults to a separate deadhead_restore database, so the routine test restore never touches live data; recovering the live database is a documented, explicit command with the server stopped.
  • The step's test compares a player row by row: after a guest's sign-up and tutorial run, the check backs up, restores into a fresh database and compares that player's rows across nine tables as JSON; they match exactly.
  • check.sh disables Git Bash's path conversion (MSYS_NO_PATHCONV) so container paths reach Docker unchanged on Windows; its temporary settings file sits next to the compose file (deploy/.check.env, git- and docker-ignored) for the same reason.

More developer tools (user request)

  • Pilot and ship tools act on one chosen pilot or ship (pickers in Settings → Developer) and answer with the whole player state, so the screen updates without a refetch. Like the other developer routes they are registered outside production only.
  • "Level up" sets XP to the next level's threshold rather than adding a fixed amount, so each press is exactly one level and skill points follow from the server's own rules. At the top level it does nothing.
  • "Max upgrades" installs every upgrade at its cap for that model (per-model caps from CR-009/CR-012), free and without shop time; "Finish shop work" ends a job now, so the usual "out of the shop" note still appears.
  • "Move here" keeps the rules that matter: only an idle ship, only to a discovered place. Its pilot moves with it, as assigned pilots follow their ship.
  • "Discover everywhere" marks every location discovered (no first-contact pay), for testing range, reach and boards.

CR-014 — Local-First (Phase 14)

  • Renumbered on arrival: titled "CR-011 … Phase 10K"; CR-011 to CR-013 and Phases 10K–13 already exist, so it is CR-014, done as Phase 14 (its 10K.n are 14.n).
  • Decisions: its 0.5.66–0.5.71 are 0.5.87–0.5.92. It replaces 0.5.18 and 0.5.20 (same numbers here) and its "0.5.53–0.5.55" are this plan's 0.5.71–0.5.73 (CR-011's accounts, backup code, several devices). Its "CR-008" is this repo's CR-011; its "CR-001 to CR-010" means CR-001 to CR-013.
  • Order: the "Fixed Map and Fuel Tanks" request arrived first (also titled CR-014) but is written for this local-first engine; the user chose to build local-first first. It will be recorded as CR-015 (Phase 15).
  • tech-stack.md wasn't yet marked for this change in this repo (the CR says the designer had marked it); it is updated during the phase.

Step 14.1

  • The device clock is a small class with injected device time, so tests set the device clock forward and back. It remembers the latest time it handed out; the save stores that (lastTimeSeen) and restore applies it on load, so restarting the app with the device clock set back still can't rewind the game.
  • The developer offset only moves forward (advance), like the old developer route; there is no "set offset" any more, because moving back would be clamped anyway.

Step 14.2

  • The save mirrors the database, one company per document: the same fields as the tables, minus player ids (there's one player per save) and minus the backup-code and login fields, which CR-014 removes. Statuses use the API's words (in-flight), not the database enum's (in_flight).
  • Version 1 needs no migration: it is the first local format and the CR allows resetting dev data. The CR's "identity migration for version 1" is covered by the framework itself (a version-1 save passes through unchanged and validates); the chaining is tested with made-up versions 1 → 2 → 3.
  • The stored contract, dispatch record and resolution are checked against the sim's types at compile time, so a sim change that the save doesn't follow fails the build instead of a player's load.
  • migrateSave never throws: unknown shapes, missing or future versions and invalid fields all come back as a readable message (Zod's pretty error for field problems), for the storage fallback (14.3) and import (14.10).

Step 14.3

  • Rotation and fallback are written once, over text slots: each backend only reads, writes and removes whole slots atomically. A save is a few slot writes (backups shift, then the new main), each all-or-nothing, so an interruption at any point leaves a loadable main save, at worst with a duplicated backup.
  • Backups are the previous saves, not snapshots in time: with a save after every command, the three backups are the last three states before the current one. That's what recovers from a corrupted write; it isn't a history feature.
  • Platform objects are handed in (IDBFactory, the Capacitor Filesystem plugin through a five-method interface), so the engine has no browser or Capacitor imports and the backends are tested with fake-indexeddb and a fake plugin. The real plugin is wired up in the web app (14.7).
  • The engine's TypeScript config adds the DOM library for the IndexedDB and Web Locks types; it still only touches them through what it's given.
  • Pruning old resolved dispatches belongs to the save document, not storage; it happens when a command saves (14.4).

Step 14.4

  • Copy, change, validate, save, adopt: a command works on a structuredClone of the save and the engine only adopts the result after it validates and is written. That replaces row locks and transactions (Step 6.14): with one queue there are no concurrent writers, and a failed command has nothing to roll back.
  • Reads go through the same queue, so a read never sees a half-applied command.
  • The tab lock gates writes, not reads: a tab that lost the game can still show what it had, but every command is refused with GameLockedError until it takes the game back. After taking over, a tab drops its cached save and reloads from storage, so it continues from the other tab's last save.
  • Where Web Locks don't exist (native apps have one window; very old browsers) the tab simply holds the game.
  • The race test uses a minimal purchase command here; 14.5 repeats it with the real buyShip.

Step 14.5

  • Same commands, same schemas, same codes: the engine runs the route catalog's endpoints by name with their request and response schemas, and refuses with the server's error codes and messages, so the client (14.7) only swaps its transport. "No company yet" answers unauthorized, which the client already reads as "show the start screen".
  • Every command resolves due arrivals first, as the server's withPlayer did, and a command that changes nothing (most getPlayerState calls) doesn't write the save.
  • Pruning needed two small save additions: lifetime stats (contract and Priority deliveries) and each ship's lastArrivalAt. Goals and the Contracts screen read these instead of counting old dispatches, so pruning seen trips older than a week (and boards older than 24 periods, as the server's cleanup job did) never changes progress. Unseen summaries and in-flight trips are never pruned. Version 1 of the save isn't released, so it changed in place.
  • Creating a company when one exists returns the existing one instead of a second company; one save per device (decision 0.5.89).
  • Backup-code endpoints aren't ported: they need a server. Their tests are dropped with them; export/import replaces them in 14.10.
  • Tests were ported, not rewritten: the helpers keep the server tests' names and shapes (call(app, guest, "POST", "/api/ships", …) routes the old path to its command and answers with the old status code), so most files moved with import changes and database writes turned into save edits. Tests of things that no longer exist were dropped, each listed in progress.md. A coverage test checks every command has at least one engine test.
  • The server stays until 14.11, so both suites run until then.

Step 14.6

  • The loop replaces the pg-boss arrival job: arrivals were already resolved lazily before every command, so the loop's job is to keep the save and screen current while the app is open and nothing is being clicked, and to catch up the moment it opens or comes back.
  • Rest endings and finished shop jobs change no state (they are times compared with now), so the loop only reports them, for the screen to refresh; a pass that resolves nothing writes nothing.
  • Passes never overlap: a tick that comes while one is running reuses it, so a resume during an interval tick doesn't run resolution twice at once (it would be harmless anyway; resolution is idempotent).

Step 14.7

  • The client keeps its API layer and swaps the transport: createEngineClient implements the same ApiClient over engine commands and turns refusals into the same ApiErrors (status and code as the server sent), so every screen, hook and component test stays as it was.
  • The clock still "syncs", but with the engine clock (getServerTime is now the device clock plus the developer offset), so the developer tools' fast-forward shows in the UI as before.
  • Offline mode (Step 11.2) is removed (CR-014 §6): there's nothing to be offline from. The cached last state, the offline banner, needsServer buttons and the connection store went with it. The "while you were away" sheet and the deal show whenever they apply again.
  • Developer tools in test builds: vite build --mode e2e reads .env.e2e (VITE_DEV_TOOLS=true), which turns on developer commands, persists the developer clock offset in local storage (so a fast-forward survives a reload) and exposes window.deadhead.call for browser tests. Deployable builds (pnpm build) have none of it.
  • Browser tests are the web build alone: the API server left the Playwright config; helpers run engine commands through the hook. Two waits were needed that a server never needed: the hook appears only after the page takes the tab lock, and an acknowledgement is written in the page, so a test must not reload before it is saved.
  • "Closed while it landed" (12.4) is now a new page opening with the developer offset already past the arrival: the engine resolves the trip on load, exactly as for a player who closed the app.

Step 14.8

  • "Start your company" keeps its command name (createGuest): the start screen, its tests and the route catalog stay unchanged; the command now builds the save on the device instead of an account and a database row.
  • The test pins the whole starting state with fixed ids and a fixed seed, so any drift from CR-001's start shows up as a diff, not as a vague mismatch.

Step 14.9

  • The schedule is derived, not tracked: after every saved change the engine works out every future event from the save and replaces the platform's whole schedule. Nothing remembers notification ids, so a moved arrival, a sold ship or a new company can't leave a stale notification behind. Ids are just 1..n per schedule.
  • A combined notification fires at its minute's last event, so "2 updates" never announces something that hasn't happened yet.
  • Permission is asked by the engine client right after acceptDeal succeeds (decision 0.5.91: not at first launch). The tutorial's loaner runs don't need notifications; owning the business is when waiting starts to matter. Without permission the native scheduler just schedules nothing.
  • The toggle is a device preference (default on), read by the engine through a callback; changing it resyncs at once. The section shows on the web too, labelled "iOS and Android apps only", so the setting carries over if the same preferences ever matter there.

Step 14.10

  • Export and import are catalog commands (exportSave, importSave), so screens call them like everything else and the engine checks their schemas. The server never implemented them; its backup routes stay listed as server-only until it's removed in 14.11.
  • The code is DEADHEAD1. + base64url(gzip(JSON)), using the platform's CompressionStream, so no compression library is added. The prefix makes "that isn't a save code" a clear error and leaves room for a different encoding later. Base64url has no characters a chat app or a file name would mangle; whitespace is ignored so a wrapped paste still works.
  • Import resolves due arrivals at once, as opening the app would, and the save it replaces goes through the normal save path, so the old company is still in the storage backups.
  • A refused import changes nothing: the code is decoded and checked before the write queue, and errors are invalid-save with the reason ("That isn't a Deadhead save code.", "The save code is incomplete or damaged.", or the schema's damage report).
  • Confirmation is a second press ("Import" → "Replace company", or "Open company" on a fresh device). The warning shows only when a company would be replaced.
  • "Share as file" uses the Web Share API with a file where the device supports it (phones, including the Capacitor web view), otherwise a download. No Capacitor share plugin.
  • Only save version 1 exists, so the "older save migrates" test injects a migration through the engine's migrate option; applyMigrations's stepping is tested in the schema package.
  • Debug info reads storage through a small GameContext that GameRoot provides; outside it (component tests) storage shows as "unknown".

Step 14.11

  • The route catalog stays as the command list, with method and path kept: about 30 ported test files route by them, and renaming every command reference wouldn't change behavior. auth now reads as "needs a company".
  • The production-build check runs a real production build (dist-production, built by build:e2e alongside the test build) rather than the test build with requests blocked: the test build has developer code the production one doesn't, so only the real build shows what players get. It records every request instead of blocking, so a stray request fails the test instead of being quietly aborted.
  • Deploying is static hosting with the same Caddy setup, minus the API and backups: the domain, HTTPS and caching rules from Phase 13 still hold. Player saves live on devices; Export save is the backup.
  • The web Dockerfile was missing the engine package's manifest since 14.7 (pnpm install --filter @deadhead/web... couldn't link it), found while rewriting deploy/; fixed here.

Step 14.12

  • The plan keeps its server-era steps as written, with "Superseded", "Replaced" and "Amended by CR-014" notes, the same way earlier change requests amended it, so the record of what was built stays readable and each note points to the step that changed it.
  • CR-014 §7's translation table is plan §0.6, next to the decisions, because it governs how every later change request (starting with CR-015, Fixed Map and Fuel Tanks) is read.
  • tech-stack.md is rewritten, not annotated: it is a description of the current stack, not a history. The old server stack is one row in "Alternatives considered", with the reason it went.
  • The phone check stays manual (airplane mode and OS notifications can't be driven from CI); progress.md scripts it step by step for the user.

CR-015 recorded (Fixed Map and Fuel Tanks)

  • Numbering: received as "CR-014 — Fixed Map and Fuel Tanks" for "Phase 14"; stored as CR-015, built as Phase 15 (its 14.n = 15.n). Its decisions CR-014.D1–D7 are 0.5.93–0.5.99. Its references: CR-006 → CR-009, CR-007 → CR-010, CR-009 → CR-012 (10I.n → 10L.n), CR-011 → CR-014; decisions 0.5.24 → 0.5.32, 0.5.49/0.5.50 → 0.5.67/0.5.68, 0.5.60 → 0.5.78, 0.5.61 → 0.5.79, 0.5.63 → 0.5.81. The numbering note at the top of the CR file has the full map.
  • Fuel burn follows the pay curve (user decision, amends D3 = 0.5.95): fuel for a leg = burn rate × economic distance(distance ÷ origin range factor). Burning by plain distance while pay uses economic distance (0.5.84) would make a Bulk Hauler's Earth→Jupiter leg burn ~2,000× an Earth→Moon leg's fuel for ~140× the pay. With the curve, long runs keep today's profitability and full-tank reach in plain km is still exactly the old effective range, so the CR's §4 table holds unchanged.

Step 15.1

  • Positions are computed in content, not in the sim: a location carries its own fixed position, and the Moon's is Earth's moved 0.384M km straight out from the Sun (offsetPosition), with orbitParent recording the link for the map and a test that checks it. The sim stays a pure function of the location it is given.
  • The Moon's offset points away from the Sun, a fixed choice (before, it was a fixed +x offset off a moving Earth). Moon–Mars is then 0.4M km longer than Earth–Mars, which changes no reach answer.
  • epoch.ephemerisDate is renamed gameStart, same value: the game clock and its displayed date are unchanged; only the planets stopped depending on it.
  • Positions switched in this step, not 15.2: removing astronomy-engine (this step's test) leaves nothing else to compute them. The sim's time arguments stay, unused, until 15.2 removes them.
  • Ranges moved into CR-015 §4's bands here, ahead of 15.3's tank conversion, because the fixed map otherwise breaks the game at once (the deal's Clipper, at 198M km, couldn't reach Mars at 201). Chosen with margins inside each band: Clipper 250 (band 201–302 from the §4 cells), Freighter 460 (402–515), Courier tanks +1,400 %/level (level 2 must stay under 154 so it can't reach Mars from the Moon; level 3 at least 201). 15.3 turns these into tank capacity ÷ burn rate with the same reach.
  • Lighter price 42,400 → 36,000 (balance re-check, decision 0.5.84's targets): with Mars always 201M km away, purchases land on multiples of the Clipper's ~43-minute first Mars leg, so the median first Market purchase was 86.7 min, outside 45–75. Of the knobs tried (Clipper speed, first-contact pay, Lighter price), the price alone fixes it without touching the reach bands: first purchase 53.3 min, discovery 43.7, loan paid off 135 min, four ships 115 (Lighter-first) vs 130 (Freighter-first). 38,000 was too high (91.9) and 34,000 pushed Lighter-first four ships to 156; 36,000 sits between.
  • No base ship can strand on the fixed map (every destination can reach some discovered place back), so the would-strand tests use the one case that still can: a Freighter with maxed tanks flies Moon→Jupiter (867 ≥ 792) but from Jupiter (667) reaches neither Earth nor the Moon while Mars and Ceres are undiscovered.

Step 15.2

  • Every time argument that only located planets is removed, not just those on locationPosition and routeDistance: checkRoute, tripFuelCost, contractTripGameHours, priorityWindow, wouldStrand and the web helpers built on them. Keeping unused time parameters would invite callers to think the answer depends on when.
  • wouldStrand takes only the ship, the destination and what is discovered: its origin, cargo mass and departure time existed to compute the arrival time and the planets' positions then. The configured 48-game-hour margin (noStrandMarginGameHours, decision 0.5.79) is removed with them; 15.5 adds fuel to the rule.
  • The Market's reach keeps its shape for one step: shipReach now checks each leg once, so "other dates" is always empty; the field and its "Other dates" line go together in 15.8, the step that removes date wording from the client.

Step 15.3

  • Tank sizes come from the old ranges: tank = burn × economic distance(old range), so full-tank reach from Earth is what the range was (Courier 4.9, Lighter 9.8, Clipper 252, Freighter 460, Bulk Hauler 1,251M km) and the §4 table holds unchanged. Burn rates are the old per-M-km fuel costs ÷ 10 (the base price), so fuel stays in proportion between models; the Clipper still burns most per M km.
  • Tank upgrades use smaller percentages than the range ones did: reach grows as capacity^(1 ÷ 0.65) on the economic-distance curve, so +20 %/level of tank would have let a maxed Clipper reach Jupiter from Mars. Each override keeps the old maxed reach: default 11 % (× 1.55 reach, as +20 % range), Freighter 9 % (≈ 664M km, short of Jupiter from Earth), Courier 380 % (level 3 ≈ 234M km reaches Mars from Earth; level 2 ≈ 133M km doesn't, from Earth or the Moon).
  • checkRoute and rangeFrom take the config, since reach depends on the economic-distance curve; every caller already had the content bundle at hand.
  • A trip is still charged at departure in this step, now its leg fuel at the origin's price: the tank state arrives in 15.4. The balance targets all pass with it.
  • The Market shows "Reach" (a full tank from Earth) instead of "Range"; the upgrade screen shows the tank in fu. The fuel gauge and prices come in 15.7.

Step 15.4

  • The engine's save fields and migration came in this step, not 15.6: once the sim requires fuel on board, an engine without tanks can't dispatch anything. 15.6 keeps the refuel, policy and emergency fuel commands and their tests.
  • Deal ships come with a quarter tank (user decision; config.deal.tankShare 0.25). With full tanks the deal's Clipper flew to Mars at once and landed at minute 36; Market purchases happen when it lands, so the first one moved to 79 min, outside 45–75. A quarter tank leaves the Clipper earning locally until the player can fuel a Mars run, as before tanks: first purchase 64, discovery 49, loan 136, four ships 136 vs 136. Market ships still arrive full (CR-015 §3).
  • Auto top-up spends the credits after this delivery's pay: land, get paid, refuel. affordableRefuel never charges more than the credits on hand, rounding included.
  • Costs are whole credits; fuel amounts are fractional fu: a leg burns what the curve says, and the UI rounds fu for display.
  • credits left the dispatch and deadhead proposals: nothing in validation depends on cash any more, so keeping the field would have been misleading. insufficient-credits is replaced by not-enough-fuel in both failure lists.
  • The migration fills tanks at the price where each ship sits (the base price while in flight), so the first legs after an update cost what fuel there costs.
  • The balance simulator models the player's "Refuel here first": before choosing work, an idle ship tops up where it is as far as credits go. The no-strand and soft-lock checks now ask "with a full tank" and "with the fuel on board plus what the credits buy" instead of "with any credits".

Step 15.5

  • "The fuel it could have there" is a full tank wherever fuel is sold, regardless of the player's credits: the no-strand rule is about whether a ship can ever leave, and running short of cash is what emergency fuel is for.
  • Emergency fuel covers the cheapest leg the ship could fly on a full tank, among discovered places it can land. A leg no tank could fly doesn't count, so a ship that can't leave even when full gets no offer (the no-strand rule prevents that state).
  • "Can't afford" means the credits don't cover the shortfall at the local price, rounded up to whole credits; at a location that sells no fuel the player can't buy at all, so emergency fuel is offered whenever the tank is short.

Step 15.6

  • A refuel is all or nothing: asking for more than the credits cover is refused (insufficient-funds) rather than buying part of it. Only Auto top-up buys "what the player can afford"; a manual purchase does what was asked. The dispatch sheet's "Refuel here first" (15.7) asks for exactly what is needed or a fill.
  • Emergency fuel goes into the tank's average at its marked-up price, so the next legs' "fuel used" (and the pilot's share) reflect what it really cost, even though it was paid through the loan.
  • The new error codes are no-fuel-here, tank-full and emergency-fuel-unavailable; refuelling an in-flight ship reuses ship-not-idle-here.

Step 15.7

  • Fuel lives in its own sheet, opened from the ship card, beside Upgrades: the app has no ship detail screen, and the Upgrades sheet already set the pattern for per-ship actions.
  • "Refuel here first" fills the tank if the player can pay for it, else buys only the leg's shortfall, so the button works whenever the leg is affordable at all. The engine's all-or-nothing refuel (15.6) then never refuses it for want of credits unless even the shortfall is unaffordable, which is what emergency fuel is for.
  • "Blocked only by fuel" is read from the sim: fuel is validateDispatch's last check, so a not-enough-fuel result means every other rule passed. The deadhead picker shows one "Refuel here first" for the ship, sized for its hungriest destination that is short only of fuel.
  • The cheapest fuel is marked in text ("· cheapest") as well as colour, so it reads without the colour.

Step 15.8

  • Body positions are computed once per app load, not per frame: nothing about them can change. The per-frame scene still rebuilds flights, idle counts and discovery, which do.
  • The date-free wording drops "now" too ("From Earth: …" rather than "From Earth now: …"): with a fixed map there is no other time to contrast it with.

Step 15.9

  • "Never needs emergency fuel" is checked where the player decides: each time an idle ship is about to choose work, after its "Refuel here first" top-up. Checking only at landings would miss a ship that ran down by deadheading.
  • Plan notes, not rewrites, as for every earlier change request: each amended decision or step says what CR-015 changed and points to the step.

CR-016 recorded (Venus, Io, Europa, and the Asteroid Belt)

  • Numbering: received as "CR-015 — Venus, Io, Europa, and the Asteroid Belt" for "Phase 15"; stored as CR-016, built as Phase 16 (its 15.n = 16.n). Its decisions CR-015.D1–D6 are 0.5.100–0.5.105. Its references: CR-005 → CR-007, CR-009 → CR-012, CR-011 → CR-014, CR-014 → CR-015, decision 0.5.31 → 0.5.39; CR-003 is the same. The numbering note at the top of the CR file has the full map.
  • The belt image holds six rocks: belt-asteroids.png has a transparent background and six separate regions at alpha ≥ 16 (fainter specks of glow are ignored), so Step 16.5 slices it into six sprites.
  • Landing "Any" is the vacuum requirement: every landing class meets it.

Step 16.1

  • Bay lease prices for the new places (the CR gives none): Venus 20,000 like Mars, a similar distance from Earth; Io and Europa 70,000 each, a little under Jupiter's platform (80,000), since they are meant as a second home for small ships in the Jupiter system.
  • Class mixes follow the goods: Venus makes Standard, High-Value and Dangerous goods and wants Standard, High-Value and Heavy; Io makes Standard, Heavy and High-Value and wants all four; Europa makes all four and wants Standard, High-Value and Heavy.
  • Each new place's Standard good came with the locations, ahead of 16.2: the board generator refuses a place that makes nothing Standard. The rest of the goods changes stay in 16.2.
  • First Market purchase target widened to 45–95 min (user decision). With Venus as a second first-contact destination, the median moved from 64 to ~92 min. Purchases happen at the Clipper's long landings, so the median jumps between about 49, 64 and 92 min with small content changes; no tank-share, Lighter-price or first-contact-pay change landed it reliably inside 75.
  • CR-007's class-mix regression is measured on its original five places: new destinations change the mix legitimately (and so do first contacts to undiscovered places), so the baseline is compared against the content it was taken from.

Step 16.3

  • No content changed: the Phase 15 tank sizes already satisfy CR-016 §3 with margins (the closest call is a Freighter from Mars to Io, ≈ 529M km of reach for ≈ 592). The step adds the tests only.

Step 16.4

  • No save migration for new locations: CR-016 §9 asks for one that adds the three places as undiscovered, but the save (since CR-014) stores only discovered places, so there is nothing to add. Bumping the save version for no data change would only make older builds refuse newer saves for no reason; the test shows an older save opens correctly instead.

Step 16.5

  • The manifest declares how many rocks the sheet holds (pieces: 6), and the script checks it: the loader needs to know the file names without reading the folder, and a changed sheet then fails loudly instead of silently loading the wrong set.
  • Slicing uses alpha ≥ 16 and drops everything else around a rock: the user's rocks have a soft glow that reaches toward their neighbours; below 16/255 it is invisible on the map, and keeping it would put slivers of other rocks' glow in each sprite.
  • Rocks are exported at 64 and 128 px (the CR's sizes), named belt-rocks-<n>@1x/@2x, in reading order of the sheet.

Step 16.6

  • The haze mesh lives in map units: the map's screen transform is a uniform scale plus an offset, so the ring is built once and the camera only moves and scales it, instead of rebuilding 512 vertices every frame.
  • The generated strip is seeded and small (1024 × 64): it is stretched around the ring, so detail beyond that is invisible, and a seed keeps the belt identical between visits.
  • Review screenshots now come from a Playwright script against the test build (reach the deal, discover everything with the developer tool, open the map at 1280 × 800 and 390 × 844), since the server-era setup that took the earlier ones is gone.

Step 16.7

  • Rocks are fixed in map units but sized in screen pixels, like every other sprite on the map (decision 0.5.38): zooming in spreads them out rather than turning them into boulders.
  • Ceres's clear area is in screen pixels (its drawn radius plus 6 px), applied as the rocks are placed each frame: a fixed area in map units would be too small when zoomed out or needlessly large when zoomed in.

Step 16.8

  • The Jupiter system folds below a zoom threshold (2.5), not only on collision: Io and Europa sit at fixed screen offsets (decision 0.5.39), so zooming in never moves their labels apart by itself; the threshold is what makes "zoom in to see all three" work. Earth and the Moon, whose labels don't collide, keep the CR's collision-only rule.
  • Child labels face away from their parent first, then below, then above: that is what keeps three labels apart in a 64 px family.
  • The "+N" marker toggles: a second tap folds the family again; zooming out below the threshold doesn't re-fold a family the player opened.
  • Io and Europa's angles (100° and −160° from the outward direction) keep them off Jupiter's platform badge (screen −45°) and 100° apart, so their 18 px sprites never touch at the largest sprite scale.
  • Haze strip 2048 × 256, repeated 8 times around the ring: the 1024 × 64 strip wrapped once made specks into dashes when zoomed in on the belt.
  • On a phone the Jupiter family's 64 px ring is large next to the zoomed-out map and sits near Ceres; it stays legible, and is flagged for the user's review.

Step 16.9

  • The game design document is the designer's (CR-016 §11): its destinations table and map description are left for them to update.

CR-017 recorded (Tutorial XP and Skill Point Prompt)

  • Numbering: received as "CR-016 — Tutorial XP and Skill Point Prompt" for "Phase 16"; stored as CR-017, built as Phase 17 (its 16.n = 17.n). Its decisions CR-016.D1–D4 are 0.5.106–0.5.109. Its references: CR-007 → CR-010, CR-008 → CR-011, CR-010 → CR-013, CR-011 → CR-014; its "CR-001 0.5.22" is 0.5.30. The numbering note at the top of the CR file has the full map.

Step 17.1

  • The "loaner at Earth" condition went with the run count (it is Step 17.4's rule): with five runs ending at the Moon, the deal could never come otherwise. No placement code was needed: accepting the deal assigns the avatar to the Courier at the Earth bay, and an assigned pilot is wherever their ship is (decision 0.5.27).

Step 17.2

  • The tutorial XP multiplier stays at 2, with no margin: five of the cheapest runs (10 XP each, doubled) make exactly the 100 XP that level 1 needs. Any change that lowers tutorial XP (cheaper XP per run, a sixth threshold below 100 removed) breaks the guarantee, and the test will say so.

Step 17.4

  • No save migration (CR-017 §3 asks for one): a tutorial save already stores only its completed-run count, and whether the deal is on offer is worked out from the current config, so an older save with four runs needs one more run with no change to its data.

Step 17.5

  • The badge counts points, not pilots: one pilot with three points to spend shows 3, which matches what the Step 17.6 pop-up lists.

Step 17.6

  • The pop-up's trigger is a state, not an event: tutorial over, permission prompt answered, some pilot with points, tip not dismissed. That makes "Show tips again" bring it back as CR-011 tips do, and survives a reload between the deal and the pop-up. An existing owner who still has unspent points sees it once after updating; that is the nudge working.
  • Waiting for the permission prompt goes through an optional ApiClient.notificationPrompt(): the engine client already made the request after acceptDeal and now keeps its promise. A client without it (the tests' fake) has no prompt.
  • Names: the avatar is "You" (with "have"), others by first name ("Rosa has"); two pilots "both have", more "all have".
  • Go to Pilots also dismisses the tip: the player has gone where it pointed.
  • The pop-up waits for delivery summaries, like the deal sheet.

Step 17.7

  • CR-001's own text is amended with notes, not rewritten, as with earlier CRs: its 0.5.22 is marked as plan 0.5.30 and points at CR-017.

CR-018 recorded (Fuel Tuning)

  • Numbering: received as "CR-017 — Fuel Tuning" for "Phase 17"; stored as CR-018, built as Phase 18 (its 17.n = 18.n). Its decisions CR-017.D1–D4 are 0.5.110–0.5.113. Its references: CR-010 → CR-013 (step 10J.3 → 10M.3), CR-011 → CR-014, CR-014 → CR-015 (steps 14.n → 15.n; CR-014.D5 → 0.5.97), CR-015 → CR-016. The numbering note at the top of the CR file has the full map.
  • D2 replaces the quarter-tank deal ships (user decision during CR-015): the CR assumes deal ships may start empty; here they started at a quarter tank. With full tanks and cheaper fuel, config.deal.tankShare becomes 1.

Step 18.1

  • Fuel is the same for any load (fuel has no mass, decision 0.5.94), so "a typical loaded leg" is just the leg's fuel at the origin's price.
  • "Contracts that fit the ship" means the hold: mass at most the cargo capacity, any cargo class (a pilot can learn the skill), measured on boards generated with that ship as the fleet so the boards fit it as they would in play.
  • One price can't meet the 15–25% band (user decision, 2026-10-10): shares spread over 100× because bigger ships carry richer jobs for the same fuel. The user chose: halve the base price (CR-018's starting point), report every row, and fail only on a row above 25%, with the Clipper's Mars → Earth leg (few small Mars → Earth jobs fit its 30 t hold) as the known exception.

Step 18.2

  • Base fuel price halved to 5 cr/fu, with no further tuning (user decision): the gate is "no route above 25%", which the halved price meets on every route but the Clipper's Mars → Earth. Most routes are now under 15% (the Courier's Earth → Moon is 10.5%); fuel is cheap by design.
  • First Market purchase window 30–95 min (user decision, was 45–95): halving fuel moved the median to 41.6 min, and full deal tanks (Step 18.3) to about 37. Buying sooner is what CR-018 is for.

Step 18.3

  • config.deal.tankShare removed rather than set to 1: the deal's ships are now full like every other new ship, so there's no setting left to tune.
  • Save version 3 carries the Manual migration: a version-2 save can't tell a chosen Auto from the default, and CR-018 §3 says nobody chose it.
  • The simulated player refuels lazily: before a job it compares the best job on the fuel aboard with the best after topping up (as far as credits go), and buys only if that opens a better job; with no job here it fills up to go looking. The milestones match always filling up (36.7 min first purchase), and no world needs emergency fuel.

Step 18.4

  • "Longest yes leg" is measured to discovered places only: a warning about somewhere the player can't fly yet would be noise. The ship's effective stats count, so Extended Fuel Tanks raise the bar along with the reach.
  • The warning names the leg ("Ceres Station needs 928 fu"), so the player knows what the fuel is for; it stays a notice, not a block, and "Refuel here first" in the dispatch sheet is still the main way to refuel.
  • No warning in flight: the check is about leaving from where the ship is.
  • Manual comes first in the toggle and its hint begins "The default".

Step 18.5

  • The e2e flying helper refuels before every trip, as a Manual player would. It had relied on Auto top-up, so e2e 12.6 failed on the Step 18.3 and 18.4 commits; the unit suites didn't cover it.

CR-019 recorded (Only Show Jobs the Fleet Can Complete)

  • Numbering: received as "CR-018 — Only Show Jobs the Fleet Can Complete" for "Phase 18"; stored as CR-019, built as Phase 19 (its 18.n = 19.n). Its decisions CR-018.D1–D4 are 0.5.114–0.5.117. Its references: CR-005 → CR-007 (steps 10E.n → 10G.n), CR-006 → CR-009, CR-009 → CR-012, CR-011 → CR-014, CR-014 → CR-015; CR-002, CR-004 and plan steps 3.9, 3.11, 9.4 are unchanged.
  • It replaces this repo's CR-006 board rules, which its numbering doesn't know about: the open and preview slots of decision 0.5.52 and the weight warning of 0.5.53.

Step 19.1

  • One rule set, two uses: permanentFailure checks the same things in the same order as validateDispatch, but it treats the tank as fillable where the origin sells fuel, because fuel on board is temporary there. validateDispatch keeps its "now" checks (idle, shop, rest, assignment, expiry, fuel on board) in front of and behind the permanent ones. The dispatch sheet still shows validateDispatch's reason per pair, which is a superset.
  • "No pair here" also covers ships with no pilot: the whole-fleet fallback applies whenever no ship-and-pilot pair qualifies, not only when no ship is there. A pilotless ship can't take a job, so a board built for it alone would be empty.
  • Whole-fleet pairs are each ship with its assigned pilot and with every unassigned pilot, wherever they are, the most generous reading of "every owned ship and pilot pair".
  • "Arrives before the period ends" is strict (arrivalAt < periodEndsAt).

Step 19.2

  • CR-006's slot plan stays as the candidate source: fit slots aim candidates at the fleet, so a lone Courier still fills its board in a few attempts. Open and preview slots now only add variety, because whatever they draw must still be completable. boardPreviewSlots and boardOpenSlotsPerBand keep their values.
  • Candidates go round the slots: a rejected candidate's slot is tried again on the next pass, so bands the fleet can't carry give way to bands it can, and the board size holds.
  • The class-mix baseline stays fleet-free (CR-019 §3 asks for a full-capability fleet): this repo's fit slots (CR-006) pick goods the crew can carry rather than drawing a class from the mix, so with any fleet the Earth mix has been Standard ~48%, Heavy ~20%, Dangerous ~10% since CR-006. Filtering is not the cause. The ±3-point test keeps checking class selection with no fleet, and a new test shows a full-capability fleet filters nothing out.
  • No fleet means no filter in the sim API (tools, the class-mix test). The engine always passes one, and an empty fleet gets an empty board.
  • Contract ids number the kept jobs (origin:period:n, n = 0…), as before.
  • The simulated player's fuel reserve is now the larger of 5,000 cr and what filling every landed ship costs where it is. Under Manual refuelling, the flat 5,000 let a purchase leave a Clipper at Venus unable to buy fuel (seed 777).
  • The fuel-share check's pilot is skilled in everything, so the check measures what fits the ship; an unskilled pilot would now see only local, low-paying jobs.

Step 19.3

  • The display filter uses the qualifying fleet as it is now, not the stored snapshot: that is what hides a job once its only capable ship has left. The snapshot is kept so the generated board can be reproduced, and for debugging.
  • When every ship leaves a location, its board falls back to the whole fleet (decision 0.5.115), so its jobs show again for deciding where to go. That is why the hiding test keeps the Courier at Earth.
  • Dispatch doesn't consult the filter: a contract hidden from the board fails validateDispatch for every pair anyway, with its reason.
  • Discarding boards mid-period can re-use contract ids: a regenerated board numbers its jobs from 0 again, so a job accepted before the update hides the regenerated job with its id for the rest of that period. That lasts at most one 10-minute period after the update.

Step 19.4

  • "Needs Heavy Cargo 2" stays on cards: it describes the job, as distance and mass do, and every job shown can be taken by someone. Only the rose problem pills and the red mass went.
  • One empty-board message for both cases: a board emptied by filtering and one whose jobs were all taken read the same, "No jobs here your fleet can take right now", with the countdown. The client can't tell the two apart, and the advice is the same.
  • The "No idle ships here" hint above the board is unchanged; on an empty board it still points to the Fleet screen.

Step 19.5

  • e2e 12.6 no longer shows a Heavy job blocked by skill on the board: under CR-019 the job isn't offered until someone has the skill, so the test checks that instead and dispatches once the skill is learned. The dispatch sheet's "Needs Heavy Cargo 1 (pilot has 0)" reason is still covered by the component tests.
  • The balance simulator reads boards the way the engine does (qualifying fleet, read-time filter). The new jobsShown/uncompletableShown counts make "never sees a job it can't complete" a checked invariant.

CR-020 — Bridge Dashboard and HQs Tab (Phase 20)

  • Requested by the user directly (not a supplied CR), so it takes the next free numbers: CR-020, Phase 20, decisions 0.5.118–0.5.121.
  • Asked: the Dashboard's tab label (user chose "Bridge", since eight tabs leave about 48 px each on a phone and "Dashboard" won't fit) and whether Alerts should already list alerts the game can derive (user chose an empty placeholder for the future in-flight alerts/news).

Follow-up after Phase 20: fewer Dashboard tiles

  • The Dashboard drops its Credits and Game date tiles (user request): both are always in the header's chips. Ships idle and Ships in flight stay, side by side.

CR-021 recorded (Independent Pilots)

  • Numbering: received as "CR-019 — Independent Pilots" for "Phase 19"; stored as CR-021, built as Phase 21 (its 19.n = 21.n). Its decisions CR-019.D1–D9 are 0.5.126–0.5.130. Its references: CR-005 → CR-007, CR-007 → CR-010, CR-011 → CR-014 (its 10K.6 → Step 14.6), CR-014 → CR-015, CR-016 → CR-017, CR-017 → CR-018, CR-018 → CR-019; CR-001 and CR-004 are unchanged.
  • Plan 0.3: automation/standing routes is struck from the deferred list; standing orders, pilot traits, decision events and a hiring market stay deferred.

Step 21.1

  • The settings live in one independentPilots group in GameConfig.
  • The Dispatch Office costs exactly a Lighter (36,000 cr), the CR's "start around the price of a Lighter", until Step 21.8's balance pass says otherwise.

Step 21.2

  • "Stay above the floor" is credits − fuel cost ≥ floor: a purchase may leave exactly the floor.
  • A pilot buys exactly the leg's shortfall, rounded up to 0.001 fu so the tank never ends a hair short. Where the origin sells no fuel, only jobs flyable on what's aboard count.
  • Minimum profit compares the job's profit (payout − fuel cost − pilot share), the same figure profit per hour divides.
  • Pilots run every validateDispatch check on the tank as it would be after buying, so they obey the no-strand rule, skills, Priority windows and expiry exactly as the player must.
  • Contract ids compare as strings for the last tie-break; board ids are origin:period:n, so on one board that is slot order.
  • Deadheading home uses the same fuel rules, then validateReposition.

Step 21.3

  • The Dispatch Office sits on PlayerState as dispatchOffice { owned, spendingFloor }, not on Company: the API company stays the name, credits and loan that many screens and fixtures share; the save keeps both on company.
  • Migrated pilots get home Earth (the deal's bay): a save doesn't record where a pilot was hired. New hires are homed where hired; the avatar and the veteran at Earth.
  • A pilot's home must be one of the player's bays, since "home HQ" is a bay.
  • Switching to Manual is immediate in the save, and that already means "when the current trip ends": an Independent pilot only decides while idle, so a trip in progress finishes either way.
  • Orders are kept even in Manual, so switching back to Independent restores them.
  • Local checks: pnpm -s typecheck printed nothing while the web package had type errors (the exit code was 2). Types are now checked with pnpm typecheck and an "error TS" search.

Step 21.4

  • Decision points are stored, not derived: each pilot keeps nextDecisionAt. A landing sets it to a turnaround after the arrival, or after the rest it triggers; setting a pilot Independent sets it to a turnaround from then. Rest, the shop and the first-look window each push it later; a wait sets it to the next board period's first-look end. Shop endings and rest endings need no events of their own: the decision point checks them.
  • Board periods matter only to waiting pilots, so they appear as those pilots' next decision points rather than as separate events.
  • Arrivals resolve at their arrival time (resolvedAt = arrival), not when the app notices them. Without this, splitting time would change Auto top-up credits and every later decision.
  • Ids made during catch-up are ind-<pilot>-<time>-<n>, for both dispatches and boards a pilot generates, so the same replay gives the same save. Player commands keep random ids.
  • The 7-day cap counts from the save's lastTimeSeen when a catch-up starts. A pilot whose decision fell after the cap resumes from the catch-up's end, not from the past.
  • Splitting time leaves only lastTimeSeen different: a piece that changes nothing isn't saved, so it doesn't move lastTimeSeen.
  • A refused pilot action, which shouldn't happen because the sim chose by the same rules, leaves the pilot waiting for the next board period instead of stopping the replay.
  • Chunks are 500 events, each its own save, so a week's catch-up never holds the screen for long.

Step 21.5

  • "Viewed" means the dispatch sheet is open: it's the contract's detail view. Browsing the board isn't viewing one contract.
  • The open contract is held in memory only (GameEngine.setViewedContract). It never reaches the save, and catch-up after a reload has none, as the CR's "live play only" says.

CR-021 renumbered (was CR-020)

  • Two sessions recorded a CR-020 the same evening: the Bridge dashboard and HQs tab (recorded 22:19, built as Phase 20, decisions 0.5.118–0.5.121), and Independent Pilots (recorded 22:59 in another session working in the same checkout). The first one keeps CR-020. Independent Pilots becomes CR-021 / Phase 21, decisions 0.5.122–0.5.130. Its first five steps had already been committed and pushed as "Step 20.1"–"Step 20.5"; they are Steps 21.1–21.5 in the plan, progress and here, and the code and test comments now say CR-021. History isn't rewritten.
  • "The HQ screen" in CR-021 is the Bridge (the old HQ tab, now the Dashboard). The new HQs tab is information only (decision 0.5.121), so the Dispatch Office is bought on the Bridge.

Step 21.6

  • The Dispatch Office lives on the Bridge (the old HQ screen, see "CR-021 renumbered"), in the right-hand column above Goals. It's hidden during the tutorial.
  • The mode toggle reads "When you send them / Independent" rather than "Manual / Independent", so it doesn't sound like the ships' Manual refuel setting.
  • Orders are edited as a draft and saved together with "Save orders": several checkboxes changing one at a time would otherwise each fire a command.
  • "Anywhere you know" is destinationIds: null, so places discovered later are included. Unticking it starts from every place known now.
  • The Fleet badge sits on the pilot line: beside the status pill on a phone it truncated the ship's name.
  • The spending floor's button is "Save floor", distinct from the company name's "Save".

Step 21.7

  • The "Yours for" chip appears only when a pilot is Independent: without one, nobody competes for the job and the chip would be noise. First contacts never get it; they're always the player's.
  • The logbook replaces per-trip cards for pilots' own trips: an hour away is a dozen trips per pilot. Each pilot is a collapsible section, open when there's only one.
  • A logbook job's payout is credits + pilot share + loan repayment, rebuilt from the summary: what the contract paid before deductions.
  • The indicator follows the loop's own pass, via onBusy, not a separate catch-up call, so it covers the first pass on opening and each resume.

Step 21.8

  • "The same fleet" means the window buys and upgrades nothing in either mode, and claims no goals, so only who flies differs. In active mode the player still spends skill points as they're earned; Independent pilots don't (decision 0.5.122).
  • Earnings are credits gained plus loan repaid over the window: repayment is income that went to the lender.
  • The window opens at minute 180, when every world owns four ships. Seed 777 (0.34) and 99 (0.38) fall below 40% on their own; the target is checked on the median, like the other balance targets.
  • No tuning: the default 15-game-minute turnaround and 3-minute first look give a median of 56%.
  • A stranded Independent pilot is a violation when the company has money above the floor but the ship can't reach anywhere. None occurred.

Step 21.9

  • The game design document is left to the designer (CR-021 §6), as with CR-016.

CR-022 recorded (Welcome Screens)

  • Numbering: received as "CR-020 — Welcome Screens" for "Phase 20"; stored as CR-022, built as Phase 22 (its 20.n = 22.n), since CR-020 (Bridge) and CR-021 (Independent Pilots) exist. Its decisions CR-020.D1–D2 are 0.5.131–0.5.132. Its references: CR-008 → CR-011, CR-011 → CR-014; CR-001 is unchanged.

Step 22.1

  • "Halvorsen Lunar Freight" is now content (tutorialCompanyName): the CR says the welcome takes it "from the existing content name", but it was hard-coded in three screens. Card 3 says {company}, and Step 22.2 points the screens at the content name too.
  • welcomeSeen lives on the save's player, alongside the tutorial state, since it belongs to the company, not the device. That's why it isn't a one-time tip in device preferences like the tutorial intro card.

Step 22.2

  • The welcome is its own dialog, not the shared Sheet: it needs a focus trap and swipe handling, which the sheet doesn't have.
  • Escape skips, like Skip.
  • "Cross-fade instantly" means no animation: with reduced motion the card changes at once. The global reduced-motion CSS would stop the slide anyway; the component also drops the class, so the rule doesn't rely on CSS alone.
  • The gate hides itself as soon as the player closes it, before the command returns, so the cards never flash back while the state refreshes.

Step 22.3

  • The welcome gate remembers a close per company id: "Start a new company" keeps the save's id but makes a new company id, and the new company must get its own welcome.
  • How to play doesn't leave Settings: "Close" just closes it; only a new company's welcome goes to Contracts.

Step 22.4

  • "First dispatch under 30 seconds" is a new end-to-end check, since this repo's Phase 12 had no such target. The balance simulator has no screens, so a welcome can't slow it. The new e2e test times a real start, from "Start your company" through Skip to the first tutorial run in flight, against 30 seconds.

Fix: boards ignore Independent pilots

  • Decision 0.5.133: the qualifying fleet is the player's own. The player can't send an Independent pilot, so jobs only that pilot could fly were noise on the board. They are left out of both generation and the read filter. Independent pilots aren't harmed: they choose with validateDispatch against their own pair, and when they have no fitting job they wait or head home as before.
  • Fallback when no Manual pair exists: the whole fleet counts, so a company whose only flyable ships are Independent still gets a board for them.
  • An Independent pilot's ship stays out even when another pilot is spare: it stays assigned to the Independent pilot, so pairing it with an unassigned pilot would offer jobs the player can't send without first taking that pilot off it.

Fix: ready ships, not idle ships

  • Decision 0.5.134: the Contracts chips count ships ready for a job. "Idle" counted ships whose pilot was resting, so a location looked like it had a pilot available when none could fly. Uncrewed ships, ships in the shop and Independent pilots' ships don't count either: none of them can take a job the player picks. The chip's label is now "N ship(s) ready".
  • Rest is checked against the clock on the board, not the resting flag from the last state fetch, so the count changes when rest ends. The Jobs tab badge keeps using the flag, to avoid re-rendering the whole app every second.
  • The Dispatch sheet is unchanged: it already lists a resting pilot's ship as blocked, with the time left.

CR-023 recorded (Shop Time Counts as Rest)

  • Numbering: received as "CR-021 — Shop Time Counts as Rest" for "Phase 21"; stored as CR-023, built as Phase 23 (its 21.n = 23.n), since CR-021 (Independent Pilots) and CR-022 (Welcome Screens) exist. Its decision CR-021.D1 is 0.5.135. Its references: CR-006 → CR-009, CR-011 → CR-014, CR-019 → CR-021; CR-004 is unchanged.

Step 23.1

  • The content test compares in game hours, as the CR asks: shop time is set in real minutes and rest in game hours, so the shop time is multiplied by the time compression first.

Step 23.2

  • The pilot is recorded on the ship (shopPilotId), the CR's second option. An "assigned since" time on the pilot would also need the shop's start time; the ship already knows when the shop ends.
  • Leaving the ship is the only way to lose the rest. A pilot can fly only their assigned ship, so flying another one means being unassigned first. Leaving and coming back during the upgrade also loses it: they weren't with the ship the whole time.
  • The shop's end is a catch-up event, not a check on read, so it happens at its own time in the queue (decision 0.5.129): an Independent pilot waiting on it decides after it, and the save is the same however the time is split. At the same moment it comes before arrivals and decisions.
  • A rest that ends with the shop brings an Independent pilot's decision forward to the shop's end plus the usual turnaround. Without it, the pilot would sit idle until the old rest end.
  • Existing saves: the migration gives every ship shopPilotId: null. Recording the current pilot instead would reset duty for long-finished upgrades too, since the migration can't tell the time.
  • "Shorter than a rest doesn't count" is checked at the start of the upgrade (shop time against the rest length), so no pilot is recorded.
  • No sim change: the reset is bookkeeping on the save (who was aboard, when the shop ends), so it lives in the engine beside the other catch-up events; the sim's duty and rest rules are unchanged.

Step 23.3

  • The shop line replaces the duty bar while it shows, like an ordinary rest: duty is reset when the shop time ends, so the old hours would only mislead. It wins over an ordinary rest too, since that rest now ends with the shop at the latest.
  • The test unassigns through the state, not a button: the app has no unassign control (pilots are unassigned by the engine command), so the component test swaps in the unassigned pilot's state and refetches; the engine test checks that unassigning clears restingInShopUntil.

CR-024 recorded (Fleet Sleep Mode)

  • Numbering: received as "CR-022 — Fleet Sleep Mode" for "Phase 22"; stored as CR-024, built as Phase 24 (its 22.n = 24.n), since CR-022 (Welcome Screens) and CR-023 (Shop Time Counts as Rest) exist. Its decisions CR-022.D1–D4 are 0.5.136–0.5.139. Its references: CR-008 → CR-011, CR-011 → CR-014, CR-019 → CR-021; CR-004 and plan 12.1 are unchanged.
  • "HQ" is the Bridge, as in CR-021: the dashboard with the Dispatch Office panel. The HQs tab (CR-020) only lists bays.

Step 24.1

  • "Set to the migration time" without a clock in the migration. Migrations are pure functions of the save, so the 8 → 9 step leaves lastManualDecisionAt null and the engine's catch-up sets a null one to the time it runs, before anything else. That is the first time the engine opens the save (load or import), so the effect is the CR's: nobody's fleet is asleep the moment they update.
  • The sleep settings are their own config block (fleetSleep), not part of independentPilots, whose values are all checked as positive settings of a different kind.
  • The threshold lives on the company, beside the Dispatch Office and spending floor: it travels with the save, unlike device preferences.

Step 24.2

  • The catalog is routes (packages/schema/src/routes.ts): manualDecision is a required field of RouteDef, so a new command fails the build until it says.
  • Counted beyond the CR's list: unassigning and resting a pilot, refuel policy and emergency fuel (refuelling decisions), the spending floor (an order to the pilots) and renaming the company. They're all game commands the player makes; the CR counts "any game command".
  • Not counted: markWelcomeSeen (like dismissing), new company and the dev tools. A new company starts with its creation as the last decision anyway.
  • Display preferences aren't commands (they live on the device, CR-011), so they can't stamp it; the test checks the engine-side things the player can do without deciding: reading, boards, dismissing, the welcome and export.
  • A refused command isn't a decision: the stamp happens after the handler succeeds, in the same write.

Step 24.3

  • Sleep is one more limit on decisions in catch-up, beside the 7-day cap: decideLimit = min(until, decideUntil, sleepsAt − 1 ms). Nothing else in the queue changes, so arrivals, rest and shop ends resolve as before, and catch-up stays deterministic. Pilots due after the limit wait at until, the time of the command or loop pass that caught up, which is how a manual decision then wakes them from that moment (Step 24.4).
  • "At or after the sleep time": a decision exactly at sleepsAt doesn't happen (hence the −1 ms).
  • asleep is computed, not stored: now ≥ sleepsAt. The client decides whether to show it, since a fleet with no Independent pilots has nothing to wake.

Step 24.4

  • "Back to work" stamps the time itself, as well as through the catalog flag: the engine stamps after the handler, and the command's reply (the player state) should already read awake.
  • Waking needs no other change: pilots asleep were left waiting at the time of the last catch-up, which is the moment of the waking command, so the next catch-up has them decide from then.

Step 24.5

  • The banner and the Bridge status show only with an Independent pilot. Sleep only stops Independent pilots; with none, "Fleet asleep" would ask the player to wake nobody.
  • The Bridge status sits right under the header, before the ship panels, so a returning player sees it on a phone without scrolling.
  • The banner shows inside the away screen when it opens, which needs unseen summaries. A fleet that went quiet with nothing new to report still shows the Bridge status.
  • Changing the threshold is itself a manual decision: it's a game command the player makes, so it also wakes the fleet.
  • "3 days", not "72 h": thresholds over 48 hours that are whole days are written in days.

Step 24.6

  • Scheduled from the save, like the others: the notification scheduler already rebuilds the whole plan after every saved change and replaces what was scheduled. So "each manual decision reschedules it" and "cancelled on new company or import" need no code of their own: the sleep time moves with the decision, and a new or imported save plans its own.
  • Only with an Independent pilot: with none, nobody stops working, and "Your pilots are waiting for orders" would be untrue.
  • Not grouped by minute: it's a separate notification, so it never merges with an arrival in the same minute.

Step 24.7

  • The week-away check runs in the balance simulator, beside the 8-hour scenario: the scenario's start is the player's last decision, and the fleet goes quiet the default 24 hours later. "A 24-hour absence plus the trips in flight at the sleep time" is measured as the 24-hour run's earnings plus the gross payouts of the week run's trips flying at that moment, which bounds anything that lands later.
  • CR-011's "Settings → Company" is the Settings screen's step (10H.2), where the Company section was laid out.

CR-025 recorded (Short "While You Were Away" Summary)

  • Numbering: received as "CR-023 — Short 'While You Were Away' Summary" for "Phase 23"; stored as CR-025, built as Phase 25 (its 23.n = 25.n), since CR-023 (Shop Time Counts as Rest) and CR-024 (Fleet Sleep Mode) exist. Its decisions CR-023.D1–D3 are 0.5.140–0.5.142. Its references: CR-011 → CR-014, CR-014 (leg fuel cost) → CR-015, CR-019 → CR-021, CR-022 → CR-024; CR-001 and plan 9.2 are unchanged.
  • "HQ" is the Bridge, as in CR-021 and CR-024.

Step 25.1

  • One "flight" kind for deliveries and deadheads, with a deadhead flag, as the delivery summaries had: both are a dispatch landing, with the same route, fuel and XP fields.
  • Ids come from what an entry records (<dispatch>:flight, <ship>:upgrade:<time>, goal:<id>), not from the random source, so catch-up in one go and in pieces writes identical entries. A repeat in the same moment gets #2.
  • Every shop end is now a catch-up event (ship.shopOpen), not only ones with a pilot waiting it out: "upgrade finished" is logged at the time the shop time ends, in order with arrivals.
  • The absence is the time since the summary was last closed (player.awaySince). The engine can't tell when the app was closed, but the summary opens on every landing, so its last "Done" is the last time the player looked. Closing it records the absence it covered (lastAbsence) for the Logbook's "This absence".
  • The pop-up still opens on unseen deliveries and finished upgrades, as before (summaries, acknowledgeSummaries): the logbook adds detail and a window, it doesn't change when the summary appears.
  • Goals are logged once (player.readyGoalsLogged), so pruning old entries doesn't log them again. After the update, goals that were already ready are logged at the first catch-up.
  • Migration: resolved dispatches still in the save (7 days of them) become flight entries with their top-ups, rests and discoveries. Level-ups and goals can't be rebuilt and are dropped; finished upgrades the player hadn't seen are logged by catch-up at their shop end.

Step 25.2

  • The builder returns structure, not text. Highlights and lines carry ids and amounts; the client words them from content.awayWording (and names places, goods, goals and pilots), so the sim stays free of wording and number formatting.
  • Problems come in as input: they're found from the current state (fuel, boards, credits), not the logbook, so the engine finds them and passes them to the pure builder (Step 25.3).
  • "Events" for a short absence are flights (deliveries and deadheads), discoveries, level-ups and finished upgrades. The pop-up also opens for a deadhead or a finished upgrade alone, and it should then list it. Refuels, rests and goals aren't events.
  • Each discovery, level-up, goal and scripted delivery is its own highlight, in time order within its rank; the biggest job is one highlight (the highest payout, the earliest on a tie); each problem is one.
  • Pilot lines count flights: a pilot who only deadheaded is active (0 jobs, a negative net). Ties in net go by name.

Step 25.3

  • The client builds the summary from getLogbook with the sim's pure builder: wording stays in content and the client, and "where a pilot is now" comes from the player state the client already has. The engine finds the problems, which need its boards and fuel rules.
  • "Fits one screen" is checked in a browser, not jsdom, which has no layout: the e2e test opens the pop-up at 375×667 with 8 active pilots (more than the 5 lines) and checks the sheet doesn't scroll. With at most 3 highlights and 5 lines, more pilots don't make it longer. The component test checks the caps with 50 pilots.
  • Ready goals are a highlight now, without the old "Claim on the Bridge" button: the pop-up has two buttons (View logbook, Done), and the Bridge's goals card has its badge.
  • "No reachable jobs" uses the board stored for this period: if the player hasn't opened that board, there's nothing to judge, and no problem is shown. Independent pilots' ships are left out: they wait or head home on their own.
  • A short absence doesn't repeat its events as highlights. Discoveries, level-ups and the biggest job are already in its list, so the builder drops them before taking 3, and "+N more" counts only what isn't shown.
  • One level-up highlight per pilot, at the highest level reached: a pilot who went from 1 to 3 reads "reached level 3".
  • View logbook also closes the pop-up, so the absence it showed becomes lastAbsence, which is what the Logbook's "This absence" shows (Step 25.4).
  • Seen while building it: pilot names are drawn without checking the roster, so two hired pilots can share a name (two "Dmitri Brennan" in the screenshot run). That's older than this CR and isn't changed here.

Step 25.4

  • The Logbook isn't a tab: the navigation already has eight. It opens from "While you were away" and from a card on the Bridge, like Settings opens from the gear.
  • Every pilot, the player included: the CR-021 logbook showed only Independent pilots' own trips, beside cards for the player's. The Logbook has everything, so the player's trips are in the Captain's section and goals in a "Company" section.
  • Newest first, 50 lines a pilot at a time: 7 days of a busy fleet runs to hundreds of lines per pilot.
  • Times show the date on earlier days, judged by the game clock, so "Last 7 days" reads clearly.
  • "This absence" after the pop-up closes is the last absence (lastAbsence): View logbook closes the pop-up, and the Logbook still shows what it summarised. Its net and deliveries are the pop-up's (the test checks both).

CR-026 recorded (Random Events, Radio Calls, and Hull Condition)

  • Numbering: received as "CR-024 — Random Events, Radio Calls, and Hull Condition" for "Phase 24"; stored as CR-026, built as Phase 26 (its 24.n = 26.n), since CR-024 (Fleet Sleep) and CR-025 (Short Away Summary) exist. Its decisions CR-024.D1–D9 are 0.5.143–0.5.151. Its references: CR-005 → CR-007, CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-015 → CR-016, CR-018 → CR-019, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025; CR-001, CR-004 and plan Steps 3.13, 3.17 and 12 are unchanged.
  • "HQ" is the Bridge, as in CR-021, CR-024 and CR-025.
  • The catalog has 14 events: nine bad or neutral and five good, as the CR's tables list them.

Step 26.1

  • Places get a region (inner, belt, jupiter), so event conditions read from content: a leg between regions crosses or enters the belt; Earth↔Moon and legs inside the Jupiter system are the "local loop"; legs with an end in the inner system or belt can meet spot freight.
  • Events whose effects are a share of the payout need a contract: the derelict pod, distress beacon, spot freight and customs hold, as well as the grateful client. On a deadhead they would be worth nothing.
  • An option is "always" effects plus exclusive chances: push through the debris field is 35% hull damage, 10% lost pay, otherwise nothing; seal the hazmat leak is +2 h always and 30% hull damage.
  • Weights: bad 70 (flare 10, micrometeor 8, hiccup 10, debris 10, storm 8, customs 8, hazmat 6, load shift 6, spoilage 4), good 36 (tailwind 10, grateful client 10, pod 6, beacon 4, spot freight 6): 34% good. Step 26.9 tunes them against the balance targets.
  • Hull Plating adds a flat +1 a level (flatPerLevel), since a percent of a rating of 1–3 would be fractions; its cost and shop time follow the usual upgrade levels.

Step 26.2

  • The cap applies after the route multipliers: min(35%, …) × multipliers would let a belt crossing to Jupiter reach 55%, against the CR's "long hauls 35% or less". The chance is capped at 35% again after them.
  • One random stream per trip (deriveSeed(seed, "event")), drawn in a fixed order: whether, which, when, then every option's outcome in order. So an option's outcome is the same whenever, and whether, it's chosen.
  • The hull rating cuts both the chance and the amount of hull damage (decision 0.5.148): a chance bucket with hull damage is scaled by (1 − reduction) and doubled below 25% condition; the amount by (1 − reduction). Automatic hull damage (micrometeor) always happens once rolled, so only its amount is cut.
  • The first event waits for a long-enough contract: until the company has had one, trips under 6 game hours, and deadheads, are quiet, so the pod really is the first event.
  • A radio call too close to another is dropped, not swapped for an automatic event, as the CR says ("the trip is quiet").

Step 26.3

  • Delays are measured from the original arrival, which is the same as "from the fire time" for a trip that keeps flying: the event adds its hours to what was left.
  • A speed-up's floor is the later of half the trip and the event's own time, so a tailwind early in a short trip can't land the ship before it happened.
  • The Priority check uses the actual trip time after the event against the delivery window; missing it costs payout − payout ÷ Priority multiplier, the bonus only (decision 0.5.146).
  • Losses never take the payout below zero, and bonuses come on top (decision 0.5.147): a vented load and a fee on a small job leave 0, plus any tip.
  • The pilot's share is taken from the payout after the event, like the loan: a salvage bonus is shared with the pilot who flew it, and a loss costs them too.

Step 26.4

  • The hull reason comes after the pilot checks and before expiry and fuel, like a ship condition the player can fix: the dispatch sheet still lists every problem, and the fuel reason stays last ("Refuel here first" relies on it meaning nothing else is wrong).
  • ShipState.hullConditionPct is optional (absent = 100), so the tutorial loaner and older test fixtures need nothing new.
  • Condition is kept to one decimal, as rolled hits are, so repeated hits add up exactly.

Step 26.5

  • The event lives on the saved dispatch (event, eventApplied), next to the sim's record, which stays the record of the trip as flown: applying an option updates its arrivalAt (and fuel), so rest and duty count the longer trip.
  • Events come first at any moment in catch-up, before shop ends, arrivals and decisions: a radio call whose window ends at the arrival must move the arrival before the landing resolves.
  • A radio call is "waiting" from its fire time until its window ends and nothing has applied it. Answering before it fires is radio-call-not-found; after, radio-call-closed (catch-up has already applied the default by then).
  • The player can answer an Independent pilot's call (decision 0.5.151); the call says it's theirs (independent).
  • Fleet pacing counts every radio call in the save, flying or landed (seven days of them), so a call that just fired keeps the next one 45 minutes away.
  • The pacing counters: a trip with an event resets the ship's quietTrips to 0, a quiet one adds 1; every trip after the deal adds 1 to tripsSinceDeal. Tutorial runs touch neither.
  • A repair is paid when it starts, like an upgrade, and the hull is whole when the shop time ends; the shop time counts as rest when it's long enough (decision 0.5.135), which a repair of 20 points or more is.
  • Migration: a company that has made the deal starts past its grace trips (it has flown plenty), but still gets the derelict pod first.
  • Fix from Step 25.1: a goal already ready when a catch-up starts was logged at whatever moment first touched the save, so one long catch-up and several short ones logged it at different times. It's now logged at the save's last-seen time, before anything else.

Step 26.6

  • A radio call is a sheet, since it's time-limited: it opens on its own wherever the player is, and waits for "While you were away" to close first (one sheet at a time, as the deal does). Its countdown keeps running meanwhile.
  • Options show what they might do, never the pre-rolled outcome: the effects and chances come from content, so answering stays a choice under uncertainty, and the outcome is still fixed (decision 0.5.143).
  • The default is labelled "(if you don't answer)", and the countdown line says what the pilot will do when it runs out.
  • Decide later puts a call off for the session; the Bridge lists every waiting call with Answer, and its tab badge counts them alongside goals to claim.
  • PlayerState.nextRadioCallAt tells the app when to refresh for the next call (a call appears just by time passing, which no save change marks). It only gives the time, not which event.

Step 26.7

  • "Ship detail" is the Upgrades sheet: it's the one per-ship sheet, and repairs use the same shop, so the hull and its Repair button sit at its top. Its subtitle now says repairs are done there too.

  • Which events are highlights: good events (whatever was chosen) and any that hurt a hull, ranked just below level-ups. A detour or a fee without damage is in the Logbook and the short list, not a highlight.

  • The short list shows a trip's event next to the trip, without it counting as one of the 3 events: a delivery with a detour still reads as a quick check-in.

  • The hull bar colours follow the rules: amber below 50% (no Dangerous cargo), red below 25% (Needs repair).

  • Developer tools: Damage hull sets a ship to 20%, to try the warning and repairs (the CR's manual check).

  • A sleep test's world is fixed now (fleet-sleep.test.ts, "trips in flight still pay"): each test company gets a random world, and with events shifting trip times, a world sometimes had no trip in the air at the sleep time, failing the check that one lands after it. Seen as an intermittent failure after Step 26.7's commit; fixed in a follow-up commit.

Step 26.8

  • Scheduled from the save, like every notification: the plan is rebuilt after each change, so "cancel if the trip or company changes" needs nothing of its own, and an answered or fired call simply drops out.
  • Only trips the player sent: Independent pilots' trips are chosen during catch-up, so their calls aren't known ahead (as their arrivals aren't announced, decision 0.5.130).
  • Fleet sleep doesn't silence them: a call on a trip already flying still happens when the fleet is asleep (CR-026 §5).

Step 26.9

  • Decision 0.5.152: hull wear (the user's choice). The usual simulated fleet flies 93% Earth↔Moon shuttles, where events are capped at 3%, so 8–15% of trips with events was impossible, and repairs only came from rare event damage (≈ 0% of income). Asked, the user chose to measure the rate and repair targets on a long-haul fleet and to add slow wear: 0.35 condition points per game hour flown, applied on landing (never below 5%), in the engine and the simulator.
  • Repairs cost 1% of the model's price per point, not CR-026 §4's 0.5%: long hauls pay 100k–500k cr, so at 0.5% even a hull worn to the floor cost under 5% of income. The CR allows tuning repair cost; wear is kept moderate so hulls reach home before the floor.
  • "Defaults" and "answered well" are measured by the events' own worth, not by comparing two whole runs: a delay changes which job a ship takes next, so runs drift apart (±10% per world). Each event's worth is its pay change, its delay at the job's pay rate and its hull damage at repair cost, counted for the default and for the best option on the same events, against the window's income. Whole-run ratios are still printed by balance/measure-events.ts.
  • The measured fleet starts past its first event (firstEventDone), as a mid-game company is, and events start with the scenario window, so the run before it matches a no-events run.
  • Weights retuned for the realised good share: most bad events need belt, Jupiter, Venus or special-cargo routes, so on the long hauls the fleet flies, good events came out at 75%. Bad events that can happen anywhere (customs at Earth and Mars, flares, micrometeors, hiccups) now weigh more and the route-specific ones less; total good weight is 30%.
  • The away scenario for the report's world reads 90% (above 40–80%) now that the active player pays for repairs and Independent pilots don't repair; the test's median over seven worlds holds.

Step 26.10

  • CR-018's completability rule is CR-019's Step 18.1 in this repo: the hull reason is recorded there as a temporary one.
  • The game design document is left to the designer (CR-026 §9).

Fix: job length on the board

  • A job's length depends on the ship, so the board shows it for the player's fastest crewed ship that could take the job (the permanent checks: hold, range, landing, skills), preferring ships already at the job's origin, since those are the ones that would fly it. Its label says it's game time, and a tooltip says which ship it's for.
  • It's the whole trip in game hours (travel plus loading and takeoff/landing), the figure the Priority window and duty use.
  • Long catch-up and simulation tests get 60 s (vi.setConfig in balance.test.ts, catch-up.test.ts, fleet-sleep.test.ts): with events, a week's replay sometimes passed the 5 s default under coverage on CI.

Fix: refuel cost and credits in view

  • The refuel button is priced with the sim's own `refuel`, for exactly what the button will buy: a full tank when the credits cover it, else the leg's fuel (Step 15.7's rule). When even the leg's fuel is too dear, the button is disabled and says why, instead of failing on tap.
  • Every sheet opens below the top bar, rather than drawing a backdrop over it, so the credits and the clock stay in view and change as the player spends. The sheet's backdrop covers the rest, the bottom navigation included. The bar's height (3.5rem) is written in the sheet, since sheets render outside the layout that sets `--header-h`.

CR-027 recorded (Service Bookings and Auto-Repair)

  • Numbering: received as "CR-025 — Service Bookings and Auto-Repair" for "Phase 25"; stored as CR-027, built as Phase 27 (its 25.n = 27.n), since CR-025 (Short Away Summary) and CR-026 (Random Events) exist. Its decisions CR-025.D1–D7 are 0.5.153–0.5.159. Its references: CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025, CR-024 → CR-026.
  • All seven decisions are recorded: §8 says "Add CR-025.D1–D6", but §6 lists D7 (no double waiting) too.
  • No double waiting, with short repairs: CR-023 counts the shop as rest only when it lasts a full rest; a repair can take 5 minutes, shorter than a 10-minute rest. Then the rest simply runs on to its end, which is still "the later of the two", so the CR's rule holds without changing CR-023's.

Step 27.1

  • The booking lives on the ship (one per ship, as the CR has one booking per ship), with its timings: startedAt once the work is in the shop (the booking clears at the shop end), waitingSince while it waits for funds at the HQ.
  • One upgrade level per booking (upgradeId: the next level of that upgrade when the work starts), as the CR allows one level per visit.

Step 27.2

  • "Reachable" is one leg, with a full tank where the ship stands if fuel is sold there (the CR's "even with refueling"), else the fuel aboard, and the no-strand rule. Multi-leg routes aren't in the game (plan 0.3).
  • The auto-repair trigger fires on crossing the threshold (from at or above it to below it), not on every hit while below: a player who cancels an automatic booking isn't re-booked by the next scratch.
  • Repair recommended is a state, not an event: a Manual crew's hull below the threshold with auto-repair on and no repair booked.
  • The pilot tests book at Mars: a Clipper can't reach Ceres from Earth in one leg, which the reach test checks.

Step 27.3

  • The charge is taken when the work starts, not when booked, at the hull the ship has then (after landing wear), so the estimate shown while booked can move.
  • Service checks run after every command and catch-up event, so the work starts at the exact arrival, and a waiting booking starts on the first check once the credits cover it (a delivery, a sale, the dev tool).
  • "Waiting for funds" is logged once per wait, not on every check.
  • A stay shorter than a rest doesn't take the pilot along (CR-023's rule): a short repair runs beside the pilot's rest, which ends on its own, so the pilot is still free at the later end.
  • An automatic booking with no reachable HQ goes to the first HQ and shows as "can't reach"; the pilot works as usual (decision 0.5.155).
  • Booking again replaces an unstarted booking: that is the "Change" in the Service section.

Step 27.4

  • A service trip is any decision while the ship has an unstarted, reachable booking and isn't at the HQ yet: only those pilots decide past the fleet's sleep time (still before the 7-day cap). A pilot waiting at the HQ (for funds) follows the sleep rule, so a long wait doesn't fill the catch-up with idle looks.
  • After the work, the pilot goes back to their orders; hull wear on the next trips can cross the threshold again and book the next repair.

Step 27.5

  • The Service section lives in the ship's Upgrades sheet, under the hull: that sheet is the ship's detail view.
  • The tag is a wrapping chip, not a pill: "Service booked: Cargo Bay Expansion 2 at Ceres Station" doesn't fit a pill on a phone. Cyan when on track, amber for "Repair recommended", waiting for funds or out of reach.
  • The slider saves as it moves (the engine is local, so each step is cheap) and is disabled while auto-repair is off.
  • An Independent pilot's line puts the service first (heading in, in the shop, waiting for funds); out of reach, it shows their usual work, and the ship's tag carries the warning.

Step 27.6

  • Only automatic bookings are logged as "booked": the player's own bookings are their decisions, already on screen. Start, finish and waiting for funds are logged for both.
  • "Service finished" sits beside the repair and upgrade lines it covers, so the costs stay where they were (the repair line with the repair's cost, the start line with the whole charge).
  • The problems are found from the state like the others (decision 0.5.140): a booking waiting for funds, or an unstarted one whose HQ is out of reach, wherever the ship is.

Step 27.7

  • Asleep, a service trip flies empty. The CR lets service trips go on during fleet sleep, but taking a paid job to the HQ would earn while the fleet is quiet, breaking CR-024's week-away bound (it failed for one seed). Awake, the pilot still takes the best job that ends at the HQ.
  • "Neglected" means a second trip in a row below 25% while the repair is affordable above the spending floor: the first such trip is usually the one to the HQ.
  • The away median drops from 60% to 49% of active play: until now the simulator never repaired away hulls, so the old figure left out repair costs and shop time. Still inside 40–80%.
  • Amendments go on each amended CR as "Amended by CR-027" lines (CR-023 and CR-026 get an Amendments section).

CR-028 recorded (Live Toasts)

  • Received as "CR-026 … Phase 26"; stored as CR-028, Phase 28, decisions CR-026.D1–D4 = plan §0.5.160–0.5.163, with every reference mapped in the CR's numbering note.
  • "The HQ banner" is the Bridge's "Fleet asleep" panel (CR-024), the panel that already shows while the fleet sleeps.

Step 28.1

  • seen is on the entry itself, not a list of seen ids: it's pruned with the entry after 7 days, and catch-up writes entries with the same ids however it's split, so a replayed entry can't come back unseen.
  • Marking seen takes ids (what was toasted or shown), ignores unknown ones, and returns how many were newly seen; it's not a manual decision, so a toast doesn't wake the fleet.
  • The away-period fields (awaySince, lastAbsence) stay for now: Step 28.4 decides what the pop-up uses.

Step 28.2

  • Presence times come from the game clock, not Date.now(): logbook entries are stamped with it, and test builds move it forward with the developer tools.
  • A cold start returns from the save's lastTimeSeen: while the game is open the resolution loop runs a command every few seconds, so that's when the game was last on screen (or running in a background tab).
  • On iOS and Android, appStateChange decides; the web build uses the Page Visibility API. A page opened in a background tab is away until it's shown.