Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
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);
|
||
}
|
||
if (scenario === "contest") {
|
||
// The contest's 20% roll: pace on and off the test grass.
|
||
for (let step = 0; step < 100 && !(await page.locator("#battle").isVisible()); step++) {
|
||
const key = step % 2 ? "ArrowRight" : "ArrowLeft";
|
||
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();
|
||
return;
|
||
}
|
||
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<string>();
|
||
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|PSYWAVE|SCRATCH/ }).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).
|
||
// The menu is the original's 2x2 grid: FIGHT PKMN / PACK RUN.
|
||
await page.locator('[data-game-button="down"]').click();
|
||
await expect(menu.getByRole("button", { name: "PACK", exact: true })).toHaveClass(/battle-selected/);
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
|
||
await page.keyboard.press("ArrowUp");
|
||
await expect(menu.getByRole("button", { name: "PKMN", 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);
|
||
// Importing continues the game: _BackUpMapIndices records the loaded
|
||
// map for the roaming Pokémon, and everything else is as committed.
|
||
expect(restored.state.roamMonsMap).toBe("24:7");
|
||
expect({ ...restored.state, roamMonsMap: state.roamMonsMap }).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();
|
||
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).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();
|
||
// BattlePack opens on the Items pocket first.
|
||
await expect(menu.getByRole("button", { name: "ITEM POCKET", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||
await menu.getByRole("button", { name: "BALL POCKET", 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();
|
||
});
|
||
|
||
test("battle PKMN switches through the party menu and its submenu, then shares the experience", async ({ page }) => {
|
||
await start(page, "switch");
|
||
const menu = page.locator("#battle-menu");
|
||
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", 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: "PKMN", exact: true }).click();
|
||
await expect(page.locator("#battle-text")).toHaveText("Choose a POKéMON.");
|
||
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||
// BattleMonMenu: B returns to the party list.
|
||
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeEnabled();
|
||
await page.keyboard.press("KeyZ");
|
||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
|
||
// The withdrawal and send-out texts continue on their own.
|
||
const shown: string[] = [];
|
||
for (let tick = 0; tick < 20 && (await page.locator("#battle-player-name").innerText()) !== "TOTODILE"; tick++) {
|
||
shown.push(await page.locator("#battle-text").innerText());
|
||
await page.clock.runFor(300);
|
||
}
|
||
await expect(page.locator("#battle-player-name")).toHaveText("TOTODILE");
|
||
expect(shown.join("\n").replace(/\n/g, " ")).toContain("CYNDAQUIL, that's enough! Come back!");
|
||
const messages = await finish(page);
|
||
// Pidgey's 23 experience points are halved for the two participants.
|
||
expect(messages.replace(/\n/g, " ")).toContain("CYNDAQUIL gained 11 EXP. Points!");
|
||
expect(messages.replace(/\n/g, " ")).toContain("TOTODILE gained 11 EXP. Points!");
|
||
const { state } = await exportState(page);
|
||
expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([155, 158]);
|
||
expect(state.party[1].exp).toBe(135 + 11);
|
||
});
|
||
|
||
test("battle PACK uses a Potion from the Items pocket on the Pokémon out", async ({ page }) => {
|
||
await start(page, "potion");
|
||
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 menu.getByRole("button", { name: /^POTION/ }).click();
|
||
await expect(page.locator("#battle-text")).toHaveText("Use on which POKéMON?");
|
||
await menu.getByRole("button", { name: /^CYNDAQUIL/ }).click();
|
||
const shown: string[] = [];
|
||
for (let tick = 0; tick < 20 && !shown.some((text) => text.includes("recovered")); tick++) {
|
||
shown.push((await page.locator("#battle-text").innerText()).replace(/\n/g, " "));
|
||
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||
else await page.clock.runFor(300);
|
||
}
|
||
// From 3 HP, a Potion's 20 stops at the level 5 maximum of 19.
|
||
expect(shown).toContain("CYNDAQUIL recovered 16HP!");
|
||
await finish(page, "run");
|
||
const { state } = await exportState(page);
|
||
expect(state.bag.items).toEqual([]);
|
||
});
|
||
|
||
test("battle PKMN STATS shows the stats screen's three pages and returns with B", async ({ page }) => {
|
||
await start(page, "switch");
|
||
const menu = page.locator("#battle-menu");
|
||
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", 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: "PKMN", exact: true }).click();
|
||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||
await menu.getByRole("button", { name: "STATS", exact: true }).click();
|
||
const stats = page.locator("#stats-screen");
|
||
await expect(stats).toBeVisible();
|
||
await expect(page.locator("#stats-header")).toContainText("№.158");
|
||
await expect(page.locator("#stats-header")).toContainText("/TOTODILE");
|
||
await expect(page.locator("#stats-page")).toContainText("WATER");
|
||
await expect(page.locator("#stats-page")).toContainText("EXP POINTS");
|
||
await page.keyboard.press("ArrowRight");
|
||
await expect(page.locator("#stats-page")).toContainText("SCRATCH");
|
||
await page.locator("#stats-next").click();
|
||
await expect(page.locator("#stats-page")).toContainText("SPCL.ATK");
|
||
await expect(page.locator("#stats-page")).toContainText("OT/");
|
||
await page.keyboard.press("KeyZ");
|
||
await expect(stats).toBeHidden();
|
||
// BattleMenuPKMN_ReturnFromStats: back to the party menu.
|
||
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||
});
|
||
|
||
test("throws Park Balls from the contest menu and switches the stocked Pokémon", async ({ page }) => {
|
||
await start(page, "contest");
|
||
const menu = page.locator("#battle-menu");
|
||
let balls = 20,
|
||
switched = false;
|
||
const texts: string[] = [];
|
||
for (let guard = 0; guard < 200 && (await page.locator("#battle").isVisible()); guard++) {
|
||
texts.push(await page.locator("#battle-text").innerText());
|
||
const ball = menu.getByRole("button", { name: /^PARKBALL×\d\d$/ });
|
||
if (await ball.count()) {
|
||
// ContestBattleMenu replaces PACK, with the count in two digits.
|
||
await expect(ball).toHaveText(`PARKBALL×${String(balls).padStart(2, "0")}`);
|
||
await expect(menu.getByRole("button", { name: "PACK" })).toHaveCount(0);
|
||
await ball.click();
|
||
balls--;
|
||
} else if (await menu.getByRole("button", { name: /^STOCK SCYTHER :L14/ }).count()) {
|
||
await expect(menu.getByRole("button", { name: /^THIS / })).toBeVisible();
|
||
await expect(page.locator("#battle-text")).toContainText("Switch");
|
||
// The cursor skips the two boxes: down to NO, back up to YES, then A.
|
||
await page.keyboard.press("ArrowDown");
|
||
await page.keyboard.press("ArrowUp");
|
||
await page.keyboard.press("KeyX");
|
||
switched = true;
|
||
} else if (await menu.getByRole("button", { name: /Continue/ }).count())
|
||
await menu.getByRole("button", { name: /Continue/ }).click();
|
||
else await page.clock.runFor(1200);
|
||
}
|
||
expect(switched, texts.join(" | ")).toBe(true);
|
||
expect(texts.join(" ").replace(/\n/g, " ")).toMatch(/Caught [A-Z]+!/);
|
||
});
|