import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { Battle, type BattleEffect, type BattleMon, type BattleTrainer } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { partyMon } from "./battle-driver"; import { defaultTextContext } from "../src/game/scripts/text"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const RATTATA = 19, PIDGEY = 16; // TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). const SWITCH_SOMETIMES = 1 << 2, ALWAYS_USE = 1 << 4, UNKNOWN_USE = 1 << 5, CONTEXT_USE = 1 << 6; describe.skipIf(!existsSync(romPath))("trainer item AI (AI_TryItem)", () => { let assets: CrystalAssets; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); }); const enemyMon = ( level: number, hp: number, maxHp: number, status = 0, ): BattleMon => ({ species: RATTATA, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70, level, status, hp, maxHp, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA", }); const makeTrainer = ( overrides: Partial = {}, levels: number[] = [30], ): BattleTrainer => ({ name: "", className: "CLAIR", party: levels.map((level) => partyMon(assets, RATTATA, level)), aiFlags: 0, switchFlags: CONTEXT_USE | SWITCH_SOMETIMES, items: [0, 0], baseReward: 25, reward: 100, ...overrides, }); /** Builds a Battle with the enemy's battle Pokémon and curOTMon set * directly (as the reference specs do), skipping the opening send-out, * and exposes the private AI_TryItem for direct calls. */ const rig = ( trainer: BattleTrainer, enemy: BattleMon, { curOTMon = 0, random = () => 0 }: { curOTMon?: number; random?: () => number } = {}, ) => { const battle = new Battle({ data: assets.pokemon, party: [partyMon(assets, PIDGEY, 10)], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1234, playerName: "CHRIS", timeOfDay: 1, random, trainer, text: () => defaultTextContext("CHRIS"), }); battle.turn = 1; battle.curOTMon = curOTMon; battle.enemy.mon = enemy; const internal = battle as unknown as { aiTryItem(): Generator; aiSwitchOrTryItem(): Generator; }; return { battle, internal }; }; const run = (gen: Generator) => { const texts: string[] = []; let step = gen.next(); while (!step.done) { if (step.value.kind === "text") texts.push(step.value.text.replace(/\n/g, " ")); step = gen.next(); } return { used: step.value, texts }; }; it("does nothing with no items held", () => { const trainer = makeTrainer({ items: [0, 0] }); const enemy = enemyMon(30, 10, 100, 1 << C.FRZ); const { internal } = rig(trainer, enemy); const { used, texts } = run(internal.aiTryItem()); expect(used).toBe(false); expect(texts).toEqual([]); expect(enemy.status).toBe(1 << C.FRZ); }); it.each(["mean-look", "wrap"])("a trainer blocked from switching by %s can still heal", (trap) => { const trainer = makeTrainer({ items: [C.POTION, 0] }); const enemy = enemyMon(30, 10, 100); const { battle, internal } = rig(trainer, enemy, { random: () => { throw new Error("Trapped trainers must skip the switch roll"); }, }); if (trap === "mean-look") battle.player.set(5, C.SUBSTATUS_CANT_RUN); else battle.enemy.wrapCount = 2; expect(run(internal.aiSwitchOrTryItem()).used).toBe(true); expect(enemy.hp).toBe(30); expect(trainer.items).toEqual([0, 0]); }); it.each([ [C.POTION, 20], [C.SUPER_POTION, 50], [C.HYPER_POTION, 200], ])("item %i heals %i HP, clamped to the maximum, and is consumed", (item, amount) => { const trainer = makeTrainer({ items: [item, 0] }); const enemy = enemyMon(30, 10, 100); const { internal } = rig(trainer, enemy); const { used, texts } = run(internal.aiTryItem()); expect(used).toBe(true); expect(enemy.hp).toBe(Math.min(10 + amount, 100)); expect(trainer.items).toEqual([0, 0]); expect(texts).toEqual([expect.stringMatching(/^CLAIR used .* on RATTATA!$/)]); }); it("Max Potion and Full Restore fill to the maximum HP", () => { for (const item of [C.MAX_POTION, C.FULL_RESTORE]) { const trainer = makeTrainer({ items: [item, 0] }); const enemy = enemyMon(30, 1, 100); const { internal } = rig(trainer, enemy); const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(enemy.hp).toBe(100); } }); it("Full Restore clears confusion, but Full Heal keeps it (a kept bug)", () => { for (const [item, staysConfused] of [[C.FULL_RESTORE, false], [C.FULL_HEAL, true]] as const) { const trainer = makeTrainer({ items: [item, 0] }); const enemy = enemyMon(30, item === C.FULL_HEAL ? 100 : 1, 100, 1 << C.FRZ); const { battle, internal } = rig(trainer, enemy); battle.enemy.set(3, C.SUBSTATUS_CONFUSED); const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(enemy.status).toBe(0); expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(staysConfused); } }); it("Full Heal only cures Toxic, Freeze or Sleep under CONTEXT_USE, not plain status", () => { const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] }); const enemy = enemyMon(30, 100, 100, 1 << C.PAR); const { internal } = rig(trainer, enemy); const { used } = run(internal.aiTryItem()); expect(used).toBe(false); expect(enemy.status).toBe(1 << C.PAR); }); it("X Accuracy, Guard Spec and Dire Hit set their SubStatus4 flag", () => { const cases = [ [C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY], [C.GUARD_SPEC, C.SUBSTATUS_MIST], [C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY], ] as const; for (const [item, bit] of cases) { const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE }); const enemy = enemyMon(30, 100, 100); // .XItem's first-turn-out gate (`cp 128; jp c, DontUse`) declines // below 128, so a high roll is the one that lets CONTEXT_USE through. const { battle, internal } = rig(trainer, enemy, { random: () => 254 }); const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(battle.enemy.has(4, bit)).toBe(true); } }); it.each([ [C.X_ATTACK, C.ATTACK], [C.X_DEFEND, C.DEFENSE], [C.X_SPEED, C.SPEED], [C.X_SPECIAL, C.SP_ATTACK], ])("item %i raises the matching stat", (item, stat) => { const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE }); const enemy = enemyMon(30, 100, 100); const { battle, internal } = rig(trainer, enemy, { random: () => 254 }); const before = battle.enemy.levels[stat]; const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(battle.enemy.levels[stat]).toBe(before + 1); }); it("consumes whichever of the two slots the match came from", () => { // item2 (Hyper Potion) is tried before item1 (Potion) is ever reached, // since AI_Items lists Hyper Potion first. const trainer = makeTrainer({ items: [C.POTION, C.HYPER_POTION] }); const enemy = enemyMon(30, 10, 100); const { internal } = rig(trainer, enemy); const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(enemy.hp).toBe(100); // Hyper Potion's 200 HP, clamped expect(trainer.items).toEqual([C.POTION, 0]); }); it("BUG: a declined second item strands the first for the rest of that scan", () => { // Clair: FULL_HEAL, HYPER_POTION. Full HP declines Hyper Potion, after // which the scan can never see item1 (Full Heal) again this call, even // though a frozen status would otherwise ask for it. const stuck = makeTrainer({ items: [C.FULL_HEAL, C.HYPER_POTION] }); const frozenStuck = enemyMon(30, 100, 100, 1 << C.FRZ); const { internal: stuckInternal } = rig(stuck, frozenStuck); const { used: stuckUsed } = run(stuckInternal.aiTryItem()); expect(stuckUsed).toBe(false); expect(frozenStuck.status).toBe(1 << C.FRZ); expect(stuck.items).toEqual([C.FULL_HEAL, C.HYPER_POTION]); // The same Full Heal works fine once nothing else occupies the scan. const clear = makeTrainer({ items: [C.FULL_HEAL, 0] }); const frozenClear = enemyMon(30, 100, 100, 1 << C.FRZ); const { internal: clearInternal } = rig(clear, frozenClear); const { used: clearUsed } = run(clearInternal.aiTryItem()); expect(clearUsed).toBe(true); expect(frozenClear.status).toBe(0); }); it("BUG: the stuck pointer can drift into the base reward and misread it as a third item", () => { // No real item1; item2 (Hyper Potion) is declined at full HP, which // strands the scan at item2. It then reaches Full Heal's table entry // with the stuck pointer one past item2, i.e. wEnemyTrainerBaseReward: // a base reward that happens to equal FULL_HEAL's id is misread as // that item and "used", simply zeroing the reward (docs/bugs_and_glitches.md // "AI might use its base reward value as an item"). No real trainer's // base reward aliases an item id, so this needs an explicit setup. const trainer = makeTrainer({ items: [0, C.HYPER_POTION], baseReward: C.FULL_HEAL }); const enemy = enemyMon(30, 100, 100, 1 << C.FRZ); const { internal } = rig(trainer, enemy); const { used } = run(internal.aiTryItem()); expect(used).toBe(true); expect(enemy.status).toBe(0); expect(trainer.baseReward).toBe(0); expect(trainer.items).toEqual([0, C.HYPER_POTION]); }); it("only the trainer's highest-level Pokémon (ties included) may use an item", () => { const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] }, [20, 30]); const enemy = enemyMon(20, 100, 100, 1 << C.FRZ); const { internal } = rig(trainer, enemy, { curOTMon: 0 }); expect(run(internal.aiTryItem()).used).toBe(false); expect(enemy.status).toBe(1 << C.FRZ); // A tie at the trainer's highest level still counts. trainer.party[0].level = 30; const tied = enemyMon(30, 100, 100, 1 << C.FRZ); const { internal: tiedInternal } = rig(trainer, tied, { curOTMon: 0 }); expect(run(tiedInternal.aiTryItem()).used).toBe(true); expect(tied.status).toBe(0); }); it("HealItem's random gate: above a quarter but at or below half HP, under CONTEXT_USE", () => { const trainer = makeTrainer({ items: [C.POTION, 0] }); const enemy = () => enemyMon(30, 30, 100); // 30% of max: below half, above a quarter for (const [roll, expected] of [[0, true], [254, false]] as const) { trainer.items = [C.POTION, 0]; const mon = enemy(); const { internal } = rig(trainer, mon, { random: () => roll }); expect(run(internal.aiTryItem()).used).toBe(expected); } }); it("HealItem's UNKNOWN_USE path (dead in this game, but ported)", () => { const trainer = makeTrainer({ items: [C.POTION, 0], switchFlags: UNKNOWN_USE }); // At or below a quarter: a roll below 50/255 declines; otherwise uses. for (const [roll, expected] of [[0, false], [254, true]] as const) { const mon = enemyMon(30, 10, 100); const { internal } = rig(trainer, mon, { random: () => roll }); expect(run(internal.aiTryItem()).used).toBe(expected); } }); it("Status's ALWAYS_USE and plain-random paths (dead in this game, but ported)", () => { const always = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: ALWAYS_USE }); const alwaysMon = enemyMon(30, 100, 100, 1 << C.PAR); const { internal: alwaysInternal } = rig(always, alwaysMon); expect(run(alwaysInternal.aiTryItem()).used).toBe(true); const plain = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: 0 }); for (const [roll, expected] of [[0, true], [254, false]] as const) { plain.items = [C.FULL_HEAL, 0]; const mon = enemyMon(30, 100, 100, 1 << C.PAR); const { internal } = rig(plain, mon, { random: () => roll }); expect(run(internal.aiTryItem()).used).toBe(expected); } }); it("XItem is more eager on the enemy's first turn out than later", () => { const trainer = makeTrainer({ items: [C.X_SPEED, 0], switchFlags: CONTEXT_USE }); // First turn out (turnsTaken 0): the first random draw declines below // 128 (`cp 128; jp c, DontUse`); CONTEXT_USE then needs no second draw. { const mon = enemyMon(30, 100, 100); const { battle, internal } = rig(trainer, mon, { random: () => 254 }); battle.enemy.turnsTaken = 0; expect(run(internal.aiTryItem()).used).toBe(true); } { trainer.items = [C.X_SPEED, 0]; const mon = enemyMon(30, 100, 100); const { battle, internal } = rig(trainer, mon, { random: () => 0 }); battle.enemy.turnsTaken = 0; expect(run(internal.aiTryItem()).used).toBe(false); } // Not the first turn out: ALWAYS_USE_F is required (CONTEXT_USE alone // is not enough), so this trainer never uses it again this way. { trainer.items = [C.X_SPEED, 0]; const mon = enemyMon(30, 100, 100); const { battle, internal } = rig(trainer, mon, { random: () => 254 }); battle.enemy.turnsTaken = 1; expect(run(internal.aiTryItem()).used).toBe(false); } }); it("clears Bide, Fury Cutter, Protect, Rage and the last counter move only when an item is used", () => { const trainer = makeTrainer({ items: [C.POTION, 0] }); const enemy = enemyMon(30, 10, 100); const { battle, internal } = rig(trainer, enemy); battle.enemy.set(3, C.SUBSTATUS_BIDE); battle.enemy.set(4, C.SUBSTATUS_RAGE); battle.enemy.furyCutterCount = 3; battle.enemy.protectCount = 2; battle.enemy.rageCounter = 4; battle.enemy.lastCounterMove = 33; expect(run(internal.aiTryItem()).used).toBe(true); expect(battle.enemyGoesFirst).toBe(1); expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(false); expect(battle.enemy.has(4, C.SUBSTATUS_RAGE)).toBe(false); expect(battle.enemy.furyCutterCount).toBe(0); expect(battle.enemy.protectCount).toBe(0); expect(battle.enemy.rageCounter).toBe(0); expect(battle.enemy.lastCounterMove).toBe(0); }); it("leaves those substatuses untouched when nothing is used", () => { const trainer = makeTrainer({ items: [0, 0] }); const enemy = enemyMon(30, 10, 100); const { battle, internal } = rig(trainer, enemy); battle.enemy.set(3, C.SUBSTATUS_BIDE); battle.enemy.furyCutterCount = 3; expect(run(internal.aiTryItem()).used).toBe(false); expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(true); expect(battle.enemy.furyCutterCount).toBe(3); }); });