Checks / web (push) Canceled after 0s
Run the forced-shiny, trap, forced-item, Celebi, Suicune, roaming and Bug-Catching Contest battle types. Roaming battles keep their struct's DVs and HP byte, and every other battle makes CleanUpBattleRAM's roaming roll. The contest shows PARKBALL×, counts down Park Balls, ends when they run out, keeps its catch outside the party and asks before switching. Scripts load the special types with loadvar, and while a contest runs, encounters use BugCatchingContestBattleScript and ContestMons. Validated with 596 unit tests, 155 browser tests (including the contest menu and switch screen), native/reference builds and reference/vendor/release checks. National Park's contest event, the roaming release and the Ilex Forest Celebi event come with their maps. Closes #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
18 KiB
TypeScript
346 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).
|
||
await page.locator('[data-game-button="down"]').click();
|
||
await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/);
|
||
await page.keyboard.press("ArrowDown");
|
||
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();
|
||
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]+!/);
|
||
});
|