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
+782 -37

No files matched your search

+10 -2
View File
@@ -263,8 +263,16 @@ set it. A status heal after a full turn therefore penalises a paralysed
or burned enemy again, as the original does. Kurt's balls keep their
documented bugs (Heavy Ball bank, Fast Ball's three bytes, Moon Ball's
BURN_HEAL compare, Love Ball's same gender). The Heavy Ball is refused for
Kadabra and Sunflora, whose original weight read runs into VRAM. The
Bug-Catching Contest's Park Ball count and contest Pokémon remain gaps.
Kadabra and Sunflora, whose original weight read runs into VRAM.
Special battle types follow `LoadEnemyMon`, `TryToRunAwayFromBattle`,
`BattleMenu`, `CheckContestBattleOver`, `PokeBallEffect`,
`BugContest_SetCaughtContestMon` and `BattleEnd_HandleRoamMons`. The
contest's Park Balls and caught Pokémon (`BugContest`) and a roaming
Pokémon's struct (`RoamMon`) are passed in and updated by the battle. The
world keeps the contest in its script state while one runs, and switches
encounters to `BugCatchingContestBattleScript` and `ContestMons`. It has
no roaming Pokémon yet.
`engine/pokemon/stats_screen.asm`: the stats screen's three pages (HP,
status, types, experience to the next level; item and moves with PP; ID,
+9 -2
View File
@@ -311,7 +311,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- For Kadabra and Sunflora the original Heavy Ball scan runs off the wrong bank into VRAM, so that throw is refused.
- `tests/reference-capture.spec.ts` runs the unmodified `PokeBallEffect` over 6,153 more states per browser: every species for the Heavy, Fast, Moon and Love Balls, level pairs for the Level Ball and fishing for the Lure Ball.
- There is no Safari Ball to port: `SAFARI_BALL` is Moon Stone's item number and never reaches `PokeBallEffect`.
- Card #28 stays in progress for the Bug-Catching Contest's battle type (Park Ball count, contest Pokémon) and the other special battle types.
## Implemented: special battle types
- The battle engine runs every remaining wild battle type. The Red Gyarados (`BATTLETYPE_FORCESHINY`) has shiny DVs; Ho-Oh, Lugia and Snorlax (`FORCEITEM`) hold their first item without the item rolls, even when reloaded after a catch. The player cannot run from those, the trap battles, Celebi or Suicune. Celebi has its own appearance text and sets `BATTLERESULT_CAUGHT_CELEBI` when caught.
- Roaming battles take and return a `wRoamMon` struct: the DVs are drawn once, Speed/Special first, and only the HP's low byte is kept. A win or a catch removes the Pokémon from the map. Every other battle ends with `CleanUpBattleRAM`'s one-in-sixteen roll to move the roaming Pokémon. The result reports it, but nothing roams yet: Raikou, Entei and Suicune are released in the Burned Tower.
- In the Bug-Catching Contest the menu shows PARKBALL× with the count, and RUN always works. Each throw takes a Park Ball rather than an item from the pack, and the battle ends as a draw when the last one misses. A catch goes to the contest, not the party; a second catch shows both Pokémon and asks to switch. While a contest runs (`bugContest` in the script state), encounters use `BugCatchingContestBattleScript` and its `ContestMons` table, as `RandomEncounter` does.
- Scripts can load the forced-shiny, trap, forced-item, Suicune and contest types with `loadvar VAR_BATTLETYPE`.
- Still to come with their maps: National Park's contest event (registration, timer, judging; the contest state is not saved), Raikou's, Entei's and Suicune's roaming, and the Ilex Forest Celebi event with `CheckCaughtCelebi`.
## Native battles: current state
@@ -395,7 +402,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too. Next: the Bug-Catching Contest and the other special battle types (board #28).
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Kurt's balls are thrown too, and the battle engine handles every special battle type (board #28 is complete). Next: Route 31 to Violet City (board #31).
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+31
View File
@@ -1276,3 +1276,34 @@ Next on #28: the Bug-Catching Contest battle type (Park Ball count,
`BugContest_SetCaughtContestMon`) and the other special battle types.
Neither is reachable yet: National Park and the legendary encounters
are not enabled.
## Special battle types: 2026-10-03
Same session; finishes card #28.
- `BattleOptions.battleType` takes FORCESHINY, TRAP, FORCEITEM, CELEBI,
SUICUNE, CONTEST (with `contest`) and ROAMING (with `roamer`); invalid
combinations raise `UnsupportedBattle` before any draw. `loadvar
VAR_BATTLETYPE` passes the script-set ones; CleanUpBattleRAM's clear
follows `startbattle`.
- `run()` ends with `BattleEnd_HandleRoamMons`: one extra BattleRandom
after every non-roaming battle (`updateRoamMons` in the outcome). The
world ignores the flag until roaming Pokémon exist.
- Contest: `{ kind: "action", parkBalls }` drives the PARKBALL× button;
`{ kind: "contestswitch" }` is answered like Yes/No (world, keyboard and
DOM). `ScriptState.bugContest` is runtime-only (not saved);
`readmem wParkBallsRemaining` reads it. The fixture scenario `contest`
covers it in `tests/battle-world.test.ts` and
`tests/battle-browser.spec.ts`. The browser test found that the switch
text was missing from the text box; it is fixed.
- `tests/battle-types.test.ts` covers each type with the local ROM, plus
a synthetic `loadvar` script.
Validation: 596 unit tests passed (3 skipped); without the ROM 141 passed,
458 skipped. Browser suite: 155 passed, 19 skipped. Both builds,
reference/release/vendor checks and `git diff --check` passed.
Left for the map cards: National Park's contest event (GiveParkBalls,
timer, out-of-balls/time-up scripts, judging, saving the contest state),
Burned Tower's roaming release with UpdateRoamMons/JumpRoamMons and
roaming encounters, and Ilex Forest's CelebiShrineEvent/CheckCaughtCelebi.
+1
View File
@@ -195,6 +195,7 @@ const ramNames = {
curHPAnimDeltaHP: "wCurHPAnimDeltaHP",
visitedSpawns: "wVisitedSpawns",
tempWildMonSpecies: "wTempWildMonSpecies",
parkBallsRemaining: "wParkBallsRemaining",
coins: "wCoins",
bgMapMode: "hBGMapMode",
blueCardBalance: "wBlueCardBalance",
+180 -17
View File
@@ -111,8 +111,12 @@ export type BattleEffect =
| { kind: "text"; text: string; prompt: boolean }
/** HP, status or level changed; renderers re-read the battle state. */
| { kind: "hud" }
/** The battle menu: answered with an action. */
| { kind: "action" }
/** The battle menu: answered with an action. In the Bug-Catching
* Contest, PACK is PARKBALL× with this wParkBallsRemaining. */
| { kind: "action"; parkBalls?: number }
/** DisplayCaughtContestMonStats: the stocked contest Pokémon beside the
* one just caught, with ContestAskSwitchText; answered Yes/No (1/0). */
| { kind: "contestswitch"; text: string; stock: ContestMonSummary; caught: ContestMonSummary }
| { kind: "yesno"; text: string }
/** Choose a move to forget (0-3); 4 cancels, like B on the list. */
| { kind: "forget"; moves: number[]; learning: number }
@@ -127,6 +131,36 @@ export type BattleEffect =
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
| { kind: "move"; text: string; moves: number[]; pp: number[] };
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
export type ContestMonSummary = { name: string; level: number; maxHp: number };
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
export interface BugContest {
parkBalls: number;
mon: PartyMon | null;
}
/** A wRoamMon struct: Raikou's, Entei's or Suicune's. An HP of 0 means
* the DVs and HP are drawn at the next encounter. */
export interface RoamMon {
species: number;
level: number;
/** The map it roams, or null (GROUP_N_A/MAP_N_A) once defeated or caught. */
mapId: string | null;
/** The low byte of its HP. */
hp: number;
dvs: number;
}
/** BATTLERESULT_CAUGHT_CELEBI, which CheckCaughtCelebi reads. */
export const BATTLERESULT_CAUGHT_CELEBI = 0x40;
/** wBattleType values that BattleOptions.battleType accepts. */
const SPECIAL_BATTLE_TYPES: readonly number[] = [
C.BATTLETYPE_ROAMING,
C.BATTLETYPE_CONTEST,
C.BATTLETYPE_FORCESHINY,
C.BATTLETYPE_TRAP,
C.BATTLETYPE_FORCEITEM,
C.BATTLETYPE_CELEBI,
C.BATTLETYPE_SUICUNE,
];
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
type ItemUse = { item: number; slot: number };
export type BattleAction =
@@ -134,7 +168,8 @@ export type BattleAction =
| { action: "run" }
/** PKMN: open the party menu to switch. */
| { action: "pkmn" }
/** PACK: use the item in this slot of its pocket (only Balls so far). */
/** PACK: use the item in this slot of its pocket. In the Bug-Catching
* Contest it throws a PARK_BALL (any slot). */
| { action: "item"; item: number; slot: number };
export type BattleInput = number | string | BattleAction | undefined;
/** wBattleResult, the prize money and wEvolvableFlags (a bit per party
@@ -147,6 +182,9 @@ export type BattleOutcome = {
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
payDayMoney?: number;
evolvable?: number;
/** BattleEnd_HandleRoamMons called UpdateRoamMons: after a roaming
* battle that was not won, and after one battle in sixteen otherwise. */
updateRoamMons?: true;
};
export interface BattleTrainer {
@@ -195,6 +233,14 @@ export interface BattleOptions {
tree?: boolean;
/** BATTLETYPE_FISH: a wild Pokémon hooked on a fishing rod. */
fish?: boolean;
/** wBattleType for the other wild battles: BATTLETYPE_FORCESHINY, TRAP,
* FORCEITEM, CELEBI, SUICUNE, CONTEST (with `contest`) or ROAMING (with
* `roamer`). */
battleType?: number;
/** The contest's Park Balls and caught Pokémon, which the battle updates. */
contest?: BugContest;
/** The roaming Pokémon's struct, which the battle updates. */
roamer?: RoamMon;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
/** wMapGroup:wMapNumber, which LoadEnemyMon compares with the Lake of
@@ -561,6 +607,7 @@ export class Battle {
if (options.tutorial) this.battleType = C.BATTLETYPE_TUTORIAL;
else if (options.tree) this.battleType = C.BATTLETYPE_TREE;
else if (options.fish) this.battleType = C.BATTLETYPE_FISH;
else if (options.battleType !== undefined) this.battleType = options.battleType;
}
private get reader(): RomReader {
return this.options.data.reader;
@@ -741,12 +788,24 @@ export class Battle {
throw new UnsupportedBattle("Unown encounters are not ported yet.");
const data = this.options.data,
base = data.baseData(species);
// 25% chance of an item: 23% the first, 2% the second.
// 25% chance of an item: 23% the first, 2% the second. Ho-Oh, Lugia
// and Snorlax (BATTLETYPE_FORCEITEM) always hold the first.
let item = 0;
if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
if (this.battleType === C.BATTLETYPE_FORCEITEM) item = base.items[0];
else if (this.random() >= percent(75) + 1) item = this.random() < percent(8) ? base.items[1] : base.items[0];
// Roaming Pokémon keep their DVs, drawn (Speed/Special first) at the
// first encounter; the Red Gyarados is shiny.
const roamer = this.battleType === C.BATTLETYPE_ROAMING ? this.options.roamer! : undefined;
let dvs: number;
do dvs = (this.random() << 8) | this.random();
while (species === C.MAGIKARP && this.rerollMagikarp(dvs));
if (roamer) {
if (!roamer.hp) {
const low = this.random();
roamer.dvs = (this.random() << 8) | low;
}
dvs = roamer.dvs;
} else if (this.battleType === C.BATTLETYPE_FORCESHINY) dvs = 0xeaaa;
else dvs = (this.random() << 8) | this.random();
while (species === C.MAGIKARP && this.rerollMagikarp(dvs)) dvs = (this.random() << 8) | this.random();
const stats = calcStats(data, species, dvs, level);
const moves = this.levelMoves(species, level);
this.enemySpecies = species;
@@ -760,7 +819,8 @@ export class Battle {
level,
// .TreeMon: some Pokémon fall from trees asleep.
status: this.sleepingTreeMon(species) ? C.TREEMON_SLEEP_TURNS : 0,
hp: stats[0],
// A roaming Pokémon's struct keeps only its HP's low byte.
hp: roamer?.hp ? (stats[0] & 0xff00) | roamer.hp : stats[0],
maxHp: stats[0],
stats: stats.slice(1),
types: [base.types[0], base.types[1]],
@@ -771,6 +831,7 @@ export class Battle {
catchRate: base.catchRate,
exp: base.baseExp,
};
if (roamer && !roamer.hp) roamer.hp = stats[0] & 0xff;
this.options.pokedex.seen.add(species);
this.enemy.stats = [...this.enemy.mon.stats];
this.wildMoves = [...moves];
@@ -889,11 +950,27 @@ export class Battle {
/** StartBattle through ExitBattle. */
*run(): Gen<BattleOutcome> {
const outcome = yield* this.doBattle();
// ExitBattle: CheckPayDay after a win.
if (outcome.result & 0x0f) return outcome;
let outcome = yield* this.doBattle();
// ExitBattle: CheckPayDay after a win. EvolveAfterBattle follows in
// the world, before CleanUpBattleRAM's roaming update below.
if (!(outcome.result & 0x0f)) {
const payDayMoney = yield* this.checkPayDay();
return payDayMoney ? { ...outcome, payDayMoney } : outcome;
if (payDayMoney) outcome = { ...outcome, payDayMoney };
}
return this.handleRoamMons() ? { ...outcome, updateRoamMons: true } : outcome;
}
/** BattleEnd_HandleRoamMons: a roaming Pokémon keeps its HP, or leaves
* the map for good when defeated or caught. Other battles move the
* roaming Pokémon one time in sixteen. True when UpdateRoamMons runs. */
private handleRoamMons(): boolean {
if (this.battleType !== C.BATTLETYPE_ROAMING) return !(this.random() & 0x0f);
const roamer = this.options.roamer!;
if (!(this.battleResult & 0x0f)) {
Object.assign(roamer, { hp: 0, mapId: null, species: 0 });
return false;
}
roamer.hp = this.enemy.mon.hp & 0xff;
return true;
}
/** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */
private *checkPayDay(): Gen<number> {
@@ -927,6 +1004,15 @@ export class Battle {
}
if (this.options.tutorial && (trainer || !this.options.wild))
throw new UnsupportedBattle("The catching tutorial needs a wild Pokémon.");
if (this.options.battleType !== undefined) {
const type = this.options.battleType;
if (!SPECIAL_BATTLE_TYPES.includes(type) || trainer || !this.options.wild)
throw new UnsupportedBattle(`Battle type ${type} is not supported here.`);
if (type === C.BATTLETYPE_CONTEST && !this.options.contest)
throw new UnsupportedBattle("A contest battle needs the contest's state.");
if (type === C.BATTLETYPE_ROAMING && this.options.roamer?.species !== this.options.wild.species)
throw new UnsupportedBattle("A roaming battle needs its roaming Pokémon.");
}
this.loadEnemyMon();
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
if (this.curBattleMon < 0)
@@ -951,6 +1037,8 @@ export class Battle {
? BATTLE.HookedPokemonAttackedText
: this.battleType === C.BATTLETYPE_TREE
? BATTLE.PokemonFellFromTreeText
: this.battleType === C.BATTLETYPE_CELEBI
? BATTLE.WildCelebiAppearedText
: BATTLE.WildPokemonAppearedText,
);
this.resetBattleParticipants();
@@ -1026,6 +1114,11 @@ export class Battle {
private *battleTurn(): Gen {
for (let turns = 0; turns < 1000; turns++) {
// CheckContestBattleOver: out of Park Balls, the battle ends a draw.
if (this.battleType === C.BATTLETYPE_CONTEST && !this.options.contest!.parkBalls) {
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
return;
}
this.curDamage = 0;
this.enemyIsSwitching = 0;
this.playerIsSwitching = 0;
@@ -1146,10 +1239,14 @@ export class Battle {
* the battle ended from the menu. */
private *battleMenu(): Gen<boolean> {
for (;;) {
const input = yield { kind: "action" };
const contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
const input = yield contest ? { kind: "action", parkBalls: contest.parkBalls } : { kind: "action" };
if (!input || typeof input !== "object") continue;
if (input.action === "item") {
const used = yield* this.battleMenuPack(input.item, input.slot);
// BattleMenu_Pack's .contest: PARKBALL× throws a Park Ball.
const used = contest
? yield* this.pokeBallEffect(C.PARK_BALL, -1)
: yield* this.battleMenuPack(input.item, input.slot);
if (this.wildMon) {
// BattleMenu_Pack.UseItem's .run: a caught Pokémon ends the
// battle as a WIN, keeping the result's flag bits.
@@ -1524,7 +1621,14 @@ export class Battle {
* Dude's own ball and passes -1. Returns false if no ball was used. */
private *pokeBallEffect(item: number, slot: number): Gen<boolean> {
const data = this.options.data,
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL;
tutorial = this.battleType === C.BATTLETYPE_TUTORIAL,
contest = this.battleType === C.BATTLETYPE_CONTEST ? this.options.contest! : undefined;
// .return_from_capture: the tutorial keeps the Dude's ball, the contest
// counts down its Park Balls, and other balls leave the pack.
const useBall = () => {
if (contest) contest.parkBalls = (contest.parkBalls - 1) & 0xff;
else if (!tutorial) this.tossItem("balls", item, slot);
};
// _DoItemEffect: GetItemName, then CopyName1 into wStringBuffer2.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.itemName(item);
if (this.battleMode !== C.WILD_BATTLE) {
@@ -1545,6 +1649,8 @@ export class Battle {
// The Dude's tutorial reaches this check too, and the original then
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
// The contest's menu would go on to the player's move.
if (contest) throw new UnsupportedBattle("A Park Ball cannot be used with a full party and box.");
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
yield* this.say(BATTLE.BallBoxFullText);
return false;
@@ -1568,7 +1674,7 @@ export class Battle {
BATTLE.BallSoCloseText,
][wobbles - 1],
);
if (!tutorial) this.tossItem("balls", item, slot);
useBall();
return true;
}
@@ -1578,7 +1684,7 @@ export class Battle {
// to wWildMonMoves and wWildMonPP. A transformed Pokémon is reloaded as
// a Ditto with its backup DVs and keeps that Ditto's moves (a kept bug
// for anything else).
if (this.random() >= percent(75) + 1) this.random();
if (this.battleType !== C.BATTLETYPE_FORCEITEM && this.random() >= percent(75) + 1) this.random();
if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) {
const moves = this.levelMoves(C.DITTO, this.enemy.mon.level);
Object.assign(this.enemy.mon, {
@@ -1610,6 +1716,12 @@ export class Battle {
// NewPokedexEntry's Pokédex page is not presented yet.
yield* this.say(BATTLE.NewDexDataText);
}
if (contest) {
yield* this.setCaughtContestMon(contest, species, level);
useBall();
return true;
}
if (this.battleType === C.BATTLETYPE_CELEBI) this.battleResult |= BATTLERESULT_CAUGHT_CELEBI;
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
@@ -1643,6 +1755,47 @@ export class Battle {
this.tossItem("balls", item, slot);
return true;
}
/** BugContest_SetCaughtContestMon: a second catch shows both Pokémon
* (DisplayCaughtContestMonStats) and asks to switch. The kept one is
* GeneratePartyMonStats' copy of the wild Pokémon, which has no caught
* data; the contest names it later. */
private *setCaughtContestMon(contest: BugContest, species: number, level: number): Gen {
const data = this.options.data,
enemy = this.enemy.mon;
if (contest.mon) {
const stock = contest.mon;
this.strings[RAM.stringBuffer1] = data.speciesName(stock.species);
yield* this.say(BATTLE["DisplayAlreadyCaughtText.ContestAlreadyCaughtText"]);
const answer = yield {
kind: "contestswitch",
text: readTextEnd(this.reader, BATTLE.ContestAskSwitchText, this.context()).text,
stock: { name: data.speciesName(stock.species), level: stock.level, maxHp: stock.stats[0] },
caught: { name: enemy.nickname, level: enemy.level, maxHp: enemy.maxHp },
};
if (!answer) return;
}
contest.mon = {
species,
item: enemy.item,
moves: [...this.wildMoves] as PartyMon["moves"],
otId: this.options.playerId,
ot: this.options.playerName,
nickname: data.speciesName(species),
exp: data.expAtLevel(data.baseData(species).growthRate, level),
statExp: [0, 0, 0, 0, 0],
dvs: enemy.dvs,
pp: [...this.wildPP] as PartyMon["pp"],
happiness: C.BASE_HAPPINESS,
pokerus: 0,
caughtData: 0,
level,
status: enemy.status,
hp: enemy.hp,
stats: calcStats(data, species, enemy.dvs, level),
};
this.strings[RAM.stringBuffer1] = data.speciesName(species);
yield* this.say(BATTLE["BugContest_SetCaughtContestMon.ContestCaughtMonText"]);
}
/** PokeBallEffect's decision, before animation and caught-mon loading. */
private rollCapture(item: number, tutorial: boolean) {
let caught = tutorial || item === C.MASTER_BALL;
@@ -2086,6 +2239,16 @@ export class Battle {
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape.
* The Speed compared is the battle Pokémon's, except after a faint. */
private *tryToRunAway(playerSpeed = this.player.mon.stats[C.SPEED]): Gen<boolean> {
// The contest always lets the player go; the event battles never do.
if (this.battleType === C.BATTLETYPE_CONTEST) {
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
yield* this.say(BATTLE.BattleText_GotAwaySafely);
return true;
}
if (this.cannotFlee()) {
yield* this.say(BATTLE.BattleText_CantEscape);
return false;
}
if (this.options.trainer) {
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
return false;
+1
View File
@@ -385,6 +385,7 @@ export const C = {
TRAINER_BATTLE: 2,
HELD_AMULET_COIN: 76,
BATTLETYPE_NORMAL: 0,
BATTLETYPE_CANLOSE: 1,
BATTLETYPE_DEBUG: 2,
BATTLETYPE_TUTORIAL: 3,
BATTLETYPE_FISH: 4,
+7
View File
@@ -160,6 +160,7 @@ export const RAM = {
curHPAnimDeltaHP: 0xd1f3,
visitedSpawns: 0xdca5,
tempWildMonSpecies: 0xd22e,
parkBallsRemaining: 0xdc79,
coins: 0xd855,
/** hBGMapMode: HRAM, like hMoneyTemp below. */
bgMapMode: 0xffd4,
@@ -193,6 +194,8 @@ export const ENGINE_FLAGS = {
/** Overworld scripts, routines and tables, keyed by their PRET labels so
* that `npm run check:reference` verifies each address by name. */
export const OVERWORLD = {
BugCatchingContestBattleScript: 0x135eb,
ContestMons: 0x97d87,
"OverworldTownMap": 0xc2c0,
"TownMapGFX": 0xf8ba0,
"JohtoMap": 0x91fff,
@@ -593,6 +596,10 @@ export const BATTLE = {
"HeavyBall_GetDexEntryBank.PokedexEntryBanks": 0xec4c,
"HeavyBallMultiplier.WeightsTable": 0xecc4,
FleeMons: 0x3c59a,
WildCelebiAppearedText: 0x80793,
ContestAskSwitchText: 0xcc0c2,
"DisplayAlreadyCaughtText.ContestAlreadyCaughtText": 0xcc0d0,
"BugContest_SetCaughtContestMon.ContestCaughtMonText": 0xe71d,
DownpourText: 0x817a3,
SunGotBrightText: 0x817b8,
+35 -4
View File
@@ -20,6 +20,7 @@ import {
} from "../rom/profile";
import { PokemonData } from "../rom/pokemon";
import { C } from "../battle/constants";
import type { BugContest } from "../battle/battle";
import {
copyBag,
emptyBag,
@@ -140,6 +141,10 @@ export interface ScriptState {
blueCardBalance: number;
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
options: GameOptions;
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
* runs (STATUSFLAGS2_BUG_CONTEST_TIMER_F). Nothing starts a contest yet,
* so saves do not keep it. */
bugContest?: BugContest;
}
export const emptyScriptState = (): ScriptState => ({
decorations: emptyDecorations(),
@@ -211,6 +216,12 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
coins: state.coins,
blueCardBalance: state.blueCardBalance,
options: { ...state.options },
...(state.bugContest && {
bugContest: {
parkBalls: state.bugContest.parkBalls,
mon: structuredClone(state.bugContest.mon),
},
}),
});
/** A field move's context from TrySurfOW, TryCutOW and similar: the party
@@ -232,6 +243,17 @@ export interface FieldMoveContext {
* objects. LAST_TALKED is resolved before an effect is emitted. */
export const PLAYER = 0;
const LAST_TALKED = 0xfe;
/** The wBattleType values map scripts load (CelebiEvent_SetBattleType and
* the encounter routines set the others). */
const SCRIPT_BATTLE_TYPES: readonly number[] = [
C.BATTLETYPE_NORMAL,
C.BATTLETYPE_CANLOSE,
C.BATTLETYPE_CONTEST,
C.BATTLETYPE_FORCESHINY,
C.BATTLETYPE_TRAP,
C.BATTLETYPE_FORCEITEM,
C.BATTLETYPE_SUICUNE,
];
/** Map steps for wPlayerDirection's DOWN, UP, LEFT and RIGHT. */
const FACING_VECTORS = [
[0, 1],
@@ -331,6 +353,9 @@ export type ScriptEffect =
tree?: boolean;
/** BATTLETYPE_FISH: a Pokémon hooked on a fishing rod. */
fish?: boolean;
/** The other wBattleType values scripts load: FORCESHINY, TRAP,
* FORCEITEM, SUICUNE and CONTEST. */
battleType?: number;
}
/** loademote: the emote graphics (EMOTE_*) that show_emote displays
* next, when they go to the emote bubble's tiles. */
@@ -666,6 +691,7 @@ export class ScriptRun {
let winText = this.tempTrainer?.beatenText || undefined,
lossText = this.tempTrainer?.lostScript || undefined;
let canLose = false;
let battleType: number | undefined;
const temp = (offset: number, opcode: number) => {
if (!this.tempTrainer)
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
@@ -1036,6 +1062,8 @@ export class ScriptRun {
value = this.strengthSpecies;
else if (address === RAM.tempWildMonSpecies)
value = tempWild?.species ?? 0;
else if (address === RAM.parkBallsRemaining && this.state.bugContest)
value = this.state.bugContest.parkBalls;
else throw new UnsupportedScript(offset, opcode);
break;
}
@@ -1061,21 +1089,22 @@ export class ScriptRun {
break;
}
case 0x1e: {
// loadvar: VAR_BATTLETYPE's normal and CANLOSE modes, and
// VAR_MOVEMENT (wPlayerState).
// loadvar: VAR_BATTLETYPE (wBattleType) and VAR_MOVEMENT
// (wPlayerState).
const variable = byte(),
setting = byte();
if (variable === 8) {
this.setPlayerState(setting, offset, opcode);
break;
}
if (variable !== 3 || (setting !== 0 && setting !== 1))
if (variable !== 3 || !SCRIPT_BATTLE_TYPES.includes(setting))
throw new UnsupportedScript(
offset,
opcode,
" (battle type or variable)",
);
canLose = setting === 1;
canLose = setting === C.BATTLETYPE_CANLOSE;
battleType = setting > C.BATTLETYPE_CANLOSE ? setting : undefined;
break;
}
case 0x1d: {
@@ -1172,6 +1201,7 @@ export class ScriptRun {
canLose,
tree: treeBattle || undefined,
fish: fishBattle || undefined,
battleType,
});
if (typeof result !== "number")
throw new Error("Invalid battle result.");
@@ -1179,6 +1209,7 @@ export class ScriptRun {
value = result & 0x3f; // Script_startbattle also updates wScriptVar.
// CleanUpBattleRAM clears wBattleType and wTempWildMonSpecies.
treeBattle = fishBattle = false;
battleType = undefined;
tempWild = undefined;
break;
}
+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;
+42 -4
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", () => {
// 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), {
+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([]);