Files
Crystal/tests/trainer-browser.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 e00dfab42e Open Route 30 with map trainers and multi-Pokémon trainer battles
Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:36:54 +02:00

129 lines
6.1 KiB
TypeScript

import { test, expect } 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; trainer script is a test fixture."));
test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({ page }) => {
// Route substitution stays in the test harness; production imports no fixture.
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 { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = trainerFixtureWorld(assets, undefined, false, true);`) });
});
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();
await page.keyboard.press("KeyX");
await expect(page.locator("#battle")).toBeVisible();
await expect(page.locator("#battle-foe-name")).toHaveText("RATTATA");
let triedRun = false;
const messages = new Set<string>();
for (let guard = 0; guard < 60; guard++) {
if (!(await page.locator("#battle").isVisible())) break;
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 (!triedRun) {
triedRun = true;
await menu.getByRole("button", { name: "RUN", exact: true }).click();
} else {
await fight.click();
await menu.getByRole("button", { name: /TACKLE/ }).click();
}
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
await menu.getByRole("button", { name: /Continue/ }).click();
} else await page.clock.runFor(1200);
}
await expect(page.locator("#battle")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
const shown = [...messages].join("\n");
expect(shown).toContain("JOEY");
expect(shown).toMatch(/No!.*running/s);
expect(shown).toContain("Ack! I lost again!");
expect(shown).toContain("64");
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"));
const state = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(state.money).toBe(3064);
expect(state.pokedex.seen).toContain(19);
expect(state.events).toContain(256);
});
test("Route 30: Bug Catcher Don spots the player, walks over and sends out both Caterpie", async ({ page }) => {
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 { route30FixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = route30FixtureWorld(assets);`) });
});
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();
await page.keyboard.down("ArrowUp");
await page.clock.runFor(70);
await page.keyboard.up("ArrowUp");
// Both send-outs print the same text, so only repeats in a row are dropped.
const messages: string[] = [];
const add = (text: string) => {
if (messages.at(-1) !== text) messages.push(text);
};
let dialogues = 0;
for (let guard = 0; guard < 120; guard++) {
if (await page.locator("#battle").isVisible()) {
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()) {
await fight.click();
await menu.getByRole("button", { name: /SCRATCH/ }).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()) {
dialogues++;
add(await page.locator("#dialogue-text").innerText());
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else break;
}
expect(dialogues).toBeGreaterThan(0);
await expect(page.locator("#battle")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "10");
const shown = messages.join("\n");
expect(shown).toContain("I found");
expect(shown.match(/sent out/g)?.length).toBe(2);
expect(shown).toContain("48");
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"));
const state = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(state).toMatchObject({ map: "26:1", x: 1, y: 10 });
expect(state.events).toContain(1336);
expect(state.objects).toContainEqual({ id: 5, x: 1, y: 9, direction: "down" });
expect(state.pokedex.seen).toContain(10);
});