Port the special battle types and contest battles
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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 15:56:30 +02:00
1 parent b547ca29ff
commit 8fae1f0c0f
15 files changed
+787 -42

No files matched your search

+43
View File
@@ -34,6 +34,18 @@ async function start(page: Page, scenario: BattleScenario = "win") {
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");
}
@@ -300,3 +312,34 @@ test("battle PKMN STATS shows the stats screen's three pages and returns with B"
// 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]+!/);
});