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