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]+!/);
});
+11 -1
View File
@@ -2,7 +2,7 @@ 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" | "catch" | "switch" | "potion";
export type BattleScenario = "win" | "loss" | "unsupported" | "catch" | "switch" | "potion" | "contest";
/** Test-only encounters on an existing floor: production maps stay gated.
* Everything after selection runs through the ROM's real wild-battle script. */
@@ -40,6 +40,16 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.hp = 3;
world.state.bag.items = [{ item: 18, quantity: 1 }]; // POTION
}
// A Bug-Catching Contest in progress, already holding a Scyther: the
// state National Park's event would leave (GiveParkBalls and a catch).
if (scenario === "contest")
world.state.bugContest = {
parkBalls: 20,
mon: generatePartyMon(assets.pokemon, {
species: 123, level: 14, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x8888, caughtData: 0,
}),
};
world.state.pokedex.seen.add(155);
world.state.pokedex.caught.add(155);
world.state.money = 3001;
+310
View File
@@ -0,0 +1,310 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { RomReader } from "../src/game/rom/reader";
import {
Battle,
BATTLERESULT_CAUGHT_CELEBI,
UnsupportedBattle,
type BattleEffect,
type BattleInput,
type BattleOptions,
type BattleOutcome,
type BugContest,
type RoamMon,
} from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { ScriptRun, emptyScriptState } from "../src/game/scripts/interpreter";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const CATERPIE = 10,
SCYTHER = 123,
GYARADOS = 130,
SNORLAX = 143,
TYPHLOSION = 157,
RAIKOU = 243,
SUICUNE = 245,
CELEBI = 251,
TACKLE = 33;
describe("loadvar VAR_BATTLETYPE", () => {
const host = {
mapId: "1:1",
lastTalked: 0,
sceneOwner: () => undefined,
timeMask: () => 2,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
clockDay: () => 0,
text: () => defaultTextContext("KAWA"),
};
const battle = (type: number) => {
// loadvar VAR_BATTLETYPE, type; loadwildmon GYARADOS, 30; startbattle;
// startbattle; end. CleanUpBattleRAM clears the type for the second.
const bytes = Uint8Array.from([0x1e, 3, type, 0x5d, GYARADOS, 30, 0x5f, 0x5f, 0x91]);
const run = new ScriptRun(new RomReader(bytes), 0, emptyScriptState(), host);
const first = run.resume();
const second = run.resume(0);
return [first, second] as const;
};
it("passes the event battle types to the battle and clears them afterwards", () => {
for (const type of [
C.BATTLETYPE_CONTEST,
C.BATTLETYPE_FORCESHINY,
C.BATTLETYPE_TRAP,
C.BATTLETYPE_FORCEITEM,
C.BATTLETYPE_SUICUNE,
]) {
const [first, second] = battle(type);
expect(first).toMatchObject({ kind: "battle", canLose: false, battleType: type });
expect(second).toMatchObject({ kind: "battle", battleType: undefined });
}
expect(battle(C.BATTLETYPE_CANLOSE)[0]).toMatchObject({ canLose: true, battleType: undefined });
});
it("refuses battle types that only the engine's own routines set", () => {
for (const type of [C.BATTLETYPE_TUTORIAL, C.BATTLETYPE_ROAMING, C.BATTLETYPE_CELEBI, 13])
expect(() => battle(type)).toThrow("battle type");
});
});
describe.skipIf(!existsSync(romPath))("special battle types with the local ROM", () => {
let data: CrystalAssets["pokemon"];
beforeAll(() => {
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
});
const strong = (): PartyMon => {
const mon = generatePartyMon(data, {
species: TYPHLOSION, level: 100, item: 0, otId: 1234, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [TACKLE, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
return mon;
};
/** A battle whose Random bytes come from `bytes` first, then an LCG. */
const setup = (options: Partial<BattleOptions> & { seed?: number; bytes?: number[] } = {}) => {
let seed = options.seed ?? 1;
const bytes = [...(options.bytes ?? [])];
const draws: number[] = [];
const party = options.party ?? [strong()];
const battle = new Battle({
data,
party,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
const value = bytes.length ? bytes.shift()! : (seed >>> 16) & 255;
draws.push(value);
return value;
},
wild: { species: CATERPIE, level: 5 },
text: () => defaultTextContext("CHRIS"),
bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 5 }], keyItems: [] },
hasPokedex: true,
caughtData: () => 0x1234,
...options,
});
return { battle, party, draws };
};
/** Answer menus with `actions` and choices with `answers`; stops at a
* menu with no action left. */
const play = (battle: Battle, actions: BattleInput[], answers: BattleInput[] = []) => {
const run = battle.run();
const texts: string[] = [];
const prompts: BattleEffect[] = [];
let input: BattleInput;
for (let guard = 0; guard < 2000; guard++) {
const next = run.next(input);
input = undefined;
if (next.done) return { texts, prompts, outcome: next.value as BattleOutcome };
const effect = next.value;
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
else if (effect.kind === "action") {
prompts.push(effect);
if (!actions.length) return { texts, prompts, outcome: undefined };
input = actions.shift();
} else if (effect.kind === "yesno" || effect.kind === "name" || effect.kind === "contestswitch") {
prompts.push(effect);
input = answers.shift();
} else if (effect.kind === "forget") input = 4;
}
throw new Error("The battle did not finish.");
};
const master = { action: "item" as const, item: C.MASTER_BALL, slot: 0 };
const run = { action: "run" as const };
const park = { action: "item" as const, item: C.PARK_BALL, slot: -1 };
it("refuses unknown types and event battles missing their state", () => {
const cases: Partial<BattleOptions>[] = [
{ battleType: 13 },
{ battleType: C.BATTLETYPE_TUTORIAL },
{ battleType: C.BATTLETYPE_CONTEST },
{ battleType: C.BATTLETYPE_ROAMING },
{ battleType: C.BATTLETYPE_ROAMING, roamer: { species: SUICUNE, level: 40, mapId: "1:1", hp: 0, dvs: 0 } },
];
for (const options of cases) {
const { battle, draws } = setup(options);
expect(() => battle.run().next()).toThrow(UnsupportedBattle);
expect(draws).toEqual([]);
}
});
it("makes the Red Gyarados shiny without drawing its DVs, and keeps the player in", () => {
const { battle, draws } = setup({ battleType: C.BATTLETYPE_FORCESHINY, wild: { species: GYARADOS, level: 30 }, bytes: [0] });
const { texts } = play(battle, [run]);
expect(battle.enemy.mon.dvs).toBe(0xeaaa);
// Only the item roll (no item) precedes the menu.
expect(draws).toEqual([0]);
expect(texts).toContain("Can't escape!");
});
it("refuses to let the player run from the trap, Celebi and Suicune battles", () => {
for (const battleType of [C.BATTLETYPE_TRAP, C.BATTLETYPE_CELEBI, C.BATTLETYPE_SUICUNE]) {
const { battle } = setup({ battleType });
const { texts, outcome } = play(battle, [run]);
expect(texts).toContain("Can't escape!");
expect(outcome).toBeUndefined();
}
});
it("forces Snorlax's first held item without the item rolls", () => {
const { battle, draws } = setup({ battleType: C.BATTLETYPE_FORCEITEM, wild: { species: SNORLAX, level: 50 }, bytes: [0x12, 0x34] });
play(battle, []);
expect(battle.enemy.mon.item).toBe(data.baseData(SNORLAX).items[0]);
expect(battle.enemy.mon.dvs).toBe(0x1234);
expect(draws.slice(0, 2)).toEqual([0x12, 0x34]);
// Catching it reloads the item without a roll either.
const caught = setup({ battleType: C.BATTLETYPE_FORCEITEM, wild: { species: SNORLAX, level: 50 } });
const { outcome } = play(caught.battle, [master], [0]);
expect(outcome?.result).toBe(C.WIN);
expect(caught.draws).toHaveLength(3); // DVs and CleanUpBattleRAM's roaming roll
expect(caught.party[1].item).toBe(data.baseData(SNORLAX).items[0]);
});
it("announces Celebi and records its capture in the battle result", () => {
const { battle } = setup({ battleType: C.BATTLETYPE_CELEBI, wild: { species: CELEBI, level: 30 } });
const { texts, outcome } = play(battle, [master], [0]);
expect(texts[0]).toBe("Wild CELEBI appeared!");
expect(outcome!.result).toBe(BATTLERESULT_CAUGHT_CELEBI | C.WIN);
});
it("moves the roaming Pokémon after one battle in sixteen", () => {
const seen = new Set<boolean>();
for (let seed = 1; seed < 200 && seen.size < 2; seed++) {
const { battle, draws } = setup({ seed });
const { outcome } = play(battle, [master], [0]);
// CleanUpBattleRAM's BattleRandom is the battle's last draw.
const moved = !(draws.at(-1)! & 0x0f);
expect(outcome!.updateRoamMons).toBe(moved ? true : undefined);
seen.add(moved);
}
expect(seen.size).toBe(2);
});
it("keeps a roaming Pokémon's DVs and HP between encounters, and removes it once caught", () => {
const roamer: RoamMon = { species: RAIKOU, level: 40, mapId: "10:1", hp: 0, dvs: 0 };
// The item roll (no item), then the DVs: Speed/Special first.
const first = setup({ battleType: C.BATTLETYPE_ROAMING, roamer, wild: { species: RAIKOU, level: 40 }, bytes: [0, 0x34, 0x12] });
const fled = play(first.battle, [run]);
const maxHp = first.battle.enemy.mon.maxHp;
expect(first.battle.enemy.mon.dvs).toBe(0x1234);
expect(roamer).toMatchObject({ species: RAIKOU, dvs: 0x1234, hp: maxHp & 0xff });
expect(fled.outcome).toMatchObject({ result: C.DRAW, updateRoamMons: true });
// Damaged HP is kept as its low byte, and the DVs are not drawn again.
roamer.hp = 50;
const second = setup({ battleType: C.BATTLETYPE_ROAMING, roamer, wild: { species: RAIKOU, level: 40 }, bytes: [0] });
const caught = play(second.battle, [master], [0]);
expect(second.battle.enemy.mon.dvs).toBe(0x1234);
expect(second.party[1]).toMatchObject({ species: RAIKOU, dvs: 0x1234, hp: (maxHp & 0xff00) | 50 });
expect(caught.outcome!.result).toBe(C.WIN);
expect(caught.outcome!.updateRoamMons).toBeUndefined();
expect(roamer).toEqual({ species: 0, level: 40, mapId: null, hp: 0, dvs: 0x1234 });
});
describe("the Bug-Catching Contest", () => {
const contestBattle = (contest: BugContest, options: Partial<BattleOptions> & { seed?: number } = {}) =>
setup({ battleType: C.BATTLETYPE_CONTEST, contest, ...options });
it("shows PARKBALL×, lets the player go, and leaves the pack alone", () => {
const contest: BugContest = { parkBalls: 20, mon: null };
const { battle } = contestBattle(contest);
const { texts, prompts, outcome } = play(battle, [run]);
expect(prompts[0]).toEqual({ kind: "action", parkBalls: 20 });
expect(texts).toContain("Got away safely!");
expect(outcome!.result).toBe(C.DRAW);
});
it("keeps its first catch for the contest, outside the party", () => {
for (let seed = 1; seed < 100; seed++) {
const contest: BugContest = { parkBalls: 20, mon: null };
const { battle, party } = contestBattle(contest, { seed });
const { texts, outcome } = play(battle, [park, park, park, park, park]);
if (!contest.mon) continue;
expect(outcome!.result).toBe(C.WIN);
expect(texts).toContain("CHRIS used the PARK BALL.");
expect(texts).toContain("Caught CATERPIE!");
expect(party).toHaveLength(1);
const thrown = texts.filter((text) => text === "CHRIS used the PARK BALL.").length;
expect(contest.parkBalls).toBe(20 - thrown);
const enemy = battle.enemy.mon;
expect(contest.mon).toMatchObject({
species: CATERPIE, level: 5, dvs: enemy.dvs, hp: enemy.hp, caughtData: 0, happiness: C.BASE_HAPPINESS,
});
return;
}
throw new Error("No seed caught the Pokémon.");
});
it("asks before replacing the stocked Pokémon", () => {
for (const answer of [0, 1]) {
const stock = strong();
stock.species = SCYTHER;
stock.level = 14;
let done = false;
for (let seed = 1; seed < 100 && !done; seed++) {
const contest: BugContest = { parkBalls: 20, mon: stock };
const { battle } = contestBattle(contest, { seed });
const { texts, prompts } = play(battle, [park, park, park, park, park], [answer]);
const ask = prompts.find((prompt) => prompt.kind === "contestswitch");
if (!ask) continue;
done = true;
expect(texts).toContain("You already caught a SCYTHER.");
expect(ask).toEqual({
kind: "contestswitch",
text: "Switch #MON?".replace("#", "POKé"),
stock: { name: "SCYTHER", level: 14, maxHp: stock.stats[0] },
caught: { name: "CATERPIE", level: 5, maxHp: battle.enemy.mon.maxHp },
});
if (answer) {
expect(contest.mon!.species).toBe(CATERPIE);
expect(texts).toContain("Caught CATERPIE!");
} else {
expect(contest.mon).toBe(stock);
expect(texts).not.toContain("Caught CATERPIE!");
}
}
expect(done).toBe(true);
}
});
it("ends the battle as a draw when the last Park Ball misses", () => {
for (let seed = 1; seed < 100; seed++) {
const contest: BugContest = { parkBalls: 1, mon: null };
const { battle } = contestBattle(contest, { seed, wild: { species: SCYTHER, level: 14 } });
const { texts, outcome } = play(battle, [park, park]);
if (contest.mon) continue;
expect(contest.parkBalls).toBe(0);
// The wild Pokémon still takes its turn first.
expect(texts.some((text) => text.startsWith("Wild SCYTHER used") || text.startsWith("Enemy SCYTHER used"))).toBe(true);
expect(outcome!.result).toBe(C.DRAW);
return;
}
throw new Error("Every seed caught Scyther.");
});
});
});
+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);
+4 -3
View File
@@ -208,9 +208,10 @@ describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
input = { action: "item", item: C.MASTER_BALL, slot: 0 };
} else if (next.value.kind === "yesno") input = 0;
}
// Only LoadEnemyMon's wild item roll (one or two draws) remains.
expect(draws() - before).toBeGreaterThanOrEqual(1);
expect(draws() - before).toBeLessThanOrEqual(2);
// Only LoadEnemyMon's wild item roll (one or two draws) and
// CleanUpBattleRAM's roaming roll remain.
expect(draws() - before).toBeGreaterThanOrEqual(2);
expect(draws() - before).toBeLessThanOrEqual(3);
expect(party).toHaveLength(2);
expect(party[1].nickname).toBe("RATTATA");
expect(bag.balls).toEqual([]);