69 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