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