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

176 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |
| `markLogbookSeen` | `POST /api/logbook/seen` | `entryIds` | `{ marked }` (entries newly seen) | yes; not a manual decision; unknown ids ignored (CR-028) |
| `answerRadioCall` | `POST /api/dispatches/:dispatchId/radio` | `dispatchId`, `optionId` | `{ radioCalls }` (still waiting) | yes; a manual decision; `radio-call-not-found` before it fires, `radio-call-closed` after its window or once applied, `radio-option-unavailable` (CR-026) |
| `repairShip` | `POST /api/ships/:shipId/repair` | `shipId` | `{ ship }` (in the shop) | yes; a manual decision; at an HQ, below 100% (`hull-intact`), charged now (CR-026) |
| `bookService` | `POST /api/ships/:shipId/service` | `shipId`, `repair`, `upgradeId` (or null), `hqId` (optional: the nearest reachable HQ) | `{ ship }` (with its `booking`) | yes; a manual decision; replaces an unstarted booking; starts at once if idle at the HQ; `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started` (CR-027) |
| `cancelBooking` | `POST /api/ships/:shipId/service/cancel` | `shipId` | `{ ship }` | yes; a manual decision; `no-booking`, `service-started` (CR-027) |
| `setAutoRepair` | `POST /api/ships/:shipId/auto-repair` | `shipId`, `on`, `thresholdPct` (10–90, steps of 5) | `{ ship }` | yes; a manual decision; `validation-error` off the steps (CR-027) |
| `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 13; 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; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026; 11 → 12 added ship `booking` (null) and `autoRepair` (on, 40%), CR-027; 12 → 13 added logbook entries' `seen`, true for existing entries, CR-028), `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 | Kinds since CR-016: `rocks` (a sheet sliced into `pieces` sprites, `belt-rocks-<n>`) and `strip` (`belt-haze`).
| `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`<id>@1x.png`, `<id>@2x.png`, `bg.webp`) from the manifest with sharp; output committed | `scripts/slice.mjs` (CR-016) cuts a sheet into one sprite per connected region (alpha ≥ 16).
| `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) | CR-016: `labelBox`, `planLabels` (folds a crowded parent/child family into the parent's label with a "+N" marker; the Jupiter system below zoom 2.5) and `markerAt`.
| `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 each radio call to come on a trip the player sent (`upcomingRadioCalls`, CR-026), and the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024).
Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off" / "defaults" / "best"), `eventsFromMinute`, `firstEventDone` and `longHaulMinGameHours`; results `events` (trips, events, good, radio, repair spend, window counts, hull cost, and the events' worth with defaults and with the best answers).
`packages/sim/src/events.ts` (CR-026): `rollTripEvent` rolls a trip's event from its seed at dispatch (null when quiet): fire time, window end, every option's outcome, availability and the default; `eventChance` and `eventWeight` hold the pacing and conditions; `applyEventOption` gives the `AppliedEvent` (new arrival, hull loss, pay changes, Priority bonus lost, extra XP and fuel); `payoutAfterEvent` and `resolveDispatch`'s `event` input apply it to the delivery.
`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/events.ts` (CR-026): `rollForDispatch` rolls a contract's or deadhead's event at dispatch and keeps the pacing counters (ship `quietTrips`, company `tripsSinceDeal` and `firstEventDone`); `applyTripEvent` applies an option (new arrival on the dispatch and its record, hull hit, extra fuel, an `event` logbook entry); catch-up settles due events (`nextEventDue`: an automatic event at its fire time, an unanswered radio call at its window end, before anything else at that moment); `radioCalls` lists the waiting calls in `PlayerState.radioCalls`; `answerRadioCall` and `repairShip` are the commands; `finishRepair` ends a repair at its shop end (hull 100%, a `repair` entry). Resolution passes the applied event to the sim, so pay and XP change.
`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.
`packages/engine/src/game/service.ts` (CR-027): ship `booking` and `autoRepair`. `bookService`, `cancelBooking` and `setAutoRepair` are the commands; `startDueServices` runs after every command and every catch-up event (start, trip events, shop ends, arrivals, decisions) and starts each booked ship idle at its HQ (`tryStartService`: the charge, the upgrade level, the shop fields, `shopPilotId` when the stay covers a rest, or `waitingSince` when it can't pay); `finishShop` calls `finishService` to clear it; `autoRepairAfterDamage` runs after event damage, landing wear and the dev tool. The ship API adds `booking` (with an estimate and a status), `autoRepair` and `repairRecommended`. Logbook kind `service`.
Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep.
Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`.
Presence (CR-028 Step 28.2): `apps/web/src/platform/presence.ts` keeps `usePresence` (here, `awaySince`, `lastReturn`, `returns`) from the tab's visibility or Capacitor's app state, in game-clock time. `GameRoot` starts it with the stored save's `lastTimeSeen` (read before the resolution loop's first command), so opening the game is a return from then. Client-only settings (toast timing, the short-absence threshold) live in `apps/web/src/client-config.ts`.
Toasts (CR-028 Step 28.3): `apps/web/src/state/toasts.ts` (`useToasts`: push, dismiss, drop the overflow; batch toasts merge within `batchWindowMs`), `components/Toasts.tsx` (the stack) and `components/ToastFeed.tsx` (`entryToasts` builds toasts from logbook entries; `ToastFeed` reads `getLogbook` after each player-state refresh while here, toasts unseen entries stamped since `hereSince` and marks them seen with `markLogbookSeen`; it also toasts a booked service newly out of reach and the fleet going quiet). Both are mounted in `App`.
The away pop-up (CR-028 Step 28.4): `AwaySummary` runs on each return in `usePresence` (`returns`, `lastReturn`): it reads `getLogbook`, keeps the unseen entries stamped before the return, and shows nothing, toasts (away under `clientConfig.presence.shortAbsenceMs`) or the pop-up, then marks them seen (`markLogbookSeen`) and acknowledges the delivery summaries. `useAwayOpen` tells the other sheets to wait. `ToastFeed` acknowledges the summaries of what it toasted. Presence keeps the time the player went away in local storage (`deadhead:away-since`) for a cold start.