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, room: | boolean | "poster" | "center" | "candy" | "pokegear" | "mom" | "fly" | "headbutt" = false, ) { await page.route("**/src/game/runtime.ts", async (route) => { const response = await route.fetch(), 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 { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`, ), }); }); 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 backup(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")), }; } test("SEL registers the Bicycle, and Select rides it with keyboard and touch", async ({ page, }) => { await start(page); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), pack = page.locator("#mart"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^PACK/ }).click(); // Right twice: the Balls pocket, then Key Items. await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await pack.getByRole("button", { name: "BICYCLE", exact: true }).click(); await choices.getByRole("option", { name: "SEL", exact: true }).click(); await expect(text).toContainText("Registered the"); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyZ"); await page.keyboard.press("KeyZ"); await expect(choices).toBeHidden(); await page.keyboard.press("ShiftLeft"); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); expect((await backup(page)).state.playerState).toBe(1); await page.locator("#tab-game").click(); await page.locator("#play").click(); await page.getByRole("button", { name: "select", exact: true }).click(); await page.clock.runFor(500); expect((await backup(page)).state.playerState).toBe(0); }); test("the bedroom PC puts away a decoration and saves the result", async ({ page, }) => { await start(page, true); await page.keyboard.press("KeyX"); await expect(page.locator("#game-notice")).toHaveText(""); await expect(page.locator("#dialogue-text")).toContainText( "turned on the PC.", ); await page.keyboard.press("KeyX"); const choices = page.locator("#dialogue-choice"); await choices .getByRole("option", { name: "DECORATION", exact: true }) .click(); await expect(page.locator("#dialogue-text")).toHaveText("DECORATION"); await choices.getByRole("option", { name: "BED", exact: true }).click(); await choices .getByRole("option", { name: "PUT IT AWAY", exact: true }) .click(); await page.keyboard.press("KeyX"); await choices.getByRole("option", { name: "EXIT", exact: true }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); expect((await backup(page)).state.decorations.bed).toBe(0); }); test("Select without a registered item explains SEL, and the Bicycle stays packed indoors", async ({ page, }) => { await start(page, true); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), pack = page.locator("#mart"); await page.keyboard.press("ShiftLeft"); await expect(text).toContainText("An item in your"); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^PACK/ }).click(); await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowLeft"); await pack.getByRole("button", { name: "BICYCLE", exact: true }).click(); await choices.getByRole("option", { name: "USE", exact: true }).click(); await expect(text).toContainText("This isn't the"); await page.keyboard.press("KeyX"); await pack.getByRole("button", { name: "BACK", exact: true }).click(); await page.keyboard.press("KeyZ"); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); expect((await backup(page)).state.playerState).toBe(0); }); test("the Town Map poster opens a native map and closes with B", async ({ page, }) => { await page.setViewportSize({ width: 320, height: 568 }); await start(page, "poster"); await page.keyboard.press("KeyX"); await expect(page.locator("#dialogue-text")).toContainText("TOWN MAP"); await page.keyboard.press("KeyX"); await expect(page.locator("#town-map-view")).toBeVisible(); const colors = await page .locator("#town-map-canvas") .evaluate( (canvas: HTMLCanvasElement) => new Set(canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data) .size, ); expect(colors).toBeGreaterThan(8); await page.keyboard.press("KeyZ"); await expect(page.locator("#town-map-view")).toBeHidden(); await expect(page.locator("#game-notice")).toHaveText(""); }); test("the Pokémon Center PC deposits a Pokémon and an item", async ({ page, }) => { await start(page, "center"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"); await page.keyboard.press("KeyX"); await expect(text).toContainText("turned on the PC."); await page.keyboard.press("KeyX"); await choices.getByRole("option", { name: "BILL's PC", exact: true }).click(); await expect(text).toContainText("BILL's PC"); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await choices .getByRole("option", { name: "DEPOSIT POKéMON", exact: true }) .click(); await choices.getByRole("option", { name: "PIDGEY :L4" }).click(); await choices.getByRole("option", { name: "DEPOSIT", exact: true }).click(); await expect(text).toHaveText("Stored PIDGEY!"); await page.clock.runFor(2000); await expect(choices.getByRole("option", { name: "PIDGEY :L4" })).toHaveCount( 0, ); // B leaves the party list, then Bill's PC. await page.keyboard.press("KeyZ"); await page.keyboard.press("KeyZ"); await expect(choices.getByRole("option", { name: "TURN OFF" })).toBeVisible(); await choices .getByRole("option", { name: /'s PC$/ }) .last() .click(); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await choices .getByRole("option", { name: "DEPOSIT ITEM", exact: true }) .click(); const storage = page.locator("#mart"); await expect(storage).toHaveAttribute("aria-label", "Item storage"); await expect(page.locator("#mart-money")).toBeHidden(); await storage.getByRole("button", { name: /POTION/ }).click(); await storage.getByRole("button", { name: "Plus 1", exact: true }).click(); await storage.getByRole("button", { name: "DEPOSIT", exact: true }).click(); await expect(text).toContainText("Deposited 2 POTION(S)."); await page.keyboard.press("KeyX"); await storage.getByRole("button", { name: "BACK", exact: true }).click(); await choices.getByRole("option", { name: "LOG OFF", exact: true }).click(); await choices.getByRole("option", { name: "TURN OFF", exact: true }).click(); await page.keyboard.press("KeyX"); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await backup(page); expect(state.party).toHaveLength(1); expect( state.storage.boxes[0].map((mon: { species: number }) => mon.species), ).toEqual([16]); expect(state.pcItems).toEqual([{ item: 18, quantity: 2 }]); expect(state.bag.items).toEqual([{ item: 18, quantity: 3 }]); }); test("Start opens the start menu: SWITCH reorders the party and the pack tosses an item", async ({ page, }) => { await start(page, "center"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), pack = page.locator("#mart"); await page.keyboard.press("Enter"); await expect(choices.getByRole("option", { name: /^POKéMON/ })).toBeVisible(); await expect(choices.getByRole("option", { name: /^PACK/ })).toBeVisible(); // Movement is frozen while the menu is open. await page.keyboard.down("ArrowLeft"); await page.clock.runFor(500); await page.keyboard.up("ArrowLeft"); await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "9"); await choices.getByRole("option", { name: /^POKéMON/ }).click(); await expect(text).toHaveText("Choose a POKéMON."); await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click(); await choices.getByRole("option", { name: "SWITCH", exact: true }).click(); await expect(text).toHaveText("Move to where?"); await choices.getByRole("option", { name: /^PIDGEY/ }).click(); await expect(choices.getByRole("option").first()).toHaveAccessibleName( /^PIDGEY/, ); // B leaves the party menu for the start menu. await page.keyboard.press("KeyZ"); await choices.getByRole("option", { name: /^PACK/ }).click(); await expect(pack).toHaveAttribute("aria-label", "Pack"); await expect( pack.getByRole("button", { name: "ITEM POCKET", exact: true }), ).toHaveAttribute("aria-pressed", "true"); // Right twice: the Balls pocket, then Key Items. await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await expect(pack.getByRole("button", { name: "BICYCLE" })).toBeVisible(); await pack.getByRole("button", { name: "ITEM POCKET", exact: true }).click(); await pack.getByRole("button", { name: /POTION/ }).click(); await choices.getByRole("option", { name: "TOSS", exact: true }).click(); await expect(page.locator("#mart-prompt")).toContainText("Throw away how"); await pack.getByRole("button", { name: "TOSS", exact: true }).click(); await expect(text).toContainText("Throw away 1 POTION(S)?"); await choices.getByRole("button", { name: /YES/ }).click(); await expect(text).toContainText("Threw away"); await page.keyboard.press("KeyX"); await pack.getByRole("button", { name: "BACK", exact: true }).click(); await choices.getByRole("option", { name: /^EXIT/ }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await backup(page); expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([ 16, 155, ]); expect(state.bag.items).toEqual([{ item: 18, quantity: 4 }]); }); test("a Rare Candy levels up and evolves a Pokémon through the pack", async ({ page, }) => { await start(page, "candy"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), pack = page.locator("#mart"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^PACK/ }).click(); await pack.getByRole("button", { name: /RARE CANDY/ }).click(); await choices.getByRole("option", { name: "USE", exact: true }).click(); await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click(); await expect(text).toContainText("grew to"); await page.keyboard.press("KeyX"); await expect(text).toContainText("ATTACK"); await page.keyboard.press("KeyX"); await expect(text).toContainText("is evolving!"); await page.clock.runFor(1500); await expect(page.locator("#pokepic")).toBeVisible(); await page.clock.runFor(8000); await expect(text).toContainText("Congratulations!"); await page.keyboard.press("KeyX"); await expect(text).toContainText("QUILAVA!"); await page.keyboard.press("KeyX"); await expect(pack).toHaveAttribute("aria-label", "Pack"); await pack.getByRole("button", { name: "BACK", exact: true }).click(); await choices.getByRole("option", { name: /^EXIT/ }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await backup(page); expect([state.party[0].species, state.party[0].level]).toEqual([156, 14]); expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]); }); test("the Pokégear shows its cards and calls Elm", async ({ page }) => { await start(page, "pokegear"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), gear = page.locator("#pokegear"), card = page.locator("#pokegear-card"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^POKéGEAR/ }).click(); await expect(gear).toBeVisible(); await expect(gear.locator('[aria-current="true"]')).toHaveText("CLOCK"); await expect(card).toContainText("TUESDAY"); await expect(card).toContainText("12:00 PM"); await expect(text).toHaveText("Press any button to exit."); await page.keyboard.press("ArrowRight"); await expect(card).toContainText("▶ NEW BARK TOWN"); await expect(card.locator("canvas")).toHaveAttribute( "aria-label", "Town Map: NEW BARK TOWN, you are here", ); await expect(page.locator("#native-dialogue")).toBeHidden(); await page.keyboard.press("ArrowUp"); await expect(card).toContainText("▶ ROUTE 29"); // The on-screen controls drive the Pokégear too. const right = page.locator('[data-game-button="right"]'); await right.scrollIntoViewIfNeeded(); const bounds = (await right.boundingBox())!; await page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await page.mouse.down(); await page.mouse.up(); await page.locator("#game-screen").focus(); await expect(card.getByRole("list", { name: "Phone numbers" })).toContainText( "PROF.ELM:", ); await expect(text).toHaveText(/Whom do you want\s+to call\?/); await page.keyboard.press("ArrowDown"); await page.keyboard.press("KeyX"); await expect(card).toContainText("CALL"); await expect(card).toContainText("CANCEL"); await page.keyboard.press("KeyX"); await expect(text).toHaveText("……"); await page.clock.runFor(1000); await expect(text).toHaveText("Hello, CHRIS?"); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await expect(text).toHaveText("Click!"); await page.clock.runFor(3000); await expect(text).toHaveText(/Whom do you want\s+to call\?/); await page.keyboard.press("KeyZ"); await expect(gear).toBeHidden(); await choices.getByRole("option", { name: /^EXIT/ }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); }); test("the trainer card turns to the badges and back", async ({ page }) => { await start(page, "pokegear"); const choices = page.locator("#dialogue-choice"), card = page.locator("#trainer-card"), info = page.locator("#trainer-card-info"), body = page.locator("#trainer-card-page"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^CHRIS/ }).click(); await expect(card).toBeVisible(); await expect(page.locator("#native-dialogue")).toBeHidden(); await expect(info).toContainText("NAME/"); await expect(info).toContainText("CHRIS"); await expect(info).toContainText("ID NO"); await expect(info).toContainText("MONEY"); await expect(page.locator("#trainer-card-pic")).toHaveAttribute( "aria-label", "Chris", ); await expect(body).toContainText("PLAY TIME"); await expect(body).toContainText("BADGES▶"); await page.keyboard.press("ArrowRight"); await expect(body.locator("canvas")).toHaveAttribute( "aria-label", "0 of 8 Johto badges", ); await page.keyboard.press("ArrowLeft"); await expect(body).toContainText("PLAY TIME"); await page.keyboard.press("KeyZ"); await expect(card).toBeHidden(); await choices.getByRole("option", { name: /^EXIT/ }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); }); test("the options change the battle style and the menu account", async ({ page, }) => { await start(page, "pokegear"); const choices = page.locator("#dialogue-choice"), screen = page.locator("#options"), list = page.locator("#options-list"); await page.keyboard.press("Enter"); await expect(choices).toContainText("Party Pokémon status"); await choices.getByRole("option", { name: /^OPTION/ }).click(); await expect(screen).toBeVisible(); await expect(page.locator("#native-dialogue")).toBeHidden(); await expect(list.locator('[aria-current="true"]')).toContainText( "TEXT SPEED", ); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowRight"); await expect(list.locator('[aria-current="true"]')).toHaveText( "BATTLE STYLESET", ); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowLeft"); await expect(list.locator('[aria-current="true"]')).toHaveText( "MENU ACCOUNTOFF", ); await page.keyboard.press("KeyZ"); await expect(screen).toBeHidden(); await expect(choices).not.toContainText("Party Pokémon status"); await choices.getByRole("option", { name: /^EXIT/ }).click(); await page.clock.runFor(500); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await backup(page); expect(state.options).toEqual({ options: 3 | (1 << 6), textboxFrame: 0, printerBrightness: 0x40, options2: 0, }); }); test("Fly picks a visited town on the Fly map and lands there", async ({ page, }) => { await start(page, "fly"); const choices = page.locator("#dialogue-choice"), flyMap = page.locator("#fly-map"), bubble = page.locator("#fly-map-bubble"); await page.keyboard.press("Enter"); await choices.getByRole("option", { name: /^POKéMON/ }).click(); await choices.getByRole("option", { name: /^PIDGEOT/ }).click(); await choices.getByRole("option", { name: "FLY", exact: true }).click(); await expect(flyMap).toBeVisible(); await expect(page.locator("#native-dialogue")).toBeHidden(); await expect(bubble).toContainText("Where?"); await expect(bubble).toContainText("NEW BARK TOWN"); await expect(page.locator("#fly-map-canvas")).toHaveAttribute( "aria-label", "Fly map: NEW BARK TOWN, you are here", ); // Up skips to the next visited flypoint; B returns to the party menu. await page.keyboard.press("ArrowUp"); await expect(bubble).toContainText("CHERRYGROVE CITY"); await page.keyboard.press("KeyZ"); await expect(flyMap).toBeHidden(); await expect(page.locator("#dialogue-text")).toHaveText("Choose a POKéMON."); await choices.getByRole("option", { name: /^PIDGEOT/ }).click(); await choices.getByRole("option", { name: "FLY", exact: true }).click(); await page.keyboard.press("ArrowUp"); await expect(bubble).toContainText("CHERRYGROVE CITY"); await page.keyboard.press("KeyX"); await expect(flyMap).toBeHidden(); await page.clock.runFor(5_000); const screen = page.locator("#game-screen"); await expect(screen).toHaveAttribute("data-map", "26:3"); await expect(screen).toHaveAttribute("data-x", "29"); await expect(screen).toHaveAttribute("data-y", "4"); await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(page.locator("#game-notice")).toHaveText(""); const { state } = await backup(page); expect([state.map, state.x, state.y]).toEqual(["26:3", 29, 4]); }); test("Headbutt shakes a tree and a sleeping Hoothoot falls out", async ({ page, }) => { await start(page, "headbutt"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), screen = page.locator("#game-screen"); await page.keyboard.press("KeyX"); await expect(text).toHaveText(/A POKéMON could be\s+in this tree\./); await page.keyboard.press("KeyX"); await expect(text).toHaveText(/Want to HEADBUTT\s+it\?/); await choices.getByRole("button", { name: "YES" }).click(); await expect(text).toHaveText(/TOTODILE did a\s+HEADBUTT!/); await page.keyboard.press("KeyX"); await expect(page.locator("#native-dialogue")).toBeHidden(); await page.clock.runFor(5_000); await expect(page.locator("#battle")).toBeVisible(); await expect(page.locator("#battle-text")).toHaveText( /HOOTHOOT fell\s+out of the tree!/, ); await expect(page.locator("#battle-foe-name")).toHaveText("HOOTHOOT"); await expect(page.locator("#battle-foe-status")).toHaveText("SLP"); await expect(page.locator("#game-notice")).toHaveText(""); await expect(screen).toHaveAttribute("data-map", "24:3"); }); test("Mom keeps the player's money with the digit box", async ({ page }) => { await start(page, "mom"); const text = page.locator("#dialogue-text"), choices = page.locator("#dialogue-choice"), box = choices.locator(".mom-bank"); await page.keyboard.press("KeyX"); await expect(text).toHaveText(/Hi! Welcome home!/); await page.keyboard.press("KeyX"); await page.keyboard.press("KeyX"); await expect(text).toHaveText(/Or is this about\s+your money\?/); await choices.getByRole("button", { name: "YES" }).click(); await expect(text).toHaveText(/What do you want\s+to do\?/); await choices.getByRole("option", { name: "SAVE" }).click(); await expect(text).toHaveText(/How much do you\s+want to save\?/); await expect(box).toContainText("SAVED¥2300"); await expect(box).toContainText("HELD¥3000"); await expect(box).toContainText("DEPOSIT¥000000"); await expect(box.locator(".mom-bank-cursor")).toHaveCount(1); // The keyboard moves to the thousands and raises it; the on-screen // buttons raise it again. for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowUp"); await expect(box).toContainText("DEPOSIT¥001000"); await choices.getByRole("button", { name: "Increase digit" }).click(); await expect(box).toContainText("DEPOSIT¥002000"); await page.locator("#game-screen").focus(); await page.keyboard.press("KeyX"); await expect(text).toHaveText(/Your money's safe\s+here! Get going!/); await page.keyboard.press("KeyX"); await expect(page.locator("#native-dialogue")).toBeHidden(); const { state } = await backup(page); expect([state.money, state.momsMoney]).toEqual([1000, 4300]); });