Port Kurt's balls and the Park Ball's multiplier (#28)

Add the Heavy, Level, Lure, Fast, Friend, Moon and Love Balls with their documented bugs, the Park Ball's multiplier outside the contest, and Friend Ball happiness in the party and the PC. The Heavy Ball is refused for Kadabra and Sunflora, whose original weight read runs into VRAM.

The reference emulator compares 6,153 more catch states per browser against the unmodified PokeBallEffect. Validated with 582 unit tests, 153 browser tests, native/reference builds and reference/vendor/release checks. The Bug-Catching Contest and other special battle types remain on #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:38:56 +02:00
1 parent 77704fe848
commit b547ca29ff
8 files changed
+264 -22

No files matched your search

+7 -4
View File
@@ -150,8 +150,8 @@ tests cover the house, the rival battle and the naming field.
## Capture and the catching tutorial
`PokeBallEffect`, `BallMultiplierFunctionTable` (Poké, Great, Ultra and
Master Balls), `GetPokeBallWobble` with `WobbleProbabilities`,
`PokeBallEffect`, `BallMultiplierFunctionTable` (every ball, including
Kurt's and the Park Ball outside the contest), `GetPokeBallWobble` with `WobbleProbabilities`,
`UseBallInTrainerBattle` and `BattleMenu_Pack`'s `.UseItem` in
`engine/items/item_effects.asm`, `engine/battle_anims/pokeball_wobble.asm`
and `engine/battle/core.asm`. The catch rate is computed with the original
@@ -260,8 +260,11 @@ named by `hBattleTurn` after a switch of turns, which can be the enemy.
player's turn, `HandleHealingItems` ends a full turn on the enemy's, and
the trainer AI reports when `AI_Types`, `AI_Status` or `AIDamageCalc`
set it. A status heal after a full turn therefore penalises a paralysed
or burned enemy again, as the original does. Remaining gaps: the special
Kurt and Park balls.
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.
`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,
+11 -4
View File
@@ -116,7 +116,7 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- `GetPokeBallWobble` runs with `WobbleProbabilities` for each shake and picks the break-free message. Every throw uses the ball and, if the Pokémon breaks free, the turn. In trainer battles the ball is blocked, but the ball and turn are still used.
- A caught Pokémon is reloaded as `LoadEnemyMon` does, which redraws the wild item roll (the item itself is kept). It keeps its current HP, status, item and DVs, and gets back the moves and full PP it appeared with. It joins the party with `SetCaughtData`, is registered as caught (with the new-data message once the Pokédex is owned) and can be nicknamed; a blank name keeps the species name. The Pokédex page itself is not presented yet.
- Route 29's `Route29Tutorial1/2` and `CatchingTutorialDudeScript` run from the ROM. `catchtutorial` runs the battle as "DUDE": no Pokémon is sent out, the Dude's own Poké Ball always catches, and the Rattata is only seen, not kept. Messages advance on their own, as with `CatchTutorial`'s auto input.
- A catch with six Pokémon in the party goes to the current box (see the PC section below). Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
- A catch with six Pokémon in the party goes to the current box (see the PC section below). Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet. (Kurt's balls followed; see below.)
- A local stress run of 2,160 wild battles (three starters, ten early species, six levels) caught 2,047 Pokémon without refusals.
- Next: the PC and boxes, player switching and other items in battle, then party and bag menus.
@@ -304,11 +304,18 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Every `AI_Smart_EffectHandlers` entry is translated, so trainers with the Smart layer are no longer refused. The handlers read battle history, stat levels, both parties' reserves, the switch score and the weather.
- The AI's type checks keep the original `CheckTypeMatchup` bug: the move type and Foresight flag come from whichever side `hBattleTurn` names, so Encore and the switch score can read the player's move struct. Conversion2's write to the player's move type persists, as in the original.
- `tests/reference-smart-ai.spec.ts` runs the unmodified `AI_Smart` routine in the reference emulator over 6,024 states (all 251 moves, random battle state, either starting turn), comparing scores, Random consumption, `hBattleTurn` and the player's move type. A one-off run over 18,072 more states also matched in both browsers.
- Card #28 stays in progress for Kurt's balls, the Park and Safari balls and the special battle types.
## Implemented: Kurt's balls
- The Heavy, Level, Lure, Fast, Friend, Moon and Love Balls and the Park Ball (outside the contest) can be thrown. Their documented bugs are kept: the Heavy Ball reads Tauros's weight from the wrong bank, the Fast Ball checks only Magnemite, Grimer and Tangela, the Moon Ball never boosts, and the Love Ball boosts for the same gender. The Level Ball skips the HP, status and held-item terms. A Friend Ball catch gets 200 happiness, in the party or the PC.
- 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.
## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items.
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
This is still not a complete new game. The Pokégear's radio, the Pokédex screen, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The start menu opens the party menu and the pack, and Select uses the registered item, such as the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
@@ -388,7 +395,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. Next: Kurt's balls, the Park and Safari balls and the 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. Next: the Bug-Catching Contest and the other special battle types (board #28).
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.
+26
View File
@@ -1250,3 +1250,29 @@ physical-device validation.
Next on #28: Kurt's balls, Park and Safari balls, and special battle types.
Other boundaries listed in the previous section are unchanged.
## Kurt's balls: 2026-10-03
Same session, after the Smart AI commit.
- `Battle.kurtBallRate` translates the Heavy, Level, Lure, Fast, Moon and
Love multipliers; `catchRate` adds the Park Ball's 1.5× and the Level
Ball's skip of the HP, status and held-item terms. Friend Ball catches
get 200 happiness in the party or the PC box.
- The Heavy Ball's buggy bank makes Kadabra's (64) and Sunflora's (192)
scans run past $7fff into VRAM, which the port does not model; those
throws raise `UnsupportedBattle`. Tauros (128) reads a wrong in-ROM
weight, which is reproduced.
- `tests/reference-capture.spec.ts` gained a 6,153-state comparison
against the unmodified `PokeBallEffect`. Mutating the Love Ball gender
test or the Fast Ball's byte count fails it. A Heavy Ball light-mon
off-by-one mutation is undetectable: rates 0 and 1 both end at 1.
Validation: 582 unit tests passed (3 skipped); without the ROM 139 passed,
446 skipped. Browser suite: 153 passed, 19 skipped. Both builds,
reference/release/vendor checks and `git diff --check` passed.
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.
+88 -10
View File
@@ -369,9 +369,10 @@ export function applyTypeMatchups(
/** A native translation of Crystal's battle engine.
* The move effect scripts are the ROM's own; each battle command is a
* native translation. Unsupported AI, switching, contest and item paths are refused. */
/** PokeBallEffect's wFinalCatchRate for Poké, Great and Ultra Balls, in
* the original 8- and 16-bit registers. Returns undefined when the
* formula would divide by zero (e.g. maximum HP of 342 or 683). */
/** PokeBallEffect's wFinalCatchRate in the original 8- and 16-bit
* registers. `rate` already has the battle-dependent Kurt's ball multipliers
* applied (Battle.kurtBallRate). Returns undefined when the formula would
* divide by zero (e.g. maximum HP of 342 or 683). */
export function catchRate(input: {
rate: number;
ball: number;
@@ -384,7 +385,10 @@ export function catchRate(input: {
let rate = input.rate;
// BallMultiplierFunctionTable
if (input.ball === C.ULTRA_BALL) rate = Math.min(0xff, rate << 1);
else if (input.ball === C.GREAT_BALL) rate = Math.min(0xff, rate + (rate >> 1));
else if (input.ball === C.GREAT_BALL || input.ball === C.PARK_BALL)
rate = Math.min(0xff, rate + (rate >> 1));
// The Level Ball skips the HP, status and held-item terms.
if (input.ball === C.LEVEL_BALL) return rate;
// A maximum HP above 341 overflows the HP term (a kept bug).
let hp = (input.hp << 1) & 0xffff,
max = (input.maxHp * 3) & 0xffff;
@@ -1545,7 +1549,10 @@ export class Battle {
yield* this.say(BATTLE.BallBoxFullText);
return false;
}
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
// The Park Ball outside the Bug-Catching Contest only skips the throw
// animation's setup (a graphical bug).
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL, C.HEAVY_BALL, C.LEVEL_BALL,
C.LURE_BALL, C.FAST_BALL, C.FRIEND_BALL, C.MOON_BALL, C.LOVE_BALL, C.PARK_BALL].includes(item as never))
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
this.wildMon = 0;
yield* this.say(BATTLE.ItemUsedText);
@@ -1606,7 +1613,7 @@ export class Battle {
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
const name = data.speciesName(species);
if (this.options.party.length >= PARTY_LENGTH) {
yield* this.sendToPC(species, level, name);
yield* this.sendToPC(species, level, name, item);
this.tossItem("balls", item, slot);
return true;
}
@@ -1623,7 +1630,7 @@ export class Battle {
statExp: [0, 0, 0, 0, 0],
dvs: enemy.dvs,
pp: [...this.wildPP] as PartyMon["pp"],
happiness: C.BASE_HAPPINESS,
happiness: item === C.FRIEND_BALL ? C.FRIEND_BALL_HAPPINESS : C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData(level),
level,
@@ -1642,7 +1649,7 @@ export class Battle {
if (!caught) {
const held = this.data.heldEffect(this.player.mon?.item ?? 0);
const chance = catchRate({
rate: this.enemyBase.catchRate,
rate: this.kurtBallRate(item, this.enemyBase.catchRate),
ball: item,
hp: this.enemy.mon.hp,
maxHp: this.enemy.mon.maxHp,
@@ -1657,6 +1664,77 @@ export class Battle {
return { caught, wobbles: this.ballWobbles(caught) };
}
/** BallMultiplierFunctionTable's Kurt's balls, which depend on the battle.
* Their documented bugs are kept: the Heavy Ball reads three species'
* weights from the wrong bank, the Fast Ball checks only FleeMons' first
* three bytes, the Moon Ball compares the wrong byte with BURN_HEAL and
* the Love Ball wants the same gender. */
private kurtBallRate(item: number, rate: number): number {
const r = this.reader,
data = this.options.data,
// Each doubling saturates at 255, as the original's carry checks do.
double = (times: number) => {
for (let i = 0; i < times; i++) rate = Math.min(0xff, rate << 1);
return rate;
};
switch (item) {
case C.HEAVY_BALL: {
// wEnemyMonSpecies, which Transform changes.
const species = this.enemy.mon.species;
const bank = r.byte(BATTLE["HeavyBall_GetDexEntryBank.PokedexEntryBanks"] + ((species >> 6) & 3));
let pointer = r.word(BATTLE.PokedexDataPointerTable + (species - 1) * 2);
// Kadabra's and Sunflora's scans run off the wrong bank into VRAM.
const at = (address: number) => {
if (address < 0x4000 || address > 0x7fff)
throw new UnsupportedBattle("The Heavy Ball reads this Pokémon's weight from VRAM.");
return r.byte(r.address(bank, address));
};
while (at(pointer++) !== 0x50);
let hl = (at(pointer + 2) | (at(pointer + 3) << 8)) >> 1;
let bc = hl >> 4;
hl = (hl - bc) & 0xffff;
bc >>= 1;
hl = (hl - bc) & 0xffff;
const weight = hl >> 8;
if (weight < 4) return rate >= 20 ? rate - 20 : 1;
let entry = BATTLE["HeavyBallMultiplier.WeightsTable"];
while (weight >= r.byte(entry)) entry += 2;
return Math.min(0xff, rate + r.byte(entry + 1));
}
case C.LEVEL_BALL: {
let player = this.player.mon.level;
const enemy = this.enemy.mon.level;
for (let i = 0; i < 3; i++) {
if (enemy >= player) break;
double(1);
if (rate === 0xff) break;
player >>= 1;
}
return rate;
}
case C.LURE_BALL:
if (this.battleType !== C.BATTLETYPE_FISH) return rate;
return rate * 2 > 0xff ? 0xff : Math.min(0xff, rate * 3);
case C.FAST_BALL:
// The loop counts bytes rather than lists, stopping after three.
if (r.slice(BATTLE.FleeMons, 3).includes(this.enemySpecies)) double(2);
return rate;
case C.MOON_BALL: {
const p = r.near(SYMBOLS.evosAttacks + (this.enemySpecies - 1) * 2, r.bank(SYMBOLS.evosAttacks));
if (r.byte(p) === C.EVOLVE_ITEM && r.byte(p + 3) === C.MOON_STONE_RED) double(2);
return rate;
}
case C.LOVE_BALL: {
if (this.enemySpecies !== this.partyMon.species) return rate;
const player = data.gender(this.partyMon.species, this.partyMon.dvs),
wild = data.gender(this.enemySpecies, this.enemy.mon.dvs);
if (player && wild && player === wild) double(3);
return rate;
}
}
return rate;
}
private ballWobbles(caught: boolean): number {
// BattleAnim_ThrowPokeBall's .Shake loop runs GetPokeBallWobble until
// the ball clicks or the Pokémon breaks free.
@@ -1694,7 +1772,7 @@ export class Battle {
* first in the current box (from wEnemyMon, with restored PP), then
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
* Bill's call; the nickname and BallSentToPCText follow. */
private *sendToPC(species: number, level: number, name: string): Gen {
private *sendToPC(species: number, level: number, name: string, item: number): Gen {
const data = this.options.data,
storage = this.options.storage!,
box = currentBox(storage);
@@ -1709,7 +1787,7 @@ export class Battle {
statExp: [0, 0, 0, 0, 0],
dvs: this.enemy.mon.dvs,
pp: [...this.wildPP] as BoxMon["pp"],
happiness: C.BASE_HAPPINESS,
happiness: item === C.FRIEND_BALL ? C.FRIEND_BALL_HAPPINESS : C.BASE_HAPPINESS,
pokerus: 0,
caughtData: this.options.caughtData!(level),
level,
+11
View File
@@ -188,6 +188,17 @@ export const C = {
ULTRA_BALL: 2,
GREAT_BALL: 4,
POKE_BALL: 5,
HEAVY_BALL: 0x9d,
LEVEL_BALL: 0x9f,
LURE_BALL: 0xa0,
FAST_BALL: 0xa1,
FRIEND_BALL: 0xa4,
MOON_BALL: 0xa5,
LOVE_BALL: 0xa6,
PARK_BALL: 0xb1,
/** MoonBallMultiplier compares with this, which is BURN_HEAL. */
MOON_STONE_RED: 0x0a,
FRIEND_BALL_HAPPINESS: 200,
LUCKY_PUNCH: 30,
METAL_POWDER: 35,
BERSERK_GENE: 0x98,
+4
View File
@@ -589,6 +589,10 @@ export const BATTLE = {
RainDanceMoves: 0x390e7,
SunnyDayMoves: 0x39134,
AI_Smart_EffectHandlers: 0x386f2,
PokedexDataPointerTable: 0x44378,
"HeavyBall_GetDexEntryBank.PokedexEntryBanks": 0xec4c,
"HeavyBallMultiplier.WeightsTable": 0xecc4,
FleeMons: 0x3c59a,
DownpourText: 0x817a3,
SunGotBrightText: 0x817b8,
+37
View File
@@ -13,6 +13,7 @@ import { C } from "../src/game/battle/constants";
import { generatePartyMon, validPartyMon, type PartyMon } from "../src/game/party/pokemon";
import type { Bag } from "../src/game/party/bag";
import { defaultTextContext } from "../src/game/scripts/text";
import { emptyStorage } from "../src/game/party/storage";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
@@ -43,6 +44,13 @@ describe("catch rate formula", () => {
// These maximum HP values divide by zero in the original.
for (const maxHp of [342, 683]) expect(rate({ hp: maxHp, maxHp })).toBeUndefined();
});
it("multiplies the Park Ball like the Great Ball and gives the Level Ball its rate directly", () => {
expect(rate({ rate: 45, ball: C.PARK_BALL, hp: 10 })).toBe(44);
// The Level Ball skips the HP, status and held-item terms, even at a
// maximum HP that would divide by zero.
for (const input of [{}, { status: 1 }, { hp: 342, maxHp: 342 }, { held: { effect: C.HELD_CATCH_CHANCE, parameter: 5 } }])
expect(rate({ rate: 45, ball: C.LEVEL_BALL, ...input })).toBe(45);
});
});
describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
@@ -225,6 +233,35 @@ describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]);
});
it("sets a Friend Ball catch's happiness in the party and in the PC", () => {
for (const full of [false, true]) {
const friend = { action: "item" as const, item: C.FRIEND_BALL, slot: 0 };
let caught;
for (let seed = 1; seed < 50 && !caught; seed++) {
const storage = emptyStorage();
const { battle, party } = setup({
seed,
party: Array.from({ length: full ? 6 : 1 }, starter),
storage,
bag: { items: [], balls: [{ item: C.FRIEND_BALL, quantity: 5 }], keyItems: [] },
});
play(battle, [{ action: "fight", move: 0 }, friend, friend, friend, friend, friend], [0, 0]);
caught = full ? storage.boxes[storage.current][0] : party[1];
}
expect(caught, full ? "box" : "party").toMatchObject({ species: RATTATA, happiness: C.FRIEND_BALL_HAPPINESS });
}
});
it("refuses the Heavy Ball where the original reads a weight from VRAM", () => {
for (const species of [64, 192]) {
const { battle } = setup({
wild: { species, level: 5 },
bag: { items: [], balls: [{ item: C.HEAVY_BALL, quantity: 1 }], keyItems: [] },
});
expect(() => play(battle, [{ action: "item", item: C.HEAVY_BALL, slot: 0 }])).toThrow(/VRAM/);
}
});
it("refuses to send a Pokémon to the PC with a full party", () => {
const { battle } = setup({ party: Array.from({ length: 6 }, starter) });
expect(() => play(battle, [ball()])).toThrow(UnsupportedBattle);
+80 -4
View File
@@ -19,6 +19,15 @@ type Input = {
initialRate: number;
caught: boolean;
rolls: number[];
/** wEnemyMonSpecies; Fast, Moon and Love Balls use wTempEnemyMonSpecies. */
species: number;
tempSpecies: number;
enemyLevel: number;
enemyDvs: number;
playerSpecies: number;
playerLevel: number;
playerDvs: number;
fish: boolean;
};
const make = (overrides: Partial<Input> = {}): Input => ({
rate: 45,
@@ -31,6 +40,14 @@ const make = (overrides: Partial<Input> = {}): Input => ({
initialRate: 173,
caught: false,
rolls: [255, 0, 128, 255],
species: 19,
tempSpecies: 19,
enemyLevel: 5,
enemyDvs: 0x8888,
playerSpecies: 19,
playerLevel: 5,
playerDvs: 0x8888,
fish: false,
...overrides,
});
@@ -59,6 +76,13 @@ async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) {
status: at("wEnemyMonStatus"),
species: at("wEnemyMonSpecies"),
heldItem: at("wBattleMonItem"),
tempSpecies: at("wTempEnemyMonSpecies"),
tempBattleSpecies: at("wTempBattleMonSpecies"),
enemyLevel: at("wEnemyMonLevel"),
enemyDvs: at("wEnemyMonDVs"),
playerLevel: at("wBattleMonLevel"),
partyDvs: at("wPartyMon1DVs"),
curBattleMon: at("wCurBattleMon"),
};
await openReference(page);
const results = await page.evaluate(
@@ -164,7 +188,7 @@ async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) {
let draws = 0;
const battle = new Battle({
data: assets.pokemon,
party: [],
party: [{ species: input.playerSpecies, dvs: input.playerDvs }] as never,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1,
playerName: "CHRIS",
@@ -178,6 +202,8 @@ async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) {
});
const internal = battle as unknown as {
finalCatchRate: number;
enemySpecies: number;
battleType: number;
rollCapture(
item: number,
tutorial: boolean,
@@ -189,8 +215,13 @@ async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) {
hp: input.hp,
maxHp: input.maxHp,
status: input.status,
species: input.species,
level: input.enemyLevel,
dvs: input.enemyDvs,
} as never;
battle.player.mon = { item: input.heldItem } as never;
battle.player.mon = { item: input.heldItem, level: input.playerLevel } as never;
internal.enemySpecies = input.tempSpecies;
if (input.fish) internal.battleType = 4;
internal.finalCatchRate = input.initialRate;
const native =
mode === "capture"
@@ -205,8 +236,15 @@ async function compare(page: Page, mode: "capture" | "wobble", cases: Input[]) {
write(a.wild, mode === "wobble" && input.caught ? 19 : 0);
write(a.wobbles, 0);
write(a.item, input.ball);
write(a.battleType, input.tutorial ? 3 : 0);
write(a.species, 19);
write(a.battleType, input.tutorial ? 3 : input.fish ? 4 : 0);
write(a.species, input.species);
write(a.tempSpecies, input.tempSpecies);
write(a.tempBattleSpecies, input.playerSpecies);
write(a.enemyLevel, input.enemyLevel);
write(a.playerLevel, input.playerLevel);
word(a.enemyDvs, input.enemyDvs);
word(a.partyDvs, input.playerDvs);
write(a.curBattleMon, 0);
write(a.status, input.status);
write(a.heldItem, input.heldItem);
word(a.hp, input.hp);
@@ -310,3 +348,41 @@ test("ball shakes match original table boundaries, outcomes and draw counts", as
cases.push(make({ initialRate: rate, rolls: [roll, 255] }));
await compare(page, "wobble", cases);
});
test("Kurt's and Park Balls match the original multipliers and their bugs", async ({
page,
}) => {
const cases: Input[] = [];
const all = Array.from({ length: 251 }, (_, i) => i + 1);
// Heavy Ball: every species' weight (Transform changes wEnemyMonSpecies,
// not the temp species), around the light-mon floor and the 255 cap.
// Kadabra's and Sunflora's reads reach VRAM and are refused natively.
for (const species of all.filter((species) => species !== 64 && species !== 192))
for (const rate of [1, 20, 21, 45, 240])
cases.push(make({ ball: C.HEAVY_BALL, species, tempSpecies: 19, rate, hp: 1, maxHp: 100 }));
// Level Ball: each halving boundary and saturation; it skips HP and status.
const levels = [1, 2, 3, 4, 5, 7, 8, 9, 15, 16, 17, 31, 32, 33, 63, 64, 65, 100];
for (const playerLevel of levels)
for (const enemyLevel of levels)
for (const rate of [1, 31, 32, 64, 255])
cases.push(make({ ball: C.LEVEL_BALL, playerLevel, enemyLevel, rate, status: 1 }));
// Lure Ball only on a fishing rod battle; Park Ball everywhere.
for (let rate = 0; rate < 256; rate++) {
cases.push(make({ ball: C.LURE_BALL, rate, fish: true, hp: 1, maxHp: 100 }));
cases.push(make({ ball: C.LURE_BALL, rate, hp: 1, maxHp: 100 }));
cases.push(make({ ball: C.PARK_BALL, rate, hp: 1, maxHp: 100 }));
cases.push(make({ ball: C.FRIEND_BALL, rate, hp: 1, maxHp: 100 }));
}
// Fast and Moon Balls over every species (temp species, not wEnemyMonSpecies).
for (const tempSpecies of all)
for (const ball of [C.FAST_BALL, C.MOON_BALL])
for (const rate of [3, 64])
cases.push(make({ ball, tempSpecies, species: 19, rate, hp: 1, maxHp: 100 }));
// Love Ball: same and different species, each gender pairing, genderless.
for (const tempSpecies of all)
for (const [playerDvs, enemyDvs] of [[0x0000, 0x0000], [0xffff, 0xffff], [0x0000, 0xffff], [0xffff, 0x0000], [0x7777, 0x8888]])
cases.push(make({ ball: C.LOVE_BALL, tempSpecies, playerSpecies: tempSpecies, playerDvs, enemyDvs, rate: 30, hp: 1, maxHp: 100 }));
for (const playerSpecies of [1, 29, 32, 81, 251])
cases.push(make({ ball: C.LOVE_BALL, tempSpecies: 19, playerSpecies, rate: 30, hp: 1, maxHp: 100 }));
await compare(page, "capture", cases);
});