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

24 KiB
Raw Blame History

Architecture

Living reference for how the code is organised. Update it after any save format or command change (implementation plan rule 0.1.9). Decisions and their reasons live in DECISIONS.md.

Packages

Package Role Depends on
packages/content Static game data (locations, ship models, cargo, skills, GameConfig) as Zod-validated TypeScript, plus the content bundle with id lookups. zod
packages/sim Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. Completable boards (CR-019, completable.ts): permanentFailure/completability/isCompletable check a contract against ship-and-pilot capability snapshots (FleetMember: model, upgrades, skills, fuel); qualifyingFleet picks the pairs for a location; generateContractBoard with a fleet keeps only completable candidates (up to boardMaxAttempts). No I/O, no Date.now(), no Math.random(). balance/ holds the balance simulation (Step 12.1), outside src/ and the coverage measure. Positions are fixed (CR-015): locationPosition reads the location's position from content. Fuel (fuel.ts, CR-015): legFuel = burn × economic distance(distance ÷ origin factor), fullTankReach (its inverse for a full tank), fuelPriceAt; checkRoute(ship, from, to, config) compares the distance with full-tank reach. Tanks (Step 15.4): ShipState.tank (fuelFu, avgPrice), refuel, affordableRefuel, legFuelCost; validation fails with not-enough-fuel; records carry fuelBurnedFu and fuelCost (burned × average price); resolveDispatch takes an optional fuel block and returns topUp (Auto top-up on landing; new ships are on Manual since CR-018). balance/fuel-share.ts (CR-018): each ship's fuel share of the median payout per route. wouldStrand(model, destination, fuelOnArrivalFu, discovered, content) uses a full tank where the destination sells fuel, else the fuel left (Step 15.5); emergencyFuel gives the offer (amount, price, cost) or null. content
packages/engine The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. notifications.ts: the schedule is recomputed from the save after every saved change and replaces the platform's (NotificationScheduler: Capacitor on native, no-op on the web). DeviceClock: max(device time + dev offset, last time seen), never backward. content, schema, sim
packages/schema Zod schemas for every API request, response and error. Shared by the engine and the client. sim (error codes), zod
apps/web React client; runs the engine on the device (CR-014). content, engine, sim, schema

Conventions

  • Times are Unix milliseconds (numbers). A real time is a wall-clock instant; a game time is the instant of the in-game date (realToGame / gameToReal in sim).
  • Ids are non-empty strings. Content ids are kebab-case (earth, bulk-hauler).
  • Credits are whole numbers.
  • Static vs. player data: commands never return static data. The client imports @deadhead/content and joins by id.

API data model (packages/schema)

