Files
Crystal/tests/quest-browser.spec.ts
kawaandClaude Opus 5.5 7d03f5c5ca
Checks / web (push) Successful in 2m33s
Private ROM checks / private-rom (push) Successful in 20m32s
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>
2026-10-16 11:27:41 +02:00

140 lines
6.1 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
async function start(
page: Page,
fixture: "questFixtureWorld" | "questRivalFixtureWorld" | "questLabFixtureWorld",
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch();
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { ${fixture} } = await import('/tests/quest-fixture.ts');
const world = ${fixture}(assets);`) });
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
async function step(page: Page, direction: string) {
await page.keyboard.down(`Arrow${direction}`);
await page.clock.runFor(70);
await page.keyboard.up(`Arrow${direction}`);
await page.clock.runFor(300);
}
async function drive(page: Page, name = "") {
const messages: string[] = [];
for (let guard = 0; guard < 180; guard++) {
if (await page.locator("#battle").isVisible()) {
messages.push(await page.locator("#battle-text").innerText());
const menu = page.locator("#battle-menu");
const fight = menu.getByRole("button", { name: "FIGHT", exact: true });
if (await fight.count()) {
await fight.click();
await menu.getByRole("button", { name: /SCRATCH/ }).click();
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
await menu.getByRole("button", { name: /Continue/ }).click();
} else await page.clock.runFor(1200);
} else if (await page.locator("#dialogue-choice input").isVisible()) {
messages.push(await page.locator("#dialogue-text").innerText());
const input = page.locator("#dialogue-choice input");
await expect(input).toBeFocused();
await input.fill(name);
await input.press("Enter");
await page.clock.runFor(500);
} else if (await page.locator("#native-dialogue").isVisible()) {
messages.push(await page.locator("#dialogue-text").innerText());
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else {
// A scene script starts a tick after the tick that ends a warp, so a
// single idle observation is not the end of the scene.
await page.clock.runFor(200);
if (
(await page.locator("#game-screen").getAttribute("data-activity")) !== "idle" ||
(await page.locator("#native-dialogue").isVisible())
)
continue;
await expect(page.locator("#game-notice")).toHaveText("");
return messages.join("\n");
}
}
throw new Error("Quest scene did not finish.");
}
async function backup(page: Page) {
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await downloaded).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
return { path, state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")) };
}
test("Mr. Pokémon's ROM scene grants the Egg and Pokédex, heals and survives reload", async ({ page }) => {
await start(page, "questFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "26:10");
const shown = await drive(page);
expect(shown).toContain("MYSTERY EGG");
expect(shown).toContain("POKéDEX");
const { path, state } = await backup(page);
expect(state.events).toContain(30);
expect(state.bag.keyItems).toContain(0x45);
expect(state.scenes).toMatchObject({ "26:10": 1, "26:3": 1, "24:5": 3 });
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
await page.locator("#save-file").setInputFiles(path);
await page.locator("#import-current").click();
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
const restored = await backup(page);
expect(restored.state).toEqual(state);
});
test("the real Cherrygrove return scene battles the rival and commits its exit", async ({ page }) => {
await start(page, "questRivalFixtureWorld");
await drive(page); // a pending outdoor call may run before the encounter
await step(page, "Right");
const shown = await drive(page);
expect(shown).toContain("CHIKORITA");
expect(shown).toContain("???");
await expect(page.locator("#battle")).toBeHidden();
const { state } = await backup(page);
expect(state.scenes["26:3"]).toBe(0);
expect(state.pokedex.seen).toContain(152);
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.events).toContain(30);
});
test("the officer in Elm's lab names the rival through the naming field", async ({ page }) => {
await start(page, "questLabFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "24:5");
await drive(page);
for (let y = 11; y > 5; y--) await step(page, "Up");
const shown = await drive(page, "BLUE");
expect(shown).toContain("RIVAL'S NAME?");
expect(shown).toContain("BLUE");
const { state } = await backup(page);
expect(state.rivalName).toBe("BLUE");
expect(state.scenes["24:5"]).toBe(2);
});