import { test, expect } from "@playwright/test"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { CrystalAssets } from "../src/game/rom/assets"; import { generatePartyMon } from "../src/game/party/pokemon"; import { GameWorld } from "../src/game/world/world"; import type { Direction } from "../src/game/world/types"; import { VECTORS } from "../src/game/world/types"; import { settle } from "./world-driver"; const romPath = resolve( process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc", ); test("local maps follow a stairs transition and importing a previous position", async ({ page, }) => { test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); // Find a walkable route in the local fixture, then drive the actual UI. const world = new GameWorld( new CrystalAssets(new Uint8Array(readFileSync(romPath))), ); const target = world.map.warps[0]; const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }]; const seen = new Set(); let path: Direction[] | undefined; const directions = [ ["up", 0, -1], ["down", 0, 1], ["left", -1, 0], ["right", 1, 0], ] as const; for (let i = 0; i < queue.length; i++) { const node = queue[i]; const key = `${node.x},${node.y}`; if (seen.has(key)) continue; seen.add(key); if (node.x === target.x && node.y === target.y) { path = node.path; break; } for (const [direction, dx, dy] of directions) { const x = node.x + dx, y = node.y + dy; if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) continue; if ( world.assets.permission(world.assets.collision(world.map, x, y)) !== 0 ) continue; if (world.objects().some((object) => object.x === x && object.y === y)) continue; queue.push({ x, y, path: [...node.path, direction] }); } } expect(path).toBeDefined(); await page.goto("/"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#tab-saves").click(); const downloaded = page.waitForEvent("download"); await page.locator("#backup").click(); const backup = await downloaded; await page.locator("#play").click(); for (const direction of path!) { const [dx, dy] = VECTORS[direction]; const stepX = world.x + dx, stepY = world.y + dy; expect(world.move(direction)).toBe(true); settle(world); await page.keyboard.down( `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`, ); // Release as the step starts, before a warp and its forced exit complete. await expect(page.locator("#game-screen")).toHaveAttribute( "data-x", String(stepX), ); await expect(page.locator("#game-screen")).toHaveAttribute( "data-y", String(stepY), ); await page.keyboard.up( `Arrow${direction[0].toUpperCase()}${direction.slice(1)}`, ); await expect(page.locator("#game-screen")).toHaveAttribute( "data-map", world.mapId, ); await expect(page.locator("#game-screen")).toHaveAttribute( "data-x", String(world.x), ); await expect(page.locator("#game-screen")).toHaveAttribute( "data-y", String(world.y), ); await expect(page.locator("#game-screen")).toHaveAttribute( "data-activity", "idle", ); } await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:6"); await page.locator("#tab-map").click(); await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:6"); await expect(page.locator("#map-location")).toHaveText( `${world.map.name} · ${world.map.location}`, ); await page.locator("#tab-saves").click(); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles((await backup.path())!); await expect(page.locator("#status")).toContainText("Backup restored"); await page.locator("#tab-map").click(); await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7"); await expect(page.locator("#world-map")).toHaveAttribute("data-x", "3"); await expect(page.locator("#world-map")).toHaveAttribute("data-y", "3"); }); test("Mom's opening scene runs with native keyboard and pointer choices", async ({ page, }) => { test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#play").click(); const screen = page.locator("#game-screen"); const step = async (key: string, map: string, x: number, y: number) => { await page.keyboard.down(key); await expect(screen).toHaveAttribute("data-map", map); await expect(screen).toHaveAttribute("data-x", String(x)); await expect(screen).toHaveAttribute("data-y", String(y)); await page.keyboard.up(key); }; // Bedroom (3,3) to the stairs, then down toward Mom. await step("ArrowUp", "24:7", 3, 2); for (const x of [4, 5, 6, 7]) await step("ArrowRight", "24:7", x, 2); await step("ArrowUp", "24:7", 7, 1); await step("ArrowUp", "24:7", 7, 0); await expect(screen).toHaveAttribute("data-map", "24:6"); await expect(screen).toHaveAttribute("data-y", "1"); await expect(screen).toHaveAttribute("data-activity", "idle"); for (const y of [2, 3, 4]) await step("ArrowDown", "24:6", 9, y); const dialogue = page.locator("#native-dialogue"); await expect(dialogue).toBeVisible(); const seen: string[] = []; for (let guard = 0; guard < 60 && (await dialogue.isVisible()); guard++) { const text = (await page.locator("#dialogue-text").textContent()) ?? ""; seen.push(text); const choice = page.locator("#dialogue-choice"); if (await choice.isVisible()) { if (await choice.locator("output").count()) { // Weekday box: Up selects the next day, X confirms. await expect(choice.locator("output")).toHaveText("SUNDAY"); await page.keyboard.press("ArrowUp"); await expect(choice.locator("output")).toHaveText("MONDAY"); await page.keyboard.press("KeyX"); } else { await choice.getByRole("button", { name: "YES" }).click(); } } else await page.keyboard.press("KeyX"); await expect .poll(async () => (await dialogue.isVisible()) ? await page.locator("#dialogue-text").textContent() : "closed", ) .not.toBe(text); } expect(seen).toContain("MONDAY, is it?"); expect(seen.some((text) => text.includes("received"))).toBe(true); await expect(dialogue).toBeHidden(); // Mom walks back before the scene commits. await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(page.locator("#game-notice")).toHaveText(""); // The completed scene persists in the exported native save. await page.locator("#tab-saves").click(); const download = page.waitForEvent("download"); await page.locator("#backup").click(); const record = JSON.parse( readFileSync((await (await download).path())!, "utf8"), ); const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8")); expect(save).toMatchObject({ version: 3, map: "24:6", scenes: { "24:6": 1 }, phone: [1], }); expect(save.clock.dst).toBe(true); expect(errors).toEqual([]); }); test("choosing a starter shows its picture, asks for a nickname and saves the party", async ({ page, }, testInfo) => { test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); // Start in Elm's lab after his introduction, facing Cyndaquil's ball. const bytes = readFileSync(romPath); const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes))); const save = { ...world.save(), map: "24:5", x: 6, y: 4, direction: "up", scenes: { "24:6": 1, "24:5": 1 }, }; const backup = testInfo.outputPath("lab.crystal-save.json"); writeFileSync( backup, JSON.stringify({ version: 1, romHash: createHash("sha1").update(bytes).digest("hex"), coreId: "crystal-native-world-v1", savedAt: new Date().toISOString(), state: Buffer.from(JSON.stringify(save)).toString("base64"), }), ); await page.goto("/"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#tab-saves").click(); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles(backup); await expect(page.locator("#status")).toContainText("Backup restored"); await page.locator("#play").click(); await page.keyboard.press("KeyX"); const picture = page.locator("#pokepic"); await expect(picture).toBeVisible(); await expect(page.locator("#pokepic-canvas")).toHaveAttribute( "aria-label", "CYNDAQUIL", ); // The picture is drawn from ROM tiles, not left blank. expect( await page.locator("#pokepic-canvas").evaluate((element) => { const canvas = element as HTMLCanvasElement; const pixels = canvas.getContext("2d")!.getImageData(0, 0, 56, 56).data; const colors = new Set(); for (let i = 0; i < pixels.length; i += 4) colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]); return colors.size; }), ).toBeGreaterThanOrEqual(3); await page.locator("#pokepic-next").click(); await expect(picture).toBeHidden(); await expect(page.locator("#dialogue-text")).toContainText("CYNDAQUIL"); await page.getByRole("button", { name: "YES" }).click(); const nickname = page.locator("#dialogue-choice input"); for (let guard = 0; guard < 12 && !(await nickname.count()); guard++) { const choice = page.getByRole("button", { name: "YES" }); if (await choice.count()) await choice.click(); else await page.keyboard.press("KeyX"); await page.waitForTimeout(100); } await expect(page.locator("#dialogue-text")).toHaveText( "CYNDAQUIL'S\nNICKNAME?", ); await expect(nickname).toBeFocused(); await nickname.fill("BLAZE"); await nickname.press("Enter"); const screen = page.locator("#game-screen"); for (let guard = 0; guard < 30; guard++) { if ((await screen.getAttribute("data-activity")) === "idle") break; if (await page.locator("#native-dialogue").isVisible()) await page.keyboard.press("KeyX"); await page.waitForTimeout(120); } await expect(screen).toHaveAttribute("data-activity", "idle"); await page.locator("#tab-saves").click(); const download = page.waitForEvent("download"); await page.locator("#backup").click(); const record = JSON.parse( readFileSync((await (await download).path())!, "utf8"), ); const saved = JSON.parse( Buffer.from(record.state, "base64").toString("utf8"), ); expect(saved.party).toHaveLength(1); expect(saved.party[0]).toMatchObject({ species: 155, nickname: "BLAZE", level: 5, }); expect(saved.scenes["24:5"]).toBe(5); expect(errors).toEqual([]); }); test("a home exit mat survives save/reload and waits for the exit direction", async ({ page, }, testInfo) => { test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); const bytes = readFileSync(romPath); const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes))); world.restore({ ...world.save(), map: "24:6", x: 6, y: 6, scenes: { "24:6": 1 }, }); const backup = testInfo.outputPath("home.crystal-save.json"); writeFileSync( backup, JSON.stringify({ version: 1, romHash: createHash("sha1").update(bytes).digest("hex"), coreId: "crystal-native-world-v1", savedAt: new Date().toISOString(), state: Buffer.from(JSON.stringify(world.save())).toString("base64"), }), ); await page.goto("/"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#tab-saves").click(); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles(backup); await expect(page.locator("#status")).toContainText("Backup restored"); await page.locator("#play").click(); const screen = page.locator("#game-screen"); await page.keyboard.down("ArrowDown"); await expect(screen).toHaveAttribute("data-y", "7"); await page.keyboard.up("ArrowDown"); await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(screen).toHaveAttribute("data-map", "24:6"); await page.locator("#tab-saves").click(); await page.locator("#save").click(); await expect(page.locator("#save-status")).toContainText("Session saved"); await page.reload(); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("saved session"); await expect(screen).toHaveAttribute("data-map", "24:6"); await expect(screen).toHaveAttribute("data-y", "7"); await page.locator("#play").click(); // Walking back off the carpet is allowed and does not leave the house. await page.keyboard.down("ArrowUp"); await expect(screen).toHaveAttribute("data-y", "6"); await page.keyboard.up("ArrowUp"); await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(screen).toHaveAttribute("data-map", "24:6"); await page.keyboard.down("ArrowDown"); await expect(screen).toHaveAttribute("data-y", "7"); await page.keyboard.up("ArrowDown"); await expect(screen).toHaveAttribute("data-activity", "idle"); await page.keyboard.down("ArrowDown"); await page.waitForFunction( () => document.querySelector("#game-screen")?.getAttribute("data-map") === "24:4", ); await page.keyboard.up("ArrowDown"); await expect(screen).toHaveAttribute("data-x", "13"); await expect(screen).toHaveAttribute("data-y", "6"); await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(page.locator("#game-notice")).toHaveText(""); }); test("walks from New Bark Town onto Route 29 after receiving a starter", async ({ page, }, testInfo) => { test.skip(!existsSync(romPath), "Requires a local user-supplied ROM."); const bytes = readFileSync(romPath); const assets = new CrystalAssets(new Uint8Array(bytes)); const world = new GameWorld(assets); const town = assets.map("24:4"); const edge = Array.from({ length: town.height }, (_, y) => y).find( (y) => assets.permission(assets.collision(town, 0, y)) === 0, )!; world.state.party.push( generatePartyMon(assets.pokemon, { species: 155, level: 5, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }), ); // SCENE_NEWBARKTOWN_NOOP: after the starter the teacher no longer stops you. world.restore({ ...world.save(), map: "24:4", x: 0, y: edge, direction: "left", scenes: { "24:4": 1 } }); const backup = testInfo.outputPath("new-bark-edge.crystal-save.json"); writeFileSync( backup, JSON.stringify({ version: 1, romHash: createHash("sha1").update(bytes).digest("hex"), coreId: "crystal-native-world-v1", savedAt: new Date().toISOString(), state: Buffer.from(JSON.stringify(world.save())).toString("base64"), }), ); await page.goto("/"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#tab-saves").click(); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles(backup); await expect(page.locator("#status")).toContainText("Backup restored"); await page.locator("#play").click(); const screen = page.locator("#game-screen"); await page.keyboard.down("ArrowLeft"); await page.waitForFunction( () => document.querySelector("#game-screen")?.getAttribute("data-map") === "24:3", ); await page.keyboard.up("ArrowLeft"); await expect(screen).toHaveAttribute("data-activity", "idle"); await expect(page.locator("#location")).toHaveText("Route 29"); await expect(page.locator("#game-notice")).toHaveText(""); }); test("development server does not serve ROMs or the private reference checkout", async ({ request, }) => { for (const path of [ "/Pokemon%20-%20Crystal%20Version%20(UE)%20(V1.1)%20%5BC%5D%5B!%5D.gbc", "/.reference/pokecrystal/README.md", ]) { const response = await request.get(path); expect([403, 404]).toContain(response.status()); } }); test("native player loads the actual ROM without loading an emulator and persists movement", async ({ page, context, }, testInfo) => { test.skip( !existsSync(romPath), "Requires a local user-supplied ROM; never downloaded or committed.", ); const requests: string[] = [], errors: string[] = []; page.on("request", (request) => requests.push(request.url())); page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await page.locator("#player-name").fill("KAWA"); await page.locator("#player-avatar").selectOption("kris"); await page.locator("#rom-file").setInputFiles(romPath); await expect(page.locator("#status")).toContainText("verified"); await expect(page.locator("#game-screen")).toHaveAttribute( "data-map", "24:7", ); await expect(page.locator("#minimap")).toBeVisible(); await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7"); await page.locator("#play").click(); await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3"); await page.keyboard.down("ArrowUp"); await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2"); await page.keyboard.up("ArrowUp"); await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2"); // A is ignored mid-step, as in the original; wait for the step to end. await expect(page.locator("#game-screen")).toHaveAttribute( "data-activity", "idle", ); await page.keyboard.press("KeyX"); // The radio's lines hold for `pause 45` each and advance by themselves. await expect(page.locator("#native-dialogue")).toBeVisible(); await expect(page.locator("#dialogue-text")).toContainText("TALK!"); await expect(page.locator("#dialogue-next")).toBeHidden(); await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 10_000, }); await page.locator("#play").click(); await expect(page.locator("#save-status")).toContainText("Session saved"); await page.locator("#tab-saves").click(); const exported = page.waitForEvent("download"); await page.locator("#backup").click(); const backup = await exported; page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles((await backup.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"); await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2"); await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2"); await page.locator("#play").click(); await page.keyboard.press("KeyX"); await expect(page.locator("#dialogue-text")).toContainText("POKéMON"); await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 5_000 }); // Opening any menu pauses and releases game input, even while a key is held. await page.keyboard.down("ArrowUp"); await page.locator("#tab-map").click(); await expect(page.locator("#power-label")).toHaveText("PAUSED"); await expect(page.locator("#world-map")).toHaveAttribute("data-y", "2"); await expect(page.locator("#map-empty")).toBeHidden(); await expect(page.locator('[data-game-button="up"]')).toHaveAttribute( "aria-pressed", "false", ); await page.keyboard.up("ArrowUp"); await page.locator("#tab-game").click(); await expect(page.locator("#pause-overlay")).toBeVisible(); for (const [width, height] of [ [1440, 1000], [1024, 900], [390, 844], [320, 740], [320, 568], [844, 390], ]) { await page.setViewportSize({ width, height }); await page.evaluate(() => scrollTo(0, 0)); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); if (width <= 390 || height <= 390) { const box = (await page.locator(".console").boundingBox())!; expect(box.y + box.height).toBeLessThanOrEqual(height); const toolbar = (await page.locator(".global-bar").boundingBox())!; expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(height); } // Responsive native rendering draws a different area, not a stretched GB frame. await expect .poll(async () => page.locator("#game-screen").evaluate((el) => { const canvas = el as HTMLCanvasElement; const bounds = canvas.getBoundingClientRect(); return Math.abs( canvas.width / canvas.height - bounds.width / bounds.height, ); }), ) .toBeLessThan(0.03); if (width >= 1024) { expect( await page .locator("#game-screen") .evaluate((el) => (el as HTMLCanvasElement).width), ).toBeGreaterThan(160); } } await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: testInfo.outputPath("native-local-rom.png"), fullPage: true, }); const other = await context.newPage(); await other.goto("/"); await other.locator("#rom-file").setInputFiles(romPath); await expect(other.locator("#status")).toContainText("another tab"); await other.close(); expect( requests.some((url) => /binjgb|\.wasm|\/src\/emulator\//.test(url)), ).toBe(false); expect(requests.every((url) => new URL(url).hostname === "127.0.0.1")).toBe( true, ); expect(errors).toEqual([]); });