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

+55 -4
View File
@@ -348,7 +348,17 @@ export interface BattleView {
/** A choice the battle is waiting for, if any. */
prompt?: Extract<
BattleEffect,
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
{
kind:
| "action"
| "yesno"
| "contestswitch"
| "forget"
| "stats"
| "name"
| "party"
| "move";
}
>;
/** Yes/No cursor, as on the original boxes. */
selected: 0 | 1;
@@ -1565,6 +1575,7 @@ export class GameWorld {
)
return;
if (isIce(collision)) return;
if (state.bugContest) return this.bugContestEncounter(previous, collision);
const table = this.encounterTable(state);
if (!(this.random() < table.rate)) return;
const chosen = this.chooseWildEncounter(table);
@@ -1581,6 +1592,40 @@ export class GameWorld {
// The step replays after a reload, so it keeps its Repel step.
if (state.repel && this.script) this.script.committed.repel++;
}
/** _TryWildEncounter_BugContest: 40% in super tall grass and 20%
* elsewhere (halved by a Cleanse Tag; no radio music changes it yet),
* a ContestMons pick, the Repel check, then
* BugCatchingContestBattleScript. */
private bugContestEncounter(previous: { x: number; y: number }, collision: number) {
const state = this.state;
let rate = Math.floor(
((collision === OW.COLL_LONG_GRASS || collision === OW.COLL_LONG_GRASS_1C ? 40 : 20) * 255) / 100,
);
if (state.party.some((mon) => mon.item === C.CLEANSE_TAG)) rate >>= 1;
if (!(this.random() < rate)) return;
// ChooseWildEncounter_BugContest: 0-99, then percentages in ContestMons.
let roll: number;
do roll = this.random();
while (roll >= 200);
roll >>= 1;
const r = this.assets.reader;
let entry = OVERWORLD.ContestMons;
while (roll >= r.byte(entry)) {
roll -= r.byte(entry);
entry += 4;
}
const species = r.byte(entry + 1),
min = r.byte(entry + 2),
span = r.byte(entry + 3) - min;
const level = span ? min + (this.random() % (span + 1)) : min;
const lead = state.party.find((mon) => mon.hp > 0);
if (!lead || (state.repel && level < lead.level)) return;
this.startScript(OVERWORLD.BugCatchingContestBattleScript, PLAYER, {
committedPosition: previous,
wild: { species, level },
});
if (state.repel && this.script) this.script.committed.repel++;
}
/** GetMapEncounterRate: CheckOnWater picks the water table and rate,
* which a Cleanse Tag halves. */
private encounterTable(state: ScriptState) {
@@ -2456,6 +2501,8 @@ export class GameWorld {
canLose: effect.canLose,
tree: effect.tree,
fish: effect.fish,
battleType: effect.battleType,
contest: draft.bugContest,
mapId: this.mapId,
text: () => this.host(PLAYER).text(),
bag: draft.bag,
@@ -3172,6 +3219,7 @@ export class GameWorld {
const prompt = this.battle?.prompt;
if (
prompt?.kind === "yesno" ||
prompt?.kind === "contestswitch" ||
prompt?.kind === "forget" ||
prompt?.kind === "party" ||
prompt?.kind === "move"
@@ -3182,7 +3230,8 @@ export class GameWorld {
private battleA() {
const view = this.battle!;
const prompt = view.prompt;
if (prompt?.kind === "yesno") return this.pumpBattle(view.selected);
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
return this.pumpBattle(view.selected);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
@@ -3190,7 +3239,8 @@ export class GameWorld {
private battleB() {
const view = this.battle!;
const prompt = view.prompt;
if (prompt?.kind === "yesno") return this.pumpBattle(0);
if (prompt?.kind === "yesno" || prompt?.kind === "contestswitch")
return this.pumpBattle(0);
if (prompt?.kind === "forget") return this.pumpBattle(4);
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
if (prompt?.kind === "move") return this.pumpBattle(-1);
@@ -4046,7 +4096,8 @@ export class GameWorld {
}
/** Change the highlighted answer (up/down on the original menus). */
moveCursor(direction: Direction) {
if (this.battle?.prompt?.kind === "yesno") {
const battlePrompt = this.battle?.prompt?.kind;
if (this.battle && (battlePrompt === "yesno" || battlePrompt === "contestswitch")) {
if (direction === "up") this.battle.selected = 1;
if (direction === "down") this.battle.selected = 0;
return;