import { beforeAll, describe, expect, it, vi } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { TrainerData } from "../src/game/rom/trainers"; import { Battle, type BattleEffect, type BattleOptions } from "../src/game/battle/battle"; import { C, COMMAND } from "../src/game/battle/constants"; 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 SPLASH = 150, TACKLE = 33, TRANSFORM = 144, MIMIC = 102, SMOKE_BALL = 0x6a; describe.skipIf(!existsSync(romPath))("held items and obedience with the local ROM", () => { let assets: CrystalAssets, trainers: TrainerData; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); trainers = new TrainerData(assets.reader); }); const mon = (species = 143, level = 30, moves = [SPLASH], extra: Partial = {}) => { const result = partyMon(assets, species, level); result.moves = Array.from({ length: 4 }, (_, i) => moves[i] ?? 0) as PartyMon["moves"]; result.pp = result.moves.map((move) => move ? assets.pokemon.move(move).pp : 0) as PartyMon["pp"]; return Object.assign(result, extra); }; const duel = (party: PartyMon[], enemy = [mon()], extra: Partial = {}) => new Battle({ ...battleOptions(assets, trainers, party, { wild: { species: 129, level: 10 } }), wild: undefined, trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, reward: 40 }, ...extra, }); const drain = (run: Generator) => { const texts: string[] = []; for (let next = run.next(); !next.done; next = run.next()) { if (next.value.kind === "text") texts.push(next.value.text.replace(/\n/g, " ")); else expect(next.value.kind).toBe("hud"); } return texts.join(" "); }; // Direct routine access lets branch tests supply exactly the BattleRandom // bytes consumed by CheckObedience, without menu/AI draws obscuring them. const internal = (battle: Battle) => battle as unknown as { turn: number; alreadyDisobeyed: number; attackMissed: number; effectFailed: number; script: number[]; scriptIndex: number; enemySpecies: number; checkObedience(): Generator; updateMoveData(): void; handleMysteryBerry(): Generator; handleStatBoostingHeldItems(): Generator; dittoMetalPowder(side: Battle["player"], attack: number, defense: number): [number, number]; data: { heldEffect(item: number): { effect: number; parameter: number } }; }; it("Smoke Ball escapes without a speed roll or consumption, after the trap checks", () => { const holder = mon(143, 5, [SPLASH], { item: SMOKE_BALL }); const battle = duel([holder], [], { trainer: undefined, wild: { species: 129, level: 20 } }); const steps = battleSteps(battle); battle.enemy.mon.stats[C.SPEED] = 999; battle.enemy.set(5, C.SUBSTATUS_CANT_RUN); steps.answer({ action: "run" }); expect(steps.done).toBe(false); expect(steps.texts.join(" ")).toContain("Can't escape"); battle.enemy.reset(5, C.SUBSTATUS_CANT_RUN); steps.answer({ action: "run" }); expect(steps.done).toBe(true); expect(steps.texts.join(" ")).toContain("fled using a SMOKE BALL"); expect(steps.texts.join(" ")).toContain("Got away safely"); expect(holder.item).toBe(SMOKE_BALL); }); it("Smoke Ball cannot escape a trainer", () => { const battle = duel([mon(143, 30, [SPLASH], { item: SMOKE_BALL })]); const steps = battleSteps(battle).answer({ action: "run" }); expect(steps.done).toBe(false); expect(steps.texts.join(" ")).toContain("No! There's no running"); }); it.each([0, 1])("MysteryBerry replenishes the first exhausted stored move on side %i", (sideIndex) => { const holder = mon(143, 30, [SPLASH, TACKLE], { item: C.MYSTERYBERRY, pp: [0x81, 0xc0, 0, 0] }); const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]); const steps = battleSteps(battle); steps.answer({ action: "fight", move: 0 }); const side = sideIndex ? battle.enemy : battle.player; expect(holder.pp[0]).toBe(0x85); expect(holder.pp[1]).toBe(0xc0); expect(side.mon.pp[0]).toBe(0x85); expect(holder.item).toBe(0); expect(side.mon.item).toBe(0); expect(steps.texts.join(" ")).toContain("recovered PP using"); }); it("MysteryBerry stays held when only a copied move has no PP", () => { const holder = mon(132, 30, [TRANSFORM], { item: C.MYSTERYBERRY }); const battle = duel([holder]); battleSteps(battle).answer({ action: "fight", move: 0 }); battle.player.mon.pp[0] = 0; drain(internal(battle).handleMysteryBerry()); expect(holder.item).toBe(C.MYSTERYBERRY); expect(battle.player.mon.pp[0]).toBe(0); }); it.each([TRANSFORM, MIMIC])("MysteryBerry restores stored move %i without refilling its copy", (move) => { const holder = mon(132, 30, [move], { item: C.MYSTERYBERRY, pp: [1, 0, 0, 0] }); const battle = duel([holder], [mon(135)]); const steps = battleSteps(battle).answer({ action: "fight", move: 0 }); expect(steps.done).toBe(false); expect(holder.pp[0]).toBe(5); expect(holder.item).toBe(0); expect(battle.player.mon.moves[0]).toBe(SPLASH); expect(battle.player.mon.pp[0]).toBe(5); }); it("MysteryBerry restores Sketch by one PP and preserves PP Ups", () => { const holder = mon(235, 30, [SPLASH, C.SKETCH], { item: C.MYSTERYBERRY, pp: [10, 0xc0, 0, 0] }); const battle = duel([holder]); battleSteps(battle).answer({ action: "fight", move: 0 }); expect(holder.pp[1]).toBe(0xc1); expect(battle.player.mon.pp[1]).toBe(0xc1); }); it.each([0xad, 0x53])("a trainer's consumed berry %i is removed from its stored party too", (item) => { // Berry and Bitter Berry exercise ConsumeHeldItem and the separate // UseConfusionHealingItem removal path. const holder = mon(143, 30, [SPLASH], { item }); const battle = duel([mon()], [holder]); const steps = battleSteps(battle); if (item === 0xad) battle.enemy.mon.hp = 20; else { battle.enemy.set(3, C.SUBSTATUS_CONFUSED); battle.enemy.confuseCount = 5; } steps.answer({ action: "fight", move: 0 }); expect(battle.enemy.mon.item).toBe(0); expect(holder.item).toBe(0); }); it.each([0, 1])("Berserk Gene activates before the menu on side %i, with the 256-turn confusion bug", (sideIndex) => { const holder = mon(143, 30, [SPLASH], { item: C.BERSERK_GENE }); const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]); const steps = battleSteps(battle), side = sideIndex ? battle.enemy : battle.player; expect(holder.item).toBe(0); expect(side.levels[C.ATTACK]).toBe(9); expect(side.has(3, C.SUBSTATUS_CONFUSED)).toBe(true); expect(side.confuseCount).toBe(0); expect(steps.texts.join(" ")).toContain("BERSERK GENE activated"); steps.answer({ action: "fight", move: 0 }); expect(side.confuseCount).toBe(255); expect(steps.texts.filter((text) => text.includes("BERSERK GENE activated"))).toHaveLength(1); }); it("Metal Powder checks original species on either side and keeps the overflow quirk", () => { const battle = duel([mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })], [mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })]); battleSteps(battle); for (const side of [battle.player, battle.enemy]) { side.mon.species = 143; expect(internal(battle).dittoMetalPowder(side, 101, 170)).toEqual([101, 255]); expect(internal(battle).dittoMetalPowder(side, 101, 171)).toEqual([50, 128]); expect(internal(battle).dittoMetalPowder(side, 1, 255)).toEqual([1, 191]); } }); it("unused held stat effects respect failure and consume both party and battle items on success", () => { const holder = mon(143, 30, [SPLASH], { item: C.MYSTERYBERRY }); const battle = duel([holder]); battleSteps(battle); const state = internal(battle); const spy = vi.spyOn(state.data, "heldEffect").mockImplementation((item) => ({ effect: item ? C.HELD_ATTACK_UP : 0, parameter: 0 })); state.attackMissed = 1; drain(state.handleStatBoostingHeldItems()); expect(holder.item).toBe(C.MYSTERYBERRY); state.attackMissed = state.effectFailed = 0; drain(state.handleStatBoostingHeldItems()); expect(holder.item).toBe(0); expect(battle.player.mon.item).toBe(0); expect(battle.player.levels[C.ATTACK]).toBe(8); spy.mockRestore(); }); it.each([1, 2])("Exp. Share divides its half between %i healthy holders, excluding fainted holders", (count) => { const fighter = mon(143, 50, [TACKLE]); const holders = Array.from({ length: count }, () => mon(143, 30, [SPLASH], { item: C.EXP_SHARE })); const fainted = mon(143, 30, [SPLASH], { item: C.EXP_SHARE, hp: 0 }); const party = [fighter, ...holders, fainted], before = party.map((p) => p.exp); const battle = duel(party, [], { trainer: undefined, wild: { species: 16, level: 10 } }); expect(playBattle(battle).result).toBe(C.WIN); const base = assets.pokemon.baseData(16); expect(fighter.exp - before[0]).toBe(Math.floor((base.baseExp >> 1) * 10 / 7)); holders.forEach((holder, i) => { expect(holder.exp - before[i + 1]).toBe(Math.floor(Math.floor((base.baseExp >> 1) / count) * 10 / 7)); expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => Math.floor((value >> 1) / count))); }); expect(fainted.exp).toBe(before.at(-1)); expect(fainted.statExp).toEqual([0, 0, 0, 0, 0]); }); it("an active Exp. Share holder receives both halves, including Pokérus stat experience", () => { const holder = mon(143, 50, [TACKLE], { item: C.EXP_SHARE, pokerus: 1 }); const before = holder.exp; const battle = duel([holder], [], { trainer: undefined, wild: { species: 16, level: 10 } }); const result = playBattle(battle); const base = assets.pokemon.baseData(16); expect(holder.exp - before).toBe(2 * Math.floor((base.baseExp >> 1) * 10 / 7)); expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => 4 * (value >> 1))); expect(result.texts.filter((text) => text.includes("gained"))).toHaveLength(2); }); const obedience = (bytes: number[], extra: Partial = {}, level = 50) => { const holder = mon(143, level, [SPLASH, TACKLE], { otId: 4321 }); const battle = duel([holder], [mon()], extra); battleSteps(battle); const state = internal(battle); state.turn = 0; state.script = [COMMAND.checkobedience, COMMAND.usedmovetext, COMMAND.endmove]; state.scriptIndex = 1; battle.player.curMove = SPLASH; battle.player.moveNum = 0; state.updateMoveData(); const random = vi.spyOn(battle, "random").mockImplementation(() => { const byte = bytes.shift(); if (byte === undefined) throw new Error("Unexpected obedience RNG draw"); return byte; }); return { battle, holder, state, random, texts: () => drain(state.checkObedience()) }; }; it.each([[0, 10], [2, 30], [8, 50], [0x20, 70], [0x80, 100]])("badge mask %i guarantees obedience through level %i without RNG", (johtoBadges, level) => { const test = obedience([], { johtoBadges }, level); expect(test.texts()).toBe(""); expect(test.random).not.toHaveBeenCalled(); }); it("owned Pokémon obey regardless of level, and foreign Pokémon can pass their first roll", () => { const owned = obedience([], {}, 100); owned.holder.otId = 1234; expect(owned.texts()).toBe(""); expect(owned.random).not.toHaveBeenCalled(); const foreign = obedience([0xff, 0]); // reject 255, then obey expect(foreign.texts()).toBe(""); expect(foreign.random).toHaveBeenCalledTimes(2); }); it("disobedience loafs, clears Encore and remembered moves, and spends no PP", () => { const test = obedience([1, 20, 0xff, 0]); // swap(1)=16: disobey; neither nap nor self-hit test.battle.player.set(5, C.SUBSTATUS_ENCORED); test.battle.player.encoreCount = 3; test.battle.player.lastMove = test.battle.player.lastCounterMove = TACKLE; const pp = [...test.holder.pp]; expect(test.texts()).toContain("loafing around"); expect(test.holder.pp).toEqual(pp); expect(test.battle.player.lastMove).toBe(0); expect(test.battle.player.lastCounterMove).toBe(0); expect(test.battle.player.encoreCount).toBe(0); expect(test.battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(false); expect(test.state.script[1]).toBe(COMMAND.endmove); }); it("disobedience can nap or inflict confusion damage without using PP", () => { const nap = obedience([1, 20, 0, 0, 8]); // reject zero sleep, then one turn expect(nap.texts()).toContain("began to nap"); expect(nap.battle.player.mon.status).toBe(1); const hit = obedience([1, 20, 3]); // swap(3)=48, in [40,80) const hp = hit.battle.player.mon.hp; expect(hit.texts()).toContain("hurt itself"); expect(hit.battle.player.mon.hp).toBeLessThan(hp); expect(hit.battle.player.mon.pp).toEqual(hit.holder.pp); }); it("a sleeping foreign Pokémon ignores Snore before the second obedience roll", () => { const test = obedience([1]); test.battle.player.curMove = C.SNORE; test.battle.player.mon.status = 3; test.battle.player.lastMove = TACKLE; test.state.updateMoveData(); expect(test.texts()).toContain("ignored orders"); expect(test.random).toHaveBeenCalledTimes(1); expect(test.battle.player.lastMove).toBe(TACKLE); // IgnoreSleepOnly returns early }); it("disobedience can use a different move, spending only that slot's PP", () => { const test = obedience([1, 0, 0, 1, 255, 255, 0]); // 16 fails obedience; 0 selects UseInstead; reject original slot 0, // choose Tackle, then its critical, damage-variation and hit draws. const pp = [...test.holder.pp], hp = test.battle.enemy.mon.hp; expect(test.texts()).toContain("instead"); expect(test.battle.enemy.mon.hp).toBeLessThan(hp); expect(test.holder.pp).toEqual([pp[0], pp[1] - 1, 0, 0]); expect(test.battle.player.moveNum).toBe(0); expect(test.state.alreadyDisobeyed).toBe(1); }); it.each(["disabled", "no PP", "one move"])("disobedience loafs when an alternate move is unavailable: %s", (reason) => { const test = obedience([1, 0, 0]); if (reason === "disabled") test.battle.player.disabledMove = TACKLE; else if (reason === "no PP") test.battle.player.mon.pp[1] = 0xc0; else test.battle.player.mon.moves[1] = 0; expect(test.texts()).toContain("loafing around"); expect(test.random).toHaveBeenCalledTimes(3); expect(test.state.alreadyDisobeyed).toBe(0); }); });