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
@@ -37,6 +37,49 @@ function finish(world: GameWorld, action: "fight" | "run" = "fight") {
}
describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => {
it("runs a Bug-Catching Contest encounter through its battle script", () => {
const world = fixture("contest");
const party = structuredClone(world.state.party),
bag = structuredClone(world.state.bag);
// Pace on and off the test grass until the contest's 20% roll hits.
for (const direction of ENCOUNTER_WALK) if (!world.battle) world.move(direction);
for (let step = 0; step < 200 && !world.battle; step++) world.move(step % 2 ? "right" : "left");
expect(world.battle, world.diagnostic).toBeDefined();
const contest = world.state.bugContest!;
let asked = false;
const messages = new Set<string>();
for (let tick = 0; tick < 10000 && (world.battle || world.busy || world.dialogue); tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) world.advanceDialogue();
world.tick();
continue;
}
if (view.message) messages.add(view.message.text.replace(/\n/g, " "));
if (view.prompt?.kind === "action") {
expect(view.prompt.parkBalls).toBeGreaterThan(0);
world.battleAction({ action: "item", item: 0xb1, slot: -1 });
} else if (view.prompt?.kind === "contestswitch") {
asked = true;
expect(view.prompt.stock).toMatchObject({ name: "SCYTHER", level: 14 });
world.battleChoose(1);
} else if (view.message?.prompt) world.interact();
else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
// Committed once the script ended: one Park Ball per throw, the new
// catch in the contest, and the party and pack untouched.
const thrown = [...messages].filter((text) => text.includes("used the PARK BALL")).length;
expect(thrown, [...messages].join(" | ")).toBeGreaterThan(0);
expect(world.state.bugContest!.parkBalls).toBe(20 - thrown);
expect(world.state.bugContest!.parkBalls).toBeLessThan(20);
expect(asked).toBe(true);
expect(world.state.bugContest!.mon!.species).not.toBe(123);
expect(world.state.party).toEqual(party);
expect(world.state.bag).toEqual(bag);
expect(contest).not.toBe(world.state.bugContest);
});
it("commits a victory, level-up, move learning and Pokédex sighting", () => {
const world = fixture();
encounter(world);