Translate Opportunist, Aggressive and Risky trainer AI

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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 14:36:46 +02:00
1 parent 3e92936cb9
commit 0d1bedb3b1
10 files changed
+294 -11

No files matched your search

+77 -7
View File
@@ -39,6 +39,9 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
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");
@@ -51,20 +54,35 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
},
},
battleData: {
byteList: (address) =>
address === SYMBOLS.statusOnlyEffects
? [C.EFFECT_SLEEP, C.EFFECT_TOXIC, C.EFFECT_POISON, C.EFFECT_PARALYZE]
: [1, 2],
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 untranslated layers, including Rival1, before consuming randomness", () => {
for (const weights of [16, 32, 64, 512, 0x3d3, -1, 65536])
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(0xfd8f)).not.toThrow();
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]);
@@ -204,4 +222,56 @@ describe("trainer AI native scoring", () => {
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]);
});
});