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
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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