Shape Fields
ServerTime realTime, gameTime
Company id, name, credits, loanBalance
Ship id, modelId, name, status (idle / in-flight), locationId (null in flight), dispatchId (null when idle), pilotId, lastArrivalAt (latest resolved arrival, else delivery time; picks the default Contracts location), upgrades ({ upgradeId: level }), stats (effective: speed, capacity, tank capacity and burn rate (CR-015), overhead, handling per tonne), inShopUntil (null unless in the shop now), shop ({ upgradeId, level, until } of the latest job until acknowledged; CR-009)
Pilot id, name, isAvatar, isVeteran (the deal's veteran, CR-010), xp, level, skills ({ skillId: level }), skillPointsAvailable, assignedShipId, status, locationId (null in flight), dutyHours, dutyLimit, resting, restEndsAt (null unless resting; CR-004), restingInShopUntil (waiting with their ship in the shop, which counts as rest: when the shop time ends, else null; CR-023)
Bay id, locationId
LocationState id, discovered
Contract mirrors sim Contract: route, cargoTypeId (class), goodId, shipper, priority, deliveryWindowGameHours (Priority only, else null), tier, massTonnes (to 0.1 t under 10 t), payout, requirements, offeredAt/expiresAt, firstContact, optional scriptedId on a scripted tutorial contract (CR-007)
Dispatch mirrors sim DispatchRecord + status (in-flight / resolved), resolvedAt
ResolutionSummary dispatchId, shipId, pilotId, originId, destinationId, tutorial, deadhead, fuelCost, goodId / massTonnes / shipper (null for a deadhead, CR-007), credits (net), loanRepayment, pilotPay (hired pilot's profit share, CR-010), pilotXp, discoveredLocationId, scriptedId, restEndsAt (set when the trip ended in mandatory rest), resolvedAt
TutorialState loanerLocationId (earth / moon), runInFlight, runsCompleted, dealAvailable
GoalState id, status (incomplete / ready / claimed)
PlayerState playerId (CR-011), company, ships, pilots, bays, locations, tutorial (null once over), activeDispatches, summaries, goals (empty during the tutorial), fleetSleep (afterHours, sleepsAt, asleep; CR-024)

Errors

Every refusal carries { code, message }: the engine's CommandError, which the client turns into an ApiError with the HTTP status the old server sent. code is one of ERROR_CODES: every sim dispatch and reposition validation reason (DISPATCH_FAILURES, REPOSITION_FAILURES), unchanged (CR-007 adds too-slow: the ship can't make a Priority contract's delivery window; CR-012 adds would-strand: from the destination, at arrival plus noStrandMarginGameHours, the ship couldn't reach any other discovered location), plus the server codes in SERVER_ERROR_CODES (validation-error, unauthorized, not-found, insufficient-funds, ...).

API routes (packages/schema/src/routes.ts)

The routes catalog is the list of engine commands (CR-014). Each entry has request (path parameters merged with the body), response, auth (whether it needs a company) and manualDecision (CR-024: whether it is one of the player's decisions; required, so every new command declares it). After a successful manualDecision command the engine sets company.lastManualDecisionAt to now; the fleet sleeps a threshold after it. method and path are kept from the server's HTTP API: ported tests route by them.

Name Method & path Request Response Auth
getServerTime GET /api/time — ServerTime no
createGuest POST /api/guest — { playerId, state: PlayerState } (playerId is the save id) no
getPlayerState GET /api/state — PlayerState yes
buyDispatchOffice / setSpendingFloor POST /api/company/dispatch-office, /api/company/spending-floor {} / credits { company, dispatchOffice } (CR-021) yes
setPilotMode / setPilotOrders POST /api/pilots/:pilotId/mode, /orders pilotId, mode / orders { pilot } (CR-021) yes
getContractBoard GET /api/locations/:locationId/board locationId { contracts, periodEndsAt } (only jobs the qualifying fleet can complete, CR-019; Independent pilots and their ships don't count, decision 0.5.133) yes
getTutorialBoard GET /api/tutorial/board — { contracts, periodEndsAt } yes
createDispatch POST /api/dispatches { kind: "contract", contractId, shipId, pilotId } / { kind: "tutorial", contractId } / { kind: "reposition", shipId, pilotId, destinationId } { dispatch } yes
startUpgrade POST /api/ships/:shipId/upgrades shipId, upgradeId (next level; ship idle at an HQ) { ship } yes
buyShip POST /api/ships modelId, bayId (one of the player's bays; the ship is delivered there) { ship } yes
leaseBay POST /api/bays locationId { bay } yes
hirePilot POST /api/pilots locationId (a player bay) { pilot } yes
assignPilot POST /api/pilots/:pilotId/assign pilotId, shipId { pilot } yes
unassignPilot POST /api/pilots/:pilotId/unassign pilotId { pilot } yes
restPilot POST /api/pilots/:pilotId/rest pilotId (idle, not resting) { pilot } yes
spendSkillPoint POST /api/pilots/:pilotId/skills pilotId, skillId { pilot } yes
acknowledgeSummaries POST /api/summaries/acknowledge dispatchIds, optional shipIds (finished upgrades seen; at least one id overall) { acknowledged } yes
acceptDeal POST /api/deal/accept — PlayerState yes
setSleepThreshold POST /api/fleet/sleep-after hours (one of fleetSleep.optionsHours, else validation-error) PlayerState yes; a manual decision (CR-024)
getLogbook GET /api/logbook — { entries, absence: { from, to }, lastAbsence, problems } (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) yes
backToWork POST /api/fleet/wake — PlayerState (awake) yes; a manual decision that does nothing else: wakes the fleet (CR-024)
exportSave engine only (CR-014) — { code }: DEADHEAD1. + base64url(gzip(save JSON)) yes
importSave engine only (CR-014) code (whitespace ignored) { playerId, state }; invalid-save if it isn't a good save no
devAdvanceClock POST /api/test/advance-clock advanceMs { offsetMs, realTime } yes; developer builds only
devAddCredits POST /api/test/add-credits amount { company } yes; developer builds only
devPilot POST /api/test/pilot pilotId, action: level-up (XP to the next level) / clear-rest (duty 0, rest ended) PlayerState yes; developer builds only
devShip POST /api/test/ship shipId, action: max-upgrades / reset-upgrades / finish-shop / move (+ locationId, idle ship, discovered place) PlayerState yes; developer builds only
devDiscoverAll POST /api/test/discover-all — PlayerState yes; developer builds only
newCompany POST /api/company/new — PlayerState (the fresh start) yes
renameCompany POST /api/company/name name (trimmed, 3–32 letters, digits, spaces, . , ' & ! ? -) { company } yes
repayLoan POST /api/loan/repay amount { company } yes
refuelShip POST /api/ships/:shipId/refuel shipId, optional amountFu (absent fills) { ship } yes
setRefuelPolicy POST /api/ships/:shipId/refuel-policy shipId, policy (auto / manual) { ship } yes
orderEmergencyFuel POST /api/ships/:shipId/emergency-fuel shipId { ship }; cost added to the loan yes
claimGoal POST /api/goals/:goalId/claim goalId { company } yes

Save document (packages/schema/src/save.ts, CR-014)

One document per company on the device, validated on every load and save: saveVersion (currently 10; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot mode and orders and the company's dispatchOffice and npcSpendingFloor, CR-021; 5 → 6 added pilot nextDecisionAt and emptyPeriods and dispatch independent, CR-021; 6 → 7 added player.welcomeSeen, true for existing companies, CR-022; 7 → 8 added ship shopPilotId, null for existing ships, CR-023; 8 → 9 added company lastManualDecisionAt (null until the engine's first catch-up sets it) and sleepAfterHours, CR-024; 9 → 10 added the logbook, player awaySince, lastAbsence and readyGoalsLogged, and ship shopOpen, turning resolved dispatches into entries, CR-025), saveId, lastTimeSeen (the clock never goes below it), player (world seed, tutorial state, deal accepted), company (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), ships (with upgrades as a level map, shop fields including shopPilotId (the pilot waiting out the shop as rest, CR-023), and fuelFu, fuelAvgPrice, refuelPolicy since CR-015), pilots, bays, discoveredLocations, contractBoards (stored boards), dispatches (sim record, status, resolution, acknowledged), acceptedContracts, claimedGoals, logbook (CR-025: entries of kind flight (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), refuel (top-up, bought, pilot or emergency), rest, upgrade, discovery, level-up and goal-ready, each with time, pilot and ship; ids made from what they record; kept logbookRetentionRealDays (7) and pruned on save). Written by packages/engine/src/game/logbook.ts helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every shopOpen ship), and goals becoming ready (after every catch-up event and command). It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. migrateSave(raw) steps an older save through SAVE_MIGRATIONS (applyMigrations) and validates it, returning a readable error instead of throwing.

Commands (packages/engine/src/game/): GameEngine.call(name, input) runs the route catalog's endpoints on the save, with the same schemas and error codes (CommandError); getPlayerState with no save answers unauthorized. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. stats keeps lifetime delivery counts for goals, so seen trips older than savedDispatchRetentionRealHours can be pruned.

Resolution loop (packages/engine/src/loop.ts): on load, on resume and every 5 s, resolveDue applies due arrivals (saving only if any) and reports rests and shop jobs that ended, so the app refreshes; timer and resume events come from a LoopHost.

Storage (packages/engine/src/storage.ts): SaveStorage keeps the save in slot main and the previous three in backup-1..3 (rotated on every save), falling back to the newest valid backup if the main save is unreadable. Backends store text per slot, all-or-nothing: MemoryBackend (tests), IndexedDbBackend (web, database deadhead, store saves), FilesystemBackend (native, deadhead/<slot>.json in the Capacitor data directory, temp file then rename).

The server (removed)

Until Step 14.11 a Fastify API with Postgres (Drizzle), Better Auth guest sessions and pg-boss jobs ran the game; the engine is a port of its game logic and its tests. Its code and this document's sections on it are in the git history before the commit "Step 14.11".

Deployment (deploy/)

File Role
compose.yml One service (project deadhead): web, Caddy serving plain HTTP on container port 80, published on HTTP_PORT (default 8080) for Nginx Proxy Manager to forward to
web.Dockerfile, Caddyfile Caddy image with the built app on :80: client routes fall back to index.html, hashed assets cached for a year, shell, service worker and manifest never; HTTPS is NPM's
README.md Setup behind Nginx Proxy Manager, start/update, other static hosts
.env.example HTTP_PORT; copy to deploy/.env
check.sh Builds and starts the site under a throwaway project, checks shell, routes, caching and compression, removes it

Client (apps/web)

Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs in the page; there is no API to call.

Module Role
src/api/client.ts The ApiClient interface (typed by the route catalog) and ApiError; src/api/engine-client.ts implements it over the engine. Its optional notificationPrompt() settles once the permission prompt asked for after the deal is answered (CR-017)
src/api/context.tsx ApiProvider / useApi (tests inject a fake)
src/clock/clock.ts Zustand useClock: offset to the engine clock (which includes the developer offset), serverNow(), 1 s ticker
src/state/player.ts usePlayerState (TanStack Query, key player-state): null means no company on this device; refetch on focus and on arrival
src/App.tsx Clock sync; loading / start screen / routed layout
src/layout/Layout.tsx Header (credits, game clock), screen outlet, bottom tab bar
src/routes.ts Screen list shared by router and navigation
src/map/art/manifest.json Map art manifest (CR-003): asset id → { kind, file } in public/assets/map/ (never edited); null file = missing, placeholder used
scripts/map-art.mjs Generates public/assets/map-gen/ (<id>@1x.png, <id>@2x.png, bg.webp) from the manifest with sharp; output committed
src/map/style.ts Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by mapStyleSchema
src/map/art/load.ts, art/visuals.ts Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41)
src/map/layout.ts Pure screen layout: backgroundRect, spriteScale, childOffset, layoutScene (centres, sizes, tap targets; used by renderer and hit test), decorate (label and badge placement)
src/map/belt.ts The asteroid belt (CR-016, visual only): radii on the compressed scale, ringGeometry (the haze mesh, built once in map units), hazeStripPixels/hazeSource (generated haze unless belt-haze.png exists), beltRocks (220 seeded rocks) and placeRocks (screen placement, clear of Ceres)
src/map/overlays.ts Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer
src/map/ticker.ts runWhileVisible: pause the map's ticker while the page is hidden; stop it on unmount
src/map/renderer.ts MapRenderer: all PixiJS drawing for the map, layer by layer; MapCanvas owns the app, input and loading state
vite.config.ts (PWA) vite-plugin-pwa (Step 11.1): manifest, generateSW precache of the shell, woff2 fonts, map-gen art and icons (public/icons, from scripts/icons.mjs)
e2e/*.e2e.ts Playwright tests against vite preview of two builds made by build:e2e: the test build in dist (developer tools, window.deadhead hook; engine commands run through the hook) and the production build in dist-production (Step 14.11: loads only its own files)
src/state/preferences.ts Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera, notifications on/off (CR-014); local storage, every access wrapped
src/screens/* HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen; Logbook (/logbook, not a tab: from "While you were away" and the Bridge; pilot and range filters in the URL, CR-025)
src/components/* AwaySummary dialog (the highlights pop-up built with buildAwaySummary from getLogbook, CR-025), PilotLogbook (pilotLogbook(state, entries): per pilot, newest first, with totals; the Logbook screen's body), Countdown
src/components/Fuel.tsx Fuel UI (CR-015): FuelGauge, the ShipFuel sheet (refuel, policy, emergency fuel), FuelPrice (cheapest marked), refuelForLeg ("Refuel here first")
src/state/actions.ts useLoadedPlayer, useGameAction(route) (mutation + invalidate state and boards)
src/map/* System map: projection (square-root radial scale), camera (Zustand pan/zoom), scene (locations, flights, idle badges), hitTest, MapCanvas (PixiJS v8, lazy-loaded with the Map screen)
src/game.ts, src/format.ts Sim-based predictions (distance, trip time) and display formatting

Since CR-014 the client runs the game itself: src/platform/game.ts (storage, device clock, tab lock, engine), src/api/engine-client.ts (the ApiClient over engine commands), src/GameRoot.tsx (tab lock screen, resolution loop, the window.deadhead test hook in developer builds). src/platform/notifications.ts picks the notification scheduler; src/platform/context.tsx gives screens the Game (debug info). src/screens/SaveSettings.tsx: Export save and Import save. src/components/SkillPointPrompt.tsx (CR-017): the one-time skill point pop-up (tip id skill-points), shown once the tutorial is over, the permission prompt is answered and some pilot has unspent points.

On the device the client keeps the save (IndexedDB, or files on native) and, in local storage, device preferences, seen board ids and, in developer builds, the developer clock offset. The game works the same with or without a network.

Independent pilots and catch-up (CR-021)

Notifications (packages/engine/src/notifications.ts) also plan the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024).

packages/sim/src/away.ts: buildAwaySummary({ from, to, entries, pilots, problems }) builds "While you were away" from logbook entries (CR-025): duration, totals, ≤3 highlights in priority order with an overflow count, ≤5 pilot lines by net with an overflow line and the idle count, and a direct list for short absences. flightNet is a flight's net. Its LogbookEntry type is the save's LogEntry.

packages/engine/src/game/catchup.ts: catchUp(save, until, { decideUntil, maxEvents, viewedContractId }) replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's nextDecisionAt, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes nextDecisionAt later), then asks the sim's chooseAction about the pilot's board. It acts through the normal refuelShip, dispatchContract and dispatchReposition, with ids made from the pilot and the moment (ind-…), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop maxCatchUpRealDays after the save's lastTimeSeen (decideUntil) and at the fleet's sleep time (sleepsAt in state.ts: company.lastManualDecisionAt plus sleepAfterHours, CR-024), and later ones resume from until, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null lastManualDecisionAt (a save just migrated) is set to until first. Every command runs catchUp to its now before it acts. GameEngine.resolveDue(since, { chunkEvents, onProgress }) runs the loop's pass in chunks (500 events, one save each), with onProgress(0…1) in between. GameEngine.setViewedContract(id) tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged independent, and no notification is scheduled for them.