Step 14.12: plan amendments and regression
CI / check (push) Successful in 5m52s Details

Applies CR-014's amendments to the implementation plan (server phases
superseded, client and end-to-end steps amended, Phase 13 replaced by
static hosting) and adds its table for translating later change
requests as plan section 0.6. Rewrites tech-stack.md for the
local-first stack. Phase 14 is complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-09 23:56:38 -04:00
parent 0125af2086
commit 13eb058052
4 changed files with 116 additions and 102 deletions

View File

@ -1423,3 +1423,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The production-build check runs a real production build** (`dist-production`, built by `build:e2e` alongside the test build) rather than the test build with requests blocked: the test build has developer code the production one doesn't, so only the real build shows what players get. It records every request instead of blocking, so a stray request fails the test instead of being quietly aborted.
- **Deploying is static hosting with the same Caddy setup**, minus the API and backups: the domain, HTTPS and caching rules from Phase 13 still hold. Player saves live on devices; Export save is the backup.
- **The web Dockerfile was missing the engine package's manifest** since 14.7 (`pnpm install --filter @deadhead/web...` couldn't link it), found while rewriting `deploy/`; fixed here.
## Step 14.12
- **The plan keeps its server-era steps as written**, with "Superseded", "Replaced" and "Amended by CR-014" notes, the same way earlier change requests amended it, so the record of what was built stays readable and each note points to the step that changed it.
- **CR-014 §7's translation table is plan §0.6**, next to the decisions, because it governs how every later change request (starting with CR-015, Fixed Map and Fuel Tanks) is read.
- **`tech-stack.md` is rewritten, not annotated**: it is a description of the current stack, not a history. The old server stack is one row in "Alternatives considered", with the reason it went.
- **The phone check stays manual** (airplane mode and OS notifications can't be driven from CI); `progress.md` scripts it step by step for the user.

View File

@ -14,8 +14,8 @@ This plan covers the **base game only**: the core loop of taking contracts, disp
2. Each step is **one commit** (or one PR). The commit message names the step number.
3. Every step's test must be **automated** unless the step explicitly says "manual check."
4. `packages/sim` must stay **pure**: no `Date.now()`, no `Math.random()`, no network, no database, no file I/O. Time and seeds are always passed in as arguments.
5. All game numbers (prices, speeds, payouts, XP curves) live in `packages/content`, never hard-coded in sim, server, or client.
6. The server is the authority. The client may *predict* (ETAs, positions, previews) using `sim`, but never decides outcomes.
5. All game numbers (prices, speeds, payouts, XP curves) live in `packages/content`, never hard-coded in sim, server, or client. *(Since CR-014: sim, engine, or client.)*
6. The server is the authority. The client may *predict* (ETAs, positions, previews) using `sim`, but never decides outcomes. *(Amended by CR-014: `packages/engine` is the authority, running on the device; screens decide outcomes only through engine commands.)*
7. If a step is ambiguous, choose the simplest interpretation that satisfies its test, and leave a short note in `DECISIONS.md` at the repo root.
8. Do not add features not listed in a step.
9. Update `progress.md` after every step. Update `architecture.md` after any database schema or API change; the full Drizzle schema lives in `architecture.md`.
@ -149,6 +149,20 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
91. **Local notifications** (CR-014). On iOS and Android the app schedules a local notification for each arrival, each pilot's rest ending and each upgrade finishing, cancelled if the event changes; several in the same minute become one. Permission is asked right after the deal. The web build has none.
92. **Save export/import** (CR-014). Export gives one text code (the save, compressed and encoded) to copy or share as a file; import validates and migrates it and replaces the current company after confirmation.
### 0.6 Translating change requests written for the server (CR-014 §7)
The game has had no server since CR-014. When a later or unfinished change request says:
| It says | Do this instead |
|---|---|
| Server endpoint | Engine command with the same schemas (add it to the route catalog) |
| Database table or column | Field in the save document, with a save migration |
| Database migration | Save migration (bump `saveVersion`) |
| Transaction with row lock | Mutation queue (already serial) |
| pg-boss job or scheduled job | Resolution loop, plus a local notification if the player should hear about it |
| Test-only server endpoint | Developer-only engine command |
| Server integration test | Engine test with in-memory storage |
---
## Phase 1 — Repository Setup
@ -344,6 +358,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
## Phase 5 — Server Foundation
> **Superseded by CR-014:** this phase's game logic and tests were ported to `packages/engine` (Steps 14.1–14.6) and the server was removed (Step 14.11). Kept as the record of what was built.
### Step 5.1 — Fastify skeleton
- Create the Fastify app in `apps/server` with environment config loading, structured logging, and a health endpoint.
- **Test:** An integration test starts the app and the health endpoint returns success.
@ -377,6 +393,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
## Phase 6 — Server Game Logic
> **Superseded by CR-014:** this phase's game logic and tests were ported to `packages/engine` (Steps 14.1–14.6) and the server was removed (Step 14.11). Kept as the record of what was built.
### Step 6.1 — Lazy resolution on read
- When player state is fetched, resolve every in-flight dispatch whose arrival time has passed, in arrival order, inside one database transaction, using the sim resolution function.
- Resolution is idempotent: a dispatch moves from in_flight to resolved exactly once, guarded by a conditional update on status.
@ -450,6 +468,8 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
## Phase 7 — Background Workers
> **Superseded by CR-014:** the jobs' work is done by the resolution loop (Step 14.6), and this phase's logic and tests were ported to `packages/engine` (Steps 14.1–14.6) and the server was removed (Step 14.11). Kept as the record of what was built.
### Step 7.1 — pg-boss setup
- Add pg-boss to the server, running workers in the same process for now, with a setting to disable workers.
- **Test:** An integration test enqueues a no-op job and observes it complete.
@ -476,20 +496,24 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated.
- **Test:** Unit tests with a mocked fetch confirm a valid response is returned typed and an invalid response raises a validation error.
- **Amended by CR-014:** The client calls engine commands in the page through the same typed interface (`createEngineClient`, Step 14.7); the HTTP client is gone (Step 14.11).
### Step 8.3 — Server-synced clock
- On startup, fetch server time and store the offset. Provide a "now" helper that returns device time plus offset, and a ticking store that updates every second.
- **Test:** With a mocked server time 10 minutes ahead of the device, the "now" helper returns a time 10 minutes ahead.
- **Amended by CR-014:** No server time: the offset is to the engine's device clock, which only differs by the developer offset (Step 14.7, decision 0.5.88).
### Step 8.4 — Guest onboarding
- On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session.
- **Test:** Component test: clicking the button calls create-guest and navigates to HQ. Reloading with a stored session skips the start screen.
- **Amended by CR-001:** After creating the guest, the client lands on Contracts, not HQ (Step 10A.11).
- **Amended by CR-011:** The start screen gains "Restore a company" under "Start your company" (Step 10K.9).
- **Amended by CR-014:** No guest account: "Start your company" creates the save on the device (Step 14.8). "Restore a company" becomes "Import save" (Step 14.10).
### Step 8.5 — Player state store
- Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes.
- **Test:** With mocked API, when the clock passes an arrival time, a refetch is triggered automatically.
- **Amended by CR-014:** The resolution loop also refreshes state when an arrival, a rest or a shop job comes due (Step 14.6).
---
@ -639,6 +663,8 @@ Steps 10J.1–10J.7 are specified in `memory-bank/change-requests/CR-010-veteran
Steps 10K.1–10K.12 are specified in `memory-bank/change-requests/CR-011-settings-backup.md` §5 (written there as 10H.1–10H.12), with its §6 amendments. Done after Phase 10J and before Phase 11; stop for review at the end.
**Amended by CR-014:** The backup code (10K.8–10K.9) is replaced by Export save and Import save, a Notifications toggle is added (Steps 14.9–14.10), and debug info gives the save id, save version and storage instead of the player id.
---
## Phase 10L — Lighter and Clipper, Per-Leg Range, Gravity-Well Range (CR-012)
@ -665,6 +691,7 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.
- **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled.
- **Amended by CR-011:** Settings that need the server (rename, backup, restore, new company, developer tools) are disabled while offline; display and help settings still work.
- **Amended by CR-014:** No offline mode: the game always works offline. The offline banner and disabled actions are removed (Step 14.7); the PWA shell cache from 11.1 stays.
---
@ -694,6 +721,7 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran
- **Test:** The E2E test passes and final state shows one owned ship, one Earth bay, and tutorial mode off.
- **Amended by CR-001:** Rewritten: guest → four tutorial runs → accept deal → state shows one owned Courier at Earth crewed by the avatar, one Earth bay, a loan balance, and tutorial off → one contract dispatched and resolved with a repayment applied.
- **Amended by CR-011:** End-to-end tests (12.3–12.6) may use "Start a new company" and the developer tools (advance clock, add credits, finish flights) to reset and fast-forward.
- **Amended by CR-014:** End-to-end tests (12.3–12.6) run against the web build alone (a test build with developer tools), using the developer clock offset to fast-forward; "advance the server clock" means that offset, and "close the page" is reopening it with the offset already past the arrival (Step 14.7).
### Step 12.4 — End-to-end: offline progress
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen.
@ -713,6 +741,8 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran
## Phase 13 — Deployment (Base Game)
> **Replaced by CR-014:** deployment is static hosting of the web build (Caddy, `deploy/`, Step 14.11). The server container (13.1) and database backups (13.3) are removed; players' saves live on their devices, and Export save is their backup. 13.2's single domain and HTTPS remain.
### Step 13.1 — Containerize the server
- Add a Dockerfile for the server and a production compose file with the server and Postgres. Migrations run on startup.
- **Test:** Building and starting the production compose locally serves the health endpoint and the server time endpoint.
@ -736,7 +766,7 @@ Steps 14.0–14.12 are specified in `memory-bank/change-requests/CR-014-local-fi
## Definition of Done (Base Game)
- All step tests pass in CI.
- A new player can go from guest start to owning multiple ships, bays at several locations, and leveled pilots.
- A new player can go from "Start your company" to owning multiple ships, bays at several locations, and leveled pilots.
- Closing the app and returning later correctly shows completed deliveries.
- Discovery works for all four non-starting locations.
- The app installs as a PWA on iOS and Android and is usable at phone width.

View File

@ -352,4 +352,16 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
- [x] **Step 14.9 — Local notifications.** `packages/engine/src/notifications.ts`: `upcomingEvents` (each future arrival "X delivered to Y" / "X arrived at Y", each pilot's rest ending, each upgrade finishing), `plannedNotifications` (events in the same minute share one notification, titled "Deadhead: N updates", at the minute's last event), and `NotificationSync`, which replaces the platform's whole schedule from the save after every saved change (the engine core's `afterSave` hook; a failed sync never fails a command), so moved or cancelled events and a new company clear themselves. `GameEngine` takes a `notifications` option and adds `resyncNotifications()` and `requestNotificationPermission()`. Web: `src/platform/notifications.ts` uses Capacitor `LocalNotifications` on iOS and Android (cancel pending, schedule if permission is granted) and the no-op scheduler on the web; `createGame` wires it with the new `notifications` preference (default on) and resyncs when it changes; the engine client asks for permission right after `acceptDeal` succeeds; Settings has a Notifications section (On/Off). Tests: `packages/engine/test/notifications.test.ts` (a dispatch schedules one notification at its arrival; two arrivals in the same minute make one combined notification; a new company cancels everything; with the toggle off nothing is scheduled, and turning it on catches up; the no-op scheduler never errors); `apps/web/test/notifications.test.ts` (the web uses the no-op scheduler and play works; the toggle clears and restores the schedule; permission is asked after the deal and not before).
- [x] **Step 14.10 — Settings changes.** Export and import replace the backup code. `packages/engine/src/save-code.ts`: `encodeSave` (the save as JSON, gzip via `CompressionStream`, base64url, behind `DEADHEAD1.`) and `decodeSave` (whitespace from pasting ignored; a wrong prefix, a damaged code or a non-save each give a readable error; migrates through `migrateSave`). New engine commands in the catalog: `exportSave` (`{ code }`) and `importSave` (`{ code }` → `{ playerId, state }`, no company needed; due arrivals resolved on import; refusals are `invalid-save` and leave the save as it was; the replaced company stays in the storage backups). `GameEngine` takes a `migrate` option (tests). Web: `src/screens/SaveSettings.tsx` — **Export save** (the code, Copy, Share as file through the share sheet where the device has one, otherwise a download named `deadhead-save-YYYY-MM-DD.txt`) and **Import save** (paste or open a file, then confirm; the warning about replacing the company shows when there is one); both in Settings → Company, Import save also on the start screen. `BackupSettings.tsx` and its test are gone (the server's backup routes stay until 14.11). Copy debug info: `Save`, `Save version`, `Storage` (via a new `GameContext`), `Game time`; no player id. The start screen's error no longer mentions a connection. Tests: `packages/engine/test/save-code.test.ts` (round trip, pasted line breaks; the same state on a fresh device; replaces an existing company; an older save is migrated (simulated with an injected migration, since only version 1 exists); garbage, damaged codes and non-saves are refused and change nothing; nothing to export before a company); `apps/web/test/save-ui.test.tsx` (export, Copy, import on a fresh device from the start screen; open a save file, warning, Replace company; garbage shows the error and changes nothing; file name); `about.test.tsx` updated. Developer tools were already covered (engine refuses developer commands without `devTools`; Settings hides the section in production).
- [x] **Step 14.11 — Retire the server.** Removed `apps/server` (Fastify, Drizzle and its migrations, `pg`, pg-boss, Better Auth, the server tests), the development Postgres (`docker-compose.yml`, `docker/`), the root `.env.example`, `deploy/server.Dockerfile` and `deploy/backup/`. The lockfile has no server packages left. Schema: the backup routes and their schemas, and the `restore-failed` and `rate-limited` codes, are gone; `auth` on a route now means "needs a company". Engine: `SERVER_ONLY_ROUTES` is gone; `CommandName` is every catalog route. Web: the HTTP client (`createApiClient`, `ApiValidationError`, the device id) and its test are gone; `src/api/client.ts` keeps `ApiClient` and `ApiError`; Vite no longer proxies `/api` or keeps it out of the service worker. CI has no database service. `deploy/` is static hosting: Caddy alone (`compose.yml` with one `web` service; the Caddyfile serves files only; `.env.example` is `SITE_ADDRESS` and ports; `check.sh` checks shell, client routes, caching and compression; README rewritten). The web Dockerfile now copies the engine package's manifest too (missing since 14.7). `build:e2e` also makes the production build in `dist-production`, which Playwright serves on port 4174. Test: `e2e/14-11-production.e2e.ts` (the production build starts a company, flies a tutorial run and reloads, loading only files from its own origin, nothing under `/api`, and no `window.deadhead`). The architecture document drops the server's sections.
- [ ] Step 14.12 — Plan amendments and regression
- [x] **Step 14.12 — Plan amendments and regression.** `implementation-plan.md`: rules 0.1.5 and 0.1.6 note the engine as the authority; new **§0.6** holds CR-014 §7's table for translating server-era change requests (endpoint → engine command, table/column → save field with a migration, migration → `saveVersion` bump, row lock → mutation queue, job → resolution loop plus a local notification, test endpoint → developer command, integration test → engine test); Phases 5–7 marked superseded; Steps 8.2–8.5, 11.2, 12.3–12.6 and Phase 10K (CR-011's backup code) amended; Phase 13 marked replaced by static hosting; Definition of Done says "Start your company" instead of "guest start". (§0.5 was updated in Step 14.0.) `tech-stack.md` rewritten for the local-first stack (engine, save storage, device clock, resolution loop, local notifications, export/import, static deployment; the server moves to "alternatives considered"). Regression: typecheck, lint, 1022 unit tests (coverage 96 % of sim lines), 9 browser tests, CI green.
**Manual check for the user** (CR-014 §5 Step 10K.12), on a phone, installed PWA or Capacitor dev build:
1. Open the app; start a company or import a save; get past the deal (accept notifications when asked, native build).
2. Dispatch a contract; note its arrival time.
3. Close the app completely and turn on airplane mode.
4. Wait past the arrival (native build: a notification should arrive at the arrival time while the app is closed).
5. Reopen the app, still in airplane mode: the "while you were away" logbook shows the delivery, credits include the payout, and the ship is at the destination.
6. Settings → Export save → Copy; Settings → Import save with that code: the same company comes back.
The automated parts of this already run in CI: `e2e/12-4` (closed while it landed, reopened past the arrival), `e2e/14-7` (whole opening with every outside request blocked) and `e2e/14-11` (production build loads only its own files).
**Phase 14 complete. Stopped for the user's review.**

