From 29f64f1ac2e59768c4a89eaee49a440f23cffaba Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 9 Oct 2026 19:42:41 -0400 Subject: [PATCH] Step 14.6: resolution loop Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- packages/engine/src/game/engine.ts | 21 ++++++ packages/engine/src/index.ts | 1 + packages/engine/src/loop.ts | 65 +++++++++++++++++ packages/engine/test/loop.test.ts | 113 +++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/loop.ts create mode 100644 packages/engine/test/loop.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 40c27f2..f99d56b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1378,3 +1378,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Backup-code endpoints aren't ported**: they need a server. Their tests are dropped with them; export/import replaces them in 14.10. - **Tests were ported, not rewritten**: the helpers keep the server tests' names and shapes (`call(app, guest, "POST", "/api/ships", …)` routes the old path to its command and answers with the old status code), so most files moved with import changes and database writes turned into save edits. Tests of things that no longer exist were dropped, each listed in `progress.md`. A coverage test checks every command has at least one engine test. - **The server stays until 14.11**, so both suites run until then. + +## Step 14.6 + +- **The loop replaces the pg-boss arrival job**: arrivals were already resolved lazily before every command, so the loop's job is to keep the save and screen current while the app is open and nothing is being clicked, and to catch up the moment it opens or comes back. +- **Rest endings and finished shop jobs change no state** (they are times compared with now), so the loop only reports them, for the screen to refresh; a pass that resolves nothing writes nothing. +- **Passes never overlap**: a tick that comes while one is running reuses it, so a resume during an interval tick doesn't run resolution twice at once (it would be harmless anyway; resolution is idempotent). diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index c82290a..328eafd 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -82,6 +82,8 @@ One document per company on the device, validated on every load and save: `saveV Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. +Resolution loop (`packages/engine/src/loop.ts`): on load, on resume and every 5 s, `resolveDue` applies due arrivals (saving only if any) and reports rests and shop jobs that ended, so the app refreshes; timer and resume events come from a `LoopHost`. + Storage (`packages/engine/src/storage.ts`): `SaveStorage` keeps the save in slot `main` and the previous three in `backup-1..3` (rotated on every save), falling back to the newest valid backup if the main save is unreadable. Backends store text per slot, all-or-nothing: `MemoryBackend` (tests), `IndexedDbBackend` (web, database `deadhead`, store `saves`), `FilesystemBackend` (native, `deadhead/.json` in the Capacitor data directory, temp file then rename). ## Database (`apps/server/src/db`) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index fefb8e9..d0a5178 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -346,7 +346,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 - [x] **Step 14.3 — Storage layer.** `packages/engine/src/storage.ts`: `SaveStorage` (load, save with rotation, list backups, load a backup, clear) over a `SaveBackend` that stores text in slots `main`, `backup-1..3`, each write all-or-nothing. Saving moves the previous main save to backup 1 (and 1→2, 2→3), so the last three saves are kept. Loading: no main save → empty; an unreadable or invalid one → the newest backup that validates, reported as `restored-from-backup` with the problem; nothing valid → `unreadable`. Backends: `MemoryBackend`, `IndexedDbBackend` (one object store, handed an `IDBFactory`), `FilesystemBackend` (one JSON file per slot in the app data directory, written to a `.tmp` file then renamed; handed the Capacitor Filesystem plugin through a small interface). Tests (`packages/engine/test/storage.test.ts`): one suite run on all three (IndexedDB via `fake-indexeddb`, the filesystem via a fake plugin): empty, round trip, exactly three backups newest first, corrupted main save falls back past a broken backup to the next valid one, unreadable, clear; and a native write interrupted mid-file or before the rename leaves the previous save loading. - [x] **Step 14.4 — Mutation queue and tab lock.** `packages/engine/src/core.ts`: `EngineCore` runs every `read`/`write` through one promise queue. A write gets a copy of the cached save (loaded from storage on first use, restoring the clock's last time seen), returns the new save, which is stamped with `lastTimeSeen`, validated against the save schema, saved and only then adopted; a throw or an invalid result leaves storage and cache unchanged. `canWrite` (the tab lock) refuses writes with `GameLockedError`; `reload()` drops the cache after another tab had the game; `cryptoRandom` gives ids (`randomUUID`) and 32-bit world seeds (`getRandomValues`). `src/lock.ts`: `TabLock` over `navigator.locks` (`ifAvailable` to take a free game, `steal` for "Use here", `onLost` when another tab takes over; always held where Web Locks don't exist). Tests (`test/core.test.ts`): two rapid purchases with credits for one give exactly one purchase and no negative balance; a throwing command and an invalid result leave the save and cache as they were; `lastTimeSeen` follows the clock but never drops below the save's; with a Web Locks fake, a second tab is refused until it takes over, the first tab is told and refused, and the second sees the first's saved state after `reload`. The "open in another tab" screen is built with the client switch (14.7). - [x] **Step 14.5 — Port the game logic.** `packages/engine/src/game/`: every server endpoint is an engine command with the same request and response schemas (`GameEngine.call(name, input)`: input checked against the route catalog, due arrivals resolved first as the server's `withPlayer` did, the command run on a copy of the save through the single-writer queue, the response checked, and the save written only if something changed, with old boards, accepted contracts and seen trips pruned). Modules mirror the server's: `state` (player state, ships, pilots, goals), `company` (starting save, deal, loan, rename, goals), `boards`, `dispatch`, `fleet` (bays, ships, pilots, skills, upgrades, summaries), `resolve` (arrivals, pruning), `commands` (the table), plus the server's names and messages. Refusals are `CommandError`s with the server's codes; no company yet answers `unauthorized`, as before. Developer commands only with `devTools`; the backup-code endpoints (`getBackupCode`, `createBackupCode`, `restoreCompany`) are not ported (replaced in 14.10). Save additions: `stats` (contract and Priority deliveries, for goals) and each ship's `lastArrivalAt`, so pruning never changes progress; config `boardRetentionPeriods` (24, from the server's cleanup job) and `savedDispatchRetentionRealHours` (168). Tests: 33 server test files ported to `packages/engine/test/ported/` over `test/support.ts` (the server helpers' names and shapes, with `call` routing old endpoint paths to commands and answering with the server's status codes, and `edit`/`readSave`/`addShip`/`addPilot` in place of database writes); rewritten where the thing tested changed (guest sign-up → starting a company, server time → the engine clock, request validation, the board-key constraint → one stored board per key, the cleanup job → pruning on save, the resolver race → resolving a save twice); dropped where it no longer exists (cookies and sessions, other players' bays and summaries, HTTP-only validation, the pg-boss arrival job, Postgres migrations, the health route, the backup code, the database harness). `test/catalog.test.ts` checks that every command has an engine test. -- [ ] Step 14.6 — Resolution loop +- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists. - [ ] Step 14.7 — Switch the client to the engine - [ ] Step 14.8 — Company creation on the device - [ ] Step 14.9 — Local notifications diff --git a/packages/engine/src/game/engine.ts b/packages/engine/src/game/engine.ts index e1097ef..2d72a16 100644 --- a/packages/engine/src/game/engine.ts +++ b/packages/engine/src/game/engine.ts @@ -80,6 +80,27 @@ export class GameEngine { return this.core.now(); } + /** + * The resolution loop's pass (Step 14.6): applies any due arrivals and counts + * rests and shop jobs that ended after `since`. Saves only when an arrival + * was resolved; does nothing before a company exists. + */ + resolveDue( + since: RealTime | null, + ): Promise<{ resolved: number; eventsPassed: number; now: RealTime }> { + return this.core.write((save, now) => { + if (!save) + return { save: save!, result: { resolved: 0, eventsPassed: 0, now }, unchanged: true }; + const resolved = resolveArrivals(save, now); + if (resolved > 0) pruneSave(save, now); + const ended = (t: RealTime | null) => since !== null && t !== null && t > since && t <= now; + const eventsPassed = + save.pilots.filter((p) => ended(p.restEndsAt)).length + + save.ships.filter((s) => ended(s.inShopUntil)).length; + return { save, result: { resolved, eventsPassed, now }, unchanged: resolved === 0 }; + }); + } + /** Runs one command; refusals throw `CommandError` with the server's codes. */ async call(name: N, input: RouteRequest): Promise> { const route = routes[name]; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 3d774cd..5773d74 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -33,3 +33,4 @@ export { CommandError, NOT_FOUND_CODES } from "./game/errors"; export { pruneSave, resolveArrivals } from "./game/resolve"; export { playerState } from "./game/state"; export { startingSave } from "./game/company"; +export { RESOLUTION_INTERVAL_MS, ResolutionLoop, type LoopHost } from "./loop"; diff --git a/packages/engine/src/loop.ts b/packages/engine/src/loop.ts new file mode 100644 index 0000000..c0471af --- /dev/null +++ b/packages/engine/src/loop.ts @@ -0,0 +1,65 @@ +import type { RealTime } from "@deadhead/sim"; +import type { GameEngine } from "./game/engine"; + +/** + * What the loop needs from the platform (CR-014 Step 14.6): a timer, and a way + * to hear that the app is back in front (browser `visibilitychange`, Capacitor + * `resume`). Injected so the engine stays testable. + */ +export interface LoopHost { + setInterval(fn: () => void, ms: number): unknown; + clearInterval(handle: unknown): void; + /** Calls `fn` whenever the app comes back to the foreground; returns an unsubscribe. */ + onResume(fn: () => void): () => void; +} + +export const RESOLUTION_INTERVAL_MS = 5_000; + +/** + * Keeps the game current while it's open: on start (the app has just loaded), + * on every resume, and every few seconds, it resolves due arrivals (the same + * idempotent resolution every command runs first) and reports when anything + * changed, including a rest or a shop job ending, so the screen can refresh. + */ +export class ResolutionLoop { + #handle: unknown = null; + #unsubscribe: (() => void) | null = null; + #since: RealTime | null = null; + #running: Promise | null = null; + + constructor( + private readonly engine: GameEngine, + private readonly host: LoopHost, + private readonly onChange: () => void, + private readonly intervalMs = RESOLUTION_INTERVAL_MS, + ) {} + + /** Resolves once now, then keeps going until `stop`. */ + async start(): Promise { + this.stop(); + this.#handle = this.host.setInterval(() => void this.tick(), this.intervalMs); + this.#unsubscribe = this.host.onResume(() => void this.tick()); + await this.tick(); + } + + stop(): void { + if (this.#handle !== null) this.host.clearInterval(this.#handle); + this.#handle = null; + this.#unsubscribe?.(); + this.#unsubscribe = null; + } + + /** One pass (ticks never overlap: a tick that is still running is reused). */ + tick(): Promise { + this.#running ??= (async () => { + try { + const result = await this.engine.resolveDue(this.#since); + this.#since = result.now; + if (result.resolved > 0 || result.eventsPassed > 0) this.onChange(); + } finally { + this.#running = null; + } + })(); + return this.#running; + } +} diff --git a/packages/engine/test/loop.test.ts b/packages/engine/test/loop.test.ts new file mode 100644 index 0000000..f228797 --- /dev/null +++ b/packages/engine/test/loop.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { GameEngine, ResolutionLoop, SaveStorage, type LoopHost } from "../src/index"; +import { + insertDispatch, + newTestApp, + readSave, + signUpOwner, + TEST_NOW, + type TestApp, +} from "./support"; + +/** A timer and resume signal the test drives by hand. */ +class FakeHost implements LoopHost { + ticks: (() => void)[] = []; + resumes: (() => void)[] = []; + setInterval(fn: () => void) { + this.ticks.push(fn); + return this.ticks.length; + } + clearInterval() { + this.ticks = []; + } + onResume(fn: () => void) { + this.resumes.push(fn); + return () => (this.resumes = this.resumes.filter((f) => f !== fn)); + } +} + +/** The same storage opened by a new engine: the app launched again. */ +function relaunch(test: TestApp): GameEngine { + return new GameEngine({ + storage: new SaveStorage(test.backend), + clock: { now: () => test.clock.now }, + devTools: true, + }); +} + +describe("resolution loop (Step 14.6)", () => { + it("resolves an arrival that passed while the app was closed, exactly once, on load", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + const record = await insertDispatch(test.app, { + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: TEST_NOW, + }); + // The app closes; the trip lands; the app opens again. + test.clock.now = record.arrivalAt + 60_000; + const engine = relaunch(test); + let changes = 0; + const loop = new ResolutionLoop(engine, new FakeHost(), () => changes++); + await loop.start(); + expect(changes).toBe(1); + + const save = await readSave(engine); + expect(save.dispatches.find((d) => d.id === record.id)?.status).toBe("resolved"); + expect(save.company.credits).toBeGreaterThan(0); + // "While you were away" shows it as before. + const state = await engine.call("getPlayerState", {}); + expect(state.summaries.map((s) => s.dispatchId)).toEqual([record.id]); + }); + + it("changes nothing when it runs twice in a row", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + const record = await insertDispatch(test.app, { + shipId: owner.shipId, + pilotId: owner.avatarId, + departAt: TEST_NOW, + }); + test.clock.now = record.arrivalAt; + const first = await test.app.resolveDue(null); + const afterFirst = test.backend.slots.get("main"); + const second = await test.app.resolveDue(first.now); + expect([first.resolved, second.resolved]).toEqual([1, 0]); + expect(test.backend.slots.get("main")).toBe(afterFirst); + }); + + it("ticks on the interval and on resume, and reports a rest ending", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + await test.app.call("restPilot", { pilotId: owner.avatarId }); + const host = new FakeHost(); + let changes = 0; + const loop = new ResolutionLoop(test.app, host, () => changes++); + await loop.start(); + expect(changes).toBe(0); + expect(host.ticks).toHaveLength(1); + expect(host.resumes).toHaveLength(1); + + // Nothing has happened yet. + await loop.tick(); + expect(changes).toBe(0); + // The rest ends; the next tick (here from a resume) reports it. + const { restEndsAt } = (await readSave(test.app)).pilots.find((p) => p.id === owner.avatarId)!; + test.clock.now = restEndsAt! + 1; + host.resumes[0]!(); + await loop.tick(); + expect(changes).toBe(1); + + loop.stop(); + expect(host.ticks).toHaveLength(0); + expect(host.resumes).toHaveLength(0); + }); + + it("does nothing before a company exists", async () => { + const test = newTestApp(); + let changes = 0; + await new ResolutionLoop(test.app, new FakeHost(), () => changes++).start(); + expect(changes).toBe(0); + expect(test.backend.slots.size).toBe(0); + }); +});