Files
Crystal/tests/save-slots.test.ts
kawaandClaude Opus 5.5 3c480a28f0
Checks / web (push) Failing after 19s
Private ROM checks / private-rom (push) Failing after 25m36s
Read the English 1.0 and Australian cartridges besides 1.1 (#84)
PRET's crystal and crystal_au builds reproduce both retail cartridges,
and every label the profile pins keeps its address in all three;
check:reference now verifies that when the rebuilds are present. The
revision comes from the header. Revision 1.0's Battle Tower draws only
the first 21 trainers and picks its texts' gender from the name's sixth
character, checked against each cartridge. A native backup imports
across the three, which share every map, event and object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:12:00 +02:00

526 lines
19 KiB
TypeScript

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 { CRYSTAL_ROMS } from "../src/rom/validate";
import {
SlotRepository,
recordChecksum,
type SlotSummary,
} from "../src/storage/slots";
const ROM = { bytes: new Uint8Array([1]), sha1: "test-rom", name: "Test" };
const CORE = "test-native";
const canvas = {} as HTMLCanvasElement;
let databases = 0;
/** A game whose state is JSON { player, id, steps }. */
class FakeRuntime implements SlotRuntime {
readonly id = CORE;
game = { player: "", id: 0, steps: 0 };
private games = 0;
async load() {}
newGame() {
this.game = { player: "KRIS", id: ++this.games, steps: 0 };
}
reset() {
this.game = { player: "", id: 0, steps: 0 };
}
play() {}
pause() {}
setButton() {}
dispose() {}
captureState() {
return new TextEncoder().encode(JSON.stringify(this.game));
}
restoreState(state: Uint8Array) {
this.game = this.parse(state);
}
summary(): SlotSummary {
return summarize(this.game);
}
inspectState(state: Uint8Array) {
return { summary: summarize(this.parse(state)), schema: 3 };
}
/** A "cartridge save" here is the game's JSON prefixed with "SAV". */
convertCartridge(file: Uint8Array) {
const text = new TextDecoder().decode(file);
if (!text.startsWith("SAV")) throw new Error("Not a cartridge save.");
return {
state: new TextEncoder().encode(text.slice(3)),
copy: "backup" as const,
clock: "device" as const,
notes: ["A note."],
};
}
private parse(state: Uint8Array) {
const game = JSON.parse(new TextDecoder().decode(state));
if (typeof game.player !== "string" || !Number.isInteger(game.steps))
throw new Error("Bad state");
return game as FakeRuntime["game"];
}
}
function summarize(game: FakeRuntime["game"]): SlotSummary {
return {
player: game.player,
playerId: game.id,
place: "NEW BARK TOWN",
hours: 0,
minutes: game.steps,
badges: 0,
caught: 0,
newGame: false,
};
}
function setup() {
const name = `slots-${++databases}`;
const store = new SlotRepository(name);
const runtime = new FakeRuntime();
return { name, store, runtime, session: new SlotSession(runtime, store) };
}
const state = (game: object) => new TextEncoder().encode(JSON.stringify(game));
const record = (game: object, extra: Partial<SaveRecord> = {}): 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("carries a backup over from another supported English cartridge", async () => {
const [us11, us10] = CRYSTAL_ROMS;
const context = setup();
await context.session.load({ ...ROM, sha1: us11.sha1, name: us11.name }, canvas);
const backup = record({ player: "KRIS", id: 5, steps: 3 });
const preview = await context.session.previewImport(encodeSave({ ...backup, romHash: us10.sha1 }));
if (!preview.ok) throw new Error(preview.reason);
expect(preview.madeWith).toBe(us10.name);
expect(preview.record.romHash).toBe(us11.sha1);
const added = await context.session.importBackup(preview, "new");
expect(added.romHash).toBe(us11.sha1);
// The same file under its own cartridge has nothing to carry over.
const own = await context.session.previewImport(encodeSave({ ...backup, romHash: us11.sha1 }));
expect(own.ok && own.madeWith).toBeUndefined();
});
it("previews conflicts and imports as a new slot or over one", async () => {
const { store, runtime, session } = await loaded();
const first = session.active!;
const { text } = await session.exportSlot(first.id);
const preview = await session.previewImport(text);
if (!preview.ok) throw new Error(preview.reason);
expect(preview.summary).toMatchObject({ player: "KRIS", minutes: 2 });
expect(preview.schema).toBe(3);
// The export is the running game, which was just saved.
expect(preview.conflicts).toEqual([
{ slot: expect.objectContaining({ id: first.id }), identical: true },
]);
runtime.game.steps = 8;
const added = await session.importBackup(preview, "new");
expect(added.name).toBe("KRIS");
expect(session.active?.id).toBe(added.id);
expect(runtime.game.steps).toBe(2);
// The slot left behind kept the progress made before importing.
const [left] = await store.records(first.id);
expect(JSON.parse(new TextDecoder().decode(left.record.state)).steps).toBe(
8,
);
const replaced = await session.importBackup(preview, first.id);
expect(replaced.id).toBe(first.id);
expect(session.active?.id).toBe(first.id);
// The replaced record stays as the previous generation.
const records = await store.records(first.id);
expect(
records.map(
(r) => JSON.parse(new TextDecoder().decode(r.record.state)).steps,
),
).toEqual([2, 8]);
});
it("previews a cartridge save and imports it as a new slot", async () => {
const { store, runtime, session } = await loaded();
const first = session.active!;
const file = new TextEncoder().encode(
`SAV${JSON.stringify({ player: "GOLD", id: 9, steps: 4 })}`,
);
const modified = new Date("2026-10-02T08:00:00.000Z");
const preview = await session.previewCartridge(file, modified);
if (!preview.ok) throw new Error(preview.reason);
expect(preview.summary).toMatchObject({ player: "GOLD", minutes: 4 });
expect(preview.record.savedAt).toBe(modified.toISOString());
expect(preview.cartridge).toEqual({
copy: "backup",
clock: "device",
notes: ["A note."],
});
expect(preview.conflicts).toEqual([]);
const slot = await session.importBackup(preview, "new");
expect(slot.name).toBe("GOLD");
expect(runtime.game).toEqual({ player: "GOLD", id: 9, steps: 4 });
expect((await store.list(ROM.sha1)).map((s) => s.id)).toContain(first.id);
});
it("refuses a cartridge save that cannot be converted or loaded", async () => {
const { store, runtime, session } = await loaded();
const slot = session.active!;
const before = await store.records(slot.id);
const refused = await session.previewCartridge(
new TextEncoder().encode("{}"),
new Date(),
);
expect(!refused.ok && refused.reason).toBe("Not a cartridge save.");
const unreadable = await session.previewCartridge(
new TextEncoder().encode(`SAV${JSON.stringify({ player: 1 })}`),
new Date(),
);
expect(!unreadable.ok && unreadable.reason).toContain(
"did not pass the port's checks",
);
expect(await store.records(slot.id)).toEqual(before);
expect(runtime.game.steps).toBe(2);
});
it("tells a conflicting slot that moved on from an identical one", async () => {
const { session, store, runtime } = await loaded();
const first = session.active!;
const { text } = await session.exportSlot(first.id);
runtime.game.steps = 3;
await session.save();
const other = await session.newGame();
expect((await store.list(ROM.sha1)).length).toBe(2);
const preview = await session.previewImport(text);
// The second game is another trainer; the first moved on since.
expect(other.summary?.playerId).not.toBe(first.summary?.playerId);
expect(preview.ok && preview.conflicts).toEqual([
{ slot: expect.objectContaining({ id: first.id }), identical: false },
]);
});
});