import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { TrainerData } from "../src/game/rom/trainers"; import { Battle, UnsupportedBattle, type BattleInput, type BattleOptions, } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { generatePartyMon } from "../src/game/party/pokemon"; import { defaultTextContext } from "../src/game/scripts/text"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("trainer battles", () => { let assets: CrystalAssets, trainers: TrainerData; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); trainers = new TrainerData(assets.reader); }); const setup = (level = 15, [classId, id] = [22, 1], species = 155) => { // Youngster Joey, level 4 Rattata, unless another trainer is named. const trainer = trainers.trainer(classId, id); const mon = generatePartyMon(assets.pokemon, { species, level, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); let seed = 1; const options: BattleOptions = { data: assets.pokemon, party: [mon], playerId: 1234, playerName: "CHRIS", timeOfDay: 1, pokedex: { seen: new Set(), caught: new Set() }, random: () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, text: () => defaultTextContext("CHRIS"), trainer: { name: trainer.name, className: trainer.className, party: trainers.createParty(trainer), aiFlags: trainer.attributes.aiMoveFlags, switchFlags: trainer.attributes.aiItemSwitchFlags, reward: trainer.reward, }, }; return { mon, options }; }; const finish = (battle: Battle, runFirst = false) => { const gen = battle.run(); const texts: string[] = []; let input: BattleInput, triedRun = false; for (let guard = 0; guard < 2000; guard++) { const next = gen.next(input); if (next.done) return { ...next.value, texts: texts.join(" ").replace(/\n/g, " ") }; const effect = next.value; input = undefined; if (effect.kind === "text") texts.push(effect.text); if (effect.kind === "action") { input = runFirst && !triedRun ? { action: "run" } : { action: "fight", move: 0 }; triedRun = true; } if (effect.kind === "yesno") input = 0; if (effect.kind === "forget") input = 4; } throw new Error("Battle did not finish."); }; it("announces the trainer, refuses escape without spending a turn, and awards trainer experience and money", () => { const direct = setup(), escape = setup(); const before = direct.mon.exp; const won = finish(new Battle(direct.options)); const escaped = finish(new Battle(escape.options), true); expect(won.result).toBe(0); expect(won.prizeMoney).toBe(64); expect(won.texts).toContain("YOUNGSTER JOEY wants to battle!"); expect(won.texts).toContain("sent out RATTATA!"); expect(won.texts).toContain("was defeated!"); expect(won.texts).toContain("CHRIS got ¥64"); expect(escaped.texts).toContain("trainer battle"); expect(escape.mon).toEqual(direct.mon); expect(direct.mon.exp - before).toBe(48); // floor(57 * 4 / 7), then 1.5x. expect(direct.options.pokedex.seen.has(19)).toBe(true); }); it("doubles prize money when the active Pokémon holds an Amulet Coin", () => { const { mon, options } = setup(); mon.item = 0x5b; const result = finish(new Battle(options)); expect(result.prizeMoney).toBe(128); expect(result.texts).toContain("CHRIS got ¥128"); }); it("sends a quarter of the prize to Mom while she saves some", () => { const { mon, options } = setup(); mon.item = 0x5b; const result = finish(new Battle({ ...options, mom: { saving: 1, money: 10 } })); expect(result.prizeMoney).toBe(96); expect(result.momMoney).toBe(32); expect(result.texts).toContain("CHRIS got ¥128 for winning! Sent some to MOM!"); // A full account keeps the whole prize in the wallet. const full = setup(); const kept = finish( new Battle({ ...full.options, mom: { saving: 1, money: 999999 } }), ); expect(kept.prizeMoney).toBe(64); expect(kept.momMoney).toBeUndefined(); expect(kept.texts).toContain("CHRIS got ¥64 for winning!"); expect(kept.texts).not.toContain("MOM"); }); it("scores exhausted trainer moves before the menu, then uses Struggle", () => { const { options } = setup(); options.trainer!.party[0].pp.fill(0); let draws = 0; options.random = () => { draws++; return 0; }; const battle = new Battle(options), run = battle.run(); for (let guard = 0; guard < 20; guard++) { const next = run.next(); if (!next.done && next.value.kind === "action") break; } expect(draws).toBeGreaterThan(0); // The AI selected a normal move despite exhausted PP; ParseEnemyAction // replaces it before the first attack, as the cartridge does. expect(battle.enemy.curMove).toBe(33); run.next({ action: "fight", move: 0 }); expect(battle.enemy.curMove).toBe(165); }); it("uses the native damage estimate for Aggressive move scoring", () => { const { options } = setup(); const enemy = options.trainer!.party[0]; enemy.moves = [33, 98, 0, 0]; // Tackle, Quick Attack enemy.pp = enemy.moves.map((move) => move ? assets.pokemon.move(move).pp : 0); options.trainer!.aiFlags = 1 << 6; const battle = new Battle(options), run = battle.run(); for (let guard = 0; guard < 20; guard++) { const next = run.next(); if (!next.done && next.value.kind === "action") break; } expect(battle.enemy.curMove).toBe(98); }); it("lets a leftover critical-hit flag alter the AI damage estimate", () => { const { options } = setup(); const battle = new Battle(options), run = battle.run(); for (let guard = 0; guard < 20; guard++) { const next = run.next(); if (!next.done && next.value.kind === "action") break; } const internals = battle as unknown as { criticalHit: number; enemyAIDamage(move: number): number; }; const plain = internals.enemyAIDamage(33); // Tackle battle.player.screens = 1 << C.SCREENS_REFLECT; const screened = internals.enemyAIDamage(33); // CheckDamageStatsCritical: equal stages select unboosted stats, which // ignore Reflect; DamageCalc then doubles the estimate. internals.criticalHit = 1; const critical = internals.enemyAIDamage(33); // Rattata L4 (Atk 10) Tackle into Cyndaquil (Def 18, 36 under Reflect). expect([plain, screened, critical]).toEqual([4, 3, 6]); }); it("loses without receiving prize money", () => { const { mon, options } = setup(2); mon.hp = 1; const lost = finish(new Battle(options)); expect(lost.result).toBe(1); expect(lost.prizeMoney).toBeUndefined(); expect(mon.hp).toBe(0); }); it("heals the party after a CANLOSE victory", () => { const { mon, options } = setup(); options.canLose = true; mon.hp--; finish(new Battle(options)); expect(mon.hp).toBe(mon.stats[0]); expect(mon.status).toBe(0); expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp); }); /** Run to the first battle menu; the battle's internals for AI checks. */ const atMenu = (battle: Battle) => { const run = battle.run(); for (let guard = 0; guard < 20; guard++) { const next = run.next(); if (!next.done && next.value.kind === "action") break; } return { run, internals: battle as unknown as { curOTMon: number; playerUsedMoves: number[]; findMonInOTPartyToSwitchIntoBattle(): number; checkAbleToSwitch(): number; aiSwitchOrTryItem(): Generator<{ kind: string; text?: string }, boolean>; }, }; }; it("sends out a trainer's next Pokémon with no random draw when a candidate is unthreatened", () => { // Totodile's Water type is neutral against Bug Catcher Don's Caterpie. const { options } = setup(15, [36, 1], 158); const battle = new Battle(options), { internals } = atMenu(battle); options.trainer!.party[0].hp = 0; options.random = () => { throw new Error("Unexpected random draw"); }; expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1); }); it("draws BattleRandom & 7 when the player's types threaten every candidate", () => { // Cyndaquil's Fire type is super effective against the other Caterpie. const { options } = setup(15, [36, 1], 155); const battle = new Battle(options), { internals } = atMenu(battle); options.trainer!.party[0].hp = 0; const draws = [0x0d, 0x08, 0x02, 0x09]; options.random = () => draws.shift()!; // 5 and 0 (the fainted current Pokémon) and 2 (past the party) retry. expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1); expect(draws).toEqual([]); }); it("switches out under SWITCH_SOMETIMES using the enemy move type for every matchup", () => { const { options } = setup(15, [22, 2]); // Youngster Mikey: Pidgey, Rattata const battle = new Battle(options), { internals } = atMenu(battle); const WATER = 0x15, GROUND = 0x04, GRASS = 0x16; // CheckTypeMatchup reads the enemy's move type (here Grass), so it is // super effective against the enemy's Water type and the player's Water/Ground. battle.player.mon.types = [WATER, GROUND]; battle.enemy.mon.types = [WATER, WATER]; battle.enemy.move = { ...battle.enemy.move, type: GRASS }; const rattata = options.trainer!.party[1]; // FindEnemyMonsWithAtLeastQuarterMaxHP's rotation: 16 HP gives 4, never // above the maximum, while 1 HP gives $200. rattata.hp = 16; expect(internals.checkAbleToSwitch()).toBe(0); rattata.hp = 1; expect(internals.checkAbleToSwitch()).toBe(0x11); battle.enemy.mon.hp = 3; battle.enemy.mon.status = 1 << 3; // poisoned const draws = [49]; options.random = () => draws.shift()!; const texts: string[] = []; const run = internals.aiSwitchOrTryItem(); let next = run.next(); for (; !next.done; next = run.next()) if (next.value.text) texts.push(next.value.text); expect(next.value).toBe(true); expect(texts.join(" ").replace(/\n/g, " ")).toMatch(/withdrew PIDGEY.*sent out RATTATA/); expect(internals.curOTMon).toBe(1); expect(battle.enemy.mon.hp).toBe(1); // AI_Switch keeps the withdrawn Pokémon's HP and status in the party. expect(options.trainer!.party[0]).toMatchObject({ hp: 3, status: 1 << 3 }); }); it("records the player's used moves for the switch AI", () => { const { options } = setup(15, [22, 2]); const battle = new Battle(options), { run, internals } = atMenu(battle); expect(internals.playerUsedMoves).toEqual([0, 0, 0, 0]); run.next({ action: "fight", move: 0 }); expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 0]); }); it.each([1, 2, 3])( "completes first rival starter variant %i with its ROM AI flags", (id) => { const { options } = setup(15, [9, id]); expect(options.trainer!.aiFlags).toBe(0x3d3); const result = finish(new Battle(options)); expect(result.result).toBe(0); expect(result.prizeMoney).toBe(300); expect(result.texts).toContain("was defeated!"); }, ); it("refuses untranslated Smart effects, including reserves, and empty parties before mutation", () => { for (const kind of ["empty", "ai", "reserve"] as const) { const { options } = setup(); if (kind === "empty") options.trainer!.party = []; else { options.trainer!.aiFlags |= 1 << 4; const unsupported = structuredClone(options.trainer!.party[0]); unsupported.moves = [115, 0, 0, 0]; // Reflect has an untranslated Smart handler. if (kind === "reserve") options.trainer!.party.push(unsupported); else options.trainer!.party[0] = unsupported; } const before = structuredClone(options.party); const enemyBefore = structuredClone(options.trainer!.party); let draws = 0; options.random = () => { draws++; return 0; }; expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle); expect(options.party).toEqual(before); expect(options.trainer!.party).toEqual(enemyBefore); expect(options.pokedex.seen.size).toBe(0); expect(draws).toBe(0); } }); });