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>
233 lines
9.6 KiB
TypeScript
233 lines
9.6 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);
|
|
|
|
// One from another supported English cartridge carries over.
|
|
const us10 = testInfo.outputPath("us10.crystal-save.json");
|
|
writeFileSync(
|
|
us10,
|
|
JSON.stringify({
|
|
...JSON.parse(readFileSync(backup, "utf8")),
|
|
romHash: "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
|
}),
|
|
);
|
|
await page.locator("#save-file").setInputFiles(us10);
|
|
await expect(preview).toContainText("Made with English US/Europe 1.0; it continues with this one");
|
|
await expect(page.locator("#import-new")).toHaveCount(1);
|
|
await preview.getByRole("button", { name: "Cancel" }).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([]);
|
|
});
|
|
|
|
test("save slots: a cartridge .sav is previewed, refused when blank and imported as a new save", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const cartridge = resolve(
|
|
"debug-saves/Pokémon - Crystal Version/Pokémon - Crystal Version/Pokémon - Crystal Version.sav",
|
|
);
|
|
test.skip(
|
|
!existsSync(romPath) || !existsSync(cartridge),
|
|
"Requires a local user-supplied ROM.",
|
|
);
|
|
const errors: string[] = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto("/");
|
|
await page.locator("#rom-file").setInputFiles(romPath);
|
|
await expect(page.locator("#intro-screen")).toBeVisible();
|
|
await page.locator("#tab-saves").click();
|
|
const slots = page.locator("#slot-list > li");
|
|
await expect(slots).toHaveCount(1);
|
|
|
|
// A cartridge save without a game is refused, and nothing changes.
|
|
const blank = testInfo.outputPath("blank.sav");
|
|
writeFileSync(blank, new Uint8Array(0x8000).fill(0xff));
|
|
await page.locator("#save-file").setInputFiles(blank);
|
|
const preview = page.locator("#import-dialog");
|
|
await expect(preview).toContainText("This file cannot be imported");
|
|
await expect(preview).toContainText("holds no saved game");
|
|
await expect(page.locator("#import-new")).toHaveCount(0);
|
|
await preview.getByRole("button", { name: "Close" }).click();
|
|
await expect(slots).toHaveCount(1);
|
|
|
|
await page.locator("#save-file").setInputFiles(cartridge);
|
|
await expect(preview).toContainText("Mattiaᴾᴷ's game");
|
|
await expect(preview).toContainText("Mattiaᴾᴷ · ID No. 08872");
|
|
await expect(preview).toContainText("Pokémon Crystal cartridge save");
|
|
await expect(preview).toContainText("This device's time");
|
|
await expect(preview).toContainText("Conversion notes");
|
|
await expect(preview).toContainText("tiles walked count starts from zero");
|
|
await page.locator("#import-new").click();
|
|
await expect(page.locator("#status")).toContainText("Backup restored");
|
|
await expect(slots).toHaveCount(2);
|
|
await expect(
|
|
page.locator('#slot-list > li[aria-current="true"]'),
|
|
).toContainText("Mattiaᴾᴷ");
|
|
await expect(page.locator("#game-screen")).toHaveAttribute(
|
|
"data-map",
|
|
"24:4",
|
|
);
|
|
expect(errors).toEqual([]);
|
|
});
|