Step 14.4: mutation queue and tab lock
CI / check (push) Successful in 7m24s
Details
CI / check (push) Successful in 7m24s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b100e8664b
commit
fcc3047af8
|
|
@ -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).
|
- **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.
|
- **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).
|
- **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`.
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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.5 — Port the game logic
|
||||||
- [ ] Step 14.6 — Resolution loop
|
- [ ] Step 14.6 — Resolution loop
|
||||||
- [ ] Step 14.7 — Switch the client to the engine
|
- [ ] Step 14.7 — Switch the client to the engine
|
||||||
|
|
|
||||||
|
|
@ -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<unknown> = 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<SaveDocument | null> {
|
||||||
|
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<T>(work: () => Promise<T>): Promise<T> {
|
||||||
|
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<T>(fn: (save: SaveDocument | null, now: RealTime) => T | Promise<T>): Promise<T> {
|
||||||
|
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<T>(
|
||||||
|
fn: (
|
||||||
|
save: SaveDocument | null,
|
||||||
|
now: RealTime,
|
||||||
|
) => { save: SaveDocument; result: T } | Promise<{ save: SaveDocument; result: T }>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,3 +13,11 @@ export {
|
||||||
type SaveBackend,
|
type SaveBackend,
|
||||||
type Slot,
|
type Slot,
|
||||||
} from "./storage";
|
} from "./storage";
|
||||||
|
export {
|
||||||
|
cryptoRandom,
|
||||||
|
EngineCore,
|
||||||
|
GameLockedError,
|
||||||
|
type CoreDeps,
|
||||||
|
type RandomSource,
|
||||||
|
} from "./core";
|
||||||
|
export { GAME_LOCK, TabLock, type LockManagerLike } from "./lock";
|
||||||
|
|
|
||||||
|
|
@ -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> | unknown,
|
||||||
|
): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<void>((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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<unknown> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue