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:
1 parent
3e92936cb9
commit
0d1bedb3b1
10 files changed
+294
-11
No files matched your search
+77
-7
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type BattleInput,
|
||||
type BattleOptions,
|
||||
} from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
|
||||
@@ -119,6 +120,40 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
|
||||
run.next({ action: "fight", move: 0 });
|
||||
expect(battle.enemy.curMove).toBe(165);
|
||||
});
|
||||
it("uses the native damage estimate for Aggressive move scoring", () => {
|
||||
const { options } = setup();
|
||||
const enemy = options.trainer!.party[0];
|
||||
enemy.moves = [33, 98, 0, 0]; // Tackle, Quick Attack
|
||||
enemy.pp = enemy.moves.map((move) => move ? assets.pokemon.move(move).pp : 0);
|
||||
options.trainer!.aiFlags = 1 << 6;
|
||||
const battle = new Battle(options), run = battle.run();
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const next = run.next();
|
||||
if (!next.done && next.value.kind === "action") break;
|
||||
}
|
||||
expect(battle.enemy.curMove).toBe(98);
|
||||
});
|
||||
it("lets a leftover critical-hit flag alter the AI damage estimate", () => {
|
||||
const { options } = setup();
|
||||
const battle = new Battle(options), run = battle.run();
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const next = run.next();
|
||||
if (!next.done && next.value.kind === "action") break;
|
||||
}
|
||||
const internals = battle as unknown as {
|
||||
criticalHit: number;
|
||||
enemyAIDamage(move: number): number;
|
||||
};
|
||||
const plain = internals.enemyAIDamage(33); // Tackle
|
||||
battle.player.screens = 1 << C.SCREENS_REFLECT;
|
||||
const screened = internals.enemyAIDamage(33);
|
||||
// CheckDamageStatsCritical: equal stages select unboosted stats, which
|
||||
// ignore Reflect; DamageCalc then doubles the estimate.
|
||||
internals.criticalHit = 1;
|
||||
const critical = internals.enemyAIDamage(33);
|
||||
// Rattata L4 (Atk 10) Tackle into Cyndaquil (Def 18, 36 under Reflect).
|
||||
expect([plain, screened, critical]).toEqual([4, 3, 6]);
|
||||
});
|
||||
it("loses without receiving prize money", () => {
|
||||
const { mon, options } = setup(2);
|
||||
mon.hp = 1;
|
||||
|
||||
Reference in new issue
Block a user