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

+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);
});