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

+1 -1
View File
@@ -158,8 +158,8 @@ test("pausing battle freezes messages and importing its backup restores committe
const text = await page.locator("#battle-text").textContent();
await page.clock.runFor(5000);
expect(await page.locator("#battle-text").textContent()).toBe(text);
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(path);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await expect(page.locator("#battle")).toBeHidden();
await page.locator("#play").click();
+3 -3
View File
@@ -8,9 +8,9 @@ import { encodeSave } from "../src/storage/saves";
import { foundationFixture } from "./foundation-fixture";
// Backups for the manual checks on the project board's "A tester" column,
// built from the local ROM and imported through Savegames → Import native
// backup. They hold names read from the ROM, so they stay out of the
// repository (*.crystal-save.json is ignored).
// built from the local ROM and imported through Savegames → Import backup
// (then "Add as a new save"). They hold names read from the ROM, so they
// stay out of the repository (*.crystal-save.json is ignored).
//
// MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts
const romPath =
+5 -5
View File
@@ -120,8 +120,8 @@ test("local maps follow a stairs transition and importing a previous position",
`${world.map.name} · ${world.map.location}`,
);
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#tab-map").click();
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7");
@@ -239,8 +239,8 @@ test("choosing a starter shows its picture, asks for a nickname and saves the pa
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
@@ -333,8 +333,8 @@ test("a home exit mat survives save/reload and waits for the exit direction", as
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
@@ -409,8 +409,8 @@ test("walks from New Bark Town onto Route 29 after receiving a starter", async (
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
@@ -496,8 +496,8 @@ test("native player loads the actual ROM without loading an emulator and persist
const exported = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await exported;
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
+48
View File
@@ -575,6 +575,54 @@ describe.skipIf(!existsSync(romPath))(
expect(world.save().playerId).toBeLessThan(0x10000);
expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid");
});
it("describes saves of every schema for the Savegames list", () => {
const world = new GameWorld(load(), () => new Date("2026-09-22T12:00:00"));
const flags = [27, 28, 29];
const town = world.assets.map("24:4");
const land = Array.from({ length: town.width * town.height }, (_, i) => ({
x: i % town.width,
y: Math.floor(i / town.width),
})).find(
({ x, y }) =>
world.assets.permission(world.assets.collision(town, x, y)) === 0,
)!;
world.restore({
...world.save(),
name: "GOLD",
map: "24:4",
...land,
flags,
gameTime: { hours: 3, minutes: 7, seconds: 0, frames: 0, capped: false },
pokedex: { seen: [152, 16], caught: [152] },
});
const current = world.save();
expect(world.describe()).toEqual({
player: "GOLD",
playerId: current.playerId,
place: "NEW BARK TOWN",
hours: 3,
minutes: 7,
badges: 3,
caught: 1,
newGame: false,
});
// Indoors, the landmark is the map's own (the player's house).
world.restore({ ...current, map: "24:6", x: 6, y: 6 });
expect(world.describe().place).toBe("NEW BARK TOWN");
const { scenes, phone, clock, objects, playerId, party, bag, pokedex, ...legacy } =
current;
void [scenes, phone, clock, objects, playerId, party, bag, pokedex];
for (const version of [1, 2]) {
const scratch = new GameWorld(load(), () => new Date("2026-09-22T12:00:00"));
scratch.restore({ ...legacy, version, ...(version === 2 && { scenes: {}, phone: [], clock, objects: [] }) });
expect(scratch.describe()).toMatchObject({
player: "GOLD",
place: "NEW BARK TOWN",
badges: 3,
caught: 0,
});
}
});
it("decodes species, moves, items and growth from the ROM", () => {
const data = load().pokemon;
expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([
+1 -1
View File
@@ -99,8 +99,8 @@ test("Mr. Pokémon's ROM scene grants the Egg and Pokédex, heals and survives r
expect(state.scenes).toMatchObject({ "26:10": 1, "26:3": 1, "24:5": 3 });
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(path);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
+167
View File
@@ -0,0 +1,167 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
/** The version-1 database the singleton player left, with one save. */
async function seedSingleton(page: Page, record: object) {
await page.evaluate(
(record) =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.open("crystal-native-port", 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("saves", { keyPath: "romHash" });
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction("saves", "readwrite");
const { state, ...fields } = record as { state: number[] };
transaction
.objectStore("saves")
.put({ ...fields, state: new Uint8Array(state) });
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => reject(transaction.error);
};
}),
record,
);
}
test("save slots: migration, new game, rename, continue, duplicate, delete and import preview", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const bytes = readFileSync(romPath);
const romHash = createHash("sha1").update(bytes).digest("hex");
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
world.restore({ ...world.save(), name: "GOLD", map: "24:6", x: 6, y: 6 });
const state = JSON.stringify(world.save());
await page.goto("/");
await seedSingleton(page, {
version: 1,
romHash,
coreId: "crystal-native-world-v1",
savedAt: "2026-10-01T12:00:00.000Z",
state: [...new TextEncoder().encode(state)],
});
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session is ready");
const screen = page.locator("#game-screen");
await expect(screen).toHaveAttribute("data-map", "24:6");
// The singleton save became the first slot.
await page.locator("#tab-saves").click();
const slots = page.locator("#slot-list > li");
await expect(slots).toHaveCount(1);
await expect(slots.first()).toHaveAttribute("aria-current", "true");
await expect(slots.first()).toContainText("Save 1");
await expect(slots.first()).toContainText("GOLD");
await expect(
page.getByRole("button", { name: "Delete Save 1" }),
).toBeDisabled();
// A new game gets its own slot and starts with the introduction.
await page.locator("#new-game").click();
await expect(page.locator("#status")).toContainText("“Save 2” created");
await expect(page.locator("#intro-screen")).toBeVisible();
await page.locator("#tab-saves").click();
await expect(slots).toHaveCount(2);
await expect(
page.locator('#slot-list > li[aria-current="true"]'),
).toContainText("Save 2");
// Rename the first slot, then continue it.
await page.getByRole("button", { name: "Rename Save 1" }).click();
await page.locator("#slot-name").fill("Gold run");
await page.locator("#slot-dialog-confirm").click();
await expect(page.locator("#status")).toContainText("Renamed to “Gold run”");
await page.getByRole("button", { name: "Continue Gold run" }).click();
await expect(page.locator("#status")).toContainText("“Gold run” is ready");
await expect(screen).toHaveAttribute("data-map", "24:6");
await expect(page.locator("#intro-screen")).toBeHidden();
// Duplicate it, then delete the copy after confirming.
await page.getByRole("button", { name: "Duplicate Gold run" }).click();
await expect(page.locator("#status")).toContainText(
"copied to “Gold run copy”",
);
await expect(slots).toHaveCount(3);
await page.getByRole("button", { name: "Delete Gold run copy" }).click();
await expect(page.locator("#slot-dialog")).toBeVisible();
await page
.locator("#slot-dialog")
.getByRole("button", { name: "Cancel" })
.click();
await expect(slots).toHaveCount(3);
await page.getByRole("button", { name: "Delete Gold run copy" }).click();
await page.locator("#slot-dialog-confirm").click();
await expect(page.locator("#status")).toContainText(
"“Gold run copy” deleted",
);
await expect(slots).toHaveCount(2);
// The import preview shows the trainer and the slot it conflicts with,
// and cancelling changes nothing.
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = (await (await downloaded).path())!;
await page.locator("#save-file").setInputFiles(backup);
const preview = page.locator("#import-dialog");
await expect(preview).toBeVisible();
await expect(preview).toContainText("GOLD · ID No.");
await expect(preview).toContainText("game data version 3");
await expect(preview).toContainText("“Gold run”");
await expect(preview).toContainText("the same progress as this backup");
await preview.getByRole("button", { name: "Cancel" }).click();
await expect(slots).toHaveCount(2);
// A backup for another cartridge is refused without changing any slot.
const foreign = testInfo.outputPath("foreign.crystal-save.json");
writeFileSync(
foreign,
JSON.stringify({
...JSON.parse(readFileSync(backup, "utf8")),
romHash: "0".repeat(40),
}),
);
await page.locator("#save-file").setInputFiles(foreign);
await expect(preview).toContainText("different cartridge");
await expect(page.locator("#import-new")).toHaveCount(0);
await preview.getByRole("button", { name: "Close" }).click();
await expect(slots).toHaveCount(2);
// Importing as a new save adds a slot and continues it.
await page.locator("#save-file").setInputFiles(backup);
await page.locator("#import-new").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await expect(slots).toHaveCount(3);
await expect(
page.locator('#slot-list > li[aria-current="true"]'),
).toContainText("GOLD");
// The last slot played opens after a reload.
await page.getByRole("button", { name: "Continue Gold run" }).click();
await expect(page.locator("#status")).toContainText("“Gold run” is ready");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session is ready");
await page.locator("#tab-saves").click();
await expect(
page.locator('#slot-list > li[aria-current="true"]'),
).toContainText("Gold run");
expect(errors).toEqual([]);
});
+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 },
]);
});
});