import { describe, expect, it } from "vitest"; import "fake-indexeddb/auto"; import { SlotSession, type SlotRuntime } from "../src/app/slot-session"; import { encodeSave, type SaveRecord } from "../src/storage/saves"; import { SlotRepository, recordChecksum, type SlotSummary, } from "../src/storage/slots"; const ROM = { bytes: new Uint8Array([1]), sha1: "test-rom", name: "Test" }; const CORE = "test-native"; const canvas = {} as HTMLCanvasElement; let databases = 0; /** A game whose state is JSON { player, id, steps }. */ class FakeRuntime implements SlotRuntime { readonly id = CORE; game = { player: "", id: 0, steps: 0 }; private games = 0; async load() {} newGame() { this.game = { player: "KRIS", id: ++this.games, steps: 0 }; } reset() { this.game = { player: "", id: 0, steps: 0 }; } play() {} pause() {} setButton() {} dispose() {} captureState() { return new TextEncoder().encode(JSON.stringify(this.game)); } restoreState(state: Uint8Array) { this.game = this.parse(state); } summary(): SlotSummary { return summarize(this.game); } inspectState(state: Uint8Array) { return { summary: summarize(this.parse(state)), schema: 3 }; } /** A "cartridge save" here is the game's JSON prefixed with "SAV". */ convertCartridge(file: Uint8Array) { const text = new TextDecoder().decode(file); if (!text.startsWith("SAV")) throw new Error("Not a cartridge save."); return { state: new TextEncoder().encode(text.slice(3)), copy: "backup" as const, clock: "device" as const, notes: ["A note."], }; } private parse(state: Uint8Array) { const game = JSON.parse(new TextDecoder().decode(state)); if (typeof game.player !== "string" || !Number.isInteger(game.steps)) throw new Error("Bad state"); return game as FakeRuntime["game"]; } } function summarize(game: FakeRuntime["game"]): SlotSummary { return { player: game.player, playerId: game.id, place: "NEW BARK TOWN", hours: 0, minutes: game.steps, badges: 0, caught: 0, newGame: false, }; } function setup() { const name = `slots-${++databases}`; const store = new SlotRepository(name); const runtime = new FakeRuntime(); return { name, store, runtime, session: new SlotSession(runtime, store) }; } const state = (game: object) => new TextEncoder().encode(JSON.stringify(game)); const record = (game: object, extra: Partial = {}): SaveRecord => ({ version: 1, romHash: ROM.sha1, coreId: CORE, savedAt: "2026-10-01T12:00:00.000Z", state: state(game), ...extra, }); /** A version-1 database as the singleton player left it. */ async function legacyDatabase(name: string, saved: SaveRecord) { const db = await new Promise((resolve, reject) => { const request = indexedDB.open(name, 1); request.onupgradeneeded = () => request.result.createObjectStore("saves", { keyPath: "romHash" }); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); await new Promise((resolve, reject) => { const transaction = db.transaction("saves", "readwrite"); transaction.objectStore("saves").put(saved); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); db.close(); } async function legacyRecords(name: string) { const db = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); const records = await new Promise((resolve, reject) => { const request = db.transaction("saves").objectStore("saves").getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); db.close(); return records; } /** Overwrites a stored record behind the repository's back. */ async function tamper( name: string, slot: string, generation: number, change: (stored: { record: SaveRecord; checksum: number }) => void, ) { const db = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); await new Promise((resolve, reject) => { const transaction = db.transaction("records", "readwrite"); const records = transaction.objectStore("records"); const request = records.get([slot, generation]); request.onsuccess = () => { change(request.result); records.put(request.result); }; transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); db.close(); } describe("save slot migration", () => { it("moves the singleton save into a default slot exactly once", async () => { const { name, store, runtime, session } = setup(); const saved = record({ player: "GOLD", id: 7, steps: 12 }); await legacyDatabase(name, saved); expect(await session.load(ROM, canvas)).toMatchObject({ restored: true }); expect(runtime.game).toEqual({ player: "GOLD", id: 7, steps: 12 }); const [slot] = await store.list(ROM.sha1); expect(slot).toMatchObject({ name: "Save 1", head: 1, version: 1 }); expect(session.active?.id).toBe(slot.id); expect((await store.records(slot.id))[0]).toEqual({ generation: 1, record: saved, intact: true, }); // The list reads the summary from the record until the first write. expect((await session.slots())[0].slot.summary?.player).toBe("GOLD"); // The version-1 store is kept as it was. expect(await legacyRecords(name)).toEqual([saved]); // Deleting the slot does not bring the singleton back on reopening. await session.newGame(); await session.delete(slot.id); const reopened = new SlotRepository(name); expect((await reopened.list(ROM.sha1)).map((s) => s.name)).toEqual([ "Save 2", ]); }); it("keeps a save from a newer player without rewriting it", async () => { const { name, store, session } = setup(); const future = { ...record({ player: "SILVER", id: 3, steps: 1 }), version: 2, extra: "kept", } as unknown as SaveRecord; await legacyDatabase(name, future); const result = await session.load(ROM, canvas); expect(result.restored).toBe(false); expect(result.skipped.map((slot) => slot.name)).toEqual(["Save 1"]); const entries = await session.slots(); const kept = entries.find((entry) => !entry.compatible)!; expect(kept.slot.name).toBe("Save 1"); expect(entries.find((entry) => entry.active)?.slot.name).toBe("Save 2"); await session.save(); await expect(session.switchTo(kept.slot.id)).rejects.toThrow( "newer version", ); await expect(session.duplicate(kept.slot.id)).rejects.toThrow( "newer version", ); expect((await store.records(kept.slot.id))[0].record).toEqual(future); expect(session.active?.name).toBe("Save 2"); }); it("starts a new game in a first slot on an empty database", async () => { const { store, runtime, session } = setup(); expect(await session.load(ROM, canvas)).toEqual({ restored: false, recovered: false, skipped: [], }); expect(runtime.game.player).toBe("KRIS"); expect((await store.list(ROM.sha1)).map((slot) => slot.name)).toEqual([ "Save 1", ]); }); }); describe("save slots", () => { it("creates, loads, renames, duplicates and deletes independent slots", async () => { const { store, runtime, session } = setup(); await session.load(ROM, canvas); const first = session.active!; runtime.game.steps = 5; await session.save(); const second = await session.newGame("Nuzlocke"); expect(runtime.game.steps).toBe(0); runtime.game.steps = 9; await session.save(); await session.switchTo(first.id); expect(runtime.game.steps).toBe(5); expect(session.active?.id).toBe(first.id); expect(await store.active(ROM.sha1)).toBe(first.id); await session.rename(first.id, " Main run "); expect(session.active?.name).toBe("Main run"); await expect(session.rename(first.id, "")).rejects.toThrow("1 to 24"); await expect(session.rename(first.id, "x".repeat(25))).rejects.toThrow( "1 to 24", ); runtime.game.steps = 6; const copy = await session.duplicate(first.id); expect(copy.name).toBe("Main run copy"); expect(copy.summary?.minutes).toBe(6); // The copy is independent of its source. runtime.game.steps = 7; await session.save(); await session.switchTo(copy.id); expect(runtime.game.steps).toBe(6); await expect(session.delete(copy.id)).rejects.toThrow("Load another save"); await session.switchTo(second.id); expect(runtime.game.steps).toBe(9); await session.delete(copy.id); expect( (await session.slots()).map((entry) => entry.slot.name).sort(), ).toEqual(["Main run", "Nuzlocke"]); expect(await store.records(copy.id)).toEqual([]); }); it("writes each autosave into the slot it was captured from", async () => { const { store, runtime, session } = setup(); await session.load(ROM, canvas); const first = session.active!; // Hold every write until released, as a slow device would. let release!: () => void; const held = new Promise((resolve) => (release = resolve)); const write = store.write.bind(store); store.write = async (...args) => { await held; return write(...args); }; runtime.game.steps = 3; const autosave = session.save(); const second = session.newGame(); release(); await autosave; const slot = await second; store.write = write; expect(runtime.game.steps).toBe(0); const [newest] = await store.records(first.id); expect( JSON.parse(new TextDecoder().decode(newest.record.state)).steps, ).toBe(3); expect((await store.records(slot.id)).length).toBe(1); expect((await store.get(slot.id))?.summary?.minutes).toBe(0); }); it("keeps two records and recovers from a damaged or missing newest one", async () => { const { name, store, runtime, session } = setup(); await session.load(ROM, canvas); const slot = session.active!; for (const steps of [1, 2, 3]) { runtime.game.steps = steps; await session.save(); } expect((await store.records(slot.id)).map((r) => r.generation)).toEqual([ 4, 3, ]); // A torn write: the newest record no longer matches its checksum. await tamper(name, slot.id, 4, (stored) => { stored.record.state = state({ player: "KRIS", id: 1, steps: 99 }); }); const reloaded = new SlotSession( new FakeRuntime(), new SlotRepository(name), ); expect(await reloaded.load(ROM, canvas)).toMatchObject({ restored: true, recovered: true, }); expect((reloaded.runtime as FakeRuntime).game.steps).toBe(2); // The next write supersedes the damaged record. await reloaded.save(); expect( (await store.records(slot.id)).map((r) => [r.generation, r.intact]), ).toEqual([ [5, true], [4, false], ]); // A newest record the game rejects also falls back. await tamper(name, slot.id, 5, (stored) => { stored.record.state = state({ player: 5 }); stored.checksum = recordChecksum(stored.record); }); const again = new SlotSession(new FakeRuntime(), new SlotRepository(name)); await expect(again.load(ROM, canvas)).resolves.toMatchObject({ restored: false, skipped: [expect.objectContaining({ id: slot.id })], }); }); it("leaves the slot unchanged when a write is interrupted", async () => { const { store, runtime, session } = setup(); await session.load(ROM, canvas); const slot = session.active!; runtime.game.steps = 1; await session.save(); const before = await store.records(slot.id); // A value IndexedDB cannot store aborts the transaction midway. const summary = { ...runtime.summary(), place: (() => {}) as unknown as string, }; await expect( store.write( slot.id, record({ player: "KRIS", id: 1, steps: 8 }), summary, ), ).rejects.toThrow(); expect(await store.records(slot.id)).toEqual(before); expect((await store.get(slot.id))?.head).toBe(2); }); it("restores the running game when a slot cannot be opened", async () => { const { name, store, runtime, session } = setup(); await session.load(ROM, canvas); const first = session.active!; const broken = await store.create( ROM.sha1, "Broken", record({ player: 1 }), ); runtime.game.steps = 4; await expect(session.switchTo(broken.id)).rejects.toThrow( "could not be read", ); expect(runtime.game.steps).toBe(4); expect(session.active?.id).toBe(first.id); expect(await new SlotRepository(name).active(ROM.sha1)).toBe(first.id); }); }); describe("save slot import", () => { async function loaded() { const context = setup(); await context.session.load(ROM, canvas); context.runtime.game.steps = 2; await context.session.save(); return context; } it("rejects incompatible and damaged backups without changing a slot", async () => { const { store, runtime, session } = await loaded(); const slot = session.active!; const before = await store.records(slot.id); const good = record({ player: "KRIS", id: 5, steps: 1 }); const cases: [string, string][] = [ ["not json", "not a Crystal backup"], [encodeSave({ ...good, romHash: "other" }), "different cartridge"], [encodeSave({ ...good, coreId: "reference" }), "another player"], [ JSON.stringify({ ...JSON.parse(encodeSave(good)), version: 2 }), "save format 2", ], [ JSON.stringify({ ...JSON.parse(encodeSave(good)), state: "???=" }), "damaged", ], [encodeSave(record({ player: 1 })), "could not be read"], ["x".repeat(2_000_001), "too large"], ]; for (const [text, reason] of cases) { const preview = await session.previewImport(text); expect(preview.ok).toBe(false); expect(!preview.ok && preview.reason).toContain(reason); } expect(await store.records(slot.id)).toEqual(before); expect(runtime.game.steps).toBe(2); }); it("previews conflicts and imports as a new slot or over one", async () => { const { store, runtime, session } = await loaded(); const first = session.active!; const { text } = await session.exportSlot(first.id); const preview = await session.previewImport(text); if (!preview.ok) throw new Error(preview.reason); expect(preview.summary).toMatchObject({ player: "KRIS", minutes: 2 }); expect(preview.schema).toBe(3); // The export is the running game, which was just saved. expect(preview.conflicts).toEqual([ { slot: expect.objectContaining({ id: first.id }), identical: true }, ]); runtime.game.steps = 8; const added = await session.importBackup(preview, "new"); expect(added.name).toBe("KRIS"); expect(session.active?.id).toBe(added.id); expect(runtime.game.steps).toBe(2); // The slot left behind kept the progress made before importing. const [left] = await store.records(first.id); expect(JSON.parse(new TextDecoder().decode(left.record.state)).steps).toBe( 8, ); const replaced = await session.importBackup(preview, first.id); expect(replaced.id).toBe(first.id); expect(session.active?.id).toBe(first.id); // The replaced record stays as the previous generation. const records = await store.records(first.id); expect( records.map( (r) => JSON.parse(new TextDecoder().decode(r.record.state)).steps, ), ).toEqual([2, 8]); }); it("previews a cartridge save and imports it as a new slot", async () => { const { store, runtime, session } = await loaded(); const first = session.active!; const file = new TextEncoder().encode( `SAV${JSON.stringify({ player: "GOLD", id: 9, steps: 4 })}`, ); const modified = new Date("2026-10-02T08:00:00.000Z"); const preview = await session.previewCartridge(file, modified); if (!preview.ok) throw new Error(preview.reason); expect(preview.summary).toMatchObject({ player: "GOLD", minutes: 4 }); expect(preview.record.savedAt).toBe(modified.toISOString()); expect(preview.cartridge).toEqual({ copy: "backup", clock: "device", notes: ["A note."], }); expect(preview.conflicts).toEqual([]); const slot = await session.importBackup(preview, "new"); expect(slot.name).toBe("GOLD"); expect(runtime.game).toEqual({ player: "GOLD", id: 9, steps: 4 }); expect((await store.list(ROM.sha1)).map((s) => s.id)).toContain(first.id); }); it("refuses a cartridge save that cannot be converted or loaded", async () => { const { store, runtime, session } = await loaded(); const slot = session.active!; const before = await store.records(slot.id); const refused = await session.previewCartridge( new TextEncoder().encode("{}"), new Date(), ); expect(!refused.ok && refused.reason).toBe("Not a cartridge save."); const unreadable = await session.previewCartridge( new TextEncoder().encode(`SAV${JSON.stringify({ player: 1 })}`), new Date(), ); expect(!unreadable.ok && unreadable.reason).toContain( "did not pass the port's checks", ); expect(await store.records(slot.id)).toEqual(before); expect(runtime.game.steps).toBe(2); }); it("tells a conflicting slot that moved on from an identical one", async () => { const { session, store, runtime } = await loaded(); const first = session.active!; const { text } = await session.exportSlot(first.id); runtime.game.steps = 3; await session.save(); const other = await session.newGame(); expect((await store.list(ROM.sha1)).length).toBe(2); const preview = await session.previewImport(text); // The second game is another trainer; the first moved on since. expect(other.summary?.playerId).not.toBe(first.summary?.playerId); expect(preview.ok && preview.conflicts).toEqual([ { slot: expect.objectContaining({ id: first.id }), identical: false }, ]); }); });