Add the AI_Opportunist, AI_Aggressive and AI_Risky scoring layers with the ROM's stall, reckless and risky tables, HP thresholds, last-wins ties and strict knockout test. AIDamageCalc is ported as a pre-variation damage estimate, including a critical-hit flag left over from an earlier move, which the original also reads. Smart remains the only refused layer, so the rival stays gated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
9.6 KiB
TypeScript
278 lines
9.6 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,
|
|
},
|
|
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]);
|
|
});
|
|
});
|