Battle PACK opens the Balls pocket and runs PokeBallEffect: the Poké, Great, Ultra and Master Ball multipliers, the original HP arithmetic (including the overflow above 341 maximum HP), the sleep/freeze bonus (BRN/PSN/PAR add nothing), HELD_CATCH_CHANCE and the catch draw. GetPokeBallWobble draws per shake from WobbleProbabilities and picks the break-free text. Trainer battles block the ball but still use it and the turn. A catch reloads the Pokémon as LoadEnemyMon does (the wild item roll is redrawn, then the item restored), keeps its HP, status, item and DVs, restores the moves and PP it appeared with, adds it to the party with SetCaughtData, registers it and offers a nickname with InitName's default. A catch with six Pokémon needs the PC and is refused. catchtutorial runs BATTLETYPE_TUTORIAL as "DUDE": no send-out, an automatic catch that keeps nothing and prompts that continue on their own. Both Route 29 triggers, declining and the Dude's repeat offer run from the ROM. The text reader now handles TX_LOW. PLAN.md gains a status overview and done/remaining checklists; the README, help dialog, routine map and handoff describe the milestone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
10 KiB
TypeScript
209 lines
10 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);
|
||
await route.fulfill({ response, body: source.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)}`;
|
||
await page.keyboard.down(key);
|
||
await page.clock.runFor(70);
|
||
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<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|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();
|
||
});
|