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, }, 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("rejects the untranslated Smart layer, including Rival1, before consuming randomness", () => { for (const weights of [16, 0x3d3, -1, 65536]) expect(() => validateTrainerAI(weights)).toThrow(/not translated/); expect(() => validateTrainerAI(0xffef)).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], }); }); 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], }); }); 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], }); }); 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], }); }); 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]); }); });