Checks / web (push) Canceled after 0s
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic return warps, elevators, Surf, Cut, Strength and cycling rules, working map blocks with changeblock/changemapblocks, and the remaining NPC movement types and movement commands. Add tileset animation from the ROM's command tables, grass sprite priority and special map palettes; room decorations with ownership, the bedroom PC and save migration; and Key Items access to the Bicycle. Fourteen original-engine traces compare positions, facing, actors, event flags and scripted poses. Tile animation matches original VRAM for four tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now keeps the $60 sprite offset its step function leaves behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
5.9 KiB
TypeScript
138 lines
5.9 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);
|
|
await route.fulfill({ response, body: source.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
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.locator("#save-file").setInputFiles(path);
|
|
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);
|
|
});
|