132 KiB
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/*), stricttsconfig.base.jsonextended by each package.pnpm install+pnpm typecheckpass. Repo moved toC:\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 bansMath.random,Date.nowand argument-lessnew Date()inpackages/simandpackages/content; verified it fails on a plantedMath.random(). - Step 1.3 — Test runner. Vitest 5 with one project per workspace package (
pnpm test). Trivial smoke test inpackages/sim/test/smoke.test.ts; 1 passing. - Step 1.4 — Continuous integration. Repo pushed to Gitea;
.gitea/workflows/ci.ymlruns install, typecheck, lint and test on every push (runnerhomelab-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 -dstarts Postgres 18 withdeadhead(dev) anddeadhead_testdatabases; connected to both with psql..env.examplelists the server's env vars.
Phase 2 — Content Package
- Step 2.1 — Content schemas. Zod 4 schemas in
packages/content/src/schemas.tsfor 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.exampleinto GameConfig. - Step 2.6 — Single content bundle.
contentinsrc/bundle.tsholds all data pluslocation,shipModel,cargoTypeandskilllookups that returnundefinedfor unknown ids.
Phase 3 — Simulation Package
- Step 3.1 — Game clock conversion.
src/clock.ts:realToGame/gameToRealusing the config epoch and compression; times are Unix-ms numbers. - Step 3.2 — Seeded RNG.
src/rng.ts:createRng(seed)(mulberry32) withnext,int,pick;deriveSeed(parent, label)for child seeds. - Step 3.3 — Location positions.
src/positions.ts: heliocentric ecliptic positions (million km) fromastronomy-enginefor 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:checkRoutereturnsokorcannot-land/out-of-range;canLandAtranks vacuum < atmospheric < hardened. Step 2.3 ranges confirmed against sampled real distances. - Step 3.8 — Contract model and payout.
src/contract.ts:Contractinterface andcontractPayout(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), plusboardPeriodIndex,requiredLongHaulLeveland theSimContentinput 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.SimContentgainedshipModels. - Step 3.11 — Pilot eligibility.
src/pilot.ts:checkPilotEligibility(skills, contract)returnsokorcargo-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.
createDispatchRecordinsrc/dispatch.ts:DispatchRecordwith arrival from travel time, fuel cost, departure distance, atutorialflag and a per-dispatch seed. - Step 3.14 — Reposition dispatch.
validateRepositionandcreateRepositionRecordinsrc/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) anddispatchPosition(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:resolveDispatchreturnsin-flightor aResolution(credits after pilot pay, XP, new ship location, discovery). - Step 3.18 — Public API and coverage.
src/index.tsexports an explicit list (content lookups and contract building stay internal).pnpm coverage(V8,packages/sim/src, 90% line threshold) reports 100% lines; CI now runspnpm coverageinstead ofpnpm 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 exportsDISPATCH_FAILURES/REPOSITION_FAILURES.architecture.mdstarted with the API data model. - Step 4.2 — API route catalog.
packages/schema/src/routes.ts: 13 typed endpoints under/apiwith request/response schemas and anauthflag, plusRouteRequest/RouteResponsehelper types. Route table added toarchitecture.md.
Phase 5 — Server Foundation
- Step 5.1 — Fastify skeleton.
apps/server: Zod-validated env config (src/config.ts),buildAppwith pino JSON logging andGET /api/health(src/app.ts), entry pointsrc/main.ts(loads repo-root.envif present).pnpm --filter @deadhead/server dev|startrun 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/timereturns{ realTime, gameTime }from an injectableClock(src/clock.ts, passed viabuildApp(config, { clock })). - Step 5.3 — Database schema and migrations. Drizzle schema (
apps/server/src/db/schema.ts, copied intoarchitecture.md), first migrationdrizzle/0000_init.sql,db:generate/db:migratescripts. 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) andseedPlayer(db, "tutorial" | "owner"). New-player creation lives insrc/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 insrc/db/auth-schema.ts, migration0001_auth).POST /api/guestcreates a guest session and a tutorial player;GET /api/statereturns player state for the session or401 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 forauthroutes, and checks responses outside production. Malformed JSON and unexpected fields return400 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 insidewithPlayerLock(src/game/lock.ts, a transaction holding the company row lock); a conditional status update makes each resolve exactly once.GET /api/stateruns it before reading. Routes moved tosrc/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-tutorialafter the tutorial. Every game route now runs throughwithPlayer(lock + lazy resolution). - Step 6.4 — Tutorial dispatch.
POST /api/dispatcheswithkind: "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-fundschanges nothing. - Step 6.6 — Lease a bay.
POST /api/bays(leaseBayinsrc/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 codeavatar-in-tutorial. - Step 6.9 — Bay contract board endpoint.
GET /api/bays/:locationId/board(bayBoardinsrc/game/boards.ts): generated from the discovered set on first request in a period and stored; accepted contracts hidden;409 no-bay-at-locationwithout a bay. The expired-old-board dispatch check is tested in Step 6.10. - Step 6.10 — Contract dispatch.
POST /api/dispatcheswithkind: "contract"(dispatchContract): sim validation, fuel deducted, contract accepted, ship and pilot in flight. One integration test per sim reason (cannot-landvia 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/dispatcheswithkind: "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) setsacknowledged_aton 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:JobQueueinterface, pg-boss implementation (queues always created; workers and cron schedules only whenWORKERS_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-arrivalsjob 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-onlyPOST /api/test/clock-offset(not registered in production) moves it. - Step 7.3 — Expired contract cleanup. Hourly
cleanup-expired-contractsjob (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/apito127.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 asApiErrorwith code and message. - Step 8.3 — Server-synced clock.
apps/web/src/clock: ZustanduseClockstore,syncClock(api)(offset from server time, half round trip),serverNow(),startClockTicker()(1 s), and aGameClockheader component showing in-game time. - Step 8.4 — Guest onboarding. No session (state request returns 401) shows
StartScreen; "Start your company" callscreateGuest, caches the returned state and opens HQ. The session is Better Auth's persistent cookie. TanStack Query and anApiProvidercontext 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, viauseRefetchOnArrival, 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 callsacknowledgeSummaries. - 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 simvalidateDispatch; 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 (simdispatchProgress) 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 insrc/map/projection.ts, camera insrc/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.
purchasableon ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config:tutorialLoanerShipModelId,tutorialRunsBeforeDeal4,tutorialBoardSize3,tutorialMassTonnes5/12/20,handlingGameHoursPerTonne0.02,loanRepaymentShare0.3,dealLoanAmount5000,startingCredits500. "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)addsmass × 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 (scriptedIdon the contract, at its pay multiplier). Loaner isconfig.tutorialLoanerShipModelId. Step 3.10 tests replaced. Server passesrunsCompleted: 0until Step 10A.7. - Step 10A.4 — Sim: loan repayment.
splitLoanRepayment(credits, balance, share)(src/loan.ts);resolveDispatchtakesloanBalanceand returnsloanRepaymentwith netcredits. Server passes a zero balance until Step 10A.9. - Step 10A.5 — Sim: onboarding goal evaluation.
completedGoals(goals, progress)/isGoalConditionMet(src/goals.ts) over aGoalProgresssummary; 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), tableclaimed_goals; repayment stored in each dispatch's resolution JSON. API:company.loanBalance,tutorial.runsCompleted/dealAvailable,goals, summaryloanRepayment/scriptedId; routesacceptDeal,repayLoan,claimGoal; error codestutorial-active,deal-not-available,unknown-goal,goal-not-ready,goal-already-claimed.architecture.mdupdated. - 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;dealAvailablein state; buying ships, leasing bays and hiring pilots return409 tutorial-activeduring 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(acceptDealinsrc/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-availableotherwise. OldmaybeEndTutorialremoved (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-fundswith 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/claimadds 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: whentutorial.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 callsacceptDeal, 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 callsclaimGoal; 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):
contractMassBandsgives 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(wasorigin) is the board's location; no bay notion in sim board generation. Moon board test added. - Step 10B.2 — Sim: deadhead validation.
validateRepositiontakesdiscoveredIds(not bays): any origin, destination discovered (location-undiscovered) and not the current location, range and landing.REPOSITION_FAILURESswapsno-bay-at-destinationforlocation-undiscovered. Server passes the player's discovered locations. - Step 10B.3 — Database: boards keyed by location. Migration
0003_cr002_location_boards:board_kindis nowlocation/tutorial(oldbayrows 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.
getContractBoardis nowGET /api/locations/:locationId/board(locationBoardinsrc/game/boards.ts): any discovered location, no bay needed;409 location-undiscoveredfor undiscovered,404for unknown. Step 6.9 tests moved tolocation-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.tscovers 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.
buyShiptakes{ modelId, bayId }; the ship appears idle and uncrewed at that bay's location. MissingbayIdis400 validation-error; a bay the player does not own is404 not-foundwith nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers returnearthBayId. - Step 10B.7 — Client: Contracts screen by location.
LocationBoards(wasBayBoards) 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.jsonmaps 12 asset ids to the 11 files inapps/web/public/assets/map/(all 1254×1254; sprites RGBA).badge-cloud-platformis listed withfile: null(not provided). Content:spriteon every location and ship model,badgeon 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 inbuildwith--if-available) writes 21 files topublic/assets/map-gen/(committed): bodies and Sun 256/512 px, ships 128/256 px (trimmed),bg.webp1254 px / 106 KB. The Vite build drops the source art fromdist. 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) andmapStylewith 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 bundlemap-art, each asset loaded separately so a failure only costs that asset (one warning, placeholder).map/art/visuals.tspicks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved fromMapCanvasintomap/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.tsbackgroundRect: cover viewport + 10% margin each side, zoom followzoom^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.decorateplaces 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.tslocationOverlays(scene)decides per location: undiscovered → desaturated (one sharedColorMatrixFilter), 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.separateShipsspreads 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 (MapCanvasselectedprop). Test:map-ships.test.ts. Manual check:docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png. - Step 10C.9 — Performance.
map/ticker.tsrunWhileVisible: 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 inmap-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) andrestGameHours(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;validateDispatchandvalidateRepositionfail withpilot-restingbefore the other pilot checks.ResolveInputtakespilotDutyHoursandpilotSkills;ResolutionreturnspilotDutyHoursandpilotRestEndsAt(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) andpilots.rest_ends_at(nullable). APIPilotgainsdutyHours,dutyLimit,resting,restEndsAt(null unless resting);ResolutionSummarygainsrestEndsAt(mandatory rest). RouterestPilot(POST /api/pilots/:pilotId/rest).pilot-restingis an API code via the sim's failure list.toApiPilotandloadPlayerStatetakenow. 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-activeotherwise. 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",
progressbarwith 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 callrestPilot. 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" (simtripGameHours+dutyAfterTrip) and "Will need rest on arrival" whenneedsRest. Client validation now passes the pilot's realrestEndsAt(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
periodEndsAtand refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, viapairOptions), 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/sheetanimations.body::beforeputs the map'sbg.webpunder a navy gradient behind every screen; global:focus-visiblering; all motion off underprefers-reduced-motion. Test:theme.test.ts. - Step 10E.2 — UI kit.
apps/web/src/ui/(barrelindex.ts):Button/buttonClass(primary, secondary, ghost, success, danger; sm/md/lg, 44 px tall at md; press feedback),Panel(tones,featurecorner brackets, polymorphicas) andEyebrow,Sheet(modal: bottom sheet on phones, centred card fromsm, renders its ownh2),Pill,Meter(labelledprogressbar),StatTile,ScreenHeader(the screen'sh1),Art/spriteUrl(generated map sprites, 1x/2xsrcSet, hides on error) andIcon(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 clocklabels kept) and icon navigation, a bottom bar on phones and a 96 px left rail frommd(decision 0.5.49).SCREENSgained anicon. Attention badges from the puretabAttention(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 arearia-hidden.--header-h/--nav-hCSS variables let the map fill exactly the space left on both layouts.ui/Screenis 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.tsxunchanged and passing. - Step 10E.4 — HQ dashboard.
ScreenHeaderwith the company name; icon stat tiles (2 × 2 on phones, 4 across frommd); two columns frommd. Newcomponents/RouteStrip(origin art → dashed line → destination art; withprogressthe line fills and the ship's art rides along;aria-hidden, place names drawn with CSScontentso 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;GoalsCardrows with check discs, reward amounts and a glowing Claim;LoanCardwith an emeraldMeterand 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 fromsm): 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 onSheet(route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells).PairOption.modelSpriteadded.Sheetnow portals tobody: the screens' rise-in transform made the sheet'sfixedoverlay 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 cyanMeterwith percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker onSheet, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns frommd) 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 onSheet, 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 aprogressbar: 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 amberMeterthat 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 insetdl, 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 frommd). 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 onSheet("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 lastslate-*classes are gone fromapps/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}.pngand10e9-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) andboardOpenSlotsPerBand(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 keepboardPreviewSlots(their first is guaranteed), freed slots go to flyable bands largest-first round-robin, and flyable bands are fit slots but forboardOpenSlotsPerBandopen ones.fitJobpicks 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;locationBoardpasses it asfleetwhen 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 simvalidateDispatchfor 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.ts100 % 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, optionaltier1–5,priorityEligible, optionalpriorityName,producedAt,demandedAt; min ≤ max refinement) anddata/goods.tswith all 29 goods from CR-007 §3, plusgoodsProducedAt/goodsDemandedAt. Locations:produces/demandsnow derive from the catalog (good ids); the old class lists are kept ascargoClassMix(decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet;shippersadded (≥ 1 each). Config:priorityChance0.15,priorityPayoutMultiplier1.5,priorityReferenceShipModelId"freighter",priorityWindowFactor1. 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.
ContractgainsgoodId,shipper,priority,deliveryWindowGameHours(null unless Priority); the APIcontractSchemamirrors 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 × goodpayMultiplier× tierMultiplier^tier × mass × distance × first-contact × Priority bonus.buildContracttakes 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.SimContentgainsgoods. 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). ThenchooseGood(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 withpriorityChance; 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;chooseGoodoverlap, 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-slowjoinsDISPATCH_FAILURESafter the route checks: a Priority contract fails whencontractTripGameHours(cruise + overhead + handling, distance at departure; exported) exceedsdeliveryWindowGameHours. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list).priorityWindowexported. Tests:priority.test.ts(Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler failstoo-slow; the trip is measured like the window; a normal contract never fails as too slow);board-fleet.test.tsnow 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) carriesgoodId,shipper,priority,deliveryWindowGameHours;ResolutionSummarygainsgoodId,massTonnes,shipper(null for a deadhead);too-slowis an API error code via the sim's list. Contracts are JSONB, so no columns change: migration0005_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.mdupdated. 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.tsunchanged, 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 withtoo-slowand 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 itspriorityName, 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 simcontractTripGameHours),formatWindow,realTimeOf. Location board cards, top to bottom: route and payout; the good as anh3headline 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.tsxupdated to the new card. - Step 10G.9 — Client: Priority in the dispatch sheet.
explainProblemsadds "Too slow — 7h vs 5h window" (simcontractTripGameHoursvs the window) so a slow pair is disabled with the numbers;PairOption.prioritycarries 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; newtoLocationgives "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
contractPayVariance0.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 ±contractPayVarianceuniformly as the last draw of every board contract (fit and normal);buildContracttakespayRate(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 (localStoragekeydeadhead: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 arole="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 clearslocalStorageafter 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;slotGoodsswaps 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,statfromSHIP_STATS,percentPerLevel,maxLevel, optionalmodelOverrides) anddata/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). ConfigupgradeLevels: 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 ownhandlingGameHoursPerTonne).ShipState.upgrades(optional) andshipStats(ship, content)feed every check invalidateDispatch,validateRepositionand dispatch records (capacity, range, travel time, Priority window).travelTimeGameHoursuses the ship's handling time when given. Board fit uses each fleet member's effective capacity (FleetMember.upgrades).SimContentgainsupgrades. 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)andvalidateUpgradewithUPGRADE_FAILURESin 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 failship-in-shopright aftership-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 onshipsin_shop_until,shop_upgrade_id,shop_level. APIShipgainsupgrades,stats(effective),inShopUntil,shop; one server mappertoApiShip(player state and purchases). RoutestartUpgrade(POST /api/ships/:shipId/upgrades). Upgrade failure reasons join the API error codes.acknowledgeSummariestakes optionalshipIds. Web test helpershipExtras(modelId). Dev database migrated. - Step 10I.5 — Server: start upgrade.
startUpgrade(game/upgrades.ts) runs insidewithPlayer(company row lock): simvalidateUpgrade(at an HQ = a bay where the ship is), charge, upsertship_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-hqat 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'seffectiveStats(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 callsstartUpgrade. 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.tsshipStatsOf(ship)(simeffectiveStatsfrom model + upgrades) andsimShip(ship)(simShipStatewith 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
shipIdswithacknowledgeSummaries, 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 inupgrades.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 withship-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
pilotProfitShare0.25 (a fraction 0–1); the flatpilotPayPerDeliveryis removed in 10J.2 when the sim switches to the share.veteranPilotSchemaandcontent.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.tspilotShare(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.pilotPayrecords it. The flatpilotPayPerDeliveryis 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 fromcontent.veteranPilot.name), conditionveteran-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.acceptDealinserts 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. APIPilot.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. APIResolutionSummary.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 underdeadhead: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", newgearicon) 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 whenimport.meta.env.PRODis false. Remaining items arrive with their steps. Test:settings.test.tsx. - Step 10K.3 — Client: display options applied everywhere.
clock/timeFormat.ts:formatTimeOfDayandformatGameTimefollow the clock preference (header and HQ game clock, dispatch ETAs, map flight panel, "resting until");formatSpan/formatGameHoursSpanfollow the countdown preference ("4m 16s", "4.3 game h" or both) and driveCountdown(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, andmapMotionStylezeroes 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 routerenameCompany(POST /api/company/name,{ name }→{ company }), run inwithPlayer. 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) runsnewCompanyinwithPlayer(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 callscreateStartingState, 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'scompanyNameSchema(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 callsnewCompany, 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) andbackup_code_created_at; tableplayer_logins(extra sign-ins per player).game/backup.ts: 20-character codes from23456789ABCDEFGHJKMNPQRSTUVWXYZin 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-failedfor a bad code,429 rate-limited). Session lookup falls back toplayer_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 sendsx-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 gainsplayerId. Test:about.test.tsx. - Step 10K.11 — Client and server: developer tools (non-production only). Catalog routes
devAdvanceClock(POST /api/test/advance-clock,{ advanceMs }) anddevAddCredits(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) anddev-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 optionalShipModel.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); archetypeslighter,clipper. ConfignoStrandMarginGameHours48. Manifest listsship-lighter/ship-clipperas 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'slaunchRangeFactor;checkRoutecompares 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 plusnoStrandMarginGameHours(48), can the ship reach any other discovered location from the destination with its range from there?validateDispatch(new optionaldiscoveredIds; absent = all) andvalidateRepositionfail withwould-strandafter 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; simupgrade-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.tsnow 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-strandis inERROR_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 409would-strandand 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, simcheckRoute): "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 (reachersByOrigininmap/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.tsxnow expects "From Earth: …" for a tutorial player. - Step 10L.9 — Client: stranding reason. The dispatch sheet passes the player's discovered ids to
validateDispatchand 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.wouldStrandnow 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.pngandship-clipper.png(inpublic/assets/map/) are now in the manifest;pnpm --filter @deadhead/web map-artwrote their @1x/@2x sprites tomap-gen(the other outputs regenerated byte-identical). The source-transparency test allows corner alpha up to 2/255, becauseship-lighter.pnghas 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 bydeal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, and the loan is computed bydealLoanAmount({ config, shipModels, locations })(packages/content/src/deal.ts), exposed ascontent.dealLoanAmount: 4,000 + 61,600 + 1,000 = 66,600 cr. All users (server deal, loan card, deal modal, tests) readcontent.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" (conditionmarket-ships-bought1) and goal 4priority-contract"Complete a Priority contract" (priority-deliveries1, any pilot); theveteran-contractscondition is gone.GoalProgressgainsmarketShipsBoughtandpriorityDeliveries(dropsveteranContracts). 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.
acceptDealreadsconfig.dealand 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-pwa2.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 inindex.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 inassets/map/is excluded;/apiis never served from the cache. Icons come fromscripts/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
offlineevent) 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'sonlineevent refetches and clears it.ButtongainsneedsServer, 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) checkuseOnline. The away summary and deal modal wait until back online. Display and help settings still work. Shared fixtures moved totest/fixtures.tsfor 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) andtest/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;needsServerbuttons 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.tschecks, 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 leftpackages/content/test/config.test.ts(content can't import the sim). Also addedtest/first-contact-fit.test.tsfor 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/apiproxied to it; tests run one at a time because the developer clock is shared.e2e/support.tsdrives 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 withpnpm 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(projectdeadhead): Postgres 18 with a named volume, not published, and the server, which waits for Postgres to be healthy. Settings come fromdeploy/.env(deploy/.env.example:PUBLIC_URL, Postgres user/db/password,BETTER_AUTH_SECRET, log level)..dockerignorekeeps local state and the source art out of builds. Test:deploy/check.shbuilds and starts the stack under a throwaway project with generated secrets, waits until it is healthy, checks/api/healthand/api/timefrom inside the server container, and removes everything (passes, ~25 s with a warm cache). - Step 13.2 — Deploy the client.
deploy/web.Dockerfilebuilds the web app (vite buildfrom the committed map art,APP_BUILDfor the About screen's build) into a Caddy image.deploy/Caddyfile: one site atSITE_ADDRESS(the domain, so Let's Encrypt HTTPS;:80for plain HTTP),/api/*proxied to the server, everything else from the build with client routes falling back toindex.html; Vite's hashed bundles and fonts cached for a year, the shell, service worker and manifest never. Compose gains thewebservice (ports fromHTTP_PORT/HTTPS_PORT, certificate volumes).deploy/README.mdcovers DNS, port forwarding, settings, start/update and a LAN-only setup. Test:deploy/check.shnow also checks, through Caddy from the host: the app shell, a client route,/apithrough 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
backupservice (the Postgres 18 image, scripts indeploy/backup/):schedule.shrunsbackup.shdaily atBACKUP_HOUR:00 (TZ), which writesdeadhead-<UTC stamp>.dump(pg_dumpcustom format, via a.partialname) intoBACKUP_DIR(a host folder, e.g. a NAS mount) and deletes dumps older thanBACKUP_KEEP_DAYS(default 14).restore.sh [dump] [database]loads a dump (default: the newest, into a newdeadhead_restoredatabase). README: backups, a test restore, and recovering the live database. Test:deploy/check.shtakes 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: serverdev-tools.test.ts(each action, move refused to an undiscovered place, all routes 404 in production), webdev-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).Clockinterface (now()) andDeviceClock: device time (injected,Date.nowby default) plus a developer offset, clamped to the last time it handed out;lastTimeSeenfor 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, plussaveVersion,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, fixturesave-fixture.tsreused later): a fully populated save validates; a wrong field is rejected namingcompany.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 aSaveBackendthat stores text in slotsmain,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 asrestored-from-backupwith the problem; nothing valid →unreadable. Backends:MemoryBackend,IndexedDbBackend(one object store, handed anIDBFactory),FilesystemBackend(one JSON file per slot in the app data directory, written to a.tmpfile 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 viafake-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:EngineCoreruns everyread/writethrough 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 withlastTimeSeen, 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 withGameLockedError;reload()drops the cache after another tab had the game;cryptoRandomgives ids (randomUUID) and 32-bit world seeds (getRandomValues).src/lock.ts:TabLockovernavigator.locks(ifAvailableto take a free game,stealfor "Use here",onLostwhen 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;lastTimeSeenfollows 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 afterreload. 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'swithPlayerdid, 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 areCommandErrors with the server's codes; no company yet answersunauthorized, as before. Developer commands only withdevTools; 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'slastArrivalAt, so pruning never changes progress; configboardRetentionPeriods(24, from the server's cleanup job) andsavedDispatchRetentionRealHours(168). Tests: 33 server test files ported topackages/engine/test/ported/overtest/support.ts(the server helpers' names and shapes, withcallrouting old endpoint paths to commands and answering with the server's status codes, andedit/readSave/addShip/addPilotin 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.tschecks that every command has an engine test. -
Step 14.6 — Resolution loop.
packages/engine/src/loop.ts:ResolutionLooprunsGameEngine.resolveDueonce on start (the app has just loaded), on every resume and every 5 seconds (RESOLUTION_INTERVAL_MS), never two passes at once, and callsonChangewhen an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injectedLoopHost(the browser'svisibilitychangeand Capacitor'sresumeare wired in 14.7).resolveDueuses 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.tsbuilds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), theDeviceClock, the Web LocksTabLock, and theGameEngine(developer commands in development builds and invite build --mode e2etest 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 typedApiClientover engine commands, refusals turned into the sameApiErrors (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 andvisibilitychange; a change refetches state and boards), and exposeswindow.deadhead.callin 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,needsServerbuttons, 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 withbuild: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" runscreateGuest, 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), andNotificationSync, which replaces the platform's whole schedule from the save after every saved change (the engine core'safterSavehook; a failed sync never fails a command), so moved or cancelled events and a new company clear themselves.GameEnginetakes anotificationsoption and addsresyncNotifications()andrequestNotificationPermission(). Web:src/platform/notifications.tsuses CapacitorLocalNotificationson iOS and Android (cancel pending, schedule if permission is granted) and the no-op scheduler on the web;createGamewires it with the newnotificationspreference (default on) and resyncs when it changes; the engine client asks for permission right afteracceptDealsucceeds; 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 viaCompressionStream, base64url, behindDEADHEAD1.) anddecodeSave(whitespace from pasting ignored; a wrong prefix, a damaged code or a non-save each give a readable error; migrates throughmigrateSave). New engine commands in the catalog:exportSave({ code }) andimportSave({ code }→{ playerId, state }, no company needed; due arrivals resolved on import; refusals areinvalid-saveand leave the save as it was; the replaced company stays in the storage backups).GameEnginetakes amigrateoption (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 nameddeadhead-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.tsxand its test are gone (the server's backup routes stay until 14.11). Copy debug info:Save,Save version,Storage(via a newGameContext),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.tsxupdated. Developer tools were already covered (engine refuses developer commands withoutdevTools; 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.Dockerfileanddeploy/backup/. The lockfile has no server packages left. Schema: the backup routes and their schemas, and therestore-failedandrate-limitedcodes, are gone;authon a route now means "needs a company". Engine:SERVER_ONLY_ROUTESis gone;CommandNameis every catalog route. Web: the HTTP client (createApiClient,ApiValidationError, the device id) and its test are gone;src/api/client.tskeepsApiClientandApiError; Vite no longer proxies/apior keeps it out of the service worker. CI has no database service.deploy/is static hosting: Caddy alone (compose.ymlwith onewebservice; the Caddyfile serves files only;.env.exampleisSITE_ADDRESSand ports;check.shchecks shell, client routes, caching and compression; README rewritten). The web Dockerfile now copies the engine package's manifest too (missing since 14.7).build:e2ealso makes the production build indist-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 nowindow.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 →saveVersionbump, 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.mdrewritten 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:
- Open the app; start a company or import a save; get past the deal (accept notifications when asked, native build).
- Dispatch a contract; note its arrival time.
- Close the app completely and turn on airplane mode.
- Wait past the arrival (native build: a notification should arrive at the arrival time while the app is closed).
- 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.
- 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) ande2e/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
bodyand gainposition({ 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 asorbitParent: { locationId: "earth", offsetMillionKm: 0.384 }. GameConfig's epoch keeps only a displayed start date (gameStart, wasephemerisDate; same value).astronomy-engineis removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim'slocationPositionalready reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (bodyPositionis gone; the Moon is placed fromorbitParent). 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; noastronomy-enginein the lockfile). Date-sampled tests became yes/no:reach-table.test.tsis 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), andwouldStrand(model, destination, discoveredIds, content)(it had taken cargo mass and departure only to find where planets would be on arrival).dispatchPositionruns along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig'snoStrandMarginGameHoursis gone (nothing moves in 48 game hours now). Web:distanceAt,tripRealMs,locationMapPoint,reachersByOrigin,tooSlowForAllandshipReachlose their time argument;shipReachchecks each leg once instead of sampling 16 game years (itsotherDatesis 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.tsper the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone),positions.test.ts("never moves"), newstatic-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.tsnow checks that no base ship is ever blocked on a leg it can fly;range-fromandupgrade-capslose their date loops. - Step 15.3 — Content: fuel. ShipModel:
fuelRangeMillionKmandfuelCostPerMillionKmreplaced bytankCapacityFuandburnFuPerMillionKm(Courier 40/20, Lighter 110/35, Clipper 1,170/45, Freighter 1,540/40, Bulk Hauler 5,900/80, loaner 40/20). Location:launchCostMultiplierreplaced byfuelPrice(× base: Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6) andsellsFuel(all true). GameConfig:baseFuelPricePerFu10,emergencyFuelMultiplier2.5. Extended Fuel Tanks raisestankCapacityFu: 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;rangeFromandcheckRoutenow 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. APIstatscarrytankCapacityFuandburnFuPerMillionKminstead 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-capsand the content distinctness test in tank form;ships.test.ts(the Clipper burns most per M km). Also removed four emptyimport {}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 carrytankinShipState; dispatch and deadhead validation fail withnot-enough-fuel(replacinginsufficient-credits) when fuel on board is short; proposals no longer take credits. Records gainfuelBurnedFu;fuelCostis that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none.tripFuelCostbecametripFuel(fu). Resolution takes an optionalfuelblock (tank, capacity, policy, credits) and returnstopUp(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 havefuelFu,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 carrytopUp. 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 aswould-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
- Step 15.7 — Client: fuel in the UI
- Step 15.8 — Client: fixed map
- Step 15.9 — Plan amendments and regression