View File

@ -1,32 +1,30 @@
# Tech Stack — Deadhead: Space Freight Tycoon
Companion to `game-design-document.md`.
Companion to `game-design-document.md`. Updated for CR-014: the game runs entirely on the device; there is no game server.
## TL;DR
**TypeScript end to end, in one monorepo.**
**TypeScript end to end, in one monorepo, running on the player's device.**
| Layer | Choice |
|---|---|
| Language | TypeScript (client, server, shared sim) |
| Language | TypeScript (client, engine, shared sim) |
| Monorepo | pnpm workspaces |
| Client UI | React + Vite |
| Client state | Zustand (game state) + TanStack Query (server sync) |
| System map rendering | PixiJS (via `@pixi/react`) |
| Client state | TanStack Query (engine results) + Zustand (clock, preferences, map camera) |
| System map rendering | PixiJS |
| Styling | Tailwind CSS |
| Game engine | `packages/engine`: the game's commands over one save document, in the app |
| Save storage | IndexedDB (web); a JSON file via `@capacitor/filesystem` (iOS/Android) |
| Validation | Zod (route catalog and save document) |
| Time | Device clock that never runs backward, plus a developer-only offset |
| Notifications | Local notifications via `@capacitor/local-notifications` (iOS/Android); none on the web |
| PWA | `vite-plugin-pwa` (Workbox) |
| Native wrapper | Capacitor (iOS/Android) |
| API server | Node + Fastify |
| Database | PostgreSQL |
| ORM / migrations | Drizzle ORM |
| Validation | Zod (shared schemas, client + server) |
| Auth | Better Auth (self-hosted) — or Supabase Auth if going managed |
| Job queue / scheduler | pg-boss (Postgres-backed, no Redis needed) |
| Push notifications | `web-push` (VAPID) for PWA; FCM/APNs via Capacitor for native |
| Planetary positions | `astronomy-engine` |
| Seeded RNG | Small pure-TS PRNG (e.g., sfc32 / mulberry32) |
| Testing | Vitest (unit/sim), Playwright (E2E) |
| Deployment | Docker containers (API + Postgres), static client on a CDN |
| Seeded RNG | Small pure-TS PRNG; world seeds and ids from the platform's crypto random source |
| Testing | Vitest (sim, engine, components), Playwright (end to end) |
| Deployment | Static files (Caddy on a home server, or any static host) |
---
@ -34,41 +32,33 @@ Companion to `game-design-document.md`.
The single most important architectural decision is the **deterministic dispatch model** (GDD §14): a trip's arrival time, position, and events are all derived from its dispatch record and RNG seed.
That logic has to run in **two places**:
- **Client** — to show ships moving along their routes, display ETAs, and preview a contract before dispatch.
- **Server** — as the authority that resolves payouts, XP, and events.
If both sides run the *same code*, they can never disagree. A shared TypeScript package gives you that for free. With a C# or Python backend you'd be maintaining two implementations of the sim and chasing drift bugs forever.
That logic runs in the engine (to decide outcomes) and in the screens (to show ships moving, ETAs, and contract previews). Both use the same `packages/sim`, so they can never disagree. The game started with a server as the authority; because sim already had no I/O, moving everything onto the device (CR-014) was a port rather than a rewrite.
## 2. Repo Layout
```
/apps
/web React + Vite client (also the Capacitor app)
/server Fastify API, workers
/web React + Vite client (also the Capacitor app); runs the engine
/packages
/sim Pure, deterministic game logic (no I/O)
/schema Zod schemas + shared types (API contracts)
/content Static game data: ships, destinations, goods, skills
/sim Pure, deterministic game rules (no I/O)
/engine The game's commands over a save document; storage, clock and notifications injected
/schema Zod schemas: the command catalog (requests, responses, errors) and the save document
/content Static game data: ships, locations, goods, skills, config
```
### `packages/sim` rules
- **Pure functions only.** No `Date.now()`, no `Math.random()`, no network, no DB.
- **Pure functions only.** No `Date.now()`, no `Math.random()`, no network, no storage.
- Time is always passed in as an argument.
- Randomness comes only from a seeded PRNG derived from the dispatch seed.
- Fully unit-testable: given a dispatch record + timestamp, the output is always identical.
Core functions (illustrative):
```ts
travelTime(route, ship, cargo, throttle): Duration
positionAt(dispatch, now): Vec2
rollEvents(dispatch): GameEvent[] // pre-rolled at dispatch time
resolveDispatch(dispatch, now): Resolution // payout, XP, damage, discovery
generateContracts(bay, worldState, seed): Contract[]
```
### `packages/engine` rules
- Uses storage, the clock and the notification scheduler **only through injected interfaces**, so tests run on in-memory storage and a test clock.
- One **mutation queue**: commands run one at a time; each loads the save, changes a copy, validates and saves it.
- Every command has the same request and response schemas as the route catalog in `packages/schema`.
### `packages/content`
Ships, destinations, goods, and skills live as typed data files (TS or JSON validated by Zod). This makes balancing a data edit, not a code change, and makes the content easy to hot-reload during tuning.
Ships, locations, goods, and skills live as typed data files validated by Zod. Balancing is a data edit, not a code change.
## 3. Client
@ -78,95 +68,70 @@ The game is overwhelmingly **UI**: contract boards, fleet lists, pilot sheets, b
### PixiJS for the system map
The one truly "game-like" view is the solar system map with ships moving along routes. PixiJS gives GPU-accelerated 2D rendering that stays smooth on phones, embedded inside the React app as a single component.
- Planets positioned from `astronomy-engine` (scaled/compressed for readability — soft sci-fi).
- Ships interpolated along routes with `sim.positionAt()` every frame.
- Ships interpolated along routes with `sim` every frame.
- Pan/zoom with touch support.
### State
- **TanStack Query** owns server data (fleet, contracts, company) — caching, refetch on focus, optimistic updates on dispatch.
- **Zustand** for local UI and derived real-time state (current clock, selected ship, map camera).
- **TanStack Query** caches engine results (player state, boards) behind the same typed `ApiClient` the screens always used; mutations invalidate them.
- **Zustand** for the ticking clock, device preferences and the map camera.
### Server time on the client
On connect, the client fetches server time and stores the offset. All "now" values used for rendering come from `Date.now() + serverOffset`, so a wrong device clock only affects visuals and is never trusted for outcomes.
### Time
`now = max(device time + developer offset, last time seen)`, with `last time seen` kept in the save. Time never runs backward, so setting the clock back can't undo progress; setting it forward to skip waits is accepted (decision 0.5.88).
### Resolution loop
On launch, on resume and every few seconds while open, the app resolves arrivals that have come due and refreshes the screens. Closing the app loses nothing: arrival times are in the save, and the next launch resolves them.
### Storage and saves
- **Web:** IndexedDB, one record for the save plus three rotating backups.
- **iOS/Android:** a JSON file in the app's data directory (written to a temp file, then renamed), included in iCloud and Android device backups.
- If the main save won't load, the newest valid backup is used.
- **Export save** gives the whole save as one text code (gzip + base64url) to copy or share as a file; **Import save** migrates and validates it. That is how a company moves between devices.
- Each save format change bumps `saveVersion` and ships with a migration.
### One tab at a time
In the browser, only one tab runs the game (Web Locks API); others show "Deadhead is open in another tab" with "Use here".
### PWA + Capacitor
- `vite-plugin-pwa` makes the web build installable with offline asset caching.
- Capacitor wraps the **same build** for the App Store and Play Store when you want that. It gives more reliable push notifications and native polish without a second codebase.
- Web Push works on installed PWAs on iOS 16.4+, so Capacitor can wait until after launch.
- `vite-plugin-pwa` makes the web build installable and caches it for offline use. The game works the same with or without a network.
- Capacitor wraps the **same build** for the App Store and Play Store, adding native file storage and local notifications.
## 4. Server
### Fastify
Fast, TypeScript-friendly, schema-based validation (pairs well with Zod via `fastify-type-provider-zod`). Small surface area.
### API shape
Plain REST/JSON is enough. This isn't a twitch-multiplayer game; nothing needs WebSockets at launch. The client polls/refetches on focus, which is exactly the "pick up the phone, see what happened" pattern.
### Server-authoritative resolution
- **Dispatch:** server validates (ship available, pilot qualified, landing class OK, fuel range OK), stamps departure time, generates the seed, pre-rolls events via `sim`, and stores the record.
- **Resolution:** happens lazily when the player loads state *and* via a scheduled job at arrival time (so notifications fire and the economy stays consistent even if the player never opens the app).
- Resolution must be **idempotent**: a dispatch resolves exactly once, guarded by a status column + transaction.
### PostgreSQL + Drizzle
- Relational data fits this game well: companies, ships, pilots, bays, contracts, dispatches.
- Drizzle is TypeScript-first, light, and its schema can live in `packages/schema` alongside Zod types.
- JSONB columns for flexible bits (pre-rolled event lists, ship loadouts).
### pg-boss for scheduled work
Postgres-backed job queue, so you don't need to run Redis.
- Schedule an "arrival" job per dispatch → resolve + send push notification.
- Periodic jobs: regenerate contract boards, recompute fuel prices, run automation rules (standing routes).
### Auth
- **Better Auth**: self-hosted, TypeScript-native, supports email/password, OAuth (Google/Apple/Discord), and anonymous accounts.
- Strongly recommend **guest accounts upgradable to full accounts** — players can start instantly on mobile and link an account later to sync across devices.
- If you'd rather not run auth yourself, Supabase (Auth + Postgres) is the managed alternative and plays fine with Drizzle.
## 5. Notifications
## 4. Notifications
| Platform | Mechanism |
|---|---|
| Web / installed PWA | Web Push with VAPID keys (`web-push` library) |
| Capacitor iOS | APNs via `@capacitor/push-notifications` |
| Capacitor Android | FCM via `@capacitor/push-notifications` |
| Capacitor iOS / Android | Local notifications (`@capacitor/local-notifications`) |
| Web / installed PWA | None |
Fired from the pg-boss arrival job. Batch notifications when many ships arrive at once ("4 ships arrived — +12,400 cr").
The schedule is worked out from the save after every change: one notification per arrival, rest ending and upgrade finishing, with events in the same minute combined ("Deadhead: 2 updates"). It replaces whatever was scheduled before, so changed or cancelled events never leave a stale notification. Permission is asked right after the deal, and Settings has an on/off toggle. No push service is needed because every arrival time is known at dispatch.
## 6. Planetary Positions
## 5. Planetary Positions
`astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls.
- Run it in `packages/sim` so client and server agree on distances.
- Run it in `packages/sim` so the engine and the screens agree on distances.
- Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
- For interstellar (late game), real star positions from a static dataset in `packages/content`.
## 7. Deployment
## 6. Deployment
- **Client:** static build on any CDN (Cloudflare Pages, Netlify, Vercel).
- **Server + Postgres + workers:** Docker containers. Easy to run on a home Docker host during development, then move to a VPS or a managed platform (Fly.io, Railway, Render) for production.
- **Backups:** automated Postgres dumps from day one — player saves *are* the database.
- **Web:** the static build, served by Caddy (`deploy/`, HTTPS and caching rules) or any static host that falls back to `index.html` for client routes.
- **Native:** Capacitor builds of the same web build (store release work is a later change request).
- **No server, database or backups to run:** players' saves live on their devices.
## 8. Testing
## 7. Testing
- **Vitest on `packages/sim`** is where most of the value is. Determinism tests: same dispatch + time → same result, every time.
- **Balance simulations:** scripts that run thousands of simulated dispatches through `sim` to check payout curves and progression pacing without playing the game.
- **Playwright** for a handful of critical E2E flows (sign up → first job → first ship).
- **Vitest on `packages/engine`:** every command against in-memory storage and a test clock (the server's integration tests, ported).
- **Balance simulations:** scripts that run simulated play through `sim` to check payout curves and progression pacing.
- **Playwright** against the web build alone, fast-forwarding with the developer clock offset; one test checks that the production build loads nothing but its own files.
## 9. Alternatives Considered
## 8. Alternatives Considered
| Option | Why not (for this game) |
|---|---|
| Unity | Heavy web builds, slow mobile-web load times, painful for UI-heavy screens. |
| Godot | Better than Unity for web, but still a game engine for a game that's mostly menus; larger downloads than a React app. |
| Phaser | Great for action games; the map is the only part that needs it, and PixiJS covers that more lightly. |
| .NET backend | Solid, but loses shared sim code with the client (unless you go all-in on Blazor, which is a weaker mobile web story). |
| Firebase | Fast to start, but deterministic, transactional server-side resolution is awkward in Firestore + Cloud Functions. |
| Redis + BullMQ | Excellent, but an extra service to run when Postgres + pg-boss covers the need at this scale. |
## 10. Build Order
1. `packages/sim` + `packages/content`: travel time, positions, seeded events, resolution. Fully tested, no UI.
2. Server: auth (guest accounts), dispatch + resolve endpoints, Postgres schema.
3. Client: contract board, fleet list, dispatch flow. Plain UI, no map yet.
4. pg-boss arrival jobs + Web Push.
5. PixiJS system map.
6. PWA install, then Capacitor builds.
| A game server (Fastify + Postgres + pg-boss, as first built) | Single-player with outcomes derived from timestamps, so it isn't needed; it added hosting cost, outage risk and accounts (CR-014). |
| Cloud saves / cross-device sync | Out of scope for now (CR-014 §8); export/import covers moving a company. |
| SQLite on the device | One validated JSON document is enough at this size and is easy to export, back up and migrate. |