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 } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import type { PartyMon } from "../src/game/party/pokemon"; import { battleOptions, partyMon, playBattle } from "./battle-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const PIDGEY = 16, RATTATA = 19, CYNDAQUIL = 155, TOTODILE = 158; describe.skipIf(!existsSync(romPath))("switching Pokémon in battle", () => { let assets: CrystalAssets, trainers: TrainerData; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); trainers = new TrainerData(assets.reader); }); const mon = (species: number, level: number) => partyMon(assets, species, level); const options = ( party: PartyMon[], enemy: Parameters[3], seed = 1, ) => battleOptions(assets, trainers, party, enemy, { seed }); const play = playBattle; it("switches from the PKMN menu: the enemy still attacks and both share the experience", () => { const first = mon(CYNDAQUIL, 10), second = mon(TOTODILE, 10); const battle = new Battle( options([first, second], { wild: { species: PIDGEY, level: 3 } }), ); const { texts, prompts } = play(battle, { action: [{ action: "pkmn" }], party: [0, 1], }); expect(prompts[1]).toEqual({ kind: "party", text: "Choose a POKéMON.", menu: true, cancel: true, }); const switched = texts.indexOf("CYNDAQUIL is already out."); expect(switched).toBeGreaterThan(0); // The enemy's HP is untouched, so the withdrawal is "that's enough". expect(texts.slice(switched + 1, switched + 4)).toEqual([ "CYNDAQUIL, that's enough! Come back!", "Go! TOTODILE!", expect.stringMatching(/^Enemy PIDGEY used /), ]); expect(battle.curBattleMon).toBe(1); expect(battle.lastPlayerMon).toBe(0); // Base experience 55 × level 3 ÷ 7 = 23, halved for two participants. expect(texts).toContain("CYNDAQUIL gained 11 EXP. Points!"); expect(texts).toContain("TOTODILE gained 11 EXP. Points!"); }); it("goes back to the battle menu when the party menu is cancelled", () => { const battle = new Battle( options([mon(CYNDAQUIL, 10), mon(TOTODILE, 10)], { wild: { species: PIDGEY, level: 3 }, }), ); const { prompts, texts } = play(battle, { action: [{ action: "pkmn" }], party: [-1], }); expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([ "action", "party", "action", ]); expect(texts).toContain("CYNDAQUIL gained 23 EXP. Points!"); expect(battle.curBattleMon).toBe(0); }); it("measures the withdrawal against the enemy's HP when the Pokémon went out", () => { const battle = new Battle( options( [mon(CYNDAQUIL, 12), mon(TOTODILE, 10)], { wild: { species: RATTATA, level: 12 } }, 3, ), ); // Tackle once, then switch: the lost HP picks the text. const { texts } = play(battle, { action: [{ action: "fight", move: 0 }, { action: "pkmn" }], party: [1], }); const withdrawal = texts.find((text) => text.startsWith("CYNDAQUIL, "))!; expect([ "CYNDAQUIL, come back!", "CYNDAQUIL, OK! Come back!", "CYNDAQUIL, good! Come back!", ]).toContain(withdrawal); }); it("asks for the next Pokémon after a faint and refuses a fainted choice", () => { const first = mon(CYNDAQUIL, 5), second = mon(TOTODILE, 10), fainted = mon(PIDGEY, 5); first.hp = 1; fainted.hp = 0; const battle = new Battle( options( [first, fainted, second], { wild: { species: RATTATA, level: 8 } }, 5, ), ); const { texts, prompts } = play(battle, { party: [1, 2] }); expect(texts).toContain("CYNDAQUIL fainted!"); expect(texts).toContain("Use next POKéMON?"); const forced = prompts.find((prompt) => prompt.kind === "party"); expect(forced).toEqual({ kind: "party", text: "Which POKéMON?", menu: false, cancel: false, }); expect(texts).toContain("There's no will to battle!"); const sentOut = texts.findIndex((text) => text.endsWith("TOTODILE!")); expect(texts[sentOut]).toMatch( /^(Go!|Do it!|Go for it,|Your foe's weak! Get'm,) TOTODILE!$/, ); expect(battle.curBattleMon).toBe(2); // The fainted Pokémon did not share the experience. expect(first.exp).toBe(assets.pokemon.expAtLevel(3, 5)); }); it("keeps asking for a Pokémon when the forced party menu is cancelled", () => { const first = mon(CYNDAQUIL, 5); first.hp = 1; const battle = new Battle( options( [first, mon(TOTODILE, 10)], { wild: { species: RATTATA, level: 8 } }, 5, ), ); const { prompts } = play(battle, { party: [-1, -1, 1] }); expect(prompts.filter((prompt) => prompt.kind === "party")).toHaveLength(3); expect(battle.curBattleMon).toBe(1); }); it("runs after declining the next Pokémon, with the first party Pokémon's Speed", () => { const first = mon(CYNDAQUIL, 5); first.hp = 1; const battle = new Battle( options( [first, mon(TOTODILE, 10)], { wild: { species: RATTATA, level: 8 } }, 5, ), ); // The party's stored Speed, not the fainted battle Pokémon's, decides. first.stats[3] = 999; const { texts, result } = play(battle, { yesno: [0] }); expect(texts.slice(-2)).toEqual(["Use next POKéMON?", "Got away safely!"]); expect(result).toBe(C.DRAW); }); it("offers a switch in Shift mode when a trainer sends out the next Pokémon", () => { // Youngster Mikey: Pidgey, then Rattata. const first = mon(CYNDAQUIL, 15), second = mon(TOTODILE, 15); const battle = new Battle(options([first, second], { trainer: [22, 2] })); const { texts } = play(battle, { yesno: [1], party: [1] }); const offer = texts.indexOf("YOUNGSTER MIKEY is about to use RATTATA."); expect(offer).toBeGreaterThan(0); expect(texts.slice(offer + 1, offer + 5)).toEqual([ "Will CHRIS change POKéMON?", "YOUNGSTER MIKEY sent out RATTATA!", expect.stringMatching(/^CYNDAQUIL, /), "Go! TOTODILE!", ]); expect(texts).toContain("YOUNGSTER MIKEY was defeated!"); }); it("makes no offer in Set mode", () => { const battle = new Battle({ ...options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }), shift: false, }); const { texts } = play(battle, {}); expect(texts).toContain("YOUNGSTER MIKEY sent out RATTATA!"); expect(texts.some((text) => text.includes("is about to use"))).toBe(false); expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false); expect(texts).toContain("YOUNGSTER MIKEY was defeated!"); }); it("keeps the Pokémon in when the offer is declined", () => { const battle = new Battle( options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }), ); const { texts } = play(battle, { yesno: [0] }); const offer = texts.findIndex((text) => text.includes("Will CHRIS change")); expect(texts[offer + 1]).toBe("YOUNGSTER MIKEY sent out RATTATA!"); expect(texts.some((text) => text.startsWith("CYNDAQUIL, "))).toBe(false); expect(battle.curBattleMon).toBe(0); }); it("sends out both sides without an offer after a double faint", () => { // Struggle's recoil faints the attacker as the trainer's Pidgey faints. const first = mon(CYNDAQUIL, 15); first.hp = 1; first.pp = [0, 0, 0, 0]; const battle = new Battle( options([first, mon(TOTODILE, 15)], { trainer: [22, 2] }), ); const { texts, prompts } = play(battle, { party: [1] }); const fainted = texts.indexOf("Enemy PIDGEY fainted!"); expect(fainted).toBeGreaterThan(0); // The fainted Pokémon is no longer a participant, so nobody gains // experience for the Pidgey. expect(texts.slice(fainted, fainted + 4)).toEqual([ "Enemy PIDGEY fainted!", "CYNDAQUIL fainted!", "Go! TOTODILE!", "YOUNGSTER MIKEY sent out RATTATA!", ]); expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false); expect(prompts.find((prompt) => prompt.kind === "party")).toMatchObject({ cancel: false, }); expect(texts).toContain("YOUNGSTER MIKEY was defeated!"); }); });