import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { Battle, UnsupportedBattle, catchRate, type BattleInput, type BattleOptions, type BattleOutcome, } from "../src/game/battle/battle"; 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"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe("catch rate formula", () => { const none = { effect: 0, parameter: 0 }; const rate = (input: Partial[0]>) => catchRate({ rate: 255, ball: C.POKE_BALL, hp: 20, maxHp: 20, status: 0, held: none, ...input }); it("follows PokeBallEffect's HP term and ball multipliers", () => { expect(rate({})).toBe(85); // 255 * (60 - 40) / 60 expect(rate({ hp: 1 })).toBe(246); // 255 * 58 / 60 expect(rate({ rate: 45, ball: C.GREAT_BALL, hp: 10 })).toBe(44); // 67 * 40 / 60 expect(rate({ rate: 200, ball: C.ULTRA_BALL })).toBe(85); // doubled, capped at 255 expect(rate({ rate: 1 })).toBe(1); // a zero quotient becomes 1 // Above 255 (3 * max HP), both terms are divided by four; a zero HP // byte becomes 1. expect(rate({ hp: 1, maxHp: 100 })).toBe(251); // 255 * (75 - 1) / 75 }); it("keeps the original status, held-item and high-HP bugs", () => { expect(rate({ status: 1 })).toBe(95); // sleep: +10 expect(rate({ status: 1 << C.FRZ })).toBe(95); for (const status of [1 << 3, 1 << 4, 1 << 6]) expect(rate({ status })).toBe(85); // PSN/BRN/PAR expect(rate({ held: { effect: C.HELD_CATCH_CHANCE, parameter: 5 } })).toBe(90); // Max HP 400: the low bytes wrap, so a full-HP Pokémon gets 67, not 85. expect(rate({ hp: 400, maxHp: 400 })).toBe(67); // These maximum HP values divide by zero in the original. for (const maxHp of [342, 683]) expect(rate({ hp: maxHp, maxHp })).toBeUndefined(); }); }); describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => { let data: CrystalAssets["pokemon"]; beforeAll(() => { data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon; }); const RATTATA = 19, CYNDAQUIL = 155, TACKLE = 33; const starter = (): PartyMon => { const mon = generatePartyMon(data, { species: CYNDAQUIL, level: 5, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.moves = [TACKLE, 0, 0, 0]; mon.pp = [35, 0, 0, 0]; return mon; }; const setup = (options: Partial & { seed?: number } = {}) => { let seed = options.seed ?? 1, draws = 0; const bag: Bag = options.bag ?? { items: [], balls: [{ item: C.POKE_BALL, quantity: 5 }], keyItems: [] }; const pokedex = { seen: new Set(), caught: new Set() }; const party = options.party ?? [starter()]; const battle = new Battle({ data, party, pokedex, playerId: 1234, playerName: "CHRIS", timeOfDay: 1, random: () => { draws++; seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, wild: { species: RATTATA, level: 3 }, text: () => defaultTextContext("CHRIS"), bag, hasPokedex: true, caughtData: (level) => 0x4000 | (level << 8) | 1, ...options, }); return { battle, bag, pokedex, party, draws: () => draws }; }; /** Run a battle: `actions` answer the battle menu, `answers` the Yes/No * and naming prompts. Returns the texts and the outcome. */ const play = ( battle: Battle, actions: BattleInput[], answers: BattleInput[] = [], until?: () => boolean, ) => { const run = battle.run(); const texts: string[] = []; let input: BattleInput; for (let guard = 0; guard < 2000; guard++) { const next = run.next(input); input = undefined; if (next.done) return { texts, 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") { if (until?.() || !actions.length) return { texts, outcome: undefined }; input = actions.shift(); } else if (effect.kind === "yesno" || effect.kind === "name") input = answers.shift(); else if (effect.kind === "forget") input = 4; } throw new Error("The battle did not finish."); }; const ball = (slot = 0) => ({ action: "item" as const, item: C.POKE_BALL, slot }); it("catches a wild Pokémon into the party with its original moves, PP and current HP", () => { // Weaken it first so the seeded throws succeed. for (let seed = 1; seed < 50; seed++) { const { battle, bag, pokedex, party } = setup({ seed }); const { texts, outcome } = play( battle, [{ action: "fight", move: 0 }, ball(), ball(), ball(), ball(), ball()], [1, "RATTY"], ); if (!outcome || party.length !== 2) continue; expect(outcome.result).toBe(C.WIN); expect(texts).toContain("CHRIS used the POKé BALL."); expect(texts.some((text) => text.startsWith("Gotcha! RATTATA was caught!"))).toBe(true); expect(texts).toContain("RATTATA's data was newly added to the #DEX.".replace("#", "POKé")); expect(texts).toContain("Give a nickname to RATTATA?"); const caught = party[1]; const enemy = battle.enemy.mon; expect(caught).toMatchObject({ species: RATTATA, nickname: "RATTY", level: 3, otId: 1234, ot: "CHRIS", dvs: enemy.dvs, hp: enemy.hp, status: enemy.status, item: enemy.item, happiness: C.BASE_HAPPINESS, caughtData: 0x4000 | (3 << 8) | 1, exp: data.expAtLevel(data.baseData(RATTATA).growthRate, 3), statExp: [0, 0, 0, 0, 0], }); expect(caught.hp).toBeLessThan(caught.stats[0]); // wWildMonMoves and wWildMonPP: the moves it appeared with, less the // PP it spent in the battle (DoTurn consumes wWildMonPP too). const full = caught.moves.map((move) => (move ? data.move(move).pp : 0)); expect(caught.pp).toEqual(enemy.pp); expect(caught.pp.reduce((a, b) => a + b, 0)).toBeLessThan(full.reduce((a, b) => a + b, 0)); expect(validPartyMon(data, caught)).toBe(true); expect(pokedex.caught.has(RATTATA)).toBe(true); const thrown = texts.filter((text) => text === "CHRIS used the POKé BALL.").length; expect(bag.balls).toEqual(thrown === 5 ? [] : [{ item: C.POKE_BALL, quantity: 5 - thrown }]); return; } throw new Error("No seed caught the Pokémon."); }); it("uses the turn when the Pokémon breaks free, with the wobble message", () => { const messages = [ "Oh no! The POKéMON broke free!", "Aww! It appeared to be caught!", "Aargh! Almost had it!", "Shoot! It was so close too!", ]; const seen = new Set(); for (let seed = 1; seed < 200 && seen.size < 4; seed++) { const { battle, bag, party } = setup({ seed }); const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4); if (party.length !== 1) continue; const failure = messages.find((message) => texts.includes(message)); expect(failure, texts.join(" | ")).toBeDefined(); seen.add(failure!); // The wild Pokémon then moves, and the ball is gone. expect(texts.some((text) => text.startsWith("Enemy RATTATA used")), texts.join(" | ")).toBe(true); expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]); } expect(seen.size).toBe(4); }); it("catches with a Master Ball without drawing a catch or wobble random", () => { const { battle, bag, party, draws } = setup({ bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] }, }); let before = 0; const run = battle.run(); let input: BattleInput; for (let guard = 0; guard < 100; guard++) { const next = run.next(input); input = undefined; if (next.done) break; if (next.value.kind === "action") { before = draws(); 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); expect(party).toHaveLength(2); expect(party[1].nickname).toBe("RATTATA"); expect(bag.balls).toEqual([]); }); it("keeps the species name for a blank nickname and skips the Pokédex text without a Pokédex", () => { const { battle, party } = setup({ hasPokedex: false, bag: { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] }, }); const { texts } = play(battle, [{ action: "item", item: C.MASTER_BALL, slot: 0 }], [1, " "]); expect(party[1].nickname).toBe("RATTATA"); expect(texts.join(" ")).not.toContain("newly added"); }); it("ignores a slot that does not hold the ball", () => { const { battle, bag } = setup(); const { outcome } = play(battle, [{ action: "item", item: C.GREAT_BALL, slot: 0 }, { action: "run" }]); expect(outcome).toBeDefined(); expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]); }); 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); }); it("blocks balls in trainer battles and still uses the ball and turn", () => { const joey = generatePartyMon(data, { species: RATTATA, level: 2, item: 0, otId: 0, ot: "JOEY", dvs: 0x9888, caughtData: 0, }); const { battle, bag, party } = setup({ trainer: { name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, reward: 10, }, }); const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4); expect(texts).toContain("The trainer blocked the BALL!"); expect(texts).toContain("Don't be a thief!"); expect(texts.join(" ")).not.toContain("used the POKé BALL"); expect(texts.some((text) => text.includes("RATTATA used"))).toBe(true); expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 4 }]); expect(party).toHaveLength(1); }); it("runs the Dude's tutorial without touching the player's party or pack", () => { const { battle, bag, party, pokedex } = setup({ tutorial: true, bag: undefined }); const { texts, outcome } = play(battle, []); expect(outcome?.result).toBe(C.WIN); expect(texts).toEqual([ "Wild RATTATA appeared!", "DUDE used the POKé BALL.", "Gotcha! RATTATA was caught!", ]); expect(party).toHaveLength(1); expect(bag.balls).toEqual([{ item: C.POKE_BALL, quantity: 5 }]); expect(pokedex.seen.has(RATTATA)).toBe(true); expect(pokedex.caught.has(RATTATA)).toBe(false); expect(battle.player.mon).toBeUndefined(); }); });