deadhead-space-freight-tycoon/packages/engine/test/core.test.ts

140 lines
5.5 KiB
TypeScript

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