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 } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import type { Bag } from "../src/game/party/bag"; 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, CYNDAQUIL = 155, TOTODILE = 158; const BRIGHTPOWDER = 0x03, ANTIDOTE = 0x09, PARLYZ_HEAL = 0x0d, MAX_POTION = 0x0f, POTION = 0x12, X_ATTACK = 0x31, PSNCUREBERRY = 0x4a, BERRY = 0xad; const PSN = 1 << 3, PAR = 1 << 6; describe.skipIf(!existsSync(romPath))("healing items in battle", () => { let assets: CrystalAssets, trainers: TrainerData; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); trainers = new TrainerData(assets.reader); }); const setup = (party: PartyMon[], items: Bag["items"], level = 3) => { const bag: Bag = { items, balls: [], keyItems: [] }; const battle = new Battle( battleOptions( assets, trainers, party, { wild: { species: PIDGEY, level } }, { bag }, ), ); return { battle, bag }; }; const use = (item: number, slot = 0) => ({ action: "item" as const, item, slot, }); it("restores HP with a Potion, takes the turn and uses up the Potion", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.hp = 5; const { battle, bag } = setup([mon], [{ item: POTION, quantity: 2 }]); const { texts, prompts } = playBattle(battle, { action: [use(POTION)] }); expect(prompts[1]).toEqual({ kind: "party", text: "Use on which POKéMON?", menu: false, cancel: true, }); const healed = texts.indexOf("CYNDAQUIL recovered 20HP!"); expect(healed).toBeGreaterThan(0); // The item takes the turn: the wild Pokémon moves next. expect(texts[healed + 1]).toMatch(/^Enemy PIDGEY used /); expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]); }); it("stops at the maximum HP and reports only the HP actually restored", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.hp = mon.stats[0] - 3; const { battle, bag } = setup([mon], [{ item: MAX_POTION, quantity: 1 }]); const { texts } = playBattle(battle, { action: [use(MAX_POTION)] }); expect(texts).toContain("CYNDAQUIL recovered 3HP!"); expect(bag.items).toEqual([]); }); it("heals a Pokémon that is not out without changing the one in battle", () => { const out = partyMon(assets, CYNDAQUIL, 10), resting = partyMon(assets, TOTODILE, 10); resting.hp = 1; const { battle } = setup([out, resting], [{ item: BERRY, quantity: 1 }]); const { texts } = playBattle(battle, { action: [use(BERRY)], party: [1] }); expect(texts).toContain("TOTODILE recovered 10HP!"); expect(resting.hp).toBe(11); expect(battle.curBattleMon).toBe(0); }); it("does nothing at full HP or on a fainted Pokémon, and keeps the turn", () => { const out = partyMon(assets, CYNDAQUIL, 10), fainted = partyMon(assets, TOTODILE, 10); fainted.hp = 0; const { battle, bag } = setup( [out, fainted], [{ item: POTION, quantity: 1 }], ); const { texts, prompts } = playBattle(battle, { action: [use(POTION), use(POTION)], party: [0, 1], }); expect( texts.filter((text) => text === "It won't have any effect."), ).toHaveLength(2); expect(prompts.slice(0, 5).map((prompt) => prompt.kind)).toEqual([ "action", "party", "action", "party", "action", ]); expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]); }); it("returns to the battle menu when the party menu is cancelled", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.hp = 5; const { battle, bag } = setup([mon], [{ item: POTION, quantity: 1 }]); const { prompts } = playBattle(battle, { action: [use(POTION)], party: [-1], }); expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([ "action", "party", "action", ]); expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]); }); it("cures poison with an Antidote or a PSNCUREBERRY, and only poison", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.status = PSN; const { battle, bag } = setup( [mon], [ { item: PARLYZ_HEAL, quantity: 1 }, { item: ANTIDOTE, quantity: 1 }, { item: PSNCUREBERRY, quantity: 1 }, ], ); const { texts } = playBattle(battle, { action: [use(PARLYZ_HEAL, 0), use(ANTIDOTE, 1)], }); expect(texts[texts.indexOf("It won't have any effect.") + 1]).toBe( "CYNDAQUIL's cured of poison.", ); expect(mon.status).toBe(0); expect(bag.items.map(({ item }) => item)).toEqual([ PARLYZ_HEAL, PSNCUREBERRY, ]); const berry = partyMon(assets, CYNDAQUIL, 10); berry.status = PSN; const second = setup([berry], [{ item: PSNCUREBERRY, quantity: 1 }]); expect( playBattle(second.battle, { action: [use(PSNCUREBERRY)] }).texts, ).toContain("CYNDAQUIL's cured of poison."); expect(second.bag.items).toEqual([]); }); it("restores the paralysed Pokémon's Speed with a Parlyz Heal", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.status = PAR; const { battle } = setup([mon], [{ item: PARLYZ_HEAL, quantity: 1 }]); const speeds: number[] = []; const run = battle.run(); let input: Parameters[0]; for (let step = 0, actions = 0; step < 500; step++) { const next = run.next(input); if (next.done) break; input = undefined; const effect = next.value; if (effect.kind === "action") { speeds.push(battle.player.mon.stats[C.SPEED]); input = actions++ === 0 ? use(PARLYZ_HEAL) : { action: "run" }; } else if (effect.kind === "party") input = 0; if (actions > 1 && effect.kind === "action") break; } expect(speeds[0]).toBe(Math.max(1, mon.stats[3] >> 2)); expect(speeds[1]).toBe(mon.stats[3]); expect(battle.player.mon.status).toBe(0); }); it("answers an item with no battle use with Oak's warning", () => { const { battle, bag } = setup( [partyMon(assets, CYNDAQUIL, 10)], [{ item: BRIGHTPOWDER, quantity: 1 }], ); const { texts } = playBattle(battle, { action: [use(BRIGHTPOWDER)] }); expect(texts[texts.indexOf("Go! CYNDAQUIL!") + 1]).toBe( "OAK: CHRIS! This isn't the time to use that!", ); expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]); }); it("refuses items whose battle effects are not ported", () => { const { battle } = setup( [partyMon(assets, CYNDAQUIL, 10)], [{ item: X_ATTACK, quantity: 1 }], ); expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow( UnsupportedBattle, ); }); });