deadhead-space-freight-tycoon/memory-bank/change-requests/CR-014-local-first.md

16 KiB
Raw Blame History

Change Request CR-014 — Local-First: Run the Whole Game on the Device

Numbering note (added when recorded): this request arrived titled "CR-011 — Local-First" for "Phase 10K". CR-011 to CR-013 and Phases 10K–13 already existed, so it is stored as CR-014 and done as Phase 14: every "10K.n" below means 14.n. Its decisions 0.5.66–0.5.71 are recorded as 0.5.87 Local-first, 0.5.88 Device clock, 0.5.89 No cross-device play, 0.5.90 Single writer, 0.5.91 Local notifications, 0.5.92 Save export/import. The decisions it replaces map to this plan as: 0.5.18 (testable clock) and 0.5.20 (same domain) are the same numbers; its "0.5.53 guest accounts / 0.5.54 backup code / 0.5.55 several devices" are this plan's 0.5.71 No accounts yet, 0.5.72 Backup code, 0.5.73 Several devices (from CR-011). Its "CR-008" (settings, steps 10H.n) is this repo's CR-011 (Phase 10K, steps 10K.n); its "CR-001 to CR-010" means this repo's CR-001 to CR-013. Its §6 row for Plan 13 applies to the deployment built in Phase 13. tech-stack.md had not been marked for this change in this repo; it is updated as part of the phase. The "Fixed Map and Fuel Tanks" request that arrived first (also titled CR-014) waits until this one is done and will be recorded as CR-015 (Phase 15), using the §7 translation table.

Project: Deadhead: Space Freight Tycoon Applies to: the current build, whatever change requests are complete Do this: next, as Phase 10K, before starting any change request that isn't finished yet Related docs: implementation-plan.md, tech-stack.md, all earlier change requests, DECISIONS.md


1. Why

The game is single-player and every outcome is derived from timestamps, so it doesn't need a server. The user has decided:

  • Playing the same company on web and phone doesn't matter.
  • Players changing the device clock to skip waits doesn't matter.

So the backend goes away. The game runs entirely on the device, saves locally, works fully offline, and uses local notifications for arrivals (arrival times are known at dispatch, so no push service is needed). This removes hosting costs and outage risk, makes App Store review simpler (offline play, no accounts, no data collected), and removes a whole layer of code.

The packages/sim package already holds the game rules with no I/O, which is what makes this a port rather than a rewrite.

2. Target architecture

Today After
apps/server (Fastify) handles every action A new packages/engine: the same game logic as commands, running in the app
Postgres + Drizzle One save document (validated with Zod), stored by a small storage layer
Row locks and transactions A single mutation queue: commands run one at a time; each one loads, changes, and saves the document
pg-boss arrival jobs A resolution loop in the app (on launch, on resume, and every few seconds while open) plus local notifications
Server clock A device clock that never runs backward, with a dev-only offset
Better Auth guest accounts, backup code No accounts. The company is created on first launch. Export/import save replaces the backup code
API client over HTTP The same typed client, calling engine commands in-process

Storage

  • Web: IndexedDB, one record for the save plus rotating backups.
  • iOS/Android (Capacitor): a JSON file in the app's data directory via the Capacitor Filesystem plugin, written atomically (write to a temp file, then rename). This location is included in iCloud and Android device backups.
  • Both sit behind one storage interface with an in-memory version for tests.
  • Keep the last three saves as rotating backups. If the main save fails to load or validate, load the newest valid backup and tell the player.

Save document

  • Holds everything the database holds today: company, credits, loan, ships, upgrades, pilots, bays, discovered locations, stored contract boards, accepted contracts, dispatches, goals, tutorial state, world seed, and a saveVersion.
  • Resolved dispatches older than a configured retention window are pruned on save so the document stays small; keep enough history for "while you were away" summaries.
  • Each saveVersion change ships with a migration function from the previous version.

Clock

  • now = max(device time + dev offset, last time seen). Time never runs backward, so setting the clock back can't undo progress or produce negative timers. Setting it forward is allowed (decision §4).
  • last time seen is stored in the save.

3. Rules for this change

  • All rules in implementation-plan.md §0.1 apply: one commit per step, each step's test must pass, packages/sim stays pure, all numbers live in packages/content.
  • Port, don't redesign. Every server endpoint becomes an engine command with the same request and response schemas (reuse the route catalog as a command catalog). Game behavior must not change.
  • Port the tests. Every server integration test from Phases 5–7 and from completed change requests is moved to run against the engine with in-memory storage. A test is only deleted if what it tests no longer exists (for example HTTP status codes, auth, Postgres migrations).
  • packages/engine may use storage, the clock, and the notification scheduler only through injected interfaces, so it stays testable.
  • Generate the world seed and ids with the platform's cryptographic random source, outside packages/sim.
  • Resetting existing dev data is fine; no migration from Postgres to local saves is needed.
  • Update architecture.md, tech-stack.md, and progress.md as you go.
  • Stop for the user's review at the end of Phase 10K.

4. Decisions

Record these in DECISIONS.md and implementation-plan.md §0.5.

  • 0.5.66 Local-first. The game runs entirely on the device. There is no game server.
  • 0.5.67 Device clock. Time comes from the device clock, clamped so it never runs backward. Moving the clock forward to skip waits is accepted.
  • 0.5.68 No cross-device play. Each device (and each browser) has its own company. Moving a company between devices is done by exporting and importing a save.
  • 0.5.69 Single writer. All state changes go through one mutation queue. In the browser, only one tab may run the game at a time (use the Web Locks API; other tabs show "Deadhead is open in another tab" with a "Use here" button that takes over).
  • 0.5.70 Local notifications. On iOS and Android, the app schedules a local notification for each arrival, each pilot's rest ending, and each upgrade finishing, and cancels them if the event changes (for example a new company). If several land within the same minute, schedule one combined notification. The permission prompt appears right after the player accepts the deal, not at first launch. The web build has no notifications.
  • 0.5.71 Save export/import. Export produces a single text code (the save document, compressed and encoded) that can be copied or shared as a file. Import validates it, migrates it if it's an older saveVersion, and replaces the current company after confirmation.

Replaced or removed: 0.5.18 (testable server clock → local clock with dev offset), 0.5.20 (same-domain API → no API), 0.5.53 (guest accounts → no accounts), 0.5.54 (backup code → save export/import), 0.5.55 (several devices → not applicable). Step 6.14's row locks are replaced by the mutation queue.

5. Steps — Phase 10K

Step 10K.0 — Inventory

  • List in progress.md which change requests (CR-001 to CR-010) are complete, in progress, or not started. Everything complete must be preserved by this port.
  • Test: progress.md contains the list. (Manual check by the user.)

Step 10K.1 — Engine package and clock

  • Create packages/engine with the Clock interface (§2) and a device clock implementation with dev offset.
  • Test: The clock returns device time plus offset. If device time moves backward, the clock returns the last time seen. Moving forward is reflected immediately.

Step 10K.2 — Save document schema and migrations

  • Define the save document with Zod in packages/schema, covering all state from every completed change request, plus saveVersion and lastTimeSeen.
  • Add the migration framework (an ordered list of version-to-version functions) with an identity migration for version 1.
  • Test: A fully populated example save validates. An invalid one is rejected with a readable error. Migrating a version-1 fixture produces a valid current save.

Step 10K.3 — Storage layer

  • Storage interface: load, save, list backups, load backup. Implementations: in-memory, IndexedDB (web), Capacitor Filesystem (native, atomic write). Rotate the last three backups on every save.
  • On load: if the main save is missing, return nothing; if it fails to parse or validate, fall back to the newest valid backup and report that a backup was used.
  • Test: Each implementation passes the same shared test suite (IndexedDB via a fake IndexedDB library, native via a mocked Filesystem plugin): round-trip save and load, backup rotation keeps exactly three, a corrupted main save falls back to a backup, an interrupted native write never leaves a half-written main file.

Step 10K.4 — Mutation queue and tab lock

  • All commands run through one queue: load (or use the cached document), apply, validate, save, then release. A failed command leaves the saved state unchanged.
  • In the browser, acquire a Web Lock for the game; a second tab shows the "open in another tab" screen with "Use here".
  • Test: Two rapid "buy ship" commands where only one is affordable produce exactly one purchase and no negative balance (replaces the Step 6.14 test). A command that throws leaves the save unchanged. A second tab can't run commands until it takes over the lock.

Step 10K.5 — Port the game logic

  • Move every server endpoint's logic into an engine command with the same input and output schemas. This includes all completed change requests (tutorial, deal, loan, goals, open markets, rest, goods and Priority, upgrades, veteran, settings actions, new ships).
  • Move the matching integration tests to run against the engine with in-memory storage and a fixed clock.
  • Test: The ported test suite passes. A test listing every command in the catalog confirms each has at least one engine test.

Step 10K.6 — Resolution loop

  • Resolve due dispatches, rest endings, and shop completions on load, when the app resumes (Capacitor resume, browser visibilitychange), and every 5 seconds while open. Uses the same idempotent resolution as Step 6.1.
  • Test: A dispatch whose arrival passed while the app was closed is resolved exactly once on load. Running the loop twice in a row changes nothing the second time. "While you were away" summaries appear as before.
  • Amended by CR-021: The loop (and every command) runs the event-queue catch-up: arrivals, each at its own time, and Independent pilots' decisions, in time order, at most 7 days, in chunks with a progress hook (Step 21.4).

Step 10K.7 — Switch the client to the engine

  • The typed API client calls engine commands instead of HTTP. TanStack Query keeps working with engine calls as its query and mutation functions. Remove the server-time sync (Step 8.3); use the engine clock.
  • Test: Existing component tests pass with the engine mocked through the same client interface. A Playwright test with all network requests blocked from the first page load can start a company, finish the tutorial, accept the deal, and dispatch a contract.

Step 10K.8 — Company creation on the device

  • First launch with no save shows the start screen; "Start your company" creates the starting state locally (same code path as the old guest creation, CR-001 state) with a fresh world seed.
  • Test: With empty storage, starting a company produces the exact guest starting state from CR-001. Reloading loads the same company.

Step 10K.9 — Local notifications

  • Add a notification scheduler interface with a Capacitor Local Notifications implementation (native) and a no-op implementation (web). Schedule per decision 0.5.70 whenever a dispatch, rest, or upgrade is created; cancel and reschedule when state changes; cancel all on new company or import.
  • Ask for permission right after the deal is accepted. Add a Notifications on/off toggle to Settings (CR-008 left this out because push didn't exist).
  • Test: With a mocked plugin: dispatching schedules one notification at the arrival time; two arrivals in the same minute produce one combined notification; starting a new company cancels all scheduled notifications; with the toggle off, nothing is scheduled. On web, the no-op scheduler is used and nothing errors.

Step 10K.10 — Settings changes

  • Replace the backup code (CR-008 Steps 10H.8–10H.9) with Export save (shows the code with Copy and Share as file) and Import save (paste or open a file, then confirm). Keep the import warning about replacing the current company.
  • "Start a new company", rename, and the Developer tools (clock offset, add credits, finish all flights) become local commands available only in development builds where noted.
  • "Copy debug info" drops the player id and adds the save id, saveVersion, and storage type.
  • Test: Exporting then importing on a fresh device storage reproduces the same state. Importing an older-version save migrates it. Importing garbage shows an error and changes nothing. Developer actions work in development builds and are absent in production builds.

Step 10K.11 — Retire the server

  • Remove apps/server, Postgres and the Docker Compose file, Drizzle, pg-boss, Better Auth, and their environment variables. Update CI to drop database services.
  • Test: CI is green. The production web build makes no network requests other than loading its own static files (verified with Playwright). No server dependencies remain in the lockfile.

Step 10K.12 — Plan amendments and regression

  • Apply the amendments in §6 and the translation table in §7.
  • Run the full suite.
  • Test: CI is green. Manual check: on a phone (installed PWA or Capacitor dev build), dispatch a contract, close the app, turn on airplane mode, wait past arrival, reopen, and confirm the delivery resolved and (native build) a notification arrived.

6. Amendments

Where Change
Plan 0.5 Add 0.5.66–0.5.71; remove or replace 0.5.18, 0.5.20, 0.5.53–0.5.55 as listed in §4.
Plan Phases 5–7 Superseded by packages/engine (10K.1–10K.6).
Plan 8.2–8.5 Client calls the engine; no server-time sync; no guest account (10K.7, 10K.8).
Plan 11.2 No "offline" mode: the game always works offline. Remove the offline banner and disabled actions.
Plan 12.3–12.6 End-to-end tests run against the web build alone, using the developer clock offset to fast-forward.
Plan 13 Replaced: host the static web build. Remove server containers and database backups (players' saves live on their devices).
tech-stack.md Update per §2 (the designer has already marked this).
CR-008 Backup code replaced by save export/import; Notifications toggle added (10K.9, 10K.10).

7. Translating change requests that aren't done yet

When a later or unfinished change request says:

It says Do this instead
Server endpoint Engine command with the same schemas
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

8. Out of scope

Cloud saves, cross-device sync, leaderboards, clock-cheat detection, web notifications, in-app purchases, and the App Store readiness work (Capacitor release builds, store assets, privacy policy). Those come in a later change request.