import { migrateSave } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { fullSave } from "../../schema/test/save-fixture"; import { decodeSave, encodeSave, GameEngine, MemoryBackend, SAVE_CODE_PREFIX, SaveStorage, type GameEngineOptions, } from "../src/index"; import { getState, readSave, signUpOwner, setCredits, TestClock, testContract, putBoard, } from "./support"; function freshDevice(migrate?: GameEngineOptions["migrate"]) { const clock = new TestClock(); const app = new GameEngine({ storage: new SaveStorage(new MemoryBackend()), clock: { now: () => clock.now }, migrate, }); return { app, clock }; } describe("save export and import (Step 14.10, decision 0.5.92)", () => { it("round-trips a save through the code", async () => { const save = fullSave(); const code = await encodeSave(save); expect(code.startsWith(SAVE_CODE_PREFIX)).toBe(true); expect(code).toMatch(/^[\w.-]+$/); expect(await decodeSave(code)).toEqual({ ok: true, save }); // Line breaks and spaces from pasting don't matter. const wrapped = code.replace(/(.{40})/g, "$1\n "); expect(await decodeSave(wrapped)).toEqual({ ok: true, save }); }); it("reproduces the same state on a fresh device", async () => { const home = freshDevice(); const { shipId, avatarId } = await signUpOwner(home.app); await setCredits(home.app, 12_345); const contract = testContract(); await putBoard(home.app, [contract]); await home.app.call("createDispatch", { kind: "contract", contractId: contract.id, shipId, pilotId: avatarId, }); const before = await getState(home.app); const { code } = await home.app.call("exportSave", {}); const other = freshDevice(); other.clock.now = home.clock.now; const imported = await other.app.call("importSave", { code }); expect(imported.state).toEqual(before); expect(await getState(other.app)).toEqual(before); expect(await readSave(other.app)).toEqual(await readSave(home.app)); }); it("replaces the company already on the device", async () => { const home = freshDevice(); await signUpOwner(home.app); const { code } = await home.app.call("exportSave", {}); const other = freshDevice(); await signUpOwner(other.app); const { playerId } = await other.app.call("importSave", { code }); expect(playerId).toBe((await readSave(home.app)).saveId); expect((await getState(other.app)).company).toEqual((await getState(home.app)).company); }); it("migrates an older save on import", async () => { // Only save version 1 exists so far, so an older format is simulated: this // build's migration fills in what the "older" export lacked. const old = fullSave() as Record; delete old.stats; const code = await encodeSave(old as never); const migrations: unknown[] = []; const { app } = freshDevice((raw) => { migrations.push(raw); return migrateSave({ ...(raw as object), stats: { contractDeliveries: 0, priorityDeliveries: 0 }, }); }); const { state } = await app.call("importSave", { code }); expect(migrations).toHaveLength(1); expect(state.company.name).toBe(fullSave().company.name); expect((await readSave(app)).stats).toEqual({ contractDeliveries: 0, priorityDeliveries: 0 }); }); it.each([ ["not a code", "hello there", "That isn't a Deadhead save code."], ["damaged", `${SAVE_CODE_PREFIX}AAAAbroken`, "The save code is incomplete or damaged."], ])("refuses %s and changes nothing", async (_label, code, message) => { const home = freshDevice(); await signUpOwner(home.app); const before = await readSave(home.app); await expect(home.app.call("importSave", { code })).rejects.toMatchObject({ code: "invalid-save", message, }); expect(await readSave(home.app)).toEqual(before); }); it("refuses a code that decodes to something that isn't a save", async () => { const { app } = freshDevice(); const code = await encodeSave({ hello: "world" } as never); await expect(app.call("importSave", { code })).rejects.toMatchObject({ code: "invalid-save" }); await expect(app.call("getPlayerState", {})).rejects.toMatchObject({ code: "unauthorized" }); }); it("has nothing to export before a company exists", async () => { const { app } = freshDevice(); await expect(app.call("exportSave", {})).rejects.toMatchObject({ code: "unauthorized" }); }); });