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 { Bag } from "../src/game/party/bag"; import type { PartyMon } from "../src/game/party/pokemon"; import { battleOptions, battleSteps, 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, FULL_RESTORE = 0x0e, MAX_ELIXER = 0x15, X_ACCURACY = 0x21, POKE_DOLL = 0x25, REVIVE = 0x27, GUARD_SPEC = 0x29, DIRE_HIT = 0x2c, ETHER = 0x3f, BITTER_BERRY = 0x53, ENERGYPOWDER = 0x79, REVIVAL_HERB = 0x7c, MYSTERYBERRY = 0x96, 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("raises a stat with an X item, even past the limit, and uses the turn", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.happiness = 70; const { battle, bag } = setup([mon], [{ item: X_ATTACK, quantity: 2 }]); const steps = battleSteps(battle).answer(use(X_ATTACK)); expect(steps.texts.slice(2, 5)).toEqual([ "CHRIS used the X ATTACK.", "CYNDAQUIL's ATTACK went up!", "Enemy PIDGEY used TACKLE!", ]); expect(battle.player.levels[C.ATTACK]).toBe(8); // HappinessChanges' X item row: +1 below 100. expect(mon.happiness).toBe(71); battle.player.levels[C.ATTACK] = 13; steps.answer(use(X_ATTACK)); expect(steps.texts).toContain("CYNDAQUIL's ATTACK won't rise anymore!"); expect(bag.items).toEqual([]); }); it.each([ [X_ACCURACY, C.SUBSTATUS_X_ACCURACY], [GUARD_SPEC, C.SUBSTATUS_MIST], [DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY], ])( "sets item %i's flag once, then keeps the item and the turn", (item, bit) => { const { battle, bag } = setup( [partyMon(assets, CYNDAQUIL, 10)], [{ item, quantity: 2 }], ); const steps = battleSteps(battle).answer(use(item)); expect(battle.player.sub[4] & (1 << bit)).toBeTruthy(); const before = steps.texts.length; steps.answer(use(item)); expect(steps.texts.slice(before)).toEqual(["It won't have any effect."]); expect(steps.prompt?.kind).toBe("action"); expect(bag.items).toEqual([{ item, quantity: 1 }]); }, ); it("ends a wild battle with a Poké Doll, and warns in a trainer battle", () => { const wild = setup( [partyMon(assets, CYNDAQUIL, 10)], [{ item: POKE_DOLL, quantity: 1 }], ); const { texts, result } = playBattle(wild.battle, { action: [use(POKE_DOLL)], }); expect(texts.at(-1)).toBe("CHRIS used the POKé DOLL."); expect(result).toBe(C.DRAW); expect(wild.bag.items).toEqual([]); const bag: Bag = { items: [{ item: POKE_DOLL, quantity: 1 }], balls: [], keyItems: [], }; const trainer = new Battle( battleOptions( assets, trainers, [partyMon(assets, CYNDAQUIL, 15)], { trainer: [22, 1] }, { bag }, ), ); const steps = battleSteps(trainer).answer(use(POKE_DOLL)); expect(steps.texts.at(-1)).toBe( "OAK: CHRIS! This isn't the time to use that!", ); expect(bag.items).toEqual([{ item: POKE_DOLL, quantity: 1 }]); }); it("restores HP and status with a Full Restore, or only the status at full HP", () => { const hurt = partyMon(assets, CYNDAQUIL, 10); hurt.hp = 3; hurt.status = PSN; const first = setup([hurt], [{ item: FULL_RESTORE, quantity: 1 }]); const steps = battleSteps(first.battle).answer(use(FULL_RESTORE)).answer(0); expect(steps.texts).toContain( `CYNDAQUIL recovered ${hurt.stats[0] - 3}HP!`, ); expect(first.battle.player.mon.status).toBe(0); const healthy = partyMon(assets, CYNDAQUIL, 10); healthy.status = PSN; const second = setup([healthy], [{ item: FULL_RESTORE, quantity: 1 }]); expect( playBattle(second.battle, { action: [use(FULL_RESTORE)] }).texts, ).toContain("CYNDAQUIL's health returned."); expect(healthy.status).toBe(0); }); it("revives a fainted Pokémon, which shares the experience again", () => { // As in the forced switch test, the Rattata faints the first Pokémon. const first = partyMon(assets, CYNDAQUIL, 5), second = partyMon(assets, TOTODILE, 10); first.hp = 1; const bag: Bag = { items: [{ item: REVIVE, quantity: 1 }], balls: [], keyItems: [], }; const battle = new Battle( battleOptions( assets, trainers, [first, second], { wild: { species: 19, level: 8 } }, { seed: 5, bag }, ), ); const { texts } = playBattle(battle, { party: [1, 0], action: [{ action: "fight", move: 0 }, use(REVIVE)], }); expect(texts).toContain("CYNDAQUIL fainted!"); expect(texts).toContain("CYNDAQUIL is revitalized."); // ReviveHalfHP: half the maximum, rounded down. expect(first.hp).toBeLessThanOrEqual(first.stats[0] >> 1); expect(texts.some((text) => text.startsWith("CYNDAQUIL gained"))).toBe( true, ); expect(bag.items).toEqual([]); }); it("makes bitter medicine cost happiness", () => { const fainted = partyMon(assets, TOTODILE, 10), out = partyMon(assets, CYNDAQUIL, 10); fainted.hp = 0; fainted.happiness = 70; const herb = setup([out, fainted], [{ item: REVIVAL_HERB, quantity: 1 }]); const { texts } = playBattle(herb.battle, { action: [use(REVIVAL_HERB)], party: [1], }); expect(texts).toContain("It looks bitter…"); expect(fainted.hp).toBe(fainted.stats[0]); expect(fainted.happiness).toBe(55); // -15 below 100 const hurt = partyMon(assets, CYNDAQUIL, 10); hurt.hp = 3; hurt.happiness = 150; const powder = setup([hurt], [{ item: ENERGYPOWDER, quantity: 1 }]); expect( playBattle(powder.battle, { action: [use(ENERGYPOWDER)] }).texts, ).toContain("It looks bitter…"); expect(hurt.happiness).toBe(145); // -5 below 200 }); it("cures confusion with a Bitter Berry, and does nothing otherwise", () => { const { battle, bag } = setup( [partyMon(assets, CYNDAQUIL, 10)], [{ item: BITTER_BERRY, quantity: 2 }], ); const steps = battleSteps(battle); steps.answer(use(BITTER_BERRY)); expect(steps.texts).toContain("It won't have any effect."); battle.player.sub[3] |= 1 << C.SUBSTATUS_CONFUSED; steps.answer(use(BITTER_BERRY)); expect(steps.texts).toContain("CHRIS used the BITTER BERRY."); expect(steps.texts.some((text) => text.includes("confused"))).toBe(true); expect(battle.player.sub[3] & (1 << C.SUBSTATUS_CONFUSED)).toBe(0); expect(bag.items).toEqual([{ item: BITTER_BERRY, quantity: 1 }]); }); it("restores PP with Ethers, Elixers and the MysteryBerry", () => { const mon = partyMon(assets, CYNDAQUIL, 10); mon.pp[0] = (1 << 6) | 1; // One PP Up: TACKLE's maximum is 42. mon.pp[1] = 2; const { battle, bag } = setup( [mon], [ { item: ETHER, quantity: 1 }, { item: MYSTERYBERRY, quantity: 1 }, { item: MAX_ELIXER, quantity: 1 }, ], ); const steps = battleSteps(battle).answer(use(ETHER)).answer(0); expect(steps.prompt).toMatchObject({ kind: "move", text: "Restore the PP of\nwhich move?", }); // Going back from the move list reopens the party menu. steps.answer(-1); expect(steps.prompt?.kind).toBe("party"); steps.answer(0).answer(0); expect(steps.texts).toContain("PP was restored."); expect(mon.pp[0]).toBe((1 << 6) | 11); // BattleRestorePP: the Pokémon out takes the new PP. expect(battle.player.mon.pp[0] & 63).toBe(11); steps.answer(use(MYSTERYBERRY, 0)).answer(0).answer(1); expect(mon.pp[1]).toBe(7); steps.answer(use(MAX_ELIXER, 0)).answer(0); expect(mon.pp[0]).toBe((1 << 6) | 42); expect(mon.pp[1] & 63).toBe(assets.pokemon.move(mon.moves[1]).pp); expect(bag.items).toEqual([]); }); it("reapplies the paralysed enemy's Speed penalty when a heal follows a full turn", () => { // After HandleHealingItems, hBattleTurn holds the enemy, so // CalcPlayerStats penalises the enemy again (the original's behaviour). const mon = partyMon(assets, CYNDAQUIL, 10); mon.status = PSN; const { battle } = setup([mon], [{ item: ANTIDOTE, quantity: 1 }], 5); const steps = battleSteps(battle).answer({ action: "fight", move: 1 }); battle.enemy.mon.status = PAR; const speed = battle.enemy.mon.stats[C.SPEED]; steps.answer(use(ANTIDOTE)).answer(0); expect(steps.texts).toContain("CYNDAQUIL's cured of poison."); expect(battle.enemy.mon.stats[C.SPEED]).toBe(Math.max(1, speed >> 2)); }); it("raises happiness more when leveling up where the Pokémon was caught", () => { const level = (landmark: number) => { const mon = partyMon(assets, CYNDAQUIL, 5); mon.exp = 178; // One Pidgey from level 6 mon.happiness = 70; mon.caughtData = (mon.caughtData & ~0x7f) | 3; const bag: Bag = { items: [], balls: [], keyItems: [] }; const options = battleOptions( assets, trainers, [mon], { wild: { species: PIDGEY, level: 3 } }, { bag }, ); playBattle(new Battle({ ...options, landmark })); return mon.happiness; }; expect(level(3)).toBe(80); // HAPPINESS_GAINLEVELATHOME: +10 expect(level(4)).toBe(75); // HAPPINESS_GAINLEVEL: +5 }); });