Keep several saves, with migration, atomic writes and import previews (Closes #37)
Savegames now lists independent save slots made with the loaded ROM: new game, continue, rename, duplicate, export and delete. The native database moves to IndexedDB version 2; the singleton save becomes "Save 1" inside the upgrade transaction, so it migrates exactly once and the old store is left untouched. Each write adds a generation and drops older ones in one transaction, keeping two records with CRC-32 checksums: a damaged newest record falls back to the previous one. Slots and records from a newer player are kept and never written. Imports are checked in a scratch world before anything changes and previewed with the trainer, progress, saved time, game data version and the slots of the same trainer; they go into a new save or replace one, whose previous record stays recoverable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c12c81a27
commit
7d03f5c5ca
18 files changed
+2079
-56
No files matched your search
@@ -0,0 +1,454 @@
|
||||
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 };
|
||||
}
|
||||
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> = {}): 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<IDBDatabase>((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<void>((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<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const records = await new Promise<SaveRecord[]>((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<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await new Promise<void>((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<void>((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("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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user