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

+45 -7
View File
@@ -11,6 +11,7 @@ import { LocalMapView } from "./game/rendering/minimap";
import type { Choice, GameWorld } from "./game/world/world";
import type { PokegearCard } from "./game/scripts/pokegear";
import { drawTile } from "./game/rendering/pixels";
import { C } from "./game/battle/constants";
import { PokemonData } from "./game/rom/pokemon";
import type { Direction, Palette } from "./game/world/types";
import type { Button } from "./input/types";
@@ -589,6 +590,9 @@ function handleFieldInput(button: Button): boolean {
return true;
}
/** Battle prompts answered with YES/NO. */
const yesNoPrompt = (prompt?: { kind: string }) =>
prompt?.kind === "yesno" || prompt?.kind === "contestswitch";
function battleButtons() {
return [
...get("battle-menu").querySelectorAll<HTMLButtonElement>(
@@ -636,7 +640,7 @@ function handleBattleInput(button: Button): boolean {
if (["up", "down", "left", "right"].includes(button)) {
const delta = button === "up" || button === "left" ? -1 : 1;
battleCursor = (battleCursor + delta + buttons.length) % buttons.length;
if (world.battle.prompt?.kind === "yesno")
if (yesNoPrompt(world.battle.prompt))
world.battle.selected = battleCursor === 0 ? 1 : 0;
highlightBattleChoice();
return true;
@@ -699,7 +703,9 @@ function showBattle(world: GameWorld) {
get("battle-text").textContent =
prompt?.kind === "name"
? prompt.title
: prompt?.kind === "party" || prompt?.kind === "move"
: prompt?.kind === "party" ||
prompt?.kind === "move" ||
prompt?.kind === "contestswitch"
? prompt.text
: (view.message?.text ?? "");
if (prompt?.kind !== "action") showMoves = showPack = false;
@@ -712,7 +718,7 @@ function showBattle(world: GameWorld) {
battleCursor = 0;
battleSelectionKey = selectionKey;
}
if (prompt?.kind === "yesno") battleCursor = view.selected === 1 ? 0 : 1;
if (yesNoPrompt(prompt)) battleCursor = view.selected === 1 ? 0 : 1;
const bag = world.liveState.bag;
// The Pokémon out has its live battle HP; the others their party values.
const party = world.liveState.party.map((mon, index) =>
@@ -846,13 +852,45 @@ function showBattle(world: GameWorld) {
showWorld(world);
}),
button("PKMN", () => runtime.battleAction({ action: "pkmn" })),
button("PACK", () => {
showPack = true;
showWorld(world);
}),
// ContestBattleMenu: PARKBALL× with two digits throws a Park Ball.
prompt.parkBalls !== undefined
? button(
`PARKBALL×${String(prompt.parkBalls).padStart(2, "0")}`,
() =>
runtime.battleAction({
action: "item",
item: C.PARK_BALL,
slot: -1,
}),
)
: button("PACK", () => {
showPack = true;
showWorld(world);
}),
button("RUN", () => runtime.battleAction({ action: "run" })),
);
}
} else if (prompt?.kind === "contestswitch") {
// DisplayCaughtContestMonStats: STOCK and THIS boxes, then Yes/No.
for (const [label, mon] of [
["STOCK", prompt.stock],
["THIS", prompt.caught],
] as const)
buttons.push(
button(`${label} ${mon.name} :L${mon.level}`, () => {}, {
detail: `HEALTH ${mon.maxHp}`,
disabled: " ",
wide: true,
}),
);
buttons.push(
button("YES", () => runtime.battleChoose(1), {
pressed: view.selected === 1,
}),
button("NO", () => runtime.battleChoose(0), {
pressed: view.selected === 0,
}),
);
} else if (prompt?.kind === "yesno") {
buttons.push(
button("YES", () => runtime.battleChoose(1), {