Files
Crystal/tests/trainer-browser.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 b13fd20be4
Checks / web (push) Canceled after 0s
Port the introduction's naming and clock screens
A new game now runs NewGame's InitGender and OakSpeech natively before
the bedroom: Boy or Girl, InitClock's hour and minute steppers with
their YES/NO confirmations, Oak's speech with Wooper, and NamePlayer's
preset names or naming field (a blank name takes CHRIS or KRIS). Texts,
menus and time strings come from a new INTRO profile table verified by
check:reference; Oak's and the player's pictures decode from
TrainerPicPointers, ChrisPic/KrisPic and TrainerPalettes.

The chosen time is kept as a minute offset from the device clock, which
time of day, the weekday, NPC hours and the daily reset now read. The
intro runs only when nothing is saved; a save made during it starts it
again. Saves gain intro and clock.minuteOffset, migrated by absence. The
shell's name and character fields are gone. Intentional change: the
steppers start at the device time rather than 10:00.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:53:52 +02:00

133 lines
6.5 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);
// 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 { 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);
// 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 { 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);
});