import { describe, expect, it } from "vitest"; import { chooseTrainerMove, validateTrainerAI, type TrainerAIInput, type TrainerAISide, } from "../src/game/battle/ai"; import { C } from "../src/game/battle/constants"; import { SYMBOLS } from "../src/game/rom/profile"; import type { MoveData } from "../src/game/rom/pokemon"; const move = ( id: number, effect = C.EFFECT_NORMAL_HIT, power = 40, type = 0, ): MoveData => ({ id, effect, power, type, accuracy: 255, pp: 30, chance: 0 }); const side = (): TrainerAISide => ({ mon: { moves: [1, 2, 0, 0], pp: [30, 30, 0, 0], status: 0, hp: 20, maxHp: 20, types: [0, 0], species: 1, dvs: 0x8888, stats: [20, 20, 20, 20, 20], level: 20, }, levels: [7, 7, 7, 7, 7, 7, 7], sub: [0, 0, 0, 0, 0, 0], screens: 0, turnsTaken: 0, disableCount: 0, disabledMove: 0, }); function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput { let cursor = 0; return { weights: 0, player: side(), enemy: side(), weather: 0, damage: () => { throw new Error("Unexpected damage estimate"); }, random: () => { if (cursor >= bytes.length) throw new Error("Unexpected extra random call"); return bytes[cursor++]; }, data: { move: (id) => moves.find((m) => m.id === id)!, baseData: () => { throw new Error("Unexpected base data lookup"); }, }, battleData: { byteList: (address) => { if (address === SYMBOLS.statusOnlyEffects) return [ C.EFFECT_SLEEP, C.EFFECT_TOXIC, C.EFFECT_POISON, C.EFFECT_PARALYZE, ]; if (address === SYMBOLS.recklessMoves) return [ C.EFFECT_SELFDESTRUCT, C.EFFECT_RAMPAGE, C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, ]; if (address === SYMBOLS.riskyEffects) return [C.EFFECT_SELFDESTRUCT, C.EFFECT_OHKO]; return [1, 2]; }, typeMatchups: () => [], }, }; } describe("trainer AI native scoring", () => { it("accepts all defined layer bits and rejects invalid layer words", () => { for (const weights of [-1, 65536, 1.5, NaN]) expect(() => validateTrainerAI(weights)).toThrow(/not translated/); for (const weights of [16, 0x3d3, 0xffff]) expect(() => validateTrainerAI(weights)).not.toThrow(); }); it.each([C.EFFECT_ATTACK_DOWN, C.EFFECT_DEFENSE_DOWN])( "Smart leaves the first rival's attack and stat-lowering effect %i unchanged", (effect) => { const input = fixture([move(1), move(2, effect, 0)], [3, 1]); input.weights = 16; expect(chooseTrainerMove(input)).toEqual({ move: 2, index: 1, scores: [20, 20, 80, 80], enemyTurn: false, }); }, ); it("scores exhausted Smart moves along with the other layers", () => { const input = fixture( [move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)], [0, 0, 0, 0], ); input.weights = 0x3d3; input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation. expect(chooseTrainerMove(input).index).toBe(0); }); it("validates Smart effects across a supplied full trainer party", () => { expect(() => validateTrainerAI(0x3d3, [ C.EFFECT_NORMAL_HIT, C.EFFECT_ATTACK_DOWN, C.EFFECT_DEFENSE_DOWN, ]), ).not.toThrow(); expect(() => validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]), ).not.toThrow(); }); it("uses masked random slots and retries blanks without first-slot tie bias", () => { const input = fixture(undefined, [3, 6, 253]); expect(chooseTrainerMove(input)).toEqual({ move: 2, index: 1, scores: [20, 20, 80, 80], enemyTurn: false, }); }); it("reports when AI_Types, AI_Status or AIDamageCalc set hBattleTurn", () => { const run = ( weights: number, moves = [move(1), move(2)], playerTypes = [0, 0], ) => { const input = fixture(moves, [0, 1, 2, 3, 0, 1, 2, 3]); input.weights = weights; input.player.mon.types = playerTypes; input.damage = () => 0; return chooseTrainerMove(input).enemyTurn; }; expect(run(0)).toBe(false); expect(run(4)).toBe(true); // Types expect(run(64)).toBe(true); // Aggressive's damage estimates expect(run(256)).toBe(true); // Status, for damaging moves // A poison move against a Poison type skips the matchup and the write; // a status move with no power checks nothing. const poison = [ move(1, C.EFFECT_POISON, 0), move(2, C.EFFECT_ATTACK_UP, 0), ]; expect(run(256, poison, [C.POISON, C.POISON])).toBe(false); expect(run(256, poison)).toBe(true); }); it("refuses a random source that never produces a candidate", () => { const input = fixture(); input.random = () => 3; expect(() => chooseTrainerMove(input)).toThrow(/random source/); }); it("masks PP Ups and discourages disabled/exhausted moves", () => { const input = fixture([move(1), move(2), move(3)], [0, 1, 2]); input.enemy.mon.moves = [1, 2, 3, 0]; input.enemy.mon.pp = [30, 0xc0, 0xc1, 0]; input.enemy.disabledMove = 1; expect(chooseTrainerMove(input)).toEqual({ move: 3, index: 2, scores: [80, 80, 20, 80], enemyTurn: false, }); }); it("still scores exhausted moves and consumes randomness before Struggle", () => { const input = fixture( [move(1, C.EFFECT_ATTACK_UP, 0), move(2)], [128, 3, 0], ); input.weights = 2; input.enemy.mon.pp.fill(0); expect(chooseTrainerMove(input)).toEqual({ move: 1, index: 0, scores: [78, 80, 80, 80], enemyTurn: false, }); }); it("still selects when the only move is disabled", () => { const input = fixture([move(1)], [3, 0]); input.enemy.mon.moves = [1, 0, 0, 0]; input.enemy.disabledMove = 1; expect(chooseTrainerMove(input)).toEqual({ move: 1, index: 0, scores: [80, 20, 80, 80], enemyTurn: false, }); }); it("refuses empty move lists instead of the cartridge's infinite loop", () => { const input = fixture(); input.enemy.mon.moves.fill(0); expect(() => chooseTrainerMove(input)).toThrow(/nonzero move/); }); it("Basic discourages status on a statused or safeguarded target exactly once", () => { const input = fixture([move(1, C.EFFECT_SLEEP, 0), move(2)], [1]); input.weights = 1; input.player.mon.status = 8; input.player.screens = 1 << C.SCREENS_SAFEGUARD; expect(chooseTrainerMove(input).scores).toEqual([30, 20, 80, 80]); }); it("Basic preserves Nightmare and Future Sight's cartridge redundancy bugs", () => { const input = fixture( [move(1, C.EFFECT_NIGHTMARE, 0), move(2, C.EFFECT_FUTURE_SIGHT)], [0], ); input.weights = 1; input.player.mon.status = 8; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.random = () => 1; input.enemy.screens = 1 << C.SCREENS_UNUSED; input.player.mon.status = 0; input.random = () => 0; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]); }); it("Setup uses each side's first turn and exact 128/30 byte thresholds", () => { const input = fixture( [move(1, C.EFFECT_ATTACK_UP, 0), move(2, C.EFFECT_ATTACK_DOWN, 0)], [128, 30, 0], ); input.weights = 2; input.player.turnsTaken = 1; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([18, 22]); input.random = (() => { const bytes = [127, 29, 1]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); }); it("Types discourages resistance only when another damaging type exists", () => { const input = fixture([move(1, 0, 40, 20), move(2, 0, 0, 21)], [0]); input.weights = 4; input.battleData.typeMatchups = () => [ { attacker: 20, defender: 0, multiplier: 5 }, ]; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.data.move = (id) => id === 1 ? move(1, 0, 40, 20) : move(2, 0, 40, 21); input.random = () => 1; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]); }); it("Types encourages damaging super-effective moves and respects Foresight", () => { const input = fixture([move(1, 0, 40, 0), move(2, 0, 40, 20)], [1]); input.weights = 4; input.player.mon.types = [8, 8]; input.battleData.typeMatchups = () => [ { attacker: 20, defender: 8, multiplier: 20 }, { attacker: 0xfe, defender: 0, multiplier: 0 }, { attacker: 0, defender: 8, multiplier: 0 }, ]; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 19]); input.player.sub = [0, 1 << C.SUBSTATUS_IDENTIFIED, 0, 0, 0, 0]; input.random = () => 1; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 19]); }); it("Status discourages poison against Poison and damaging immunities", () => { const input = fixture([move(1, C.EFFECT_POISON, 0), move(2)], [0]); input.weights = 256; input.player.mon.types = [3, 3]; input.battleData.typeMatchups = () => [ { attacker: 0, defender: 3, multiplier: 0 }, ]; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]); }); it("Cautious stops the entire layer on its first failed random check", () => { const input = fixture(undefined, [230, 1]); input.weights = 128; input.enemy.turnsTaken = 1; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.random = (() => { const bytes = [229, 230, 1]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]); }); it("Offensive penalizes non-damaging moves by two", () => { const input = fixture([move(1, C.EFFECT_ATTACK_DOWN, 0), move(2)], [1]); input.weights = 8; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([22, 20]); }); it("Opportunist discourages stall moves at low HP with the exact half thresholds", () => { const input = fixture(undefined, [0]); input.weights = 32; input.enemy.mon.hp = 11; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.enemy.mon.hp = 5; input.random = () => 0; expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 21]); input.enemy.mon.hp = 8; input.random = (() => { const bytes = [127, 0]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.random = (() => { const bytes = [128, 0]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 21]); }); it("Aggressive keeps the last strongest move and spares reckless alternatives", () => { const input = fixture( [move(1), move(2, C.EFFECT_SELFDESTRUCT), move(3)], [1], ); input.weights = 64; input.enemy.mon.moves = [1, 2, 3, 0]; input.enemy.mon.pp = [30, 30, 30, 0]; input.damage = (id) => (id === 1 ? 5 : 10); expect(chooseTrainerMove(input).scores.slice(0, 3)).toEqual([21, 20, 20]); }); it("Risky uses strict KO damage and applies its max-HP and random exclusions", () => { const input = fixture([move(1, C.EFFECT_SELFDESTRUCT), move(2)], [1]); input.weights = 512; input.player.mon.hp = 10; input.damage = (id) => (id === 1 ? 100 : 10); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.enemy.mon.hp = 19; input.random = (() => { const bytes = [199, 1]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]); input.random = (() => { const bytes = [200, 1]; return () => bytes.shift()!; })(); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([15, 20]); }); it("Smart Encore reads the move struct of hBattleTurn's side, not the player's last move", () => { const input = fixture( [move(1, C.EFFECT_ENCORE, 0), move(2), move(3, C.EFFECT_NORMAL_HIT, 40, 7)], [100, 0], ); input.weights = 16; input.turn = 0; input.enemy.mon.stats = [20, 20, 30, 20, 20]; input.player.lastMove = 3; input.player.move = { type: 9 }; // Type 7 (the last move) would be neutral and discourage Encore; the // player's struct type 9 is immune, so Encore is encouraged instead. input.battleData.typeMatchups = () => [{ attacker: 9, defender: 0, multiplier: 0 }]; expect(chooseTrainerMove(input)).toEqual({ move: 1, index: 0, scores: [18, 20, 80, 80], enemyTurn: false, }); }); it("Smart Conversion2 writes the player's move struct type and leaves the player's turn", () => { const input = fixture([move(1, C.EFFECT_CONVERSION2, 0), move(2)], [200, 0]); input.weights = 16; input.conversion2Type = 5; input.player.move = { type: 9 }; input.enemy.sub[1] = 1 << C.SUBSTATUS_IDENTIFIED; // Foresight on the enemy (the side opposite the player's turn) stops // before the Ghost rows, so only the first row applies. input.battleData.typeMatchups = () => [ { attacker: 5, defender: 0, multiplier: 20 }, { attacker: 0xfe, defender: 0xfe, multiplier: 0 }, { attacker: 5, defender: 0, multiplier: 0 }, ]; expect(chooseTrainerMove(input)).toEqual({ move: 1, index: 0, scores: [19, 20, 80, 80], enemyTurn: false, playerTurn: true, }); expect(input.player.move.type).toBe(5); }); });