Integrate battles with the overworld UI and validate browser flows
Checks / web (push) Canceled after 0s

- Drive wild battles through the real UI in Chromium and WebKit using a
  test-only encounter fixture: victory with level-up, escape, whiteout,
  mid-battle backup restore and unsupported-effect rollback.
- Add keyboard and on-screen D-pad navigation for battle menus.
- Accept pending stat experience in saves (awarded without CalcMonStats).
- Commit whiteout healing, money loss and destination callbacks together;
  aborted scripts restore state and map.
- Give long notices their own status-bar row on narrow screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 16:37:07 +02:00
1 parent c08d250cf6
commit 11d1153802
11 files changed
+462 -13

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
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: "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);
});
+37
View File
@@ -0,0 +1,37 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
export type BattleScenario = "win" | "loss" | "unsupported";
/** Test-only encounters on an existing floor: production maps stay gated.
* Everything after selection runs through the ROM's real wild-battle script. */
export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenario = "win") {
assets.grassCollisions = () => [assets.collision(assets.map("24:7"), 3, 2)];
assets.grassWildMons = (id) => id === "24:7" ? {
rates: [256, 256, 256],
slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: 16, level: 3 }))),
} : undefined;
let seed = 1;
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
const mon = generatePartyMon(assets.pokemon, {
species: 155, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.exp = 178; // One battle away from level 6 and Smokescreen.
if (scenario === "loss") mon.hp = 1;
if (scenario === "unsupported") {
mon.moves = [144, 0, 0, 0]; // Transform is explicitly refused.
mon.pp = [10, 0, 0, 0];
}
world.state.party.push(mon);
world.state.pokedex.seen.add(155);
world.state.pokedex.caught.add(155);
world.state.money = 3001;
return world;
}
export const ENCOUNTER_WALK = ["up", "right", "right", "right", "right"] as const;
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
import type { GameWorld } from "../src/game/world/world";
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (scenario: BattleScenario = "win") =>
battleFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), scenario);
function encounter(world: GameWorld) {
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
expect(world.battle).toBeDefined();
}
function finish(world: GameWorld, action: "fight" | "run" = "fight") {
const messages = new Set<string>();
for (let tick = 0; tick < 10000; tick++) {
if (world.battle) {
const view = world.battle;
if (view.message) messages.add(view.message.text);
if (view.prompt?.kind === "action") world.battleAction({ action, move: 0 });
else if (view.prompt?.kind === "yesno") world.battleChoose(1);
else if (view.prompt?.kind === "forget") world.battleChoose(3);
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
} else if (world.dialogue) {
messages.add(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
world.tick();
} else if (world.busy) world.tick();
else return [...messages].join("\n");
}
throw new Error(`Battle did not settle: ${world.diagnostic}`);
}
describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => {
it("commits a victory, level-up, move learning and Pokédex sighting", () => {
const world = fixture();
encounter(world);
const before = world.save();
expect(before.party[0].level).toBe(5);
expect(before.pokedex.seen).toEqual([155]);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("SMOKESCREEN");
expect(world.state.party[0].level).toBe(6);
expect(world.state.party[0].moves).toContain(108);
expect(world.state.pokedex.seen.has(16)).toBe(true);
expect(world.state.pokedex.caught.has(16)).toBe(false);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("escapes without awarding experience and returns to walking", () => {
const world = fixture();
encounter(world);
expect(finish(world, "run")).toMatch(/Got away safely/);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party[0].exp).toBe(178);
expect(world.state.pokedex.seen.has(16)).toBe(true);
expect(world.move("down")).toBe(true);
});
it("restores earned stat experience before the next stat recalculation", () => {
const world = fixture();
const mon = generatePartyMon(world.assets.pokemon, {
species: 155, level: 100, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.moves = [33, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
world.state.party = [mon];
encounter(world);
finish(world);
expect(world.notice, world.diagnostic).toBe("");
const saved = world.save();
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
expect(() => world.restore(saved)).not.toThrow();
expect(world.save()).toEqual(saved);
const corrupt = structuredClone(saved);
corrupt.party[0].stats[1] = 999;
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
});
it("whiteout heals the party, halves money and returns home", () => {
const world = fixture("loss");
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toMatch(/whited\s+out/i);
expect(world.save()).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 });
const mon = world.state.party[0];
expect(mon.hp).toBe(mon.stats[0]);
expect(mon.pp[0]).toBe(35);
expect(mon.status).toBe(0);
});
it("a mid-battle save restores the committed party and position", () => {
const world = fixture();
encounter(world);
const committed = world.save();
for (let tick = 0; tick < 5000; tick++) {
if (world.battle?.prompt?.kind === "action") {
world.battleAction({ action: "fight", move: 0 });
break;
}
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(world.save()).toEqual(committed);
world.restore(committed);
expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(committed);
expect(world.move("right")).toBe(true);
});
it("a failed whiteout map callback cannot commit healing or money loss", () => {
const world = fixture("loss");
encounter(world);
const before = world.save();
// A malformed callback models a destination whose logic is not supported.
world.assets.map("24:7").callbacks.push({ kind: 5, script: world.assets.reader.bytes.length });
finish(world);
expect(world.notice).toContain("not been ported");
expect(world.save().money).toBe(before.money);
expect(world.save().party).toEqual(before.party);
expect(world.save().pokedex).toEqual(before.pokedex);
});
it("unsupported effects discard all battle party and Pokédex changes", () => {
const world = fixture("unsupported");
encounter(world);
const before = world.save();
finish(world);
expect(world.notice).toContain("not been ported");
expect(world.save().party).toEqual(before.party);
expect(world.save().pokedex).toEqual(before.pokedex);
expect(world.save().money).toBe(before.money);
});
});