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>
168 lines
6.9 KiB
TypeScript
168 lines
6.9 KiB
TypeScript
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([]);
|
|
});
|