deadhead-space-freight-tycoon/memory-bank/progress.md

186 KiB
Raw Blame History

Progress

Phase 1 — Repository Setup

  • Step 1.1 — Create the monorepo. pnpm workspace with apps/web, apps/server, packages/sim, packages/schema, packages/content (all @deadhead/*), strict tsconfig.base.json extended by each package. pnpm install + pnpm typecheck pass. Repo moved to C:\Projects\Deadhead Space Freight Tycoon (G: is exFAT, no symlinks).
  • Step 1.2 — Linting and formatting. ESLint 10 + typescript-eslint + Prettier at the root (pnpm lint, pnpm format). Purity rule bans Math.random, Date.now and argument-less new Date() in packages/sim and packages/content; verified it fails on a planted Math.random().
  • Step 1.3 — Test runner. Vitest 5 with one project per workspace package (pnpm test). Trivial smoke test in packages/sim/test/smoke.test.ts; 1 passing.
  • Step 1.4 — Continuous integration. Repo pushed to Gitea; .gitea/workflows/ci.yml runs install, typecheck, lint and test on every push (runner homelab-runner). Run #1 failed because Prettier scanned the runner's in-workspace .pnpm-store/; ignored it, and run #2 is green.
  • Step 1.5 — Local Postgres. docker compose up -d starts Postgres 18 with deadhead (dev) and deadhead_test databases; connected to both with psql. .env.example lists the server's env vars.

Phase 2 — Content Package

  • Step 2.1 — Content schemas. Zod 4 schemas in packages/content/src/schemas.ts for Location, ShipModel, CargoType, Skill and GameConfig, with inferred types. Tests check that each schema accepts a valid example and rejects it with any one field missing.
  • Step 2.2 — The five locations. src/data/locations.ts: Earth, Moon, Mars, Ceres Station, Jupiter Cloud Platform. Earth and Moon start discovered; Earth has the highest launch multiplier.
  • Step 2.3 — The three ship models. src/data/ships.ts: Courier, Freighter, Bulk Hauler, all atmospheric. Speed, capacity and price orderings tested; range rules are checked in Step 3.7.
  • Step 2.4 — Cargo types and skills. src/data/cargo.ts: Standard, Heavy, High-Value, Dangerous; skills Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo (max level 5). The location test now checks cargo ids against the real data.
  • Step 2.5 — Default game config. src/data/config.ts. Courier + Earth bay ≈ 21 tutorial runs with the placeholder average payout. Money values rescaled ×10 so tutorial runs pay meaningful amounts; epoch moved from .env.example into GameConfig.
  • Step 2.6 — Single content bundle. content in src/bundle.ts holds all data plus location, shipModel, cargoType and skill lookups that return undefined for unknown ids.

Phase 3 — Simulation Package

  • Step 3.1 — Game clock conversion. src/clock.ts: realToGame / gameToReal using the config epoch and compression; times are Unix-ms numbers.
  • Step 3.2 — Seeded RNG. src/rng.ts: createRng(seed) (mulberry32) with next, int, pick; deriveSeed(parent, label) for child seeds.
  • Step 3.3 — Location positions. src/positions.ts: heliocentric ecliptic positions (million km) from astronomy-engine for Earth, Mars and Jupiter; Moon = Earth + fixed offset; Ceres on a circular orbit.
  • Step 3.4 — Route distance. src/distance.ts: routeDistance(from, to, gameTime), straight-line million km.
  • Step 3.5 — Travel time. src/travel.ts: travelTimeGameHours(ship, distance) = cruise time + overhead.
  • Step 3.6 — Fuel cost. src/fuel.ts: fuelCost(ship, origin, distance) = distance × cost per million km × origin launch multiplier.
  • Step 3.7 — Range and landing eligibility. src/eligibility.ts: checkRoute returns ok or cannot-land / out-of-range; canLandAt ranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances.
  • Step 3.8 — Contract model and payout. src/contract.ts: Contract interface and contractPayout (base × multiplier^tier × mass × distance × first-contact multiplier, rounded).
  • Step 3.9 — Contract board generation. src/board.ts: generateContractBoard (seeded per world, bay and period), plus boardPeriodIndex, requiredLongHaulLevel and the SimContent input type. First contact ≈10% over 1,000 boards.
  • Step 3.10 — Tutorial contract board. src/tutorial.ts: generateTutorialBoard (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); loanerModel. SimContent gained shipModels.
  • Step 3.11 — Pilot eligibility. src/pilot.ts: checkPilotEligibility(skills, contract) returns ok or cargo-skill-too-low / long-haul-too-low.
  • Step 3.12 — Dispatch validation. src/dispatch.ts: ShipState, PilotState, validateDispatch (first failing reason, plan order), tripFuelCost (rounded). One test per failure reason plus tutorial cases.
  • Step 3.13 — Dispatch record creation. createDispatchRecord in src/dispatch.ts: DispatchRecord with arrival from travel time, fuel cost, departure distance, a tutorial flag and a per-dispatch seed.
  • Step 3.14 — Reposition dispatch. validateReposition and createRepositionRecord in src/dispatch.ts: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17).
  • Step 3.15 — In-flight position and progress. src/flight.ts: dispatchProgress (clamped 0–1) and dispatchPosition (straight line from origin at departure to destination at arrival).
  • Step 3.16 — XP and leveling. src/xp.ts: deliveryXp, pilotLevel, skillPointsAvailable.
  • Step 3.17 — Dispatch resolution. src/resolution.ts: resolveDispatch returns in-flight or a Resolution (credits after pilot pay, XP, new ship location, discovery).
  • Step 3.18 — Public API and coverage. src/index.ts exports an explicit list (content lookups and contract building stay internal). pnpm coverage (V8, packages/sim/src, 90% line threshold) reports 100% lines; CI now runs pnpm coverage instead of pnpm test.

Phase 4 — Shared Schema Package

  • Step 4.1 — API data shapes. packages/schema/src: entity schemas (entities.ts), error format and codes (errors.ts), request/response bodies (requests.ts). Error codes include every sim validation reason; sim now exports DISPATCH_FAILURES / REPOSITION_FAILURES. architecture.md started with the API data model.
  • Step 4.2 — API route catalog. packages/schema/src/routes.ts: 13 typed endpoints under /api with request/response schemas and an auth flag, plus RouteRequest / RouteResponse helper types. Route table added to architecture.md.

Phase 5 — Server Foundation

  • Step 5.1 — Fastify skeleton. apps/server: Zod-validated env config (src/config.ts), buildApp with pino JSON logging and GET /api/health (src/app.ts), entry point src/main.ts (loads repo-root .env if present). pnpm --filter @deadhead/server dev|start run it with tsx. Integration test starts the app on an ephemeral port and calls health over HTTP.
  • Step 5.2 — Server time endpoint. GET /api/time returns { realTime, gameTime } from an injectable Clock (src/clock.ts, passed via buildApp(config, { clock })).
  • Step 5.3 — Database schema and migrations. Drizzle schema (apps/server/src/db/schema.ts, copied into architecture.md), first migration drizzle/0000_init.sql, db:generate / db:migrate scripts. Migration test runs against an emptied test database twice. CI now has a Postgres 18 service; server tests run one file at a time.
  • Step 5.4 — Test database harness. apps/server/test/db.ts: useTestDatabase() (migrate once, truncate all tables before each test) and seedPlayer(db, "tutorial" | "owner"). New-player creation lives in src/game/new-player.ts (createPlayer), shared with guest sign-up.
  • Step 5.5 — Guest accounts. Better Auth with the anonymous plugin (src/auth.ts, tables in src/db/auth-schema.ts, migration 0001_auth). POST /api/guest creates a guest session and a tutorial player; GET /api/state returns player state for the session or 401 unauthorized. Error handler sends the documented error format.
  • Step 5.6 — Request validation from the route catalog. registerRoute (src/http/route.ts) validates merged params/query/body before auth, checks sessions for auth routes, and checks responses outside production. Malformed JSON and unexpected fields return 400 validation-error.

Phase 6 — Server Game Logic

  • Step 6.1 — Lazy resolution on read. resolveArrivedDispatches (src/game/resolve.ts) resolves arrived dispatches in arrival order inside withPlayerLock (src/game/lock.ts, a transaction holding the company row lock); a conditional status update makes each resolve exactly once. GET /api/state runs it before reading. Routes moved to src/routes/.
  • Step 6.2 — Get full player state. Endpoint from Step 5.5 plus lazy resolution; test covers a player with one ship in flight and one idle (crewed by a hired pilot), checking ships, pilots (XP, level, skills, points, assignment, status, location), bays, dispatches, summaries and schema validity.
  • Step 6.3 — Tutorial board endpoint. GET /api/tutorial/board (src/routes/boards.ts, src/game/boards.ts): stored per (period, loaner location) on first request, accepted contracts hidden, 409 not-in-tutorial after the tutorial. Every game route now runs through withPlayer (lock + lazy resolution).
  • Step 6.4 — Tutorial dispatch. POST /api/dispatches with kind: "tutorial" (src/game/dispatch.ts): avatar + loaner, sim validation, no fuel, one run at a time; contract marked accepted. Arrival pays the stored tutorial payout and moves loaner and avatar.
  • Step 6.5 — Buy a ship. POST /api/ships (src/game/fleet.ts, src/routes/fleet.ts): deducts the price, adds an idle uncrewed ship at Earth named from the world seed (src/game/names.ts); 409 insufficient-funds changes nothing.
  • Step 6.6 — Lease a bay. POST /api/bays (leaseBay in src/game/fleet.ts): one-time lease price at a discovered location; 409 location-undiscovered, 409 bay-already-leased, 409 insufficient-funds, 404 not-found.
  • Step 6.7 — End tutorial. maybeEndTutorial (src/game/tutorial.ts) runs after each resolution pass and after every game action: with a ship, an Earth bay and no tutorial run in flight, tutorial mode turns off, the loaner is removed, and the avatar becomes an idle, unassigned pilot at Earth.
  • Step 6.8 — Hire and assign pilots. POST /api/pilots, /api/pilots/:pilotId/assign, /api/pilots/:pilotId/unassign (src/game/pilots.ts): hire at a bay for the fee with a seeded name; assign to an idle, uncrewed ship at the same location; unassign only at a bay. New error code avatar-in-tutorial.
  • Step 6.9 — Bay contract board endpoint. GET /api/bays/:locationId/board (bayBoard in src/game/boards.ts): generated from the discovered set on first request in a period and stored; accepted contracts hidden; 409 no-bay-at-location without a bay. The expired-old-board dispatch check is tested in Step 6.10.
  • Step 6.10 — Contract dispatch. POST /api/dispatches with kind: "contract" (dispatchContract): sim validation, fuel deducted, contract accepted, ship and pilot in flight. One integration test per sim reason (cannot-land via a test-only vacuum ship in mocked content), each checking nothing changed; also covers the expired-old-board case from Step 6.9.
  • Step 6.11 — Reposition dispatch. POST /api/dispatches with kind: "reposition" (dispatchReposition): sim reposition validation against the player's bays, fuel only, no payout or XP; ship and pilot arrive at the destination.
  • Step 6.12 — Spend skill points. POST /api/pilots/:pilotId/skills (spendSkillPoint): +1 skill level, +1 point spent; 409 no-skill-points, 409 skill-maxed, 404 unknown-skill.
  • Step 6.13 — Acknowledge summaries. POST /api/summaries/acknowledge (src/game/summaries.ts) sets acknowledged_at on the player's resolved, unacknowledged dispatches among the given ids and returns how many; resolved effects are untouched.
  • Step 6.14 — Concurrency and money safety. Every authenticated endpoint runs in withPlayer (transaction + company row lock). Race tests: two and eight simultaneous purchases, a contract dispatched twice at once, a bay leased twice at once; three of them fail when the lock is removed.

Phase 7 — Background Workers

  • Step 7.1 — pg-boss setup. src/jobs/queue.ts: JobQueue interface, pg-boss implementation (queues always created; workers and cron schedules only when WORKERS_ENABLED=true), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss.
  • Step 7.2 — Arrival resolution job. Every dispatch (contract, tutorial, reposition) schedules a resolve-arrivals job at its arrival (src/jobs/definitions.ts), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only POST /api/test/clock-offset (not registered in production) moves it.
  • Step 7.3 — Expired contract cleanup. Hourly cleanup-expired-contracts job (src/jobs/cleanup.ts) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.

Phase 8 — Client Foundation

  • Step 8.1 — Vite + React app. apps/web: Vite 8, React 19, Tailwind 4, React Router 8. Layout (header with credits and game clock, main area, fixed six-tab bottom navigation) and placeholder screens for HQ, Contracts, Fleet, Pilots, Market, Locations. Component tests in jsdom. Dev server proxies /api to 127.0.0.1:3000. Manual 375px check: passed on the user's iPhone.
  • Step 8.2 — Typed API client. apps/web/src/api/client.ts: createApiClient().call(name, input) built from the route catalog: path params filled, GET query / POST JSON body, credentials: "include", responses validated (ApiValidationError), server errors raised as ApiError with code and message.
  • Step 8.3 — Server-synced clock. apps/web/src/clock: Zustand useClock store, syncClock(api) (offset from server time, half round trip), serverNow(), startClockTicker() (1 s), and a GameClock header component showing in-game time.
  • Step 8.4 — Guest onboarding. No session (state request returns 401) shows StartScreen; "Start your company" calls createGuest, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and an ApiProvider context added; header shows live credits. Verified end to end through the Vite proxy.
  • Step 8.5 — Player state store. usePlayerState (TanStack Query) refetches on window focus and, via useRefetchOnArrival, once per dispatch when the server clock passes its arrival time.

Phase 9 — Client Screens

  • Step 9.1 — HQ screen. src/screens/HQ.tsx: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: src/format.ts, Countdown, useLoadedPlayer / useGameAction (src/state/actions.ts).
  • Step 9.2 — "While you were away" summary. src/components/AwaySummary.tsx: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls acknowledgeSummaries.
  • Step 9.3 — Tutorial contract board. Contracts screen shows the tutorial board during the tutorial (src/screens/TutorialBoard.tsx): route, cargo, mass, payout, sim-predicted trip time (src/game.ts), and "Fly it" (disabled while a run is in flight; server errors shown).
  • Step 9.4 — Bay contract boards. After the tutorial the Contracts screen shows BayBoards: a bay selector and, per contract, route, cargo (and tier), mass, current distance, payout, required skills, live expiry countdown and a first-contact badge.
  • Step 9.5 — Dispatch flow. src/screens/DispatchSheet.tsx: each idle ship at the pickup with its pilot, checked with sim validateDispatch; eligible pairs show trip time, fuel, net profit and game/real ETA; ineligible pairs are disabled with a short reason; Confirm sends the dispatch and shows server errors.
  • Step 9.6 — Fleet screen. src/screens/Fleet.tsx: ships with model, location or route, pilot; in-flight progress bar (sim dispatchProgress) and countdown; "Reposition" for idle crewed ships at a bay opens a picker of the player's other bays.
  • Step 9.7 — Pilots screen. src/screens/Pilots.tsx: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point.
  • Step 9.8 — Market screen. src/screens/Market.tsx: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
  • Step 9.9 — Locations screen. src/screens/Locations.tsx: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
  • Step 9.10 — Empty and error states. First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen.

Phase 10 — Simple System Map

  • Step 10.1 — PixiJS map component. Map tab (/map, lazy-loaded) with a PixiJS v8 canvas (src/map/MapCanvas.tsx): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in src/map/projection.ts, camera in src/map/camera.ts. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending.
  • Step 10.2 — Ships on the map. flightMarker / buildScene (src/map/scene.ts): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending.
  • Step 10.3 — Map interactions. Taps (single pointer, under 8 px of movement) are hit-tested (src/map/hitTest.ts, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to /contracts?bay=… which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene.

Phase 10A — Faster Start (CR-001)

Spec: memory-bank/change-requests/CR-001-faster-start.md. Decisions recorded as plan §0.5.29–0.5.34.

  • Step 10A.1 — Content: new config and loaner ship. purchasable on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: tutorialLoanerShipModelId, tutorialRunsBeforeDeal 4, tutorialBoardSize 3, tutorialMassTonnes 5/12/20, handlingGameHoursPerTonne 0.02, loanRepaymentShare 0.3, dealLoanAmount 5000, startingCredits 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (src/data/onboarding.ts). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
  • Step 10A.2 — Sim: handling time. travelTimeGameHours(ship, distance, massTonnes, config) adds mass × handlingGameHoursPerTonne; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
  • Step 10A.3 — Sim: tutorial board v2. generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex }): light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (scriptedId on the contract, at its pay multiplier). Loaner is config.tutorialLoanerShipModelId. Step 3.10 tests replaced. Server passes runsCompleted: 0 until Step 10A.7.
  • Step 10A.4 — Sim: loan repayment. splitLoanRepayment(credits, balance, share) (src/loan.ts); resolveDispatch takes loanBalance and returns loanRepayment with net credits. Server passes a zero balance until Step 10A.9.
  • Step 10A.5 — Sim: onboarding goal evaluation. completedGoals(goals, progress) / isGoalConditionMet (src/goals.ts) over a GoalProgress summary; tests check each goal just short of and at its condition. Sim line coverage still 100%.
  • Step 10A.6 — Schema and database. Migration 0002_cr001_faster_start: companies.loan_balance, players.tutorial_runs_completed / deal_accepted, contract_boards.tutorial_run (in the unique key), table claimed_goals; repayment stored in each dispatch's resolution JSON. API: company.loanBalance, tutorial.runsCompleted / dealAvailable, goals, summary loanRepayment / scriptedId; routes acceptDeal, repayLoan, claimGoal; error codes tutorial-active, deal-not-available, unknown-goal, goal-not-ready, goal-already-claimed. architecture.md updated.
  • Step 10A.7 — Server: tutorial flow v2. Tutorial boards come from the v2 generator, stored per run and period; each resolved tutorial run increments tutorial_runs_completed; dealAvailable in state; buying ships, leasing bays and hiring pilots return 409 tutorial-active during the tutorial. Tests for 6.5/6.6/6.8/6.14 now start from an owner; old Step 6.7 tests removed (their trigger is now impossible; the rule itself goes in 10A.8).
  • Step 10A.8 — Server: accept the deal. POST /api/deal/accept (acceptDeal in src/game/deal.ts) in one locked transaction: Courier at Earth (named from the seed) crewed by the avatar, Earth bay, loan = dealLoanAmount, tutorial off, loaner removed, deal accepted; returns the new player state. 409 deal-not-available otherwise. Old maybeEndTutorial removed (replaces Step 6.7).
  • Step 10A.9 — Server: loan repayment. Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). POST /api/loan/repay (src/game/loan.ts) moves up to the balance from credits to the loan under the player lock; 409 insufficient-funds with no changes if credits fall short.
  • Step 10A.10 — Server: goals. src/game/goals.ts: goalProgress (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → goalStates (empty during the tutorial) in player state; POST /api/goals/:goalId/claim adds the reward once (goal-not-ready, goal-already-claimed, 404 unknown-goal).
  • Step 10A.11 — Client: first launch goes straight to work. "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test updated.
  • Step 10A.12 — Client: tutorial board shows the tradeoff. Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated.
  • Step 10A.13 — Client: the deal. src/components/DealModal.tsx: when tutorial.dealAvailable (and no delivery summary is open), a dialog with the Halvorsen story beat, the Courier and Earth bay, the loan amount and repayment share, and one "Shake on it" button that calls acceptDeal, stores the returned state and goes to HQ.
  • Step 10A.14 — Client: loan on HQ. HQ's savings goal is replaced: during the tutorial a "Company runs: n of 4" card; afterwards LoanCard (balance, paid-off bar, "Pay down" with amounts above credits or balance blocked in the UI). Delivery summaries show "X cr went to the lender". Step 9.1 tutorial-goal tests replaced.
  • Step 10A.15 — Client: goals on HQ. src/components/GoalsCard.tsx (#goals): the next three unclaimed goals with rewards; ready goals have "Claim", which calls claimGoal; the refreshed state brings the next goal into view. Hidden during the tutorial.
  • Step 10A.16 — Client: whole map from the start. The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (src/map/reach.ts).
  • Step 10A.17 — Client: something waiting on return. The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals.
  • Step 10A.18 — Plan amendments and regression. CR-001 §6 amendments added to implementation-plan.md (steps 2.5, 3.5, 3.10, 6.3–6.8, 8.4, 9.1, 9.3, 12.1–12.3). Full suite and CI green. End-to-end smoke against the real server: four light tutorial runs take 1.9 real minutes of flying, the deal appears at Earth and produces the expected state. Open issue: after the deal the Earth board usually has no contract a Courier (20 t) can carry, because bay contracts draw mass uniformly from 5–400 t; awaiting the user's decision. Manual check pending.

Playtest fixes after Phase 10A

  • HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed.
  • Contract size bands (user's choice): contractMassBands gives every board jobs for each ship size, each band starting with a no-skill Standard job to a discovered place. Smoke test: guest → 4 runs → deal → a Courier job resolves with a loan repayment.

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

Spec: memory-bank/change-requests/CR-002-open-markets.md. New decisions recorded as plan §0.5.35–0.5.36.

  • Step 10B.1 — Sim: boards at any location. BoardInput.location (was origin) is the board's location; no bay notion in sim board generation. Moon board test added.
  • Step 10B.2 — Sim: deadhead validation. validateReposition takes discoveredIds (not bays): any origin, destination discovered (location-undiscovered) and not the current location, range and landing. REPOSITION_FAILURES swaps no-bay-at-destination for location-undiscovered. Server passes the player's discovered locations.
  • Step 10B.3 — Database: boards keyed by location. Migration 0003_cr002_location_boards: board_kind is now location / tutorial (old bay rows relabelled); unique key (player, kind, location, period, tutorial run) means one board per player, location and period. Test: two locations coexist, a duplicate is rejected.
  • Step 10B.4 — Server: board endpoint for any discovered location. getContractBoard is now GET /api/locations/:locationId/board (locationBoard in src/game/boards.ts): any discovered location, no bay needed; 409 location-undiscovered for undiscovered, 404 for unknown. Step 6.9 tests moved to location-board.test.ts.
  • Step 10B.5 — Server: no more stranded ships. Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. stranded.test.ts covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay.
  • Step 10B.6 — Server: ship delivery to a bay. buyShip takes { modelId, bayId }; the ship appears idle and uncrewed at that bay's location. Missing bayId is 400 validation-error; a bay the player does not own is 404 not-found with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return earthBayId.
  • Step 10B.7 — Client: Contracts screen by location. LocationBoards (was BayBoards) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); ?location= selects one (map link and Fleet's new "Find work here"). API: Ship.lastArrivalAt. Tests: location-boards.test.tsx, last-arrival.test.ts.
  • Step 10B.8 — Client: deadhead action. Fleet's "Reposition" is now "Deadhead (fly empty)" on every idle ship. The picker lists discovered locations except the ship's own, each with sim fuel cost and trip time; destinations the sim rejects (out of range, can't land, fuel, no pilot) are disabled with the reason. Test: fleet.test.tsx.
  • Step 10B.9 — Client: bays as HQs. Market Buy opens "Deliver to which HQ?" (radio list of bays, preselected when there is one) and sends bayId. Locations and the map panel link every discovered location to "View contracts" and say whether the HQ is there; the lease action shows "Open an HQ here: hire pilots and take delivery of new ships at this location." Tests: market, locations, map-interactions.
  • Step 10B.10 — Plan amendments and regression. CR-002 §5 amendments added to steps 3.9, 3.14, 5.3, 6.5, 6.9–6.11, 9.4, 9.6, 9.8, 9.9, 10.3, 12.1 and 12.5 (glossary and §0.5 were done with the CR). GDD §4, §10 and §12 now describe open markets and bays as HQs. Full suite and coverage green; an end-to-end smoke run against a live server (guest → 4 runs → deal → Earth→Moon → Moon→Earth backhaul with only the Earth bay) passed. Phase 10B complete — stopped for review.

Phase 10C — Map Art (CR-003)

Spec: memory-bank/change-requests/CR-003-map-art.md. New decisions recorded as plan §0.5.37–0.5.41.

  • Step 10C.1 — Inventory and asset manifest. apps/web/src/map/art/manifest.json maps 12 asset ids to the 11 files in apps/web/public/assets/map/ (all 1254×1254; sprites RGBA). badge-cloud-platform is listed with file: null (not provided). Content: sprite on every location and ship model, badge on Jupiter Cloud Platform. Tests: art-manifest.test.ts, content sprite-key tests.
  • Step 10C.2 — Asset optimization script. apps/web/scripts/map-art.mjs (sharp; pnpm --filter @deadhead/web map-art, and first in build with --if-available) writes 21 files to public/assets/map-gen/ (committed): bodies and Sun 256/512 px, ships 128/256 px (trimmed), bg.webp 1254 px / 106 KB. The Vite build drops the source art from dist. Test: map-art.test.ts (sizes, byte-identical reruns, pixels match the committed output, bg < 1 MB, transparent source corners).
  • Step 10C.3 — Map style config. apps/web/src/map/style.ts: mapStyleSchema (Zod) and mapStyle with sprite sizes (Sun 64, Jupiter 48, Earth/Mars 32, Ceres 22, Moon 18, ships 24 px), sprite scale 0.75–1.75×, Moon offset 30 px at −45° from Earth, idle and location badge offsets, 44 px minimum tap, parallax 0.15, background margin 10%, label and colors. Test: map-style.test.ts.
  • Step 10C.4 — Asset loading and fallback. map/art/load.ts: artBundle(dpr) (2x at DPR ≥ 2) registered once as PixiJS bundle map-art, each asset loaded separately so a failure only costs that asset (one warning, placeholder). map/art/visuals.ts picks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved from MapCanvas into map/renderer.ts (MapRenderer); the map shows "Loading map…" until art and renderer are ready. Test: map-art-loading.test.ts.
  • Step 10C.5 — Background. map/layout.ts backgroundRect: cover viewport + 10% margin each side, zoom follow zoom^0.05 (never below cover), pan × 0.15 parallax, clamped so no edge shows; drawn as one sprite under everything. The Map screen now fills the space down to the nav (it stopped 42 px short). Test: map-layout.test.ts. Manual check: docs/screenshots/10c5-background-tutorial-{desktop,phone}{,-panned}.png.
  • Step 10C.6 — Bodies, orbits, and labels. layoutScene (map/layout.ts) gives every body, the Sun and each flight a screen centre, size (base × spriteScale(zoom) = zoom^0.35 clamped 0.75–1.75) and tap size (≥ 44 px); hit-testing uses it. The Moon is drawn 46 px from Earth at −30° from the Sun→Earth direction, with a faint ring around Earth; Moon routes start and end there. decorate places labels (bold, dark halo) below or above to avoid bodies and other labels, and idle badges on a clear diagonal. Jupiter's badge art is drawn on its edge when provided (not yet). Tests: map-layout.test.ts. Manual check: docs/screenshots/10c6-bodies-{tutorial,midgame}-{desktop,phone}.png.
  • Step 10C.7 — Location state overlays. map/overlays.ts locationOverlays(scene) decides per location: undiscovered → desaturated (one shared ColorMatrixFilter), alpha 0.55 and a code-drawn padlock (replaces the 🔒 label of Step 10A.16; the tap panel is unchanged); bay → a small green house marker on the far side of the label; idle ships → count badge only. Test: map-overlays.test.ts. Manual check: docs/screenshots/10c7-overlays-{tutorial,midgame}-{desktop,phone}.png.
  • Step 10C.8 — Ships in flight. Each flight draws its model's sprite rotated by shipRotation (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. separateShips spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (MapCanvas selected prop). Test: map-ships.test.ts. Manual check: docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png.
  • Step 10C.9 — Performance. map/ticker.ts runWhileVisible: the PixiJS ticker stops when the page is hidden, restarts when visible, and stops on unmount. Canvas resolution capped at 2×. Labels restyle only on change. Big ship crowds (> 3) pack on a hexagonal grid (hexSpiral) instead of a long line. Tests: map-ticker.test.ts, crowd test in map-ships.test.ts. Measured (headless Chrome, software WebGL, 10 ships in flight, panning): 58.5 fps at DPR 1 even with CPU throttled 4×; 11 fps at DPR 3 before the 2× cap, 22 fps after — fill-rate bound in software rendering, not JavaScript. Manual check for the user: pan and zoom on a real mid-range phone (GPU rendering). Screenshots: docs/screenshots/10c9-busy-busy-{desktop,desktop-zoomed,phone}.png.
  • Step 10C.10 — Plan amendments and regression. CR-003 §6 amendments added to plan steps 10.1, 10.2, 10.3 and 11.1, and to CR-001 Step 10A.16 (§0.5.37–41 were added with the CR). Full suite (629 tests), coverage, lint and web build green. Review screenshots: docs/screenshots/10c10-final-{tutorial,midgame}-{desktop,phone}.png (the tutorial map also shows the undiscovered locations), plus the per-step shots from 10C.5–10C.9. Phase 10C complete — stopped for review.

Phase 10D — Pilot Duty and Rest (CR-004)

Spec: memory-bank/change-requests/CR-004-pilot-duty-rest.md. New decisions recorded as plan §0.5.42–0.5.46.

  • Step 10D.1 — Content config. GameConfig gains dutyLimitGameHours (12), longHaulDutyBonusGameHours (2) and restGameHours (60 = one 60-minute board period at 60× compression). Tests: rest in real time ≥ board period; duty numbers positive.
  • Step 10D.2 — Sim: duty and rest functions. packages/sim/src/duty.ts: dutyLimit(skills), isResting(restEndsAt, now), tripGameHours(departure, arrival), dutyAfterTrip(duty, hours, tutorial), needsRest(duty, limit), restEndsAt(start). Test: duty.test.ts (including 2,000 random rest starts all waking in a later board period).
  • Step 10D.3 — Sim: validation and resolution. PilotState.restEndsAt; validateDispatch and validateReposition fail with pilot-resting before the other pilot checks. ResolveInput takes pilotDutyHours and pilotSkills; Resolution returns pilotDutyHours and pilotRestEndsAt (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: duty-dispatch.test.ts.
  • Step 10D.4 — Schema and database. Migration 0004_cr004_pilot_duty: pilots.duty_hours (double, default 0) and pilots.rest_ends_at (nullable). API Pilot gains dutyHours, dutyLimit, resting, restEndsAt (null unless resting); ResolutionSummary gains restEndsAt (mandatory rest). Route restPilot (POST /api/pilots/:pilotId/rest). pilot-resting is an API code via the sim's failure list. toApiPilot and loadPlayerState take now. Dev database migrated.
  • Step 10D.5 — Server: apply duty and rest. resolveArrivedDispatches (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. restPilot (POST /api/pilots/:pilotId/rest): idle and not resting, any duty level, no bay; pilot-not-idle / pilot-resting / tutorial-active otherwise. Assign and unassign ignore rest. Test: duty-rest.test.ts.
  • Step 10D.6 — Client: pilot duty on the Pilots screen. Each pilot card shows a duty bar ("9.0 h of 14 h", progressbar with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call restPilot. Test: pilots-duty.test.tsx.
  • Step 10D.7 — Client: duty in the dispatch flow. The dispatch sheet disables a resting pilot's pair with "Resting — mm:ss" (new formatClock). Eligible pairs show "Duty after" (sim tripGameHours + dutyAfterTrip) and "Will need rest on arrival" when needsRest. Client validation now passes the pilot's real restEndsAt (dispatch sheet and deadhead picker, which shows "Pilot is resting"). Test: dispatch-duty.test.tsx.
  • Step 10D.8 — Client: board refresh and the "nothing here" case. Every location board shows "New contracts in mm:ss" to periodEndsAt and refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, via pairOptions), a prompt offers "Rest [pilot] — wakes to a new board" (restPilot) and "Deadhead (fly empty)" (opens the Fleet's deadhead picker, now exported). Test: board-refresh.test.tsx.
  • Step 10D.9 — Client: rest in summaries and on HQ. The "While you were away" entry for a trip that ended in mandatory rest says "[Pilot] is resting until [local time]". HQ (after the tutorial) has a "Pilot rest" card: "Pilots resting: N" and the soonest to wake with a countdown, or "Everyone is ready to fly." Test: rest-summary-hq.test.tsx.
  • Step 10D.10 — Plan amendments and regression. CR-004 §6 amendments added to plan steps 2.1, 3.12, 3.17, 5.3, 9.5, 9.7, 12.1 and 12.6 (§0.5.42–46 were added with the CR). Full suite, coverage, lint and web build green. Scripted manual check against a live server: guest → 4 runs → deal → the avatar chains Earth↔Moon trips (2.1–2.5 game h each) and rests on arrival after the sixth (12 h limit); dispatch while resting is refused with pilot-resting; on waking, Earth's board is a new set. Balance note for Phase 12: each board has one guaranteed Courier-sized job per period, so after the first trip from a location in a period the Courier often has to deadhead or rest. Phase 10D complete — stopped for review.

Phase 10E — UI Overhaul (CR-005)

Spec: memory-bank/change-requests/CR-005-ui-overhaul.md. New decisions recorded as plan §0.5.47–0.5.51.

  • Step 10E.1 — Fonts, theme tokens and backdrop. @fontsource/chakra-petch (display) and @fontsource/barlow (body), self-hosted. index.css @theme: font-display/font-sans, space-* surfaces, ink-* text, glow and panel shadows, rise/glow/sheet animations. body::before puts the map's bg.webp under a navy gradient behind every screen; global :focus-visible ring; all motion off under prefers-reduced-motion. Test: theme.test.ts.
  • Step 10E.2 — UI kit. apps/web/src/ui/ (barrel index.ts): Button/buttonClass (primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback), Panel (tones, feature corner brackets, polymorphic as) and Eyebrow, Sheet (modal: bottom sheet on phones, centred card from sm, renders its own h2), Pill, Meter (labelled progressbar), StatTile, ScreenHeader (the screen's h1), Art/spriteUrl (generated map sprites, 1x/2x srcSet, hides on error) and Icon (28 line icons). Test: ui-kit.test.tsx.
  • Step 10E.3 — App shell. Layout: HUD header (logo mark, wordmark from 420 px, glowing credits chip, clock chip; Credits / Game clock labels kept) and icon navigation, a bottom bar on phones and a 96 px left rail from md (decision 0.5.49). SCREENS gained an icon. Attention badges from the pure tabAttention(state) (layout/attention.ts): HQ = goals ready, Jobs = idle crewed ships whose pilot isn't resting (1 during the tutorial until a run is in flight), Pilots = pilots with unspent skill points; badges are aria-hidden. --header-h/--nav-h CSS variables let the map fill exactly the space left on both layouts. ui/Screen is the content column (max 48 rem, rise-in). App loading, error and offline states use the kit. Test: attention.test.tsx (counts; badge doesn't change the tab's name); layout.test.tsx unchanged and passing.
  • Step 10E.4 — HQ dashboard. ScreenHeader with the company name; icon stat tiles (2 × 2 on phones, 4 across from md); two columns from md. New components/RouteStrip (origin art → dashed line → destination art; with progress the line fills and the ship's art rides along; aria-hidden, place names drawn with CSS content so they never duplicate DOM text). "Next arrival" shows the strip, the route and a large countdown. Tutorial panel: run pips and a "Fly a company run" link to Jobs. Pilot rest with an icon; GoalsCard rows with check discs, reward amounts and a glowing Claim; LoanCard with an emerald Meter and kit input/button. Backdrop overlay lightened (0.5→0.82). Existing HQ, goals, loan and rest tests pass unchanged. Manual check: scratchpad screenshots reviewed at desktop and phone width (final set in 10E.9).
  • Step 10E.5 — Contracts and dispatch. Contracts uses Screen/ScreenHeader ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (Panel as="li", two columns from sm): route and large emerald payout, RouteStrip, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on Sheet (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). PairOption.modelSprite added. Sheet now portals to body: the screens' rise-in transform made the sheet's fixed overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. Manual check: scratchpad screenshots reviewed (final set in 10E.9).
  • Step 10E.6 — Fleet and Market. Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from md) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan Meter with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on Sheet, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from md) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on Sheet, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. Manual check: scratchpad screenshots reviewed.
  • Step 10E.7 — Pilots. Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from md): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a progressbar: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber Meter that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. Manual check: scratchpad screenshots reviewed.
  • Step 10E.8 — Locations, map panels, start screen and dialogs. Locations ("Known space"): planet-art cards (two columns from md), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset dl, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from md). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on Sheet ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last slate-* classes are gone from apps/web/src. Existing locations, map, onboarding, away and deal tests pass unchanged. Manual check: scratchpad screenshots reviewed.
  • Step 10E.9 — Regression and review. CR-005 §6 amendments added to plan steps 8.1 (the CR said 8.2; the layout is 8.1, corrected) and 11.1. Typecheck, lint, full suite with coverage (100 files, 680 tests) and the web build are green. The main JS chunk grew from 496 KB to 523 KB, past Vite's 500 KB warning; splitting it is left for Phase 11/13. Review screenshots (palette-quantized PNGs, 6 MB total): docs/screenshots/10e9-final-{hq,map,contracts,dispatch,fleet,pilots,market,locations}-midgame-{desktop,phone}.png, 10e9-final-{hq,contracts,map}-tutorial-{desktop,phone}.png and 10e9-final-start-new-{desktop,phone}.png. Phone shots are full-page captures, so the fixed bottom navigation appears partway down the image. Manual check for the user: the look on a real phone, and tap targets in the 7-tab bar on the narrowest phones. Phase 10E complete — stopped for review.

Phase 10F — Boards That Fit Your Fleet (CR-006)

Spec: memory-bank/change-requests/CR-006-boards-that-fit.md. New decisions recorded as plan §0.5.52–0.5.53.

  • Step 10F.1 — Client: weight warning. carryLimit(state, locationId) (LocationBoards.tsx): the largest capacity among the player's ships at the location, else the whole fleet, else null. Contract cards show the mass in rose and a rose "Too heavy" pill when it is over that limit. Test: weight-warning.test.tsx.
  • Step 10F.2 — Content: board config. GameConfig gains boardPreviewSlots (1) and boardOpenSlotsPerBand (1), non-negative integers. Test: schemas.test.ts (missing-field coverage plus a negative/fractional rejection test).
  • Step 10F.3 — Sim: fleet-aware boards. BoardInput.fleet?: FleetMember[] ({ modelId, skills }, exported). planSlots (board.ts): with no usable fleet, the old plan (each band's count, first slot guaranteed); otherwise bands no ship can carry keep boardPreviewSlots (their first is guaranteed), freed slots go to flyable bands largest-first round-robin, and flyable bands are fit slots but for boardOpenSlotsPerBand open ones. fitJob picks a crew that can carry the band (random start, then the next), a discovered destination it can reach and land at with its Long Haul, cargo the origin produces and the destination demands that the pilot is skilled for (else Standard), tier 1..pilot level, mass up to the ship's capacity; never first contact. A fit slot no crew can fill falls back to a guaranteed slot. No fleet: identical boards and RNG order (existing tests unchanged). Test: board-fleet.test.ts (empty fleet = old board; determinism; a lone Courier gets ≥ 5 flyable jobs on every one of 200 boards plus a preview per bigger band; skill cargo never above the pilot's level; a full fleet keeps band counts with fit jobs per band; expiry).
  • Step 10F.4 — Server: pass the fleet. playerFleet(tx, playerId) (game/boards.ts): the player's ships left-joined to their assigned pilots' skills, ordered by creation then id; locationBoard passes it as fleet when generating a board. Like discoveries, the fleet is read once per (player, location, period) and the stored board doesn't change afterwards. Test: location-board.test.ts — a new owner with one Courier gets ≥ 5 Earth contracts that pass sim validateDispatch for that Courier and the avatar.
  • Step 10F.5 — Regression. CR-006 §6 amendments added to plan steps 3.9, 6.9 and 9.4. Typecheck, lint, full suite with coverage (102 files, 693 tests; board.ts 100 % lines) and the web build are green. Manual check: a fresh owner with one Courier at Earth (docs/screenshots/10f5-lone-courier-earth-board-desktop.png) sees 5 Standard Earth→Moon jobs of 8–20 t, 1 open High-Value tier 5 job and 2 bigger-ship previews (37 t, 366 t) marked "Too heavy"; before, the same player typically had one job they could fly. Balance note for Phase 12: a lone Courier can now chain several Moon runs per 10-minute board, so early income per real hour rises; watch the payout curve and the loan payoff time. Phase 10F complete — stopped for review.

Phase 10G — Cargo Goods, Priority Contracts and Shippers (CR-007)

Spec: memory-bank/change-requests/CR-007-cargo-goods.md (supplied as "CR-005 / Phase 10E"; renumbered, see its implementation notes). New decisions recorded as plan §0.5.54–0.5.59. Baseline class mix before the change (1,000 Earth boards, all discovered): Standard 76.79 %, High-Value 23.21 %.

  • Step 10G.1 — Content: goods catalog. goodSchema (id, name, cargoClassId, massTonnes {min,max}, payMultiplier, optional tier 1–5, priorityEligible, optional priorityName, producedAt, demandedAt; min ≤ max refinement) and data/goods.ts with all 29 goods from CR-007 §3, plus goodsProducedAt/goodsDemandedAt. Locations: produces/demands now derive from the catalog (good ids); the old class lists are kept as cargoClassMix (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; shippers added (≥ 1 each). Config: priorityChance 0.15, priorityPayoutMultiplier 1.5, priorityReferenceShipModelId "freighter", priorityWindowFactor 1. Bundle: goods, good(id). Tests: goods.test.ts (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields.
  • Step 10G.2 — Sim: contracts carry goods. Contract gains goodId, shipper, priority, deliveryWindowGameHours (null unless Priority); the API contractSchema mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. contractPayout = class base × good payMultiplier × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. buildContract takes a good (the class comes from it) plus shipper and Priority fields; tierFor (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); routeGoods (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. SimContent gains goods. Tests: contract.test.ts (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal).
  • Step 10G.3 — Sim: goods, Priority and shippers in board generation. Class selection is unchanged (by cargoClassMix). Then chooseGood (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. rollPriority: eligible goods become Priority with priorityChance; window = priorityWindow (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: board-goods.test.ts (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; chooseGood overlap, bulk and capacity cases). board.test.ts / board-fleet.test.ts: the lightest band's floor is 0 for goods.
  • Step 10G.4 — Sim: Priority validation. too-slow joins DISPATCH_FAILURES after the route checks: a Priority contract fails when contractTripGameHours (cruise + overhead + handling, distance at departure; exported) exceeds deliveryWindowGameHours. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). priorityWindow exported. Tests: priority.test.ts (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails too-slow; the trip is measured like the window; a normal contract never fails as too slow); board-fleet.test.ts now also requires fit Priority jobs to be within the crew's reach.
  • Step 10G.5 — Sim: tutorial goods. Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: tutorial.test.ts (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes).
  • Step 10G.6 — Schema and database. API Contract (since 10G.2) carries goodId, shipper, priority, deliveryWindowGameHours; ResolutionSummary gains goodId, massTonnes, shipper (null for a deadhead); too-slow is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration 0005_cr007_goods (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). architecture.md updated. Tests: goods-backfill.test.ts (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), migrations.test.ts unchanged, schema cases for Priority windows, empty goods and summary goods.
  • Step 10G.7 — Server: pass-through and validation. No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's too-slow. Tests (contract-dispatch.test.ts): a Priority job whose window is shorter than the Courier's trip is refused with too-slow and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list.
  • Step 10G.8 — Client: contract card redesign. New components/Goods.tsx: goodName (a Priority good may use its priorityName, e.g. Emergency Parts), ClassChip ("High-Value · T1", toned by class), PriorityChip ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), tooSlowForAll (idle ships at the origin exist and none makes the window, via sim contractTripGameHours), formatWindow, realTimeOf. Location board cards, top to bottom: route and payout; the good as an h3 headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: contract-card.test.tsx (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); location-boards.test.tsx updated to the new card.
  • Step 10G.9 — Client: Priority in the dispatch sheet. explainProblems adds "Too slow — 7h vs 5h window" (sim contractTripGameHours vs the window) so a slow pair is disabled with the numbers; PairOption.priority carries each pair's trip and the window, and eligible pairs show "Priority: 2.1h trip, 1.4h to spare in the 3.5h window". The sheet's job details now start with the good and shipper ("Mail & Parcels for Pacific Orbital Logistics · Standard · 6 t …") and Priority jobs show the window chip. Test: dispatch-priority.test.tsx (Courier enabled with its margin, Bulk Hauler disabled with "Too slow — …", numbers from the sim).
  • Step 10G.10 — Client: goods in summaries. Each delivery in "While you were away" says "Delivered 6 t Medical Supplies for Meridian Health to the Moon" (from the summary's goodId, massTonnes, shipper; new toLocation gives "to the Moon" / "to Mars"); deadheads keep their fuel line. Test: away-summary.test.tsx (the seeded delivery line; a deadhead shows no "Delivered").
  • Step 10G.11 — Plan amendments and regression. CR-007 §7 amendments added to plan §0.5.6 and §0.5.17 and steps 2.2, 2.4, 3.8, 3.9, 3.12, 9.4, 9.5 and 12.1, and to CR-001 Step 10A.3. Typecheck, lint, full suite with coverage (108 files, 753 tests; sim lines 100 %) and the web build are green. Manual check: a fresh owner after the deal with one Courier at Earth: every contract names a good and an Earth shipper (Mail & Parcels, Consumer Goods, Packaged Food; bulk Packaged Food lots flagged Too heavy), and a Priority job appeared on the first board (Mail & Parcels, 7.7 t, window 4.3 h; the Courier makes it in 2.3 h with 2.1 h to spare). The Priority chip keeps its unit in normal case ("within 4.3h"). Screenshots: docs/screenshots/10g11-goods-contracts-solo-{desktop,phone}.png, 10g11-priority-dispatch-solo-{desktop,phone}.png. Balance notes for Phase 12: with "the band wins", bulk lots make Packaged Food and Electronics the usual big-band goods; shippers are random per origin, so e.g. Hollis & Crane Consumer can ship Mail. Phase 10G complete — stopped for review.

Phase 10H — Boards That Feel Fresh (CR-008)

Spec: memory-bank/change-requests/CR-008-fresh-boards.md. New decisions recorded as plan §0.5.60–0.5.62.

  • Step 10H.1 — Content: pay variance and new goods. Config contractPayVariance 0.15. Five Standard goods (decision 0.5.61): Water Recyclers, Spare Parts (Priority-eligible) and Lunar Base Supplies Earth → Moon; Crew Personal Effects and Recycled Alloys Moon → Earth (locations' goods lists update from the catalog). Earth → Moon now has 6 Standard goods, Moon → Earth 4. All existing tests still pass (tutorial masses unchanged: goods on both routes still hold 5/12/20 t). Test: goods.test.ts (route variety, variance is a fraction).
  • Step 10H.2 — Sim: per-contract pay rate. PayoutInput.marketRate (default 1) multiplies the payout; payRate(rng, content) draws 1 ± contractPayVariance uniformly as the last draw of every board contract (fit and normal); buildContract takes payRate (tutorial boards pass none, so tutorial payouts are unchanged). Tests: pay-rate.test.ts (payout scales with the rate; 1,000 Earth boards: rates within ±15 %, mean within 1 ± 0.01; the same good, mass and route now pays different amounts); the tutorial and CR-007 class-mix tests still pass.
  • Step 10H.3 — Client: visible refresh. useNewContracts(locationId, contracts) (LocationBoards.tsx) compares the board with the last one seen at that location (localStorage key deadhead:seen-board:<id>, read and written in try/catch), then remembers it. Contracts not seen before get a cyan "New" pill and rise in; the refresh line shows a role="status" "New contracts posted". Tags stay until the board changes again (contracts taken in between drop out); nothing is tagged on a first visit. The test setup clears localStorage after each test. Test: fresh-board.test.tsx (remembered older board → tags and notice; same board → none; first visit → none but remembered; rollover while open → notice and tags on the new board).
  • Step 10H.4 — Regression, and a fix found by the user. Plan amendments for steps 3.8 and 9.4. Manual check across a real rollover (docs/screenshots/10h4-board-rollover-desktop.png): one second after the timer hit zero the open page showed "New contracts posted" and tagged every new card New. User report after restarting: "the jobs are the same". Their Moon boards had six of eight jobs as Lunar Titanium every period: a fit job picked a class first, and for a Heavy Cargo 1 pilot at the Moon that was only Heavy, whose one route good (Lunar Titanium, 30–120 t) became an undersized bulk lot for the Courier; open jobs did the same. Fix (board.ts): fit jobs choose among every good on the route the crew is qualified for and whose range fits the slot and ship (fitsSlot), falling back to the class choice only when none does; slotGoods swaps a class whose goods are all too heavy for the slot for Standard goods on the route (goods too light for a big band still become bulk lots). With the user's save the next Moon boards carry Optical Glass, Souvenirs, Crew Personal Effects and Recycled Alloys. Test: board-fleet.test.ts (the user's Courier at the Moon: ≥ 5 flyable jobs per board, every flyable job's mass inside its good's range, no Lunar Titanium, ≥ 3 distinct goods per board on average); the class-mix test still passes. Full suite (110 files, 763 tests) green. Phase 10H complete — stopped for review.

Phase 10I — Ship Upgrades (CR-009)

Spec: memory-bank/change-requests/CR-009-ship-upgrades.md (received as "CR-006 … Phase 10F"). New decisions recorded as plan §0.5.63–0.5.68.

  • Step 10I.1 — Content: upgrades. upgradeSchema (id, name, stat from SHIP_STATS, percentPerLevel, maxLevel, optional modelOverrides) and data/upgrades.ts: engines +10 %, cargo-bay +15 %, fuel-tanks +20 % (Courier +600 %, Freighter +15 %), landing-thrusters −15 %, handling-gear −15 %, all 3 levels; upgradePercentPerLevel / upgradeMaxLevel; content.upgrades / content.upgrade(id). Config upgradeLevels: 10/20/35 % of price, 10/20/30 real minutes. Tests: content/test/upgrades.test.ts (validity, stats exist, decision 0.5.67), sim/test/courier-mars.test.ts (decision 0.5.68 over monthly samples for two game years).
  • Step 10I.2 — Sim: effective stats. sim/src/upgrades.ts: effectiveStats(model, levels, content) → EffectiveStats (the model with upgraded stats plus its own handlingGameHoursPerTonne). ShipState.upgrades (optional) and shipStats(ship, content) feed every check in validateDispatch, validateReposition and dispatch records (capacity, range, travel time, Priority window). travelTimeGameHours uses the ship's handling time when given. Board fit uses each fleet member's effective capacity (FleetMember.upgrades). SimContent gains upgrades. Test: effective-stats.test.ts; all earlier sim tests unchanged.
  • Step 10I.3 — Sim: upgrade validation and cost. upgradeCost(model, level) (whole credits, % of price), upgradeShopMs(level), nextUpgradeLevel, isInShop(ship, now) and validateUpgrade with UPGRADE_FAILURES in check order: tutorial-active, not-upgradable, ship-not-idle, ship-in-shop, not-at-hq, upgrade-maxed, insufficient-funds; success returns the level, cost and in-shop-until. ShipState.inShopUntil; dispatch and deadhead validation fail ship-in-shop right after ship-not-idle. Test: upgrade-validation.test.ts.
  • Step 10I.4 — Schema and database. Migration 0006_cr009_ship_upgrades: ship_upgrades (ship, upgrade, level; PK ship+upgrade), and on ships in_shop_until, shop_upgrade_id, shop_level. API Ship gains upgrades, stats (effective), inShopUntil, shop; one server mapper toApiShip (player state and purchases). Route startUpgrade (POST /api/ships/:shipId/upgrades). Upgrade failure reasons join the API error codes. acknowledgeSummaries takes optional shipIds. Web test helper shipExtras(modelId). Dev database migrated.
  • Step 10I.5 — Server: start upgrade. startUpgrade (game/upgrades.ts) runs inside withPlayer (company row lock): sim validateUpgrade (at an HQ = a bay where the ship is), charge, upsert ship_upgrades, set in-shop-until and the shop job; returns the ship. Unknown upgrade → not-found. Dispatch and deadhead pass the ship's upgrades and in-shop time to the sim (effective stats, ship-in-shop); fleet-fitted boards use effective capacity. Test: upgrades.test.ts (engines on a Freighter: cost, level, shop time, refused then faster trip; not-at-hq at the Moon; two simultaneous upgrades → one).
  • Step 10I.6 — Client: upgrade shop. Every Fleet card has "Upgrades" (with the count installed), opening ShipUpgrades (screens/ShipUpgrades.tsx): each of the five upgrades with level n/max, the next level as before → after from the sim's effectiveStats (e.g. "Speed 2.20M km/h → 2.40M km/h", "Capacity 100 t → 115 t"), cost and shop time; disabled ones show the sim's reason (Not at an HQ, In flight, In the shop, Maxed, Can't afford, Company ship, Finish the company runs first). Upgrading asks for confirmation, then calls startUpgrade. Ships in the shop show an "In the shop" pill and "In the shop — mm:ss" instead of their actions. Test: fleet-upgrades.test.tsx.
  • Step 10I.7 — Client: effective stats everywhere. game.ts shipStatsOf(ship) (sim effectiveStats from model + upgrades) and simShip(ship) (sim ShipState with upgrades and in-shop-until). Used by: the board's Too heavy badge (carryLimit), Too slow (tooSlowForAll), the dispatch sheet (problems, trip time, Priority window, validation; an in-shop ship lists "In the shop — mm:ss" first), and the deadhead picker. The Market lists your upgraded ships of each model with their effective speed, capacity and range; the map's "Reachable with" adds "your [ship] (upgraded)" when only upgrades get it there. Test: effective-stats-ui.test.tsx (Cargo Bay 1 clears Too heavy on a 22 t job; Fuel Tanks 3 enables Mars in the deadhead picker near closest approach).
  • Step 10I.8 — Client: shop in summaries and on HQ. The "While you were away" modal also opens for finished, unseen upgrades and lists "[Ship] is out of the shop: Engine Tuning 2"; "Got it" sends their shipIds with acknowledgeSummaries, and the server (acknowledgeFinishedUpgrades) clears only jobs whose shop time has passed. HQ has an "In the shop" card (when any) listing each ship, its upgrade and level, and a countdown, soonest first. Tests: shop-summary-hq.test.tsx, and the acknowledge case in upgrades.test.ts.
  • Step 10I.9 — Plan amendments and regression. CR-009 §6 amendments added to plan steps 2.1, 3.5, 3.7, 3.12, 5.3, 9.5, 9.6, 9.8 and 12.1 (§0.5.63–68 were added with the CR). Typecheck, lint, full suite with coverage and the web build are green. GDD §8 not yet updated: the CR says the designer updates it; it still describes ship stats with no upgrades, so there is nothing to confirm yet. Scripted manual check against a live server (scratchpad cr009-smoke.mjs): after the deal (899 cr), Cargo Bay Expansion 1 on the Courier costs 400 cr, capacity 20 → 23 t, 10 minutes in the shop; dispatch in the shop is refused with ship-in-shop; afterwards the Courier takes a 22 t job. Gameplay note: shop time (10 min) equals the board period, so a job seen before an upgrade has always expired when the ship comes out. Phase 10I complete — stopped for review.

Phase 10J — Veteran Pilot and Profit-Share Pay (CR-010)

Spec: memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md (received as "CR-007 … Phase 10G"). Decision 0.5.12 replaced; new decisions recorded as plan §0.5.69–0.5.70.

  • Step 10J.1 — Content: config and veteran. Config pilotProfitShare 0.25 (a fraction 0–1); the flat pilotPayPerDelivery is removed in 10J.2 when the sim switches to the share. veteranPilotSchema and content.veteranPilot: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: content/test/veteran.test.ts.
  • Step 10J.2 — Sim: profit-share pay. sim/src/pay.ts pilotShare(payout, fuel) = round(max(0, payout − fuel) × pilotProfitShare). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; Resolution.pilotPay records it. The flat pilotPayPerDelivery is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: profit-share.test.ts (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated.
  • Step 10J.3 — Sim: goal change. Goal 4 is now veteran-contract, "Fly a contract with Rosa Kell" (title from content.veteranPilot.name), condition veteran-contracts ≥ 1; GoalProgress.veteranContracts. The server reports 0 until the veteran is stored (10J.4). Tests: goals.test.ts (avatar deliveries alone don't complete it; one veteran contract does) and the goal-id updates in server and web goal tests.
  • Step 10J.4 — Server: veteran on the deal. Migration 0007_cr010_veteran_pilot: pilots.is_veteran. acceptDeal inserts the veteran in the same transaction: content name, XP at level 2's threshold, Heavy Cargo 1 bought (1 point spent, 3 unspent), at Earth, unassigned. API Pilot.isVeteran. Goal progress counts resolved contract deliveries (not deadheads or tutorial runs) by the veteran. Test: veteran.test.ts (state after the deal; no veteran on a refused or repeated accept; avatar rests, is swapped out, the veteran flies at once and is paid a profit share, goal 4 ready; avatar deliveries and a veteran deadhead don't count).
  • Step 10J.5 — Client: deal modal and pilot card. The deal modal says Halvorsen's pilot comes with the business and lists "Pilot: …and Rosa Kell. Halvorsen's pilot for twelve years." (from content). The veteran's Pilots card shows the bio line under the name. Test: veteran-ui.test.tsx.
  • Step 10J.6 — Client: pilot share in dispatch and summaries. The dispatch sheet shows "Pilot share" between Fuel and Net profit for hired pilots (sim pilotShare), none for the avatar; net profit is payout − fuel − share. API ResolutionSummary.pilotPay (from the stored resolution); the away summary shows "[share] pilot share to [pilot]" when above zero. Test: pilot-share.test.tsx.
  • Step 10J.7 — Plan amendments and regression. CR-010 §6 amendments: plan steps 2.1, 3.17, 9.5 and 12.1 (the 30–45 minute target is now the second ship), and CR-001 Steps 10A.5, 10A.8, 10A.13 and its §2 targets (decisions 0.5.12, 0.5.69–70 were set with the CR). Typecheck, lint, full suite with coverage (828 tests) and the web build are green. Scripted manual check against a live server (scratchpad cr010-smoke.mjs): after the deal Rosa Kell is at Earth, unassigned, level 2, Heavy Cargo 1; the avatar rests at Earth after six trips; swapped crews; Rosa flies a 20 t Machine Parts job needing Heavy Cargo 1 (the avatar has 0): payout 1118, fuel 230, pilot share 222, loan repayment 268, credits 628; goal 4 ready. Phase 10J complete — stopped for review.

Phase 10K — Settings Screen and Company Backup (CR-011)

Spec: memory-bank/change-requests/CR-011-settings-backup.md (received as "CR-008 … Phase 10H"). New decisions recorded as plan §0.5.71–0.5.76.

  • Step 10K.1 — Client: device preferences store. state/preferences.ts: usePreferences (Zustand) with clock (locale/12h/24h), countdown (real/game/both), reduced-motion override (null = follow the device), dismissed tips and saved map camera; set, dismissTip, showTipsAgain. Saved under deadhead:preferences:v1; loading validates each field and falls back to defaults; every read and write is wrapped. reducedMotionOn(prefs). Test: preferences.test.ts (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory).
  • Step 10K.2 — Client: settings screen and entry point. A gear link (aria-label="Settings", new gear icon) at the right of the header opens /settings (a route inside the layout, not a nav tab). screens/Settings.tsx: sections Company (name), Display (Clock: Device/12-hour/24-hour; Countdowns: Real time/Game time/Both; Reduced motion: Device/On/Off, saved to device preferences), Help (Reset map view, Show tips again), About (game name) and Developer, which renders only when import.meta.env.PROD is false. Remaining items arrive with their steps. Test: settings.test.tsx.
  • Step 10K.3 — Client: display options applied everywhere. clock/timeFormat.ts: formatTimeOfDay and formatGameTime follow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until"); formatSpan / formatGameHoursSpan follow the countdown preference ("4m 16s", "4.3 game h" or both) and drive Countdown (arrivals, rest, shop, board refresh, contract expiry) and the Priority chip, trip and too-slow texts. Reduced motion sets <html data-reduced-motion>, which the stylesheet uses alongside the OS query, and mapMotionStyle zeroes the map's parallax and zoom follow. The Priority chip no longer toggles on tap (it follows the preference; its tooltip shows both). Test: display-options.test.tsx; clock, layout, dispatch and contract-card tests updated for the new defaults.
  • Step 10K.4 — Client: reset map view and show tips again. The map camera starts from the saved view and saves itself (throttled to 300 ms) as the player pans and zooms; resetMapView() restores the default and clears the saved view (Settings and the map's "Reset view"). The tutorial intro card is now a one-time tip (tutorial-intro) with a dismiss button; "Show tips again" clears dismissed tips. Test: help-settings.test.tsx.
  • Step 10K.5 — Server: rename company. companyNameSchema (trimmed; 3–32 of letters, digits, spaces and . , ' & ! ? -) and route renameCompany (POST /api/company/name, { name } → { company }), run in withPlayer. New companies get a default name from their world seed (companyName(seed), e.g. "Lodestar Haulage") instead of the fixed "Deadhead Freight". Player state already carried the name. Test: company-name.test.ts.
  • Step 10K.6 — Server: start a new company. Route newCompany (POST /api/company/new, {} → player state) runs newCompany in withPlayer (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls createStartingState, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: new-company.test.ts (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8.
  • Step 10K.7 — Client: rename and new company flows. screens/CompanySettings.tsx: the company name has "Rename", an in-place field checked as you type with the server's companyNameSchema (message under the field, Save disabled until valid; saves the trimmed name). "Start a new company" opens a confirmation explaining what is lost; "Start over" stays disabled until the exact company name is typed (case-sensitive), then calls newCompany, clears dismissed tips and goes to Contracts, where the intro card shows. Test: company-settings.test.tsx.
  • Step 10K.8 — Server: backup code and restore. Migration 0008_cr011_backup_codes: players.backup_code_hash (unique) and backup_code_created_at; table player_logins (extra sign-ins per player). game/backup.ts: 20-character codes from 23456789ABCDEFGHJKMNPQRSTUVWXYZ in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, RestoreLimiter (5 failures per 15 min per IP and per device id). Routes: getBackupCode (GET, whether one exists), createBackupCode (POST, new code shown once, old one invalidated), restoreCompany (POST /api/restore, no session: a fresh guest sign-in for the device linked to the code's player; 409 restore-failed for a bad code, 429 rate-limited). Session lookup falls back to player_logins. Test: backup.test.ts (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company).
  • Step 10K.9 — Client: backup and restore flows. screens/BackupSettings.tsx: "Back up company" (Settings) creates a code and shows it once, large and grouped, with Copy and "Keep this somewhere safe. Anyone with this code can open your company."; with a code already made it offers "Generate a new code" and warns the old one stops working. "Restore a company" (Settings, and under "Start your company" on the start screen) takes a code; past the tutorial it warns the device's company will be left behind; success loads the restored state and goes to HQ; failures show the server's message in place. The API client sends x-device-id (api/device.ts) on restore. Test: backup-ui.test.tsx.
  • Step 10K.10 — Client: About and debug info. Vite bakes in __APP_VERSION__ (web package version, now 0.10.0) and __APP_BUILD__ (git short hash, "dev" outside git) — src/build-info.ts. Settings → About shows "0.10.0 · build abc1234" and "Copy debug info", which copies version, build, player id, server time, clock offset and the last screen before Settings. Player state gains playerId. Test: about.test.tsx.
  • Step 10K.11 — Client and server: developer tools (non-production only). Catalog routes devAdvanceClock (POST /api/test/advance-clock, { advanceMs }) and devAddCredits (POST /api/test/add-credits, { amount }), registered with the clock-offset route only when NODE_ENV isn't production (routes/dev.ts). Settings → Developer (screens/DevTools.tsx): Advance 10 minutes, Advance 1 hour, Add 10,000 credits, Finish all flights (advances to just past the latest arrival); afterwards the client re-syncs its clock and reloads state and boards. Tests: dev-tools.test.ts (server, including 404s in production) and dev-tools.test.tsx (client).
  • Step 10K.12 — Plan amendments and regression. CR-011 §6 amendments added to plan steps 4.2, 5.3, 8.1, 8.4, 11.2 and 12.3 (for 12.3–12.6); §0.3 and §0.5.71–76 were set with the CR. Typecheck, lint, full suite with coverage and the web build are green. Scripted manual check against a live server (scratchpad cr011-smoke.mjs, two cookie jars as two devices): a renamed company with 50,500 cr was backed up on one and restored on the other (same player, name and credits); starting a new company on the second device showed the fresh company on the first after a refresh; the same backup code still opened it. Screenshot: docs/screenshots/10k12-settings-{phone,desktop}.png. Also in this phase: the phone header fix from the user's report. Phase 10K complete — stopped for review.

Phase 10L — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (CR-012)

Spec: memory-bank/change-requests/CR-012-lighter-clipper-range.md (received as "CR-009 … Phase 10I"). New decisions recorded as plan §0.5.77–0.5.81; §0.5.67 and §0.5.68 amended.

  • Step 10L.1 — Content: range factors and new ships. Location.launchRangeFactor (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). ShipModel.role (all six) and optional ShipModel.handlingGameHoursPerTonne (the sim's effective stats now start from it). New models: Lighter (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and Clipper (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes lighter, clipper. Config noStrandMarginGameHours 48. Manifest lists ship-lighter / ship-clipper as pending art. Tests: the CR's orderings replace Step 2.3's (ships.test.ts), range factors (locations.test.ts), Lighter handling (effective-stats.test.ts).
  • Step 10L.2 — Sim: effective range from origin. rangeFrom(ship, from) = range × origin's launchRangeFactor; checkRoute compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (range-from.test.ts): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples.
  • Step 10L.3 — Sim: no-strand rule. wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content): at arrival plus noStrandMarginGameHours (48), can the ship reach any other discovered location from the destination with its range from there? validateDispatch (new optional discoveredIds; absent = all) and validateReposition fail with would-strand after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (no-strand.test.ts): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin.
  • Step 10L.4 — Content tuning: reach table. Ranges: Courier 5, Lighter 10, Clipper 198 (unchanged from 10L.1), Freighter 650 → 450, Bulk Hauler 1000 → 1250. Step 3.7's range tests (eligibility.test.ts) now name their origin; the Freighter-to-Ceres-at-farthest and Freighter-to-Jupiter-at-closest tests become "reaches Ceres at its closest, not its farthest" and "never reaches Jupiter from Earth". Tests (reach-table.test.ts): one per §3 cell over monthly samples for 16 game years, plus the two hop tests.
  • Step 10L.5 — Upgrade caps for the new lineup. Courier fuel-tanks override 600 → 450 % per level (5 → 27.5 → 50 → 72.5M km); the Freighter override stays at 15 % (450 → 652.5). No Lighter or Clipper caps were needed. Tests: the new 0.5.67 inequalities (content upgrades.test.ts; sim upgrade-caps.test.ts: a maxed Lighter never reaches Mars from Earth or the Moon, only a Bulk Hauler reaches Jupiter from Earth at its farthest even with every ship maxed); courier-mars.test.ts now samples 16 years from Earth. The web deadhead-to-Mars test moved to month 176, Mars's closest approach.
  • Step 10L.6 — Schema and server. would-strand is in ERROR_CODES (it comes from the sim's failure lists) with a server message; buy-ship already accepts any purchasable model. Tests (apps/server/test/lighter-clipper.test.ts): buying a Lighter and a Clipper places them idle at the chosen HQ; a Clipper at the Moon deadheading to Mars on a stranding date gets 409 would-strand and credits, ships and flights are unchanged.
  • Step 10L.7 — Client: Market shows roles and reach. Each Market card shows the model's role and a Reach line from shipReach (apps/web/src/game.ts, sim checkRoute): "From Earth now: Moon, Mars · Other dates: … · By hopping: Ceres Station", with one "From X now" part per HQ location. "Other dates" lists places reachable directly from an HQ but not today; "By hopping" lists places reachable only through other locations (each leg in range on some monthly date over 16 game years). Tests (market-reach.test.tsx): roles; the Clipper lists Mars from Earth only on a date when it is in range (otherwise under Other dates) and always lists Ceres by hopping; the Lighter lists only the Moon.
  • Step 10L.8 — Client: reachability from where ships actually are. An undiscovered location's map panel now reads "From Mars: Clipper, Freighter · From Earth: Freighter": one part per discovered location with an HQ or idle ship (playerOrigins, Earth when there are none yet), listing purchasable models in range from there now plus the player's ships there that reach it only thanks to upgrades (reachersByOrigin in map/reach.ts, replacing the Earth-only helpers). Tests: map-reach-origins.test.tsx (a Clipper at Mars on a date Mars→Ceres is in range: Ceres lists Clipper from Mars, not from Earth); map-locked.test.tsx now expects "From Earth: …" for a tutorial player.
  • Step 10L.9 — Client: stranding reason. The dispatch sheet passes the player's discovered ids to validateDispatch and explains a stranding pair as "Would be stranded — can't fly out of Mars from there" (strandedText); the deadhead picker disables such a destination with the same text. wouldStrand now takes a bare model or effective stats. Test (stranding-reason.test.tsx, a Clipper at the Moon on a date it could hop to Mars but not leave): the pair's only problem is the stranding text; the Mars deadhead is disabled with it while Earth stays enabled.
  • Step 10L.10 — Art. The user's ship-lighter.png and ship-clipper.png (in public/assets/map/) are now in the manifest; pnpm --filter @deadhead/web map-art wrote their @1x/@2x sprites to map-gen (the other outputs regenerated byte-identical). The source-transparency test allows corner alpha up to 2/255, because ship-lighter.png has one alpha-1 corner pixel. All manifest tests pass.
  • Step 10L.11 — Plan amendments and regression. CR-012 §7 applied: plan 0.2 (five models), 2.1, 2.3, 3.7, 3.12, 9.8 and 12.1 (Lighter-first and Clipper-first policies, for when the balance script exists); CR-001 10A.16, CR-003 10C.1 and CR-009 10F.1 notes (decisions 0.5.67, 0.5.68 and 0.5.25 were amended in 10L.4–10L.5). Full suite 938 passing, sim lines 99.75 %. Scripted manual check (temporary server): after the deal, buy a Clipper at Earth, crew it with the veteran, wait for a first-contact Earth→Mars job in range (62–95M km), deliver it (Mars discovered), spend the veteran's points on Long Haul, then take a first-contact Mars→Ceres job (186M km, inside 198 × 1.15) and deliver it (Ceres discovered).

Phase 10L complete — waiting for review.

Phase 10M — The Veteran's Clipper (CR-013)

Spec: memory-bank/change-requests/CR-013-veterans-clipper.md (received as "CR-010 … Phase 10J"). New decisions recorded as plan §0.5.82–0.5.83; §0.5.70 amended.

  • Step 10M.1 — Content: veteran skill and loan. The veteran starts with Expensive Cargo 1. Config dealLoanAmount (5,000) is replaced by deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, and the loan is computed by dealLoanAmount({ config, shipModels, locations }) (packages/content/src/deal.ts), exposed as content.dealLoanAmount: 4,000 + 61,600 + 1,000 = 66,600 cr. All users (server deal, loan card, deal modal, tests) read content.dealLoanAmount. Tests: config (deal ids; loan = Courier + Clipper + Earth lease; it moves by exactly the change when either price or the lease changes), veteran skills (content and server).
  • Step 10M.2 — Sim: goal changes. Goal 3 is now market-ship "Buy a ship from the Market" (condition market-ships-bought 1) and goal 4 priority-contract "Complete a Priority contract" (priority-deliveries 1, any pilot); the veteran-contracts condition is gone. GoalProgress gains marketShipsBought and priorityDeliveries (drops veteranContracts). Server: ships.from_deal (migration 0009, backfilling each player's first ship), set on the deal's ship; goal progress counts non-deal ships and resolved non-tutorial dispatches whose contract is Priority. Tests: sim (the deal's two ships don't complete goal 3, a bought Lighter does; Standard deliveries don't complete goal 4, a Priority one does), server goals (buying a Lighter; a Standard then a Priority delivery), web goal list ids.
  • Step 10M.3 — Server: deal creates the Clipper. acceptDeal reads config.deal and inserts both deal ships at Earth (Courier, then Clipper, consecutive seeded names, from_deal), assigns the avatar to the Courier and the veteran to the Clipper (location follows the ship), sets the 66,600 cr loan. Tests: deal.test.ts (two crewed ships at Earth, one bay, the loan; two simultaneous accepts still give two ships, not four; refused and repeated accepts change nothing), veteran.test.ts (veteran crewing the Clipper, level 2, Expensive Cargo 1; the Courier and the Clipper dispatched at the same time, the veteran's run paying their profit share).
  • Step 10M.4 — Client: deal modal. The modal shows both ship sprites and reads "Old Halvorsen is retiring. He'll sell you his Courier, his Clipper, and his Earth bay — and Rosa Kell, who flies that Clipper. Pay him back out of every job."; "You get" lists "A Courier for you, a Clipper for Rosa Kell, and a bay at Earth"; the Pilot row keeps name and bio. Ship, bay and pilot names come from content (config.deal, veteranPilot); the veteran's bio is now "Has flown Halvorsen's Clipper for twelve years." Tests (deal-modal.test.tsx): both ships, the veteran's name and bio, and the loan offered equal to the loan in the after-deal state.
  • Step 10M.5 — Plan amendments and regression. CR-013 §6 applied: CR-001 §2 targets (first Market purchase 45–75 min, loan payoff under 4 h) and Steps 10A.5, 10A.8, 10A.13; CR-010 Steps 10G.1, 10G.3, 10G.4, 10G.7 (manual check replaced); CR-012 Step 10I.11 (balance policies replaced); plan Step 12.1 (re-check targets with two ships and the larger loan; Lighter-first vs Freighter-first to "own four ships"). Full suite 941 passing. Scripted manual check on a fresh account: deal gives Courier (avatar) + Clipper (Rosa Kell, Expensive Cargo 1) and a 66,600 cr loan; goals 3 and 4 incomplete; Courier and Clipper in flight at the same time (Clipper on a Priority Mail job to the Moon); on delivery goal 4 is ready. Needed a 1,000 cr developer top-up: after the deal the player has ~890 cr, and fuel for both ships Earth→Moon is ~921 cr (Courier 230 + Clipper 691).

Phase 10M complete — waiting for review.

  • Follow-up: Earth launch cost 3 → 2.1 (user decision). Fixes the post-deal fuel shortfall: the fresh-account check (deal, Courier and Clipper in flight together, Clipper Priority delivery unlocking goal 4) passes with no developer top-up. Fuel hand-calculation test updated.

Phase 11 — PWA

  • Step 11.1 — Installable PWA. vite-plugin-pwa 2.0 (generateSW, auto-update): manifest "Deadhead: Space Freight Tycoon" / "Deadhead", standalone, theme and background #080d1d, icons 192, 512 and maskable 512 (plus favicon and Apple touch icon in index.html). The service worker precaches the app shell, the self-hosted font files (woff2) and all 25 generated map images (64 entries, ~6.7 MB); source art in assets/map/ is excluded; /api is never served from the cache. Icons come from scripts/icons.mjs (pnpm --filter @deadhead/web icons): the Courier sprite on the space background, placeholder until there is a logo. Playwright is set up for browser tests against the production build (apps/web/playwright.config.ts, e2e/*.e2e.ts, pnpm --filter @deadhead/web e2e), and CI builds the web app, installs Chromium and runs them. Tests (e2e/install.e2e.ts): Chrome reports no installability errors; the manifest has the name, standalone display, both icon sizes and a maskable icon, all served; the precache holds index.html, map-gen art and both fonts, and no source art. Manual check for the user: install to a phone home screen and launch standalone.
  • Step 11.2 — Offline shell behavior. A request that gets no reply (or the browser's offline event) marks the app offline (state/connection.ts). Each fetched player state is kept on the device (schema-checked when read back) and returned when the server can't be reached, so a reload offline opens from the service worker cache with the last state; the last clock offset is kept too. The banner reads "Offline — showing last update from HH:MM" with Retry; the browser's online event refetches and clears it. Button gains needsServer, set on every server action and the buttons that open action sheets (dispatch, deadhead, buy, hire, lease, assign, rest, goals, loan, deal, tutorial runs, and the CR-011 rename, backup, restore, new company and developer tools); the two plain buttons (deadhead destinations, raise skill) check useOnline. The away summary and deal modal wait until back online. Display and help settings still work. Shared fixtures moved to test/fixtures.ts for Playwright. Tests: e2e/offline.e2e.ts (fake API by route interception; network off and reload: Fleet renders from cache with the banner and the in-flight ship, its progress bar advances, Deadhead and Buy are disabled, server-backed settings are disabled while the clock format still changes; back online the banner goes and actions return) and test/offline.test.tsx (cache fallback only on no reply, not on an error reply; first load offline with no cache still fails; cache cleared on sign-out and ignored if malformed; needsServer buttons disabled only offline).

Phase 11 complete — waiting for review.

Phase 12 — Balance Harness and End-to-End Tests

  • Step 12.1 — Balance simulation script. packages/sim/balance/simulate.ts: a pure, deterministic simulated player that plays from the start using only sim and content functions the way the server applies them (tutorial runs, deal, boards per period, dispatch, deadhead, rest, resolution, goals, skill points, Market purchases with a hired pilot, upgrades from spare cash), with a trip log for tracing. Reports milestones (deal, first Market purchase, first discovery, loan paid off, four ships), credits/loan/ships/pilot levels every 10 minutes, and breaches of the invariants (soft-lock, idle ship with no work and no way out, dispatch while resting). pnpm --filter @deadhead/sim balance [seed] [hours] prints both policies. Design changes (user-approved): pay and fuel use economic distance (decision 0.5.84), and boards offer a first contact the fleet can fly (0.5.85). Tuning: economic-distance exponent 0.65, Clipper fuel 600 → 450 cr/M km, loan repayment share 0.3 → 0.25. Medians over 7 seeds (Lighter-first): deal 2.9 min, first purchase 66, first discovery 33, loan paid off 188, four ships 170 (Freighter-first 201); no breaches in any run. Test: test/balance.test.ts (determinism, each target on the median, four-ship comparison, invariants in every run, veteran contract in the first session, samples reported).
  • Step 12.2 — Revisit the tutorial economy test. The amended Step 2.5 check ("the tutorial share plus starting credits covers fuel for three Courier Earth→Moon runs") no longer assumes the lowest-paying tutorial contract: the simulator records the tutorial share actually earned and the credits at the deal, and packages/sim/test/balance.test.ts checks, in every seeded run, four tutorial runs, credits at the deal = starting credits + share earned, and at least three Courier Earth→Moon fuel loads. The placeholder test left packages/content/test/config.test.ts (content can't import the sim). Also added test/first-contact-fit.test.ts for decision 0.5.85 (Step 12.1): every Earth board over four hours offers a first contact to Mars that the deal's Clipper crew can take, and none once Mars is discovered.
  • Step 12.3 — End-to-end: tutorial to first ship. Playwright now also starts the real API server (port 3135) on TEST_DATABASE_URL, migrated first, next to the production web build with /api proxied to it; tests run one at a time because the developer clock is shared. e2e/support.ts drives play: start a company, fly the tutorial board, shake on the deal, dispatch from a board through the sheet, dismiss the logbook, and fast-forward with the developer routes (CR-011). Test (e2e/12-3-first-ship.e2e.ts): guest → four tutorial runs in the UI → deal → the state has the Courier (avatar) and Clipper (veteran) at Earth, one Earth bay, the 66,600 cr loan and no tutorial → one contract dispatched in the dispatch sheet and resolved, its logbook entry shows the repayment and the loan went down.
  • Step 12.4 — End-to-end: offline progress. Test (e2e/12-4-offline-progress.e2e.ts): dispatch a contract, close the page, move the server clock past the arrival, open a new page: the logbook shows the delivery, credits went up by exactly the delivery's net payout, and the ship is at the destination.
  • Step 12.5 — End-to-end: discovery. Test (e2e/12-5-discovery.e2e.ts): after the deal, the clock moves to a date when a base Clipper can fly Earth → Mars (computed with the sim, so the test doesn't depend on today's planets); the veteran's points go into Long Haul in the Pilots screen; the Earth board offers a first contact to Mars the Clipper can take (decision 0.5.85); on arrival the logbook says "Discovered Mars!", the Locations screen offers "Lease bay at Mars", and Mars's board loads with no bay there (CR-002).
  • Step 12.6 — End-to-end: pilot progression. Test (e2e/12-6-pilot-progression.e2e.ts): buy a Lighter, hire and assign a new pilot, fly them until level 1; take the Lighter to the Moon and wait for a Heavy Cargo 1 job it can carry; in the dispatch sheet the Lighter's pair is disabled with "Needs Heavy Cargo 1 (pilot has 0)"; after raising Heavy Cargo in the Pilots screen the pair is enabled; raising Long Haul adds the Long Haul duty bonus to the pilot's duty limit (CR-004).

Phase 12 complete — waiting for review.

  • Follow-up: base pay +50 % (user decision). Every cargo class's base pay per tonne per M km: Standard 100 → 150, Heavy 140 → 210, High-Value 180 → 270, Dangerous 200 → 300. Medians over 7 seeds (Lighter-first): first Market purchase 60 min, first discovery 28, loan paid off 127, four ships 127 (Freighter-first 127); no breaches. Payout hand-calculation tests now read the rate from content.

Phase 13 — Deployment (Base Game)

Choices (asked at the start of the phase, decision 0.5.86): 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 — Containerize the server. deploy/server.Dockerfile (build from the repo root): installs the server's production dependencies with pnpm, makes a self-contained copy with pnpm deploy, and runs the TypeScript with tsx (now a server runtime dependency) as a non-root user; migrations run on every start, then the server listens; a health check calls /api/health. deploy/compose.yml (project deadhead): Postgres 18 with a named volume, not published, and the server, which waits for Postgres to be healthy. Settings come from deploy/.env (deploy/.env.example: PUBLIC_URL, Postgres user/db/password, BETTER_AUTH_SECRET, log level). .dockerignore keeps local state and the source art out of builds. Test: deploy/check.sh builds and starts the stack under a throwaway project with generated secrets, waits until it is healthy, checks /api/health and /api/time from inside the server container, and removes everything (passes, ~25 s with a warm cache).
  • Step 13.2 — Deploy the client. deploy/web.Dockerfile builds the web app (vite build from the committed map art, APP_BUILD for the About screen's build) into a Caddy image. deploy/Caddyfile: one site at SITE_ADDRESS (the domain, so Let's Encrypt HTTPS; :80 for plain HTTP), /api/* proxied to the server, everything else from the build with client routes falling back to index.html; Vite's hashed bundles and fonts cached for a year, the shell, service worker and manifest never. Compose gains the web service (ports from HTTP_PORT/HTTPS_PORT, certificate volumes). deploy/README.md covers DNS, port forwarding, settings, start/update and a LAN-only setup. Test: deploy/check.sh now also checks, through Caddy from the host: the app shell, a client route, /api through the proxy, the caching headers, and a guest sign-up plus a tutorial run dispatched (all pass). Manual check for the user: deploy on the home server and confirm the site loads on desktop and a phone, a guest can start, and a tutorial run can be dispatched.
  • Step 13.3 — Database backups. A backup service (the Postgres 18 image, scripts in deploy/backup/): schedule.sh runs backup.sh daily at BACKUP_HOUR:00 (TZ), which writes deadhead-<UTC stamp>.dump (pg_dump custom format, via a .partial name) into BACKUP_DIR (a host folder, e.g. a NAS mount) and deletes dumps older than BACKUP_KEEP_DAYS (default 14). restore.sh [dump] [database] loads a dump (default: the newest, into a new deadhead_restore database). README: backups, a test restore, and recovering the live database. Test: deploy/check.sh takes a backup of the running stack, restores the newest dump into a fresh database, and compares the test guest's rows (player, company, ships, pilots, bays, discoveries, boards, dispatches, accepted contracts) between the live and restored databases: identical (13/13 checks pass).

Phase 13 complete — waiting for review. Manual checks for the user: deploy on the home server (DNS, ports 80/443, deploy/.env), load the site on desktop and a phone, start a guest and dispatch a tutorial run; confirm a dump appears on the NAS after the first night.

Follow-ups

  • More developer tools (user request). Settings → Developer gains a pilot picker (Level up: XP to the next level, so skill points appear; Clear duty and rest), a ship picker (Max upgrades: every upgrade at its cap, installed at once and free; Reset upgrades; Finish shop work; Move an idle ship to a discovered place) and Discover everywhere. Server routes devPilot, devShip, devDiscoverAll (/api/test/..., non-production only, answer with the player state). Tests: server dev-tools.test.ts (each action, move refused to an undiscovered place, all routes 404 in production), web dev-tools.test.tsx.

Phase 14 — Local-First (CR-014)

Spec: memory-bank/change-requests/CR-014-local-first.md (received as "CR-011 … Phase 10K"). New decisions recorded as plan §0.5.87–0.5.92; 0.5.18, 0.5.20 and 0.5.71–0.5.73 replaced or removed. The "Fixed Map and Fuel Tanks" request waits and becomes CR-015 (Phase 15).

  • Step 14.0 — Inventory. What the port must preserve (all complete, none in progress or not started):

    • Base plan: Phases 1–12 complete (Phase 13's server deployment is replaced by this CR: static hosting only).
    • CR-001 Faster Start (Phase 10A) · CR-002 Open Markets, Bays as HQs (10B) · CR-003 Map Art (10C) · CR-004 Pilot Duty and Rest (10D) · CR-005 UI Overhaul (10E) · CR-006 Boards That Fit Your Fleet (10F) · CR-007 Cargo Goods, Priority Contracts and Shippers (10G) · CR-008 Boards That Feel Fresh (10H) · CR-009 Ship Upgrades (10I) · CR-010 Inherited Veteran Pilot and Profit-Share Pay (10J) · CR-011 Settings Screen and Company Backup (10K; its backup code and several-devices parts are replaced by this CR) · CR-012 Lighter and Clipper, Per-Leg Range (10L) · CR-013 The Veteran's Clipper (10M) — all complete.
    • Phase 11 (installable PWA, offline shell: the offline banner and disabled actions are removed by this CR) and Phase 12 (balance simulation with economic distance and fleet-fit first contacts; end-to-end tests, which move to the web build alone) complete.
    • Follow-ups to preserve: Earth launch cost 2.1; base pay +50 %; developer tools for pilots, ships and discovery (become developer-only engine commands); deadhead.belowland.com as an allowed Vite host.
    • Not started: the Fixed Map and Fuel Tanks request (recorded after this phase as CR-015).
  • Step 14.1 — Engine package and clock. New packages/engine (depends on content, schema, sim; picked up as a Vitest project). Clock interface (now()) and DeviceClock: device time (injected, Date.now by default) plus a developer offset, clamped to the last time it handed out; lastTimeSeen for the save, restore(lastTimeSeen) after loading one, advance(ms) for the developer tools (forward only). Tests (packages/engine/test/clock.test.ts): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward.

  • Step 14.2 — Save document schema and migrations. packages/schema/src/save.ts: the save document (every database table for one company, without player ids, plus saveVersion, saveId, lastTimeSeen), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: SAVE_MIGRATIONS (ordered version-to-version steps; none yet, version 1 is the first format), applyMigrations (steps a save up one version at a time), migrateSave (migrate, then validate; never throws, gives a readable error). Tests (packages/schema/test/save.test.ts, fixture save-fixture.ts reused later): a fully populated save validates; a wrong field is rejected naming company.credits; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported.

  • Step 14.3 — Storage layer. packages/engine/src/storage.ts: SaveStorage (load, save with rotation, list backups, load a backup, clear) over a SaveBackend that stores text in slots main, backup-1..3, each write all-or-nothing. Saving moves the previous main save to backup 1 (and 1→2, 2→3), so the last three saves are kept. Loading: no main save → empty; an unreadable or invalid one → the newest backup that validates, reported as restored-from-backup with the problem; nothing valid → unreadable. Backends: MemoryBackend, IndexedDbBackend (one object store, handed an IDBFactory), FilesystemBackend (one JSON file per slot in the app data directory, written to a .tmp file then renamed; handed the Capacitor Filesystem plugin through a small interface). Tests (packages/engine/test/storage.test.ts): one suite run on all three (IndexedDB via fake-indexeddb, the filesystem via a fake plugin): empty, round trip, exactly three backups newest first, corrupted main save falls back past a broken backup to the next valid one, unreadable, clear; and a native write interrupted mid-file or before the rename leaves the previous save loading.

  • Step 14.4 — Mutation queue and tab lock. packages/engine/src/core.ts: EngineCore runs every read/write through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with lastTimeSeen, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. canWrite (the tab lock) refuses writes with GameLockedError; reload() drops the cache after another tab had the game; cryptoRandom gives ids (randomUUID) and 32-bit world seeds (getRandomValues). src/lock.ts: TabLock over navigator.locks (ifAvailable to take a free game, steal for "Use here", onLost when another tab takes over; always held where Web Locks don't exist). Tests (test/core.test.ts): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; lastTimeSeen follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after reload. The "open in another tab" screen is built with the client switch (14.7).

  • Step 14.5 — Port the game logic. packages/engine/src/game/: every server endpoint is an engine command with the same request and response schemas (GameEngine.call(name, input): input checked against the route catalog, due arrivals resolved first as the server's withPlayer did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: state (player state, ships, pilots, goals), company (starting save, deal, loan, rename, goals), boards, dispatch, fleet (bays, ships, pilots, skills, upgrades, summaries), resolve (arrivals, pruning), commands (the table), plus the server's names and messages. Refusals are CommandErrors with the server's codes; no company yet answers unauthorized, as before. Developer commands only with devTools; the backup-code endpoints (getBackupCode, createBackupCode, restoreCompany) are not ported (replaced in 14.10). Save additions: stats (contract and Priority deliveries, for goals) and each ship's lastArrivalAt, so pruning never changes progress; config boardRetentionPeriods (24, from the server's cleanup job) and savedDispatchRetentionRealHours (168). Tests: 33 server test files ported to packages/engine/test/ported/ over test/support.ts (the server helpers' names and shapes, with call routing old endpoint paths to commands and answering with the server's status codes, and edit/readSave/addShip/addPilot in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). test/catalog.test.ts checks that every command has an engine test.

  • Step 14.6 — Resolution loop. packages/engine/src/loop.ts: ResolutionLoop runs GameEngine.resolveDue once on start (the app has just loaded), on every resume and every 5 seconds (RESOLUTION_INTERVAL_MS), never two passes at once, and calls onChange when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected LoopHost (the browser's visibilitychange and Capacitor's resume are wired in 14.7). resolveDue uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (test/loop.test.ts): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.

  • Step 14.7 — Switch the client to the engine. src/platform/game.ts builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the DeviceClock, the Web Locks TabLock, and the GameEngine (developer commands in development builds and in vite build --mode e2e test builds via .env.e2e; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). src/api/engine-client.ts: the typed ApiClient over engine commands, refusals turned into the same ApiErrors (status and code), so screens and TanStack Query are unchanged. src/GameRoot.tsx: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and visibilitychange; a change refetches state and boards), and exposes window.deadhead.call in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, needsServer buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with build:e2e. Tests: test/on-device.test.tsx (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); e2e/14-7-on-device.e2e.ts (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine.

  • Step 14.8 — Company creation on the device. With no save, the app shows the start screen (no company answers unauthorized, as before); "Start your company" runs createGuest, which builds the CR-001 starting state in a new save with a fresh world seed from the platform's crypto source (startingSave, shared with "Start a new company"). Asking again when a company exists returns that company. Tests (packages/engine/test/start.test.ts, with fixed ids and seed): from empty storage the state is exactly the CR-001 start (starting credits, no loan, the avatar at Earth beside the loaner, Earth and the Moon discovered, four tutorial runs to go, no ships, bays, flights, summaries or goals, the seed's company name); a new engine on the same storage opens the same company; different seeds give different companies.

  • Step 14.9 — Local notifications. packages/engine/src/notifications.ts: upcomingEvents (each future arrival "X delivered to Y" / "X arrived at Y", each pilot's rest ending, each upgrade finishing), plannedNotifications (events in the same minute share one notification, titled "Deadhead: N updates", at the minute's last event), and NotificationSync, which replaces the platform's whole schedule from the save after every saved change (the engine core's afterSave hook; a failed sync never fails a command), so moved or cancelled events and a new company clear themselves. GameEngine takes a notifications option and adds resyncNotifications() and requestNotificationPermission(). Web: src/platform/notifications.ts uses Capacitor LocalNotifications on iOS and Android (cancel pending, schedule if permission is granted) and the no-op scheduler on the web; createGame wires it with the new notifications preference (default on) and resyncs when it changes; the engine client asks for permission right after acceptDeal succeeds; Settings has a Notifications section (On/Off). Tests: packages/engine/test/notifications.test.ts (a dispatch schedules one notification at its arrival; two arrivals in the same minute make one combined notification; a new company cancels everything; with the toggle off nothing is scheduled, and turning it on catches up; the no-op scheduler never errors); apps/web/test/notifications.test.ts (the web uses the no-op scheduler and play works; the toggle clears and restores the schedule; permission is asked after the deal and not before).

  • Step 14.10 — Settings changes. Export and import replace the backup code. packages/engine/src/save-code.ts: encodeSave (the save as JSON, gzip via CompressionStream, base64url, behind DEADHEAD1.) and decodeSave (whitespace from pasting ignored; a wrong prefix, a damaged code or a non-save each give a readable error; migrates through migrateSave). New engine commands in the catalog: exportSave ({ code }) and importSave ({ code } → { playerId, state }, no company needed; due arrivals resolved on import; refusals are invalid-save and leave the save as it was; the replaced company stays in the storage backups). GameEngine takes a migrate option (tests). Web: src/screens/SaveSettings.tsx — Export save (the code, Copy, Share as file through the share sheet where the device has one, otherwise a download named deadhead-save-YYYY-MM-DD.txt) and Import save (paste or open a file, then confirm; the warning about replacing the company shows when there is one); both in Settings → Company, Import save also on the start screen. BackupSettings.tsx and its test are gone (the server's backup routes stay until 14.11). Copy debug info: Save, Save version, Storage (via a new GameContext), Game time; no player id. The start screen's error no longer mentions a connection. Tests: packages/engine/test/save-code.test.ts (round trip, pasted line breaks; the same state on a fresh device; replaces an existing company; an older save is migrated (simulated with an injected migration, since only version 1 exists); garbage, damaged codes and non-saves are refused and change nothing; nothing to export before a company); apps/web/test/save-ui.test.tsx (export, Copy, import on a fresh device from the start screen; open a save file, warning, Replace company; garbage shows the error and changes nothing; file name); about.test.tsx updated. Developer tools were already covered (engine refuses developer commands without devTools; Settings hides the section in production).

  • Step 14.11 — Retire the server. Removed apps/server (Fastify, Drizzle and its migrations, pg, pg-boss, Better Auth, the server tests), the development Postgres (docker-compose.yml, docker/), the root .env.example, deploy/server.Dockerfile and deploy/backup/. The lockfile has no server packages left. Schema: the backup routes and their schemas, and the restore-failed and rate-limited codes, are gone; auth on a route now means "needs a company". Engine: SERVER_ONLY_ROUTES is gone; CommandName is every catalog route. Web: the HTTP client (createApiClient, ApiValidationError, the device id) and its test are gone; src/api/client.ts keeps ApiClient and ApiError; Vite no longer proxies /api or keeps it out of the service worker. CI has no database service. deploy/ is static hosting: Caddy alone (compose.yml with one web service; the Caddyfile serves files only; .env.example is SITE_ADDRESS and ports; check.sh checks shell, client routes, caching and compression; README rewritten). The web Dockerfile now copies the engine package's manifest too (missing since 14.7). build:e2e also makes the production build in dist-production, which Playwright serves on port 4174. Test: e2e/14-11-production.e2e.ts (the production build starts a company, flies a tutorial run and reloads, loading only files from its own origin, nothing under /api, and no window.deadhead). The architecture document drops the server's sections.

  • Step 14.12 — Plan amendments and regression. implementation-plan.md: rules 0.1.5 and 0.1.6 note the engine as the authority; new §0.6 holds CR-014 §7's table for translating server-era change requests (endpoint → engine command, table/column → save field with a migration, migration → saveVersion bump, row lock → mutation queue, job → resolution loop plus a local notification, test endpoint → developer command, integration test → engine test); Phases 5–7 marked superseded; Steps 8.2–8.5, 11.2, 12.3–12.6 and Phase 10K (CR-011's backup code) amended; Phase 13 marked replaced by static hosting; Definition of Done says "Start your company" instead of "guest start". (§0.5 was updated in Step 14.0.) tech-stack.md rewritten for the local-first stack (engine, save storage, device clock, resolution loop, local notifications, export/import, static deployment; the server moves to "alternatives considered"). Regression: typecheck, lint, 1022 unit tests (coverage 96 % of sim lines), 9 browser tests, CI green.

    Manual check for the user (CR-014 §5 Step 10K.12), on a phone, installed PWA or Capacitor dev build:

    1. Open the app; start a company or import a save; get past the deal (accept notifications when asked, native build).
    2. Dispatch a contract; note its arrival time.
    3. Close the app completely and turn on airplane mode.
    4. Wait past the arrival (native build: a notification should arrive at the arrival time while the app is closed).
    5. Reopen the app, still in airplane mode: the "while you were away" logbook shows the delivery, credits include the payout, and the ship is at the destination.
    6. Settings → Export save → Copy; Settings → Import save with that code: the same company comes back.

    The automated parts of this already run in CI: e2e/12-4 (closed while it landed, reopened past the arrival), e2e/14-7 (whole opening with every outside request blocked) and e2e/14-11 (production build loads only its own files).

Phase 14 complete. Stopped for the user's review.

Phase 15 — Fixed Map and Fuel Tanks (CR-015)

Spec: memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md (received as "CR-014 … Phase 14"). New decisions recorded as plan §0.5.93–0.5.99. User decision: a leg's fuel follows the economic-distance curve, like pay.

  • Step 15.1 — Content: fixed positions. Locations lose body and gain position ({ radiusMillionKm, angleDegrees }): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (offsetPosition), recorded as orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }. GameConfig's epoch keeps only a displayed start date (gameStart, was ephemerisDate; same value). astronomy-engine is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's locationPosition already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (bodyPosition is gone; the Moon is placed from orbitParent). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: packages/content/test/fixed-map.test.ts (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no astronomy-engine in the lockfile). Date-sampled tests became yes/no: reach-table.test.ts is now CR-015 §4, one test per cell; courier-mars, eligibility, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8).

  • Step 15.2 — Sim: static positions and distances. No time arguments left where a time only fed a position: locationPosition(location), routeDistance(from, to), checkRoute(ship, from, to), tripFuelCost(model, origin, destination, config), contractTripGameHours(model, contract, content), priorityWindow(origin, destination, mass, content), and wouldStrand(model, destination, discoveredIds, content) (it had taken cargo mass and departure only to find where planets would be on arrival). dispatchPosition runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's noStrandMarginGameHours is gone (nothing moves in 48 game hours now). Web: distanceAt, tripRealMs, locationMapPoint, reachersByOrigin, tooSlowForAll and shipReach lose their time argument; shipReach checks each leg once instead of sampling 16 game years (its otherDates is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: distance.test.ts per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), positions.test.ts ("never moves"), new static-routes.test.ts (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); no-strand.test.ts now checks that no base ship is ever blocked on a leg it can fly; range-from and upgrade-caps lose their date loops.

  • Step 15.3 — Content: fuel. ShipModel: fuelRangeMillionKm and fuelCostPerMillionKm replaced by tankCapacityFu and burnFuPerMillionKm (Courier 40/20, Lighter 110/35, Clipper 1,170/45, Freighter 1,540/40, Bulk Hauler 5,900/80, loaner 40/20). Location: launchCostMultiplier replaced by fuelPrice (× base: Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6) and sellsFuel (all true). GameConfig: baseFuelPricePerFu 10, emergencyFuelMultiplier 2.5. Extended Fuel Tanks raises tankCapacityFu: 11 %/level by default, Courier 380 %, Freighter 9 %. Sim (fuel.ts): legFuel (burn × economic distance of distance ÷ origin factor, decision 0.5.95 as the user decided), plainDistance (the curve's inverse), fullTankReach (factor × plainDistance(tank ÷ burn)), fuelPriceAt; rangeFrom and checkRoute now take the config and use full-tank reach. Until Step 15.4 adds tanks to ships, a trip is still charged at departure: its fuel at the origin's price. API stats carry tankCapacityFu and burnFuPerMillionKm instead of range. Web: the Market's "Range" is now "Reach" (a full tank from Earth), the upgrade screen shows "Tank" in fu. Balance re-check passes: first purchase 64 min, discovery 64, loan 156, four ships 151 vs 158. Tests: locations.test.ts (fuel prices ordered Jupiter < Ceres < Moon < Mars < Earth; every base location sells fuel); fuel.test.ts (leg fuel, the curve's inverse, full-tank reach burns exactly a tank and scales by the origin factor, the interim cost by hand); the §4 table (reach-table.test.ts) now runs on full-tank reach; courier-mars, upgrade-caps and the content distinctness test in tank form; ships.test.ts (the Clipper burns most per M km). Also removed four empty import {} lines left by Step 15.2's cleanup.

  • Step 15.4 — Sim: tank, burn, and refuel. Sim (fuel.ts): Tank (fuel and weighted average price), refuel (up to capacity, new average, whole-credit cost), affordableRefuel (the most the credits cover, never over), legFuelCost (burned × average price). Ships carry tank in ShipState; dispatch and deadhead validation fail with not-enough-fuel (replacing insufficient-credits) when fuel on board is short; proposals no longer take credits. Records gain fuelBurnedFu; fuelCost is that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none. tripFuelCost became tripFuel (fu). Resolution takes an optional fuel block (tank, capacity, policy, credits) and returns topUp (Auto: the most the credits after this delivery cover, at the destination's price; Manual or no tank: none). Pulled forward from 15.6 so the game keeps working: saved ships have fuelFu, fuelAvgPrice, refuelPolicy; save version 2 with the 1 → 2 migration (every ship full at the price where it is, the base price in flight, Auto; old records burned 0 fu, old resolutions no top-up); the engine takes fuel from the tank at departure (no credits) and applies the top-up on landing; Market ships arrive full at the bay's price; the deal's Courier and Clipper come with a quarter tank (config.deal.tankShare, user decision: with full tanks the Clipper left for Mars at once and the first purchase moved to 79 min); the API ship carries the fuel fields and summaries carry topUp. Web: the sim sees each ship's tank, the dispatch sheet and deadhead picker cost fuel at the average price and say "Not enough fuel (needs N fu, has M fu)". The balance simulator flies tanks too (Auto top-up, refuel before choosing work); results: first purchase 64 min, discovery 49, loan 136, four ships 136 vs 136. Tests: packages/sim/test/tank.test.ts (Moon leg = Earth amount at distance ÷ 1.3; 50 fu at 1.0 into 50 fu at 2.0 averages 1.5; never past capacity; affordable refuel exact; record burn and cost; pilot pay from burned × average; Auto top-up to full at the local price; Manual buys nothing; half the credits buys half; the tutorial loaner untouched); dispatch.test.ts (not-enough-fuel, passes after refuelling); packages/schema/test/save.test.ts (1 → 2 migration: tanks full at the right price, Auto, nothing else changed); packages/engine/test/fuel.test.ts (Market ship full; deal ships a quarter; burn then Auto top-up with the exact credits; Manual leaves the tank). Updated tests that expected fuel in credits (contract dispatch, deadhead, concurrency, browser 12.4); browser 12.5 fills the Clipper with an Earth–Moon round trip before its Mars run.

  • Step 15.5 — Sim: stranding and emergency fuel. wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content) (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (fuelAfterLeg). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. emergencyFuel({ ship, credits, discoveredIds }) (decision 0.5.99) returns { amountFu, price, cost } or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: no-strand.test.ts (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as would-strand, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); emergency-fuel.test.ts (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out).

  • Step 15.6 — Engine: commands and save migration. (The save fields, the 1 → 2 migration and full tanks on new ships came in 15.4.) New commands in the route catalog: refuelShip ({ shipId, amountFu? }: fill, or buy an amount, at the local price; the ship must be idle at a location that sells fuel; refused with insufficient-funds unless the player can pay for all of it, tank-full, no-fuel-here, ship-not-idle-here), setRefuelPolicy ({ shipId, policy }), orderEmergencyFuel ({ shipId }: only when the sim offers it, else emergency-fuel-unavailable; adds the fuel at 2.5 × the local price into the tank's average and the cost to the loan). Tests (packages/engine/test/fuel.test.ts): an older (version-1) save imported through the engine has every ship full on Auto; refuel of an amount charges the local price and keeps the weighted average, fill tops up to capacity, a full tank is refused; refuel with no credits fails and changes nothing; the policy toggles; emergency fuel is refused when not needed, and when needed adds exactly the shortfall and raises the loan by exactly its cost, credits untouched. A new ship arriving full is tested since 15.4.

  • Step 15.7 — Client: fuel in the UI. src/components/Fuel.tsx: FuelGauge (a meter from empty to capacity, "N fu · P%", the refuel policy), ShipFuel sheet (price here; Fill with its cost; buy an amount; Auto top-up / Manual; "Emergency fuel (added to loan)" with its amount and cost, shown only when the sim offers it), FuelPrice (a location's price, the cheapest marked "cheapest" in green), refuelForLeg ("Refuel here first" buys a full tank when the credits cover it, otherwise just the leg's shortfall). Fleet: every ship card shows the gauge and a Fuel button beside Upgrades. Dispatch sheet: every pair shows "Fuel needed · on board · left after"; the cost cell is "Fuel used (at your average price)"; a pair blocked only by fuel (the sim's last check) gets "Refuel here first", which refuels, keeps the sheet open and picks the pair once flyable. Deadhead picker: fuel on board, per destination "Needs N fu · left after M fu", and "Refuel here first" when any destination is short only of fuel. Locations screen and map panel: a Fuel line per location. Delivery summaries: "Topped up N fu at X, C cr" for Auto top-ups; deadheads say "fuel used". Tests (apps/web/test/fuel-ui.test.tsx): the gauge's values and text match the ship; "Refuel here first" fills the tank and enables and picks the pair with the sheet still open; it buys just the shortfall when a fill is unaffordable; the fuel-used cell matches the sim at the tank's average price; Jupiter's fuel is marked cheapest and Earth's isn't; emergency fuel shows (with the sim's cost) only for a stranded, broke ship.

  • Step 15.8 — Client: fixed map. The map works out each body's position and orbit ring once (BODIES in src/map/scene.ts); each frame only flights move along their fixed lines. Orbit rings stay as decoration (none for the Moon). Reach wording without dates: the Market says "From Earth: Moon, Mars · By hopping: Ceres Station" (shipReach returns fromOrigins and byHopping; otherDates is gone), the map panel says "No ship can reach it from your bases.", and the dispatch sheet's "Out of range right now (…)" is "Out of range (…)". The browser tests' nextMarsWindow date search is gone. Tests (apps/web/test/fixed-map.test.tsx): every body's on-screen centre is identical a minute, a month and 400 days on; orbit rings for every body but the Moon; the Market reach line is exactly "From Earth: Moon, Mars · By hopping: Ceres Station" with no date words; the map lists "From Earth: Clipper, Freighter, Bulk Hauler" for Mars; an out-of-range pair names the ship's reach without a date.

  • Step 15.9 — Plan amendments and regression. implementation-plan.md: §0.3 now defers fuel price drift, depot shortages and HQ fuel storage (fixed prices are in); decisions 0.5.9, 0.5.11, 0.5.12, 0.5.25, 0.5.68, 0.5.78, 0.5.79, 0.5.81 and 0.5.84 carry CR-015 notes; Steps 2.1, 2.2, 3.3, 3.4, 3.6, 3.12, 9.5, 9.6, 9.9 and 10.1 and Phases 10I (CR-009), 10J (CR-010) and 10L (CR-012) are amended. (Decisions 0.5.93–0.5.99 were added when CR-015 was recorded; tech-stack.md dropped astronomy-engine in 15.1.) The balance simulator now also flags any moment a ship would need emergency fuel while the player has credits on Auto top-up; balance.test.ts asserts it never happens in any world. Regression: typecheck, lint, 1,118 unit tests (coverage 95.8 % of sim lines), 9 browser tests; balance medians: deal 2.9 min, first Market purchase 63.9, first discovery 48.9, loan paid off 135.7, four ships 135.7 (both policies).

    Manual check for the user (CR-015 §7 Step 14.9):

    1. Get a Freighter (Market, or Settings → Developer → add credits) and fuel it at Earth.
    2. Fleet → Fuel for the Freighter → set Manual refuel.
    3. Fly it to Ceres, then on to the Jupiter Cloud Platform (discover them with contracts first, or with Developer → discover all).
    4. At Jupiter: Fuel → Fill (6 cr/fu, the cheapest; Locations marks it).
    5. Fly back toward Earth. On the next legs the dispatch sheet's "Fuel used (at your average price)" is lower than the same leg priced at Earth's 16 cr/fu, and the Fuel sheet's average price has dropped.

Phase 15 complete. Stopped for the user's review.

Phase 16 — Venus, Io, Europa, and the Asteroid Belt (CR-016)

Spec: memory-bank/change-requests/CR-016-venus-io-europa-belt.md (received as "CR-015 … Phase 15"). New decisions recorded as plan §0.5.100–0.5.105.

  • Step 16.1 — Content: new locations. Venus Cloud City (cloud platform, atmospheric, 108M km at 110°, range factor 1.0, fuel × 1.3, bay 20,000), Io Sulfur Works (new type orbital-station, vacuum landing so any ship can land, 0.42M km out from Jupiter, factor 1.2, fuel × 1.0, bay 70,000) and Europa Ice Station (new type surface-base, vacuum, 0.67M km out from Jupiter, factor 1.3, fuel × 0.7, bay 70,000); all undiscovered, selling fuel, with the CR's shippers and class mixes. Pulled forward so the game keeps working: each new place's Standard good (Cloud-Grown Botanicals at Venus, a new good; Mail & Parcels also produced at Io and Europa), since boards can't be generated where nothing Standard is made; and the three body sprites in the art manifest, with their generated art (the belt is still 16.5). The balance check's first Market purchase moved from 64 to ~92 min (the Clipper's landings decide it, and Venus changed which first contacts boards offer); the user widened the window to 45–95. Tests: packages/content/test/fixed-map.test.ts (Earth/Moon–Venus ≈ 200, Venus–Mars ≈ 332, Venus–Ceres ≈ 520, Venus–Jupiter ≈ 885, Ceres–Io/Europa ≈ 393, all within 2 %; the Jupiter system within 1.1M km; Io and Europa ride with Jupiter at their offsets; the three undiscovered, selling fuel, not Hardened; only Earth and the Moon start discovered); locations.test.ts (eight locations; fuel prices ordered Jupiter < Europa < Ceres < Io < Moon < Venus < Mars < Earth). Updated: bundle count, first contacts may go to Mars or Venus, the CR-007 class-mix baseline measured on its original five places, orbit rings for every body but those riding a parent, the Market reach line now listing Venus.

  • Step 16.2 — Content: goods. New goods: Atmospheric Data Cores (High-Value tier 2, Venus → Earth), Refined Sulfur (Heavy, Io → Earth, Mars, Venus), Volcanic Isotopes (High-Value tier 3, Io → Earth), Subsurface Ocean Samples (High-Value tier 3, Priority, Europa → Earth); Cloud-Grown Botanicals came in 16.1. Water Ice and Liquid Oxygen are also made at Europa, Industrial Chemicals at Venus; Water Ice goes to Io and Venus, Liquid Oxygen to Io, Habitat Prefabs to Io and Europa, and Packaged Food, Consumer Goods, Mail & Parcels, Medical Supplies, Electronics and Machine Parts to all three new places. Balance medians: first purchase 92.3, discovery 48.9, loan 160.5, four ships 147 vs 156. Tests (packages/content/test/goods.test.ts): the five new goods with their classes, tiers and routes (Sulfur has no tier of its own); every location makes a Standard good and wants at least one; each new place is a destination for at least five goods; the new producers on existing goods.

  • Step 16.3 — Content tuning: reach. No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (reach-table.test.ts, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places.

  • Step 16.4 — Engine: save migration and boards. No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (packages/engine/test/new-places.test.ts): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus.

  • Step 16.5 — Assets: sprites and belt rocks. (The Venus, Io and Europa sprites came in 16.1.) Two new art kinds: rocks (a sheet of separate sprites; the manifest's belt-rocks entry names belt-asteroids.png and says it holds pieces: 6) and strip (belt-haze, file null until the user provides one). scripts/slice.mjs: findRegions labels connected regions of pixels with alpha ≥ 16 (fainter glow and specks under 0.2 % of the sheet are dropped) in reading order; sliceSheet cuts each region out with every other pixel cleared, so a neighbour's glow never comes along. map-art.mjs writes belt-rocks-1 … belt-rocks-6 at 64 and 128 px (long side) and refuses a sheet whose piece count doesn't match the manifest; a haze strip would be kept within 4096 × 256. The loader registers each rock piece and the strip. The source image is only read. Tests: apps/web/test/map-art.test.ts (a fixture sheet with three separate squares and faint bridging glow slices into exactly three sprites, trimmed, in reading order; one square gives one; no faint glow survives in a piece; the real sheet's six pieces are written at the right sizes, deterministically and matching the committed output; the transparency check covers the belt image).

  • Step 16.6 — Map: belt haze. src/map/belt.ts: beltMapRadii/beltScreenRadii (the band between 314M and 494M km on the compressed radial scale), ringGeometry (quads around the ring; the strip's width wraps once around, its height runs inner → outer edge), hazeStripPixels (the strip drawn in code: a faint warm-grey band fading to transparent at top and bottom, seamless noise, thousands of one- or two-pixel specks; seeded so it is the same every time) and hazeSource (belt-haze.png when it loaded, else the generated strip). The renderer builds the haze mesh once in map units and only moves and scales it with the camera, at 35 % opacity, above the orbit rings and below routes, bodies and ships; it takes no input. Map style gains belt (radii, haze opacity, segments, strip size, seed). Tests (apps/web/test/belt-haze.test.ts): the ring's screen radii equal those of 314M and 494M km at five zooms; the mesh vertices lie exactly on the two radii with u wrapping 0 → 1 and v inner 0, outer 1; with no haze file the strip is generated without any warning, fades to under 5 % at its edges and has no visible seam; the strip is the same every time and a provided file wins. Screenshots: docs/screenshots/16-6-belt-haze-desktop.png, …-phone.png (taken from the test build after the deal with every place discovered; the crowded Jupiter-system labels are Step 16.8's).

  • Step 16.7 — Map: belt rocks. beltRocks scatters 220 rocks (map style belt.rocks) with a seeded generator: positions in map units across the band, denser toward its middle (the mean of two uniform draws), sizes 3–14 px mostly small (cubed draw), any rotation, brightness 0.55–1, each one of the six sliced rock images. placeRocks puts them on screen at the sprite scale and leaves out any rock that would come within Ceres's drawn radius plus 6 px, at any zoom. The renderer creates one sprite per rock once, in a single non-interactive container above the haze, and only moves, sizes and shows them each frame (code-drawn dots if the rock art is missing); taps never consider rocks, so they reach the place or empty map beneath. Tests (apps/web/test/belt-rocks.test.ts): the same rocks every time; every rock in the band, over 60 % in its middle half; mostly small with some large, all six images used; no rock over Ceres at five zooms on desktop and phone sizes, sizes following the sprite scale; a tap on any rock finds empty map or a place whose tap target is there. Screenshots: docs/screenshots/16-7-belt-rocks-desktop.png, …-phone.png. Phone performance check (CR-003 Step 10C.9): left for the user (no phone here); listed in the Phase 16 review.

  • Step 16.8 — Map: new bodies and Jupiter-system layout. Venus (30 px) sits at its fixed position; Io and Europa (18 px) at fixed screen offsets from Jupiter, 64 px out at 100° and −160° from the outward direction (clear of Jupiter and of the platform badge at the largest sprite scale), each with the small child ring the Moon has. Labels (decision 0.5.105): a child body's label first tries the side facing away from its parent; planLabels folds a family into its parent's label with a "+N" pill when their labels would overlap, or, for the Jupiter system, whenever zoom is below 2.5 (map style labelDeclutter); tapping "+N" (a 44 px target) opens the family, zooming in past 2.5 does too. Earth and the Moon only fold if their labels collide (they don't). The haze strip is now 2048 × 256 and repeats 8 times around the ring, so its specks don't streak when zoomed in. Tests (apps/web/test/jupiter-system.test.ts): Io and Europa exactly at their offsets from Jupiter at five zooms; at zoom 1 only Jupiter's label shows, with one "+2" marker right of it; at the threshold all three show without overlapping; tapping "+2" opens the family; Earth and the Moon both labelled; every place and marker tap target ≥ 44 px; Venus clear of every other body. The 12.5 browser test now waits for an Earth board offering a Mars first contact (since CR-016 a board may offer Venus instead), which made it flaky. Screenshots: docs/screenshots/16-8-new-bodies-desktop.png, …-phone.png, …-jupiter-desktop.png, …-jupiter-phone.png (zoom 4 on the Jupiter system).

  • Step 16.9 — Plan amendments and regression. implementation-plan.md: §0.2 lists eight locations; §0.3 now defers surface Io, Venus's surface and the Hardened class, plus belt hazards, mining claims and belt events (Venus/Io as stops are in); Step 2.2, Step 12.1 (the user's 45–95 min first-purchase window) and Phases 10C (CR-003), 10G (CR-007) and 15 (CR-015) carry CR-016 notes; decisions 0.5.100–0.5.105 were added when the CR was recorded. architecture.md describes the belt module, the new art kinds and label planning. Regression: typecheck, lint, 1,277 unit tests (coverage 95.8 % of sim lines), 9 browser tests; balance medians with eight locations: deal 2.9 min, first Market purchase 92.3 (target 45–95), first discovery 48.9 (under 90), loan paid off 160.5, four ships 147 vs 156.

    Manual checks for the user:

    1. CR-016 §10 Step 15.9: on a fresh company after the deal, take a first-contact contract to Venus with the Clipper (Jobs → Earth; if the first contact on the board is to Mars, wait for the next board or use Settings → Developer to move the clock 10 minutes). Then look at the belt and the Jupiter system on a phone: tap "+2" beside Jupiter, and zoom in.
    2. CR-003 Step 10C.9 performance check (asked for again by Step 16.7): on a mid-range phone, pan and pinch-zoom the map with the belt showing; it should stay smooth.
    3. Map review: the screenshots in docs/screenshots/16-6-*, 16-7-* and 16-8-* (desktop and phone; 16-8-new-bodies-jupiter-* zoomed in on the Jupiter system).

Phase 16 complete. Stopped for the user's review.

Phase 17 — Tutorial XP and Skill Point Prompt (CR-017)

Spec: memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md (received as "CR-016 … Phase 16"). New decisions recorded as plan §0.5.106–0.5.109.

  • Step 17.1 — Content: tutorial settings. GameConfig: tutorialRunsBeforeDeal 5 (was 4) and a new tutorialXpMultiplier 2 (at least 1; used from 17.2). Pulled forward so the game keeps working: the deal no longer waits for the loaner to be back at Earth (decision 0.5.108) in the engine (isDealAvailable) and the balance simulator, since five runs end at the Moon; accepting it already puts the avatar on the Courier at the Earth bay. Balance medians: deal 3.7 min, first purchase 92.6 (45–95), discovery 49.6, loan 138.5, four ships 136 vs 138. Tests: packages/content/test/config.test.ts (five runs at double XP; a multiplier under 1 is rejected); the engine's deal tests now expect the deal after five runs ending at the Moon (not after four), after three with the three-run config, and accepting with the loaner at the Moon to put the avatar and the Courier at Earth.
  • Step 17.2 — Sim: tutorial XP and the level-1 guarantee. deliveryXpFor(record, tier, content) in resolution.ts: a delivery's XP, × tutorialXpMultiplier (rounded) on tutorial runs; resolveDispatch uses it. The multiplier stays at 2: an Earth–Moon Standard run is 10 XP, so the cheapest five runs make exactly 100 XP, the level-1 threshold (a level gives 2 points). Tests (packages/sim/test/tutorial-xp.test.ts): a tutorial run pays exactly 2 × a normal run's XP for the same route and cargo; over 50 board periods, taking the lowest-XP job on every board for five runs (the scripted "Unmarked crates" board included) always reaches level 1 with at least one point.
  • Step 17.3 — Sim: tutorial timing. The CR-001 Step 10A.3 timing test now covers five runs (Earth → Moon → Earth → Moon → Earth → Moon), each taking the slowest job on its board, and requires the total under 7 real minutes (it was four runs under 6), keeping "own a ship in under 10 minutes". Test: packages/sim/test/tutorial.test.ts.
  • Step 17.4 — Engine: deal availability and placement. (The rule itself changed in 17.1.) No save migration and no save-version change: the save keeps the completed-run count and the deal is computed from it, so a save with four runs done now simply needs a fifth. Tests (packages/engine/test/deal-five-runs.test.ts): after four runs the deal isn't on offer (and accepting is refused), after the fifth, ending at the Moon, it is; accepting puts the avatar on the Courier and Rosa on the Clipper, both at Earth, with no loaner left; a save from before CR-017 with four runs done, opened on this build, still has four runs and no deal until one more run.
  • Step 17.5 — Client: Pilots tab badge. The Pilots tab badge now shows the total unspent skill points across all pilots (was the number of pilots with points), hidden at zero (tabAttention in apps/web/src/layout/attention.ts, decision 0.5.109). Tests (apps/web/test/attention.test.tsx): pilots with 2, 0 and 1 points count 3; on the Pilots screen, the badge reads 3 with You on 2 and Rosa on 1, drops to 2 after one raise, and disappears once all three are spent.
  • Step 17.6 — Client: skill point pop-up. SkillPointPrompt (apps/web/src/components/SkillPointPrompt.tsx, decision 0.5.109): once the tutorial is over and the notification permission prompt is answered, a one-time tip (id skill-points) titled "Your pilots are ready to train" lists each pilot with unspent points ("You: 2 points · Rosa: 3 points"), with Go to Pilots (opens the Pilots tab) and Later; either dismisses it, and Show tips again brings it back. It waits for delivery summaries. The engine client keeps the permission request it makes after acceptDeal and exposes it as ApiClient.notificationPrompt() (settled at once on the web, where there is no prompt). The e2e playToDeal now expects the pop-up, with points for You and Rosa, and dismisses it. Screenshots: docs/screenshots/17-6-skill-points-desktop.png, 17-6-skill-points-phone.png, 17-6-pilots-badge-phone.png (badge 5). Tests: apps/web/test/skill-point-prompt.test.tsx (not during the tutorial; after the deal it waits for the permission answer, then lists You 1 and Rosa 2; Go to Pilots navigates and it stays gone; Later dismisses it until Show tips again, and only pilots with points are listed; no pop-up with no points); apps/web/test/notifications.test.ts (notificationPrompt settles only when the prompt after the deal is answered); e2e suite (9 pass).
  • Step 17.7 — Plan amendments and regression. CR-017 §6 applied: plan 0.5.30 (five runs, loaner anywhere) and the 12.3 rewrite note; CR-001 §2's story, its 0.5.22, Steps 10A.1, 10A.3, 10A.7 and 10A.8; CR-011 Step 10H.4 (the second one-time tip). Full suite: typecheck, lint, 1291 unit tests and 9 e2e tests pass. Balance (both strategies, seed 12345): deal 3.7 min (target under 10), first Market purchase 92.9 (45–95), first discovery 49.5, loan paid off 136.1, no violations.

Phase 17 is complete; stopped for review. Manual check for the user: on a fresh company, fly five tutorial runs, accept the deal, check the pop-up shows points for both You and Rosa, then spend them from the Pilots tab and watch the tab's badge count down and disappear.

Phase 18 — Fuel Tuning (CR-018)

Spec: memory-bank/change-requests/CR-018-fuel-tuning.md (received as "CR-017 … Phase 17"). New decisions recorded as plan §0.5.110–0.5.113.

  • Step 18.1 — Sim: fuel-to-payout balance check. packages/sim/balance/fuel-share.ts: for every purchasable ship model and every route it can fly, boards at every origin (all locations discovered, that ship as the fleet, 40 periods × 3 worlds), the contracts that fit its hold, and the leg's fuel at the origin's price against their median payout. The balance script prints the table. At the old price (10 cr/fu) the shares ran from 0.7% (Bulk Hauler Jupiter → Moon) to 77.5% (Clipper Mars → Earth); the Courier's Earth → Moon was 20.9%. No single price can put every row in 15–25%, so the user chose to halve the price and gate only rows above 25% (Step 18.2). Tests (packages/sim/test/fuel-share.test.ts): a row for every purchasable ship, each on a route it can fly with contracts offered, Earth → Moon for every ship; the Courier's Earth → Moon row is its leg fuel × Earth's price over the median; the table has one line per row.
  • Step 18.2 — Content: lower the base fuel price. baseFuelPricePerFu 10 → 5 (Earth 8 cr/fu, Jupiter 3); location multiples unchanged. Every ship and route is now at most 25% of the median fitting payout except the Clipper's Mars → Earth (38.8%); the Courier's Earth → Moon is 10.5%. The first Market purchase target is now 30–95 min (user decision): cheaper fuel brings the Clipper's first long landing, and so the first purchase, forward. Balance medians: deal 3.7 min, first purchase 41.6, discovery 39.3, loan 128.1, no violations. Tests: packages/sim/test/fuel-share.test.ts (only the Clipper's Mars → Earth is above 25%); balance.test.ts (30–95 window); fuel prices updated in tank.test.ts, the save 1 → 2 migration test and the Locations fuel test.
  • Step 18.3 — Engine: deal ships start full; Manual by default. deliveredTank gives every new ship (deal or Market) a full tank at the bay's price on Manual; config.deal.tankShare is gone. Save version 3: the 2 → 3 migration puts every ship on Manual, leaving fuel, prices and credits as they were. The balance simulator gives the deal's ships full tanks, flies on Manual and taps "Refuel here first" only when the best job needs more fuel than is aboard, or to go looking for work. Balance medians (7 worlds): deal 3.7 min, first purchase 36.7 (30–95), discovery 36.6, loan 123.9, four ships 122.7 vs 122.9, no violations. Tests: packages/engine/test/fuel.test.ts (the deal's Courier and Clipper full at Earth's price on Manual; a Market ship full on Manual; landing on Manual buys nothing and changes the tank only by the leg; switched to Auto it still tops up on landing; an old save opens with every ship full on Manual); packages/schema/test/save.test.ts (2 → 3: every ship Manual, fuel and credits unchanged; 1 → 3 end to end); the deadhead and About tests updated.
  • Step 18.4 — Client: low-fuel warning and policy display. longestLeg/lowFuel in apps/web/src/game.ts: an idle ship's longest leg on a full tank (its effective stats) to a discovered place, and whether the tank holds less than that leg burns. A Fleet card then shows "Low fuel · {place} needs {n} fu" under the gauge. The fuel sheet's policy toggle lists Manual first and calls it the default. Screenshots: docs/screenshots/18-4-low-fuel-desktop.png, 18-4-low-fuel-phone.png (the deal's Clipper after flying empty to Mars: 160 fu, Ceres needs 928), 18-4-fuel-sheet-phone.png. Tests (apps/web/test/low-fuel.test.tsx): a Clipper at Earth with 1 fu less than its Earth → Mars leg shows Low fuel naming Mars, and Fill clears it; with enough fuel, or with Mars undiscovered, no warning; the fuel sheet shows Manual first and checked, marked as the default.
  • Step 18.5 — Plan amendments and regression. CR-018 §6 applied: plan 0.5.94, 0.5.96, 0.5.97 and Step 12.1; CR-015 §3 (refueling), its CR-014.D5, Steps 15.4 and 15.6; CR-013 Step 10M.3 (deal ships full). The e2e flyUntil helper now fills the tank before each trip (it had relied on Auto top-up, so e2e 12.6 failed from Step 18.3 until this fix), and playToDeal checks that both deal ships arrive full on Manual. Full suite: typecheck, lint, 1300 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min (under 10), first Market purchase 36.7 (30–95), discovery 36.6, loan paid off 123.9, four ships within 25%, no violations.

Phase 18 is complete; stopped for review. Manual check for the user: on a fresh company, accept the deal, check both ships are full and on Manual (Fleet → Fuel), fly several Earth–Moon jobs, and check credits grow; a ship short of fuel for its longest leg shows Low fuel on its Fleet card.

Phase 19 — Only Show Jobs the Fleet Can Complete (CR-019)

Spec: memory-bank/change-requests/CR-019-completable-boards.md (received as "CR-018 … Phase 18"). New decisions recorded as plan §0.5.114–0.5.117.

  • Step 19.1 — Sim: completability check. packages/sim/src/completable.ts: permanentFailure(contract, member, discoveredIds, content) gives a pair's first failing permanent check: over-capacity, cannot-land, out-of-range, would-strand, too-slow, cargo-skill-too-low, long-haul-too-low. It uses the same rules and order as validateDispatch, with the tank counted full where the origin sells fuel and the fuel on board where it doesn't. completability returns whether any pair passes plus each pair's failure; isCompletable is the shorthand. qualifyingFleet builds the pairs for a location (ships there or landing before the period ends, with their pilot or any unassigned pilot there if the player has a bay there; else the whole fleet). FleetMember (now with fuelFu) moved here from board.ts. Tests (packages/sim/test/completable.test.ts): one per permanent check (landing and stranding via content with a hardened Earth and a dry Mars); a dry origin counts only the fuel on board; Cargo Bay upgrades count; any one pair passing makes a job completable, with per-pair failures; rest, the shop and an empty tank block dispatch but not completability; the qualifying fleet's local pairs, ships landing before and after the period ends, spare pilots only with a bay, the whole-fleet fallback, and upgrades and fuel carried into the snapshot.
  • Step 19.2 — Sim: generation keeps only completable jobs. generateContractBoard with a fleet draws candidates slot by slot (CR-006's band plan), round and round. It keeps those isCompletable for the fleet, numbered in order kept, until the board is full or config.boardMaxAttempts (200) candidates have been tried; first contacts count only when kept. Without a fleet it is unfiltered as before (tools and the class-mix baseline). The balance simulator keeps a fuel reserve big enough to fill every landed ship before buying another; one world had stranded a Clipper at Venus after a purchase. The fuel-share check's pilot is skilled in everything: only the ship limits what fits, and now no route is above 25%, the Clipper's Mars → Earth included. Balance medians: deal 3.7 min, first purchase 36.7, discovery 36.6, loan 123.3, no violations. Tests (packages/sim/test/board-completable.test.ts): 1,000 boards over five assorted fleets and five origins never offer an uncompletable job; a level-0 avatar's Courier at Earth always gets 8 Standard Moon jobs it fits; a Courier with only Earth known gets short boards of Moon first contacts, without error; identical inputs give identical boards; first contacts are filtered too. The CR-006 tests now expect an empty board for an empty fleet, full Courier boards with no previews, and band counts kept for a fleet that can do everything. board-goods.test.ts: a full-capability fleet filters nothing. The Venus board test gives its pilot Long Haul 1.
  • Step 19.3 — Engine: boards, filtering, and migration. boardFleet(save, locationId, periodEndsAt) in packages/engine/src/game/boards.ts (replacing playerFleet) builds the qualifying fleet from the save: ships in creation order with upgrades, fuel, and the destination and arrival of a flying ship, plus pilots and bays. A location board is generated for it and stores it as fleet on the saved board (fleetMemberSchema; null for tutorial and hand-made boards). Every read hides jobs the current qualifying fleet can't complete. Save version 4: the 3 → 4 migration drops stored boards. Also fixes Step 19.2's unformatted DECISIONS.md line. Tests (packages/engine/test/completable-boards.test.ts): a Freighter's heavy jobs vanish from Earth's board once it deadheads to the Moon, leaving the Courier's jobs unchanged; a Freighter bought mid-period adds nothing until the next period, then heavy jobs appear; the stored board carries the fleet snapshot; a version-3 save opens with no boards and regenerates the same board on view. packages/schema/test/save.test.ts: 3 → 4 drops boards and changes nothing else.
  • Step 19.4 — Client: cards and empty state. Contract cards lose the "Too heavy" and "Too slow" pills and the red mass (carryLimit and tooSlowForAll removed, with the CR-006 weight-warning test); the "Needs Heavy Cargo 2" requirement line stays, as it describes the job rather than a problem. The dispatch sheet keeps its per-pair reasons. An empty board says "No jobs here your fleet can take right now. New contracts in mm:ss.", above the existing Rest and Deadhead panel for each idle ship there. Screenshots: docs/screenshots/19-4-earth-board-desktop.png, 19-4-earth-board-phone.png, 19-4-empty-board-desktop.png, 19-4-empty-board-phone.png (Mars after the deal: no pilot has Long Haul for its legs). Tests (apps/web/test/board-cards.test.tsx): heavy, rushed and skilled jobs show no problem badge; an empty board shows the message with the countdown, plus Rest and Deadhead for the idle Courier. The badge tests in contract-card.test.tsx and effective-stats-ui.test.tsx are gone, and states.test.tsx expects the new message.
  • Step 19.5 — Plan amendments and regression. CR-019 §6 applied: plan 0.5.52 and 0.5.53, Steps 3.9, 9.4 and 12.1; CR-002's 0.5.5 (stored boards keep the fleet snapshot); CR-007 Steps 10G.3 and 10G.8; and this repo's CR-006 Steps 10F.1, 10F.3 and 10F.4, which it supersedes. The balance simulator now generates each board for that location's qualifying fleet and hides jobs the fleet can no longer complete, as the engine does. It counts the jobs shown: none in any of the 14 runs was uncompletable. e2e 12.6 is reordered: a Heavy Cargo job now appears only after the pilot learns the skill, so the pilot raises Heavy Cargo first; the test then waits for a Heavy Cargo 1 job at the Moon and checks the Lighter's pair can take it. Before that, it checks no Heavy Cargo job is offered. Full suite: typecheck, lint, 1327 unit tests and 9 e2e tests pass. Balance (7 worlds): deal 3.7 min, first Market purchase 36.6 (30–95), discovery 36.6, loan paid off 123.0, four ships 118.4 vs 122.9, no violations.

Phase 19 is complete; stopped for review. Manual check for the user: with only the deal's Courier and Clipper, browse the Earth board and confirm every job can be dispatched with one of them; boards elsewhere (e.g. Mars, where neither pilot has Long Haul) say no job fits.

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

Spec: memory-bank/change-requests/CR-020-bridge-dashboard.md. New decisions recorded as plan §0.5.118–0.5.121.

  • Step 20.1 — Navigation: Bridge and HQs. SCREENS: / is now "Dashboard" with the tab label "Bridge" and a new helm icon; /hqs ("HQs", HQ icon) sits after Pilots, with a first HQs screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below sm so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on /. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: layout.test.tsx (eight links, Bridge first and current on launch, HQs at /hqs; every screen's heading at its path).
  • Step 20.2 — HQs screen. screens/HQs.tsx: one emerald feature card per bay (labelled "HQ at "): location art and name, fuel price there (cheapest highlighted), ships at the location with Idle / In the shop, flights inbound to it with countdowns, unassigned pilots there (level, resting), and links to the location's contracts and to Pilots for hiring; the header says what an HQ allows (HQ_ALLOWS). No bays: a short explanation and "Lease one in Places". Leasing stays on Places. Test: hqs-screen.test.tsx (an HQ's ships, inbound flight, spare pilot, fuel price and links; ships elsewhere not listed; one card per HQ; empty state).
  • Step 20.3 — Dashboard. screens/HQ.tsx → screens/Dashboard.tsx (Dashboard; tutorialHint moved with it). Leads with Your ship (pilotShip(state): the avatar's assigned ship, the company loaner during the tutorial, or none): art, name and model, Idle / In flight / In the shop, where it is or its route with a route strip, cargo ("6 t Medical Supplies for Meridian Health" or "Flying empty"), arrival countdown and Priority chip, shop countdown, fuel gauge with the low-fuel warning (lowFuel, decision 0.5.113; "Supplied by Halvorsen" for the loaner), the avatar's duty meter or rest countdown, and "Find work here" / "Fleet" when idle; with no ship, "Assign yourself on Pilots". Beside it, Alerts: "No alerts. In-flight alerts and news will appear here." Then the stat tiles, the tutorial panel, Ships on jobs (every active dispatch, soonest first, with route, countdown and a trip meter; replaces "Next arrival"), pilot rest, in the shop, goals and the loan. Test: dashboard.test.tsx (was hq.test.tsx): ships on jobs in arrival order with live countdowns; your ship idle (place, fuel, duty, Find work here), low fuel, in flight with cargo and arrival, in the shop with a resting pilot, no ship, the tutorial loaner; the Alerts placeholder.
  • Step 20.4 — Regression and review. CR-020 amendments added to plan Step 8.1 and CR-005 Step 10E.4. The e2e playToDeal now waits for the "Dashboard" heading. With eight tabs the desktop rail cut off Places in a 720 px-tall window (first screenshot), so rail tabs are more compact (md:size-11, md:py-1.5) and the rail scrolls if a window is shorter still. Full suite: typecheck, lint, 1341 unit tests and 9 e2e tests pass. Screenshots (taken after the deal with one ship on a job): docs/screenshots/20-4-bridge-desktop.png, 20-4-bridge-phone.png, 20-4-hqs-desktop.png, 20-4-hqs-phone.png.

Phase 20 is complete; stopped for review.

Phase 21 — Independent Pilots (CR-021)

Spec: memory-bank/change-requests/CR-021-independent-pilots.md (received as "CR-019 … Phase 19"; first recorded here as CR-021 until the clash with the Bridge CR-021 was noticed). New decisions recorded as plan §0.5.126–0.5.130. Steps 21.1–21.5 were committed as "Step 21.1"–"Step 21.5".

  • Step 21.1 — Content and config. config.independentPilots: dispatchOfficePrice 36,000 (a Lighter), firstLookRealMinutes 3, turnaroundGameMinutes 15 (15 real seconds at ×60), emptyPeriodsBeforeHome 3, maxCatchUpRealDays 7 and defaultSpendingFloor 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (packages/content/test/config.test.ts): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected.

  • Step 21.2 — Sim: eligibility and job choice. packages/sim/src/independent.ts: PilotOrders and defaultOrders(home); pastFirstLook(now); legRefuel (exactly the fuel a leg needs beyond what's aboard, rounded up to 0.001 fu, only where fuel is sold and only if credits stay at or above the spending floor); independentOptions (past the first-look window, not first contacts or the job the player has open, within the orders, passing validateDispatch with the leg's fuel bought, making at least the minimum profit). Fuel aboard is priced at the tank's average and bought fuel at the local price, less the pilot's share, then divided by trip game hours. compareOptions: highest profit per hour, then the shorter trip, then the lower id. chooseAction returns take, deadhead home (after emptyPeriodsBeforeHome empty periods away, when the fuel rules allow), or wait. Tests (packages/sim/test/independent.test.ts): first contacts never taken; nothing in the first-look window, the same job after; the open job skipped; each order (destination, class, Priority, minimum profit at the boundary); profit per hour beats total profit (Moon over Mars); fuel and share arithmetic; tie-breaks; a pilot short of credits above the floor takes the job flyable on fuel aboard; waiting, heading home after the third empty period, and not when home is out of reach.

  • Step 21.3 — Engine: Dispatch Office, modes, orders. Save version 5: pilots get mode (manual/independent) and orders (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets dispatchOffice and npcSpendingFloor. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: Pilot.mode and Pilot.orders; PlayerState.dispatchOffice holds owned and spendingFloor. Commands (packages/engine/src/game/independent.ts): buyDispatchOffice (after the tutorial, once, for its price), setPilotMode (Independent needs the office and is never the avatar), setPilotOrders (checked against locations, cargo classes and the player's bays), and setSpendingFloor. New error codes: dispatch-office-required, dispatch-office-owned, avatar-not-independent, unknown-location, unknown-cargo-class. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (packages/engine/test/independent-commands.test.ts): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. packages/schema/test/save.test.ts: 4 → 5 as above. Web and schema fixtures gained the new fields (pilotExtras in apps/web/test/fixtures.ts).

  • Step 21.4 — Engine: event-queue catch-up. catchUp in packages/engine/src/game/catchup.ts (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's resolveDue now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' nextDecisionAt and emptyPeriods, and dispatches' independent flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (packages/engine/test/catch-up.test.ts): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.

  • Step 21.5 — Engine: live play. While the app is open, the resolution loop's pass is the catch-up of Step 21.4, so pilots decide every few seconds. ApiClient.setViewedContract (the engine client calls GameEngine.setViewedContract) is set by the dispatch sheet while it's open and cleared when it closes; catch-up passes it to the sim, which skips that contract. Tests (packages/engine/test/live-play.test.ts): with the best contract open a pilot takes the next one; after it closes, another pilot takes it; the loop's tick makes an Independent pilot's decision and refreshes the screen. apps/web/test/viewed-contract.test.tsx: opening the dispatch sheet reports its contract, and Cancel clears it.

  • Step 21.6 — Client: Dispatch Office and pilot controls. apps/web/src/components/IndependentPilots.tsx:

    • DispatchOfficePanel on the Bridge: what it does and "Buy Dispatch Office (36,000 cr)"; once owned, a pointer to Pilots.
    • PilotIndependence on each hired pilot's card (none for the avatar): "Flies: When you send them / Independent", disabled before the office with "Buy a Dispatch Office on the Bridge…"; the status line from independentStatus ("Independent: flying Medical Supplies to Moon", "Waiting for new contracts at Mars", "Resting until 14:20"); and an Orders editor (anywhere you know or chosen places, cargo classes, Priority, least profit, home HQ), saved together.
    • SpendingFloor in Settings → Company.
    • IndependentBadge on Fleet cards, beside the pilot's name.

    Screenshots: docs/screenshots/21-6-dispatch-office-desktop.png, 21-6-dispatch-office-owned-phone.png, 21-6-pilot-orders-phone.png, 21-6-fleet-badge-phone.png. Tests (apps/web/test/independent-controls.test.tsx): the toggle disabled with its reason before the office and absent for the avatar; Independent sent to the engine once bought; edited orders saved; each status line; the badge only on an Independent crew; buying the office on the Bridge and saving the spending floor.

  • Step 21.7 — Client: board and logbook.

    • First-look chip. Contract cards show "Yours for mm:ss" until the first-look window ends. It appears only when some pilot is Independent, and never on first contacts.
    • Catch-up indicator. "Catching up on your fleet…" (CatchingUp in apps/web/src/components/PilotLogbook.tsx) appears when the loop's pass runs longer than 0.5 s. ResolutionLoop takes an onBusy callback.
    • Pilot logbook. The API summary carries independent. On the "While you were away" sheet, pilotLogbook groups Independent trips by pilot: jobs (good, route, payout, share, fuel), deadheads, rests, XP, and what the pilot is doing now. Company totals sit at the top. The player's own trips stay as cards.

    Screenshots: docs/screenshots/21-7-logbook-phone.png, 21-7-logbook-desktop.png (Rosa, an hour away: 11–12 jobs, two rests), 21-7-first-look-desktop.png. Tests (apps/web/test/board-logbook.test.tsx): the chip counts down from 02:31 and disappears after the window; no chip without Independent pilots or on a first contact; the indicator only after 0.5 s and never for a quick pass; the logbook's entries, XP and current status per pilot; totals that are the sums of the entries; the sheet shows the logbook and the player's own trip as a card. Summary fixtures gained independent.

  • Step 21.8 — Balance. simulate takes scenario: { atMinute, minutes, mode }. It plays as usual until atMinute (180: four ships), then for minutes neither buys, upgrades nor claims goals. In active mode the usual policy flies every ship. In away mode the avatar stays put and hired pilots act at their decision points through the sim's chooseAction, as the engine's catch-up does: turnaround, first-look window, the spending floor, waiting and heading home. scenarioEarnings is the window's credits gained plus loan repaid. The balance script prints the comparison. Results with default content (7 worlds, 8 hours): away / active ratios 0.34–0.62, median 0.56 (target 40–80%), so no tuning was needed; no pilot bought fuel below the floor or was stranded. Tests (packages/sim/test/balance.test.ts): the median ratio is within 40–80%; the away runs have no violations.

  • Step 21.9 — Plan amendments and regression. CR-021 §6 applied: plan 0.3 (automation struck; standing orders, pilot traits, decision events and a hiring market stay deferred) and 0.5 (0.5.122–0.5.130); a note on CR-001's goals (a Dispatch Office goal later, not now); CR-014 Step 14.6 (the loop is the event-queue catch-up); CR-019 (pilots choose from completable boards); plan Step 12.1 (the away scenario). game-design-document.md is the designer's to update. Full suite: typecheck, lint, 1399 unit tests and 9 e2e tests pass.

Phase 21 is complete; stopped for review. Manual check for the user: buy the Dispatch Office on the Bridge, set Rosa to Independent with default orders, close the app for an hour, reopen and read her logbook; then confirm a first-contact job stayed on the board for you.

Phase 22 — Welcome Screens (CR-022)

Spec: memory-bank/change-requests/CR-022-welcome-screens.md (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132.

  • Step 22.1 — Content and save field. Content: welcome (three cards' titles and bodies, and the Next, Skip, "Let's fly" and Close labels; welcomeSchema), with {company} in the last card filled from the new tutorialCompanyName ("Halvorsen Lunar Freight", until now written into three screens). Save version 7: player.welcomeSeen, false for a new company; the 6 → 7 migration sets it true for existing ones. Tests: packages/content/test/onboarding.test.ts (the cards validate, in order, with {company} and the labels; empty text is rejected); packages/engine/test/start.test.ts (a new company's welcome is unseen); packages/schema/test/save.test.ts (6 → 7 marks it seen and changes nothing else).

  • Step 22.2 — Client: welcome pop-up.

    • The cards. WelcomeCards (apps/web/src/components/Welcome.tsx) shows the three cards with "1 of 3" announced politely, Next, Skip on every card, and "Let's fly" (or "Close") on the last. Swipe left and right moves between cards. Focus starts on the main button, Tab and Shift+Tab stay inside, and Escape skips. Cards slide in (animate-slide) unless reduced motion is on, when they change at once.
    • The gate. WelcomeGate (in App) shows the cards while PlayerState.welcomeSeen is false. Closing them calls the new markWelcomeSeen command and goes to Contracts, where the tutorial intro card follows.
    • Other changes. The three screens that wrote "Halvorsen Lunar Freight" now use content.tutorialCompanyName. The e2e startCompany (and the production-build test) expect the welcome and skip it, waiting for the save to record it before any reload. Screenshots: docs/screenshots/22-2-welcome-1-phone.png, 22-2-welcome-3-phone.png, 22-2-after-welcome-phone.png, and the same three for desktop.

    Tests (apps/web/test/welcome.test.tsx): through all three cards to "Let's fly", marked seen, on Contracts with the intro card; Skip on each card does the same; no welcome after a reload; the slide class only without reduced motion; focus stays inside. packages/engine/test/start.test.ts: markWelcomeSeen records it, and it survives a reload.

  • Step 22.3 — Client: new company and "How to play". "Start a new company" makes a save whose welcome is unseen, so the gate shows the cards for it. The gate's "closed" flag is now kept per company id, so a welcome closed earlier in the session doesn't hide the new company's. Settings → Help has "How to play", which opens WelcomeCards ending in "Close" and leaves the save alone. Tests (apps/web/test/welcome-settings.test.tsx): after skipping one company's welcome, a company started from Settings shows card 1 again; How to play opens anywhere, ends in Close (not "Let's fly"), stays on Settings and never calls markWelcomeSeen.

  • Step 22.4 — Regression. New e2e apps/web/e2e/22-4-first-dispatch.e2e.ts: from "Start your company", through Skip on the welcome, to the first tutorial run in flight takes under 30 seconds. Full suite: typecheck, lint, 1414 unit tests and 10 e2e tests pass.

Phase 22 is complete; stopped for review. Manual check for the user: start a new company on a phone and read through the three cards (Next, swipe, Skip), then check Settings → Help → How to play.

Fixes after Phase 22

  • Boards ignore Independent pilots (decision 0.5.133). Reported: with Rosa Independent, the board still showed jobs only that pilot could fly. The board's qualifying fleet now leaves out Independent pilots and their ships, both when a board is generated and when it's read, so a board already stored this period hides those jobs at once. Independent pilots still check each job against their own pair; with no Manual pair at all, the whole fleet counts. Tests (packages/engine/test/completable-boards.test.ts): heavy jobs vanish from the board when the Freighter's pilot goes Independent, this period and the next five; the fallback to the whole fleet.
  • Ready ships, not idle ships (decision 0.5.134). Reported: a resting pilot still showed as available on the board. The location chips on Contracts counted every idle ship, so a ship whose pilot was resting still showed as ready. They now count ships ready for a job (readyShips in apps/web/src/game.ts): crewed, the pilot not resting (checked against the clock, so the count updates the moment rest ends) or Independent, and not in the shop. The Jobs tab badge uses the same rule. With idle ships here but none ready, the board says "No pilot here is ready to fly" and who wakes first. Tests (apps/web/test/ready-ships.test.tsx, location-boards.test.tsx). Screenshots: docs/screenshots/fix-ready-ships-resting-{phone,desktop}.png.

Phase 23 — Shop Time Counts as Rest (CR-023)

Spec: memory-bank/change-requests/CR-023-shop-time-rest.md (received as "CR-021 … Phase 21"). New decision recorded as plan §0.5.135.

  • Step 23.1 — Content test. Every upgrade level's shop time, in game hours (real minutes × time compression), is at least restGameHours: today 10, 20 and 30 game hours against a 10-hour rest. Test: packages/content/test/upgrades.test.ts.
  • Step 23.2 — Engine: duty reset on upgrade completion. startUpgrade records the ship's pilot as shopPilotId (save version 8; the 7 → 8 migration sets it null, so an upgrade already under way finishes without the rest), but only when the shop time covers a full rest. unassignPilot clears it: a pilot can only fly another ship after leaving this one, so "assigned the whole time" and "didn't fly" are the same check. The shop's end is a new event in the catch-up queue (before arrivals and decisions at the same moment): finishShop sets the pilot's duty to zero, ends a rest that would run later, and brings an Independent pilot's next decision forward to the turnaround after it. The dev "finish shop" does the same at once. Pilot.restingInShopUntil tells the client. Tests (packages/engine/test/shop-rest.test.ts): a pilot near the limit is at zero duty and flies the moment the upgrade ends; one who left and flew another ship keeps their duty; one who left and came back doesn't get the reset; a longer rest ends with the shop and a shorter one runs its course; an Independent pilot comes out rested and decides after the turnaround; catch-up at once equals 30-second steps. packages/schema/test/save.test.ts: the 7 → 8 migration.
  • Step 23.3 — Client: status line. While Pilot.restingInShopUntil is set, the pilot's card on Pilots and the Bridge's own-pilot panel read "Resting while in the shop — mm:ss" (ShopRest, counting down to the upgrade's end) in place of the duty bar, and an Independent pilot's status line reads "Resting while in the shop until …". The Fleet card keeps its "In the shop" countdown. Tests (apps/web/test/shop-rest.test.tsx): the line with the right countdown and no duty bar; after the pilot is unassigned the line is gone and the duty bar is back; it counts down with the clock; the Independent status. Screenshots: docs/screenshots/23-3-shop-rest-phone.png, 23-3-shop-rest-desktop.png.
  • Step 23.4 — Regression. Full suite: typecheck, lint, 1432 unit tests and 10 e2e tests pass.

Phase 23 is complete; stopped for review. Manual check for the user: fly a pilot close to the duty limit, start an upgrade at an HQ, and check that the Pilots card reads "Resting while in the shop" and the pilot is ready to fly (no rest, zero duty) the moment the upgrade finishes.