import { test, expect, type Page } from "@playwright/test"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture"; const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"); async function start(page: Page, scenario: BattleScenario = "win") { // Replace only this test's runtime module response. No fixture import or // encounter override exists in the application or production build. 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 { battleFixtureWorld } = await import('/tests/battle-fixture.ts'); const world = battleFixtureWorld(assets, ${JSON.stringify(scenario)});`) }); }); 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(); for (const direction of ENCOUNTER_WALK) { const key = `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`; await page.keyboard.down(key); await page.clock.runFor(70); await page.keyboard.up(key); await page.clock.runFor(300); } await expect(page.locator("#battle")).toBeVisible(); await expect(page.locator("#battle-foe-name")).toHaveText("PIDGEY"); } async function exportState(page: Page) { await page.locator("#tab-saves").click(); const download = page.waitForEvent("download"); await page.locator("#backup").click(); const path = (await (await download).path())!; const record = JSON.parse(readFileSync(path, "utf8")); return { path, state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")) }; } async function finish(page: Page, action: "fight" | "run" = "fight") { const messages = new Set(); for (let guard = 0; guard < 100; guard++) { if (await page.locator("#battle").isVisible()) { messages.add(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()) { if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click(); else { await fight.click(); await menu.getByRole("button", { name: /TACKLE|TRANSFORM/ }).click(); } } else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) { await menu.getByRole("button", { name: "YES", exact: true }).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("#native-dialogue").isVisible()) { messages.add(await page.locator("#dialogue-text").innerText()); await page.keyboard.press("KeyX"); await page.clock.runFor(1500); } else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") { await page.clock.runFor(1500); } else return [...messages].join("\n"); } throw new Error(`Battle did not finish: ${[...messages].join(" | ")}`); } test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; only encounter selection is a test fixture.")); test("battle UI wins, acknowledges level-up stats and saves learned moves", async ({ page }) => { await start(page); const messages = await finish(page); expect(messages).toContain("SMOKESCREEN"); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await exportState(page); expect(state.party[0]).toMatchObject({ species: 155, level: 6 }); expect(state.party[0].moves).toContain(108); expect(state.pokedex.seen).toContain(16); expect(state.pokedex.caught).not.toContain(16); }); test("battle UI escapes without experience and saves a seen species", async ({ page }) => { await start(page); expect(await finish(page, "run")).toContain("Got away safely"); const { state } = await exportState(page); expect(state.party[0].exp).toBe(178); expect(state.pokedex.seen).toContain(16); }); test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({ page }) => { await start(page); const menu = page.locator("#battle-menu"); // A continues the intro message through the same keyboard path as menus. for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "FIGHT", exact: true }).count()); tick++) { if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX"); await page.clock.runFor(1200); } await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toHaveClass(/battle-selected/); await page.keyboard.press("KeyX"); await expect(menu.getByRole("button", { name: /TACKLE/ })).toBeVisible(); await page.keyboard.press("ArrowDown"); await expect(menu.getByRole("button", { name: /LEER/ })).toHaveClass(/battle-selected/); await page.keyboard.press("KeyZ"); await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible(); // A real pointer press on the on-screen D-pad (synthetic events cannot be captured). await page.locator('[data-game-button="down"]').click(); await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/); await page.keyboard.press("KeyX"); expect(await finish(page, "run")).toContain("Got away safely"); }); test("battle UI whiteout heals the party and halves money at home", async ({ page }) => { await start(page, "loss"); expect(await finish(page)).toMatch(/whited\s+out/i); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await exportState(page); expect(state).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 }); expect(state.party[0].hp).toBe(state.party[0].stats[0]); expect(state.party[0].pp[0]).toBe(35); }); test("pausing battle freezes messages and importing its backup restores committed state", async ({ page }) => { await start(page); const { path, state } = await exportState(page); expect(state).toMatchObject({ map: "24:7", x: 6, y: 2, money: 3001 }); expect(state.party[0].level).toBe(5); expect(state.pokedex.seen).not.toContain(16); const text = await page.locator("#battle-text").textContent(); await page.clock.runFor(5000); expect(await page.locator("#battle-text").textContent()).toBe(text); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles(path); await expect(page.locator("#status")).toContainText("Backup restored"); await expect(page.locator("#battle")).toBeHidden(); await page.locator("#play").click(); await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "6"); const restored = await exportState(page); expect(restored.state).toEqual(state); }); test("an unsupported battle effect closes the UI without persisting partial changes", async ({ page }) => { await start(page, "unsupported"); await finish(page); await expect(page.locator("#game-notice")).toContainText("not been ported"); const { state } = await exportState(page); expect(state.party[0].pp[0]).toBe(10); expect(state.party[0].hp).toBe(state.party[0].stats[0]); expect(state.pokedex.seen).not.toContain(16); expect(state.money).toBe(3001); });