Keep several saves, with migration, atomic writes and import previews (Closes #37)
Checks / web (push) Successful in 2m33s
Private ROM checks / private-rom (push) Successful in 20m32s

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:
kawaandClaude Opus 5.5 committed 2026-10-16 11:27:41 +02:00
1 parent 7c12c81a27
commit 7d03f5c5ca
18 files changed
+2079 -56

No files matched your search

+454
View File
@@ -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 },
]);
});
});