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

71 KiB
Raw Blame History

Progress

Phase 1 — Repository Setup

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

Phase 2 — Content Package

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

Phase 3 — Simulation Package

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

Phase 4 — Shared Schema Package

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

Phase 5 — Server Foundation

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

Phase 6 — Server Game Logic

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

Phase 7 — Background Workers

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

Phase 8 — Client Foundation

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

Phase 9 — Client Screens

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

Phase 10 — Simple System Map

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

Phase 10A — Faster Start (CR-001)

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

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

Playtest fixes after Phase 10A

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

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

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

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

Phase 10C — Map Art (CR-003)

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

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

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

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

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

Phase 10E — UI Overhaul (CR-005)

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

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

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

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

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

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

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

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

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

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

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

Phase 10I — Ship Upgrades (CR-009)

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

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

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

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

  • Step 10J.1 — Content: config and veteran. Config pilotProfitShare 0.25 (a fraction 0–1); the flat pilotPayPerDelivery is removed in 10J.2 when the sim switches to the share. veteranPilotSchema and content.veteranPilot: Rosa Kell, "Halvorsen's pilot for twelve years.", level 2, Heavy Cargo 1. Test: content/test/veteran.test.ts.
  • Step 10J.2 — Sim: profit-share pay. sim/src/pay.ts pilotShare(payout, fuel) = round(max(0, payout − fuel) × pilotProfitShare). Resolution charges it for hired pilots on contract deliveries (avatar, tutorial runs, deadheads: 0), then takes the loan repayment from what remains; Resolution.pilotPay records it. The flat pilotPayPerDelivery is gone (config, schema, client). The dispatch sheet's net profit uses the share. Test: profit-share.test.ts (400/150 → 63; fuel ≥ payout → 0; exemptions; repayment after pay; credits + pay + repayment = payout over 500 random cases); Step 3.17 tests updated.
  • Step 10J.3 — Sim: goal change
  • Step 10J.4 — Server: veteran on the deal
  • Step 10J.5 — Client: deal modal and pilot card
  • Step 10J.6 — Client: pilot share in dispatch and summaries
  • Step 10J.7 — Plan amendments and regression