From fcc3047af8af3185970d82f538e6b44f752dc0fe Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 9 Oct 2026 19:19:03 -0400 Subject: [PATCH] Step 14.4: mutation queue and tab lock Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ memory-bank/progress.md | 2 +- packages/engine/src/core.ts | 127 +++++++++++++++++++++++++++ packages/engine/src/index.ts | 8 ++ packages/engine/src/lock.ts | 76 ++++++++++++++++ packages/engine/test/core.test.ts | 139 ++++++++++++++++++++++++++++++ 6 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/core.ts create mode 100644 packages/engine/src/lock.ts create mode 100644 packages/engine/test/core.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3502c4d..8272f10 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1360,3 +1360,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Platform objects are handed in** (`IDBFactory`, the Capacitor Filesystem plugin through a five-method interface), so the engine has no browser or Capacitor imports and the backends are tested with `fake-indexeddb` and a fake plugin. The real plugin is wired up in the web app (14.7). - **The engine's TypeScript config adds the DOM library** for the IndexedDB and Web Locks types; it still only touches them through what it's given. - **Pruning old resolved dispatches belongs to the save document**, not storage; it happens when a command saves (14.4). + +## Step 14.4 + +- **Copy, change, validate, save, adopt**: a command works on a `structuredClone` of the save and the engine only adopts the result after it validates and is written. That replaces row locks and transactions (Step 6.14): with one queue there are no concurrent writers, and a failed command has nothing to roll back. +- **Reads go through the same queue**, so a read never sees a half-applied command. +- **The tab lock gates writes, not reads**: a tab that lost the game can still show what it had, but every command is refused with `GameLockedError` until it takes the game back. After taking over, a tab drops its cached save and reloads from storage, so it continues from the other tab's last save. +- **Where Web Locks don't exist** (native apps have one window; very old browsers) the tab simply holds the game. +- **The race test uses a minimal purchase command** here; 14.5 repeats it with the real `buyShip`. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b06358f..4b4d348 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -344,7 +344,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 - [x] **Step 14.1 — Engine package and clock.** New `packages/engine` (depends on content, schema, sim; picked up as a Vitest project). `Clock` interface (`now()`) and `DeviceClock`: device time (injected, `Date.now` by default) plus a developer offset, clamped to the last time it handed out; `lastTimeSeen` for the save, `restore(lastTimeSeen)` after loading one, `advance(ms)` for the developer tools (forward only). Tests (`packages/engine/test/clock.test.ts`): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward. - [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported. - [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. -- [ ] Step 14.4 — Mutation queue and tab lock +- [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). - [ ] Step 14.5 — Port the game logic - [ ] Step 14.6 — Resolution loop - [ ] Step 14.7 — Switch the client to the engine diff --git a/packages/engine/src/core.ts b/packages/engine/src/core.ts new file mode 100644 index 0000000..a16375b --- /dev/null +++ b/packages/engine/src/core.ts @@ -0,0 +1,127 @@ +import { saveDocumentSchema, type SaveDocument } from "@deadhead/schema"; +import type { RealTime } from "@deadhead/sim"; +import type { Clock } from "./clock"; +import type { LoadResult, SaveStorage } from "./storage"; + +/** Ids and seeds (CR-014 §3): the platform's cryptographic random source, outside the sim. */ +export interface RandomSource { + id(): string; + /** A 32-bit unsigned world seed. */ + seed(): number; +} + +export const cryptoRandom: RandomSource = { + id: () => globalThis.crypto.randomUUID(), + seed: () => globalThis.crypto.getRandomValues(new Uint32Array(1))[0]!, +}; + +/** Thrown when this tab doesn't hold the game (decision 0.5.90). */ +export class GameLockedError extends Error { + constructor() { + super("Deadhead is open in another tab."); + this.name = "GameLockedError"; + } +} + +export interface CoreDeps { + storage: SaveStorage; + clock: Clock & { restore?(lastTimeSeen: RealTime): void }; + random?: RandomSource; + /** False while another tab holds the game; commands are refused. */ + canWrite?: () => boolean; +} + +/** + * The single writer (decision 0.5.90): every command runs alone, in order, on + * a copy of the save. Only when it finishes and the result validates is the + * copy saved and adopted; a command that throws leaves both untouched. + */ +export class EngineCore { + #queue: Promise = Promise.resolve(); + #doc: SaveDocument | null = null; + #loaded = false; + #lastLoad: LoadResult | null = null; + readonly random: RandomSource; + + constructor(private readonly deps: CoreDeps) { + this.random = deps.random ?? cryptoRandom; + } + + /** The time now, on the engine's clock. */ + now(): RealTime { + return this.deps.clock.now(); + } + + /** How the save was last loaded (for "a backup was used" and debug info). */ + get lastLoad(): LoadResult | null { + return this.#lastLoad; + } + + get storageKind(): string { + return this.deps.storage.kind; + } + + /** Forget the cached save (another tab may have changed it); the next command reloads. */ + reload(): void { + this.#loaded = false; + this.#doc = null; + } + + async #current(): Promise { + if (!this.#loaded) { + const result = await this.deps.storage.load(); + this.#lastLoad = result; + this.#doc = + result.status === "loaded" || result.status === "restored-from-backup" ? result.save : null; + if (this.#doc) this.deps.clock.restore?.(this.#doc.lastTimeSeen); + this.#loaded = true; + } + return this.#doc; + } + + #enqueue(work: () => Promise): Promise { + const next = this.#queue.then(work, work); + this.#queue = next.catch(() => undefined); + return next; + } + + /** Reads the save (null before a company exists) without changing it. */ + read(fn: (save: SaveDocument | null, now: RealTime) => T | Promise): Promise { + return this.#enqueue(async () => fn(await this.#current(), this.now())); + } + + /** + * Changes the save. `fn` gets a copy (or null before a company exists) and + * returns the new save, plus a result for the caller. + */ + write( + fn: ( + save: SaveDocument | null, + now: RealTime, + ) => { save: SaveDocument; result: T } | Promise<{ save: SaveDocument; result: T }>, + ): Promise { + return this.#enqueue(async () => { + if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError(); + const current = await this.#current(); + const now = this.now(); + const out = await fn(current ? structuredClone(current) : null, now); + const next = saveDocumentSchema.parse({ + ...out.save, + lastTimeSeen: Math.max(out.save.lastTimeSeen, now), + }); + await this.deps.storage.save(next); + this.#doc = next; + return out.result; + }); + } + + /** Removes the save entirely (new company, import). */ + clear(): Promise { + return this.#enqueue(async () => { + if (this.deps.canWrite && !this.deps.canWrite()) throw new GameLockedError(); + await this.deps.storage.clear(); + this.#doc = null; + this.#loaded = true; + }); + } +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 654ad8a..14de891 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -13,3 +13,11 @@ export { type SaveBackend, type Slot, } from "./storage"; +export { + cryptoRandom, + EngineCore, + GameLockedError, + type CoreDeps, + type RandomSource, +} from "./core"; +export { GAME_LOCK, TabLock, type LockManagerLike } from "./lock"; diff --git a/packages/engine/src/lock.ts b/packages/engine/src/lock.ts new file mode 100644 index 0000000..7bb90d1 --- /dev/null +++ b/packages/engine/src/lock.ts @@ -0,0 +1,76 @@ +/** + * One tab runs the game (decision 0.5.90), using the Web Locks API: the tab + * that holds the lock runs commands; another tab can take it over ("Use here"), + * and the tab that loses it stops. + */ + +/** The parts of `navigator.locks` this uses, so tests can pass a fake. */ +export interface LockManagerLike { + request( + name: string, + options: { ifAvailable?: boolean; steal?: boolean }, + callback: (lock: unknown) => Promise | unknown, + ): Promise; +} + +export const GAME_LOCK = "deadhead-game"; + +export class TabLock { + #held = false; + #release: (() => void) | null = null; + #onLost: (() => void) | null = null; + + constructor( + /** `navigator.locks`; absent (older browsers, native) means this tab always holds the game. */ + private readonly locks: LockManagerLike | undefined, + private readonly name = GAME_LOCK, + ) {} + + get held(): boolean { + return this.locks === undefined || this.#held; + } + + /** Called when another tab takes the game over. */ + onLost(callback: () => void): void { + this.#onLost = callback; + } + + /** + * Takes the game if no other tab has it (or, with `takeOver`, from the tab + * that does). Resolves true when this tab now holds it. + */ + acquire(options: { takeOver?: boolean } = {}): Promise { + if (this.locks === undefined) return Promise.resolve(true); + if (this.#held) return Promise.resolve(true); + return new Promise((resolve) => { + const request = this.locks!.request( + this.name, + options.takeOver ? { steal: true } : { ifAvailable: true }, + (lock) => { + if (lock === null) { + resolve(false); + return undefined; + } + this.#held = true; + resolve(true); + // Hold the lock until released here or stolen by another tab. + return new Promise((release) => (this.#release = release)); + }, + ); + request.catch(() => { + // Another tab stole the lock. + if (this.#held) { + this.#held = false; + this.#release = null; + this.#onLost?.(); + } + }); + }); + } + + release(): void { + this.#held = false; + this.#release?.(); + this.#release = null; + } +} diff --git a/packages/engine/test/core.test.ts b/packages/engine/test/core.test.ts new file mode 100644 index 0000000..3121cfc --- /dev/null +++ b/packages/engine/test/core.test.ts @@ -0,0 +1,139 @@ +import type { SaveDocument } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { fullSave } from "../../schema/test/save-fixture"; +import { + EngineCore, + GameLockedError, + MemoryBackend, + SaveStorage, + TabLock, + type LockManagerLike, +} from "../src/index"; + +const fixedClock = (t = 5_000_000) => ({ now: () => t }); + +async function engineWith(save: SaveDocument, backend = new MemoryBackend()) { + const storage = new SaveStorage(backend); + await storage.save(save); + return { engine: new EngineCore({ storage, clock: fixedClock() }), storage, backend }; +} + +/** A purchase: refuses unless the credits cover it (as the real command will). */ +const buy = (engine: EngineCore, price: number) => + engine.write((save) => { + if (!save) throw new Error("no company"); + if (save.company.credits < price) throw new Error("insufficient-funds"); + save.company.credits -= price; + save.bays.push({ id: `bay-${save.bays.length}`, locationId: "moon", createdAt: 0 }); + return { save, result: save.company.credits }; + }); + +describe("mutation queue (Step 14.4, decision 0.5.90)", () => { + it("runs two rapid purchases one at a time: only the affordable one happens", async () => { + const start = { ...fullSave(), company: { ...fullSave().company, credits: 1_500 } }; + const { engine, storage } = await engineWith(start); + const results = await Promise.allSettled([buy(engine, 1_000), buy(engine, 1_000)]); + expect(results.map((r) => r.status)).toEqual(["fulfilled", "rejected"]); + const saved = await storage.load(); + expect(saved.status === "loaded" && saved.save.company.credits).toBe(500); + expect(saved.status === "loaded" && saved.save.bays.length).toBe(start.bays.length + 1); + }); + + it("leaves the save and the engine's copy unchanged when a command throws", async () => { + const { engine, backend } = await engineWith(fullSave()); + const before = backend.slots.get("main"); + await expect( + engine.write((save) => { + save!.company.credits = 0; + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(backend.slots.get("main")).toBe(before); + expect(await engine.read((save) => save!.company.credits)).toBe(fullSave().company.credits); + }); + + it("refuses to save a state that doesn't validate", async () => { + const { engine, backend } = await engineWith(fullSave()); + const before = backend.slots.get("main"); + await expect( + engine.write((save) => ({ save: { ...save!, company: null as never }, result: 0 })), + ).rejects.toThrow(); + expect(backend.slots.get("main")).toBe(before); + }); + + it("records the time it last saw, never going below the save's", async () => { + const lastSeen = fullSave().lastTimeSeen; + for (const [clockAt, expected] of [ + [lastSeen + 60_000, lastSeen + 60_000], + [lastSeen - 60_000, lastSeen], + ]) { + const storage = new SaveStorage(new MemoryBackend()); + await storage.save(fullSave()); + const engine = new EngineCore({ storage, clock: fixedClock(clockAt) }); + await engine.write((save) => ({ save: save!, result: null })); + const saved = await storage.load(); + expect(saved.status === "loaded" && saved.save.lastTimeSeen).toBe(expected); + } + }); +}); + +/** Web Locks semantics: one holder; ifAvailable gets null when held; steal aborts the holder. */ +class FakeLocks implements LockManagerLike { + #holder: { reject: (e: Error) => void } | null = null; + request( + _name: string, + options: { ifAvailable?: boolean; steal?: boolean }, + callback: (lock: unknown) => unknown, + ): Promise { + if (this.#holder && options.ifAvailable) return Promise.resolve(callback(null)); + if (this.#holder && options.steal) { + this.#holder.reject(new DOMException("Lock broken by another request", "AbortError")); + this.#holder = null; + } + return new Promise((resolve, reject) => { + const holder = { reject }; + this.#holder = holder; + Promise.resolve(callback({ name: "deadhead-game" })).then((value) => { + if (this.#holder === holder) this.#holder = null; + resolve(value); + }); + }); + } +} + +describe("one tab runs the game (Step 14.4)", () => { + it("lets a second tab run commands only after it takes over", async () => { + const backend = new MemoryBackend(); + const storage = new SaveStorage(backend); + await storage.save(fullSave()); + const locks = new FakeLocks(); + const lockA = new TabLock(locks); + const lockB = new TabLock(locks); + const tabA = new EngineCore({ storage, clock: fixedClock(), canWrite: () => lockA.held }); + const tabB = new EngineCore({ storage, clock: fixedClock(), canWrite: () => lockB.held }); + let aLost = false; + lockA.onLost(() => (aLost = true)); + + expect(await lockA.acquire()).toBe(true); + expect(await lockB.acquire()).toBe(false); + await buy(tabA, 100); + await expect(buy(tabB, 100)).rejects.toBeInstanceOf(GameLockedError); + + // "Use here" in tab B. + expect(await lockB.acquire({ takeOver: true })).toBe(true); + await Promise.resolve(); + expect(aLost).toBe(true); + expect(lockA.held).toBe(false); + await expect(buy(tabA, 100)).rejects.toBeInstanceOf(GameLockedError); + + // Tab B picks up what tab A saved. + tabB.reload(); + expect(await buy(tabB, 100)).toBe(fullSave().company.credits - 200); + }); + + it("always holds the game where Web Locks don't exist", async () => { + const lock = new TabLock(undefined); + expect(lock.held).toBe(true); + expect(await lock.acquire()).toBe(true); + }); +});