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); // 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 { 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)}`; // Long enough to turn in place first (4 ticks) and start one step, // short of a second step when already facing that way. await page.keyboard.down(key); await page.clock.runFor(250); 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: "PACK", exact: true })).toHaveClass(/battle-selected/); await page.keyboard.press("ArrowDown"); 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); }); test("battle PACK throws Poké Balls, names the catch and saves it", async ({ page }) => { await start(page, "catch"); const menu = page.locator("#battle-menu"); const messages: string[] = []; let named = false; for (let guard = 0; guard < 120 && !named; guard++) { messages.push(await page.locator("#battle-text").innerText()); const pack = menu.getByRole("button", { name: "PACK", exact: true }); const nickname = menu.getByRole("textbox"); if (await nickname.count()) { await expect(page.locator("#battle-text")).toHaveText("PIDGEY'S\nNICKNAME?"); await nickname.fill("PIDGE"); await menu.getByRole("button", { name: "OK", exact: true }).click(); named = true; } else if (await pack.count()) { await pack.click(); const ball = menu.getByRole("button", { name: /POKé BALL/ }); await expect(ball).toContainText("×"); await ball.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); } expect(named).toBe(true); expect(messages.join("\n")).toContain("Gotcha!"); await finish(page); await expect(page.locator("#battle")).toBeHidden(); const { state } = await exportState(page); expect(state.party).toHaveLength(2); expect(state.party[1]).toMatchObject({ species: 16, nickname: "PIDGE", level: 3 }); expect(state.pokedex.caught).toContain(16); expect(state.bag.balls[0].quantity).toBeLessThan(10); }); test("battle PACK closes with B and returns to the command menu", async ({ page }) => { await start(page, "catch"); const menu = page.locator("#battle-menu"); for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PACK", exact: true }).count()); tick++) { if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX"); await page.clock.runFor(1200); } await menu.getByRole("button", { name: "PACK", exact: true }).click(); await expect(menu.getByRole("button", { name: /POKé BALL/ })).toBeVisible(); await page.keyboard.press("KeyZ"); await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible(); });