Files
Crystal/tests/battle-ai.test.ts
Sébastien QUEROLandClaude Opus 5.5 77704fe848 Port the Smart AI effect handlers (#28)
Translate every AI_Smart_EffectHandlers entry, so trainers with the Smart layer are no longer refused. The AI's type checks keep the CheckTypeMatchup bug: they read the move type and Foresight flag through hBattleTurn, and Conversion2's write to the player's move type persists.

The reference emulator runs the unmodified AI_Smart routine over 6,024 states per browser, comparing scores, Random consumption, hBattleTurn and the player's move type. Validated with 579 unit tests, 151 browser tests, native/reference builds and reference/vendor/release checks. Kurt's balls, Park and Safari balls and special battle types remain on #28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:30:32 +02:00

387 lines
13 KiB
TypeScript

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);
});
});