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 & { 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[] = [ { 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(); 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 & { 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."); }); }); });