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); }); });