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. - **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. - **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. - **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. 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." 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. 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. 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. 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. 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. 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`. 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. 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. 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 ## 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 ## 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 ### Step 5.1 — Fastify skeleton
- Create the Fastify app in `apps/server` with environment config loading, structured logging, and a health endpoint. - 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. - **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 ## 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 ### 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. - 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. - 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 ## 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 ### 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. - 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. - **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 ### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated. - 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. - **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 ### 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. - 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. - **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 ### 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. - 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. - **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-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-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 ### 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. - 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. - **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. 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) ## 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. - 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. - **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-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. - **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-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-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 ### Step 12.4 — End-to-end: offline progress
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen. - 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) ## 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 ### 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. - 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. - **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) ## Definition of Done (Base Game)
- All step tests pass in CI. - 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. - Closing the app and returning later correctly shows completed deliveries.
- Discovery works for all four non-starting locations. - Discovery works for all four non-starting locations.
- The app installs as a PWA on iOS and Android and is usable at phone width. - 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.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.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. - [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 # 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 ## TL;DR
**TypeScript end to end, in one monorepo.** **TypeScript end to end, in one monorepo, running on the player's device.**
| Layer | Choice | | Layer | Choice |
|---|---| |---|---|
| Language | TypeScript (client, server, shared sim) | | Language | TypeScript (client, engine, shared sim) |
| Monorepo | pnpm workspaces | | Monorepo | pnpm workspaces |
| Client UI | React + Vite | | Client UI | React + Vite |
| Client state | Zustand (game state) + TanStack Query (server sync) | | Client state | TanStack Query (engine results) + Zustand (clock, preferences, map camera) |
| System map rendering | PixiJS (via `@pixi/react`) | | System map rendering | PixiJS |
| Styling | Tailwind CSS | | 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) | | PWA | `vite-plugin-pwa` (Workbox) |
| Native wrapper | Capacitor (iOS/Android) | | 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` | | Planetary positions | `astronomy-engine` |
| Seeded RNG | Small pure-TS PRNG (e.g., sfc32 / mulberry32) | | Seeded RNG | Small pure-TS PRNG; world seeds and ids from the platform's crypto random source |
| Testing | Vitest (unit/sim), Playwright (E2E) | | Testing | Vitest (sim, engine, components), Playwright (end to end) |
| Deployment | Docker containers (API + Postgres), static client on a CDN | | 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. 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**: 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.
- **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.
## 2. Repo Layout ## 2. Repo Layout
``` ```
/apps /apps
/web React + Vite client (also the Capacitor app) /web React + Vite client (also the Capacitor app); runs the engine
/server Fastify API, workers
/packages /packages
/sim Pure, deterministic game logic (no I/O) /sim Pure, deterministic game rules (no I/O)
/schema Zod schemas + shared types (API contracts) /engine The game's commands over a save document; storage, clock and notifications injected
/content Static game data: ships, destinations, goods, skills /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 ### `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. - Time is always passed in as an argument.
- Randomness comes only from a seeded PRNG derived from the dispatch seed. - 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. - Fully unit-testable: given a dispatch record + timestamp, the output is always identical.
Core functions (illustrative): ### `packages/engine` rules
```ts - Uses storage, the clock and the notification scheduler **only through injected interfaces**, so tests run on in-memory storage and a test clock.
travelTime(route, ship, cargo, throttle): Duration - One **mutation queue**: commands run one at a time; each loads the save, changes a copy, validates and saves it.
positionAt(dispatch, now): Vec2 - Every command has the same request and response schemas as the route catalog in `packages/schema`.
rollEvents(dispatch): GameEvent[] // pre-rolled at dispatch time
resolveDispatch(dispatch, now): Resolution // payout, XP, damage, discovery
generateContracts(bay, worldState, seed): Contract[]
```
### `packages/content` ### `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 ## 3. Client
@ -78,95 +68,70 @@ The game is overwhelmingly **UI**: contract boards, fleet lists, pilot sheets, b
### PixiJS for the system map ### 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. 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). - 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. - Pan/zoom with touch support.
### State ### State
- **TanStack Query** owns server data (fleet, contracts, company) — caching, refetch on focus, optimistic updates on dispatch. - **TanStack Query** caches engine results (player state, boards) behind the same typed `ApiClient` the screens always used; mutations invalidate them.
- **Zustand** for local UI and derived real-time state (current clock, selected ship, map camera). - **Zustand** for the ticking clock, device preferences and the map camera.
### Server time on the client ### Time
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. `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 ### PWA + Capacitor
- `vite-plugin-pwa` makes the web build installable with offline asset caching. - `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 when you want that. It gives more reliable push notifications and native polish without a second codebase. - Capacitor wraps the **same build** for the App Store and Play Store, adding native file storage and local notifications.
- Web Push works on installed PWAs on iOS 16.4+, so Capacitor can wait until after launch.
## 4. Server ## 4. Notifications
### 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
| Platform | Mechanism | | Platform | Mechanism |
|---|---| |---|---|
| Web / installed PWA | Web Push with VAPID keys (`web-push` library) | | Capacitor iOS / Android | Local notifications (`@capacitor/local-notifications`) |
| Capacitor iOS | APNs via `@capacitor/push-notifications` | | Web / installed PWA | None |
| Capacitor Android | FCM via `@capacitor/push-notifications` |
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. `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). - 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. - 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`. - 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). - **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.
- **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. - **Native:** Capacitor builds of the same web build (store release work is a later change request).
- **Backups:** automated Postgres dumps from day one — player saves *are* the database. - **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. - **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. - **Vitest on `packages/engine`:** every command against in-memory storage and a test clock (the server's integration tests, ported).
- **Playwright** for a handful of critical E2E flows (sign up → first job → first ship). - **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) | | Option | Why not (for this game) |
|---|---| |---|---|
| Unity | Heavy web builds, slow mobile-web load times, painful for UI-heavy screens. | | 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. | | 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. | | 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). | | 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). |
| Firebase | Fast to start, but deterministic, transactional server-side resolution is awkward in Firestore + Cloud Functions. | | Cloud saves / cross-device sync | Out of scope for now (CR-014 §8); export/import covers moving a company. |
| Redis + BullMQ | Excellent, but an extra service to run when Postgres + pg-boss covers the need at this scale. | | SQLite on the device | One validated JSON document is enough at this size and is easy to export, back up and migrate. |
## 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.