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