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

+59 -1
View File
@@ -25,11 +25,15 @@ export interface TrainerAIInput {
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
weather: number;
random: () => number;
/** AIDamageCalc: maximum pre-variation damage for an enemy move. */
damage: (move: number) => number;
}
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
const SUPPORTED = 0xfd8f;
// Smart (bit 4) is the only translated pointer still refused. Bits 10-15
// point to AI_None in the original and are valid no-ops.
const SUPPORTED = 0xffef;
export function validateTrainerAI(weights: number): void {
if (
!Number.isInteger(weights) ||
@@ -207,6 +211,46 @@ export function chooseTrainerMove(input: TrainerAIInput): {
attributes.forEach((move, i) => {
if (!move.power) add(i, 2);
});
if (weights & 32) {
// AICheckEnemyHalfHP carries only when HP is strictly above half.
const hp = enemy.mon.hp,
maxHp = enemy.mon.maxHp;
if (hp * 2 <= maxHp) {
// At or below one quarter the layer always runs. Between one quarter
// and one half it runs only on the upper half of Random's byte range.
const discourage = hp * 4 <= maxHp || random() >= 128;
if (discourage) {
const stall = battleData.byteList(SYMBOLS.stallMoves);
moves.forEach((move, i) => {
if (stall.includes(move)) add(i, 1);
});
}
}
}
if (weights & 64) {
// Ties replace the current candidate, so the last strongest slot wins.
let strongest = -1,
highest = 0;
attributes.forEach((move, i) => {
if (!move.power) return;
const damage = input.damage(moves[i]);
if (damage >= highest) {
highest = damage;
strongest = i;
}
});
if (strongest >= 0) {
const reckless = battleData.byteList(SYMBOLS.recklessMoves);
attributes.forEach((move, i) => {
if (
i !== strongest &&
move.power >= 2 &&
!reckless.includes(move.effect)
)
add(i, 1);
});
}
}
if (weights & 128 && enemy.turnsTaken) {
const residual = battleData.byteList(SYMBOLS.residualMoves);
for (let i = 0; i < moves.length; i++) {
@@ -230,6 +274,20 @@ export function chooseTrainerMove(input: TrainerAIInput): {
add(i, 10);
}
});
if (weights & 512) {
const risky = battleData.byteList(SYMBOLS.riskyEffects);
attributes.forEach((move, i) => {
if (!move.power) return;
if (risky.includes(move.effect)) {
if (enemy.mon.hp === enemy.mon.maxHp) return;
// `79 percent - 1` is 200. Bytes below it exclude the move.
if (random() < 200) return;
}
// The original subtracts damage from HP and requires a borrow, so exact
// damage equality is not treated as a knockout here.
if (input.damage(moves[i]) > player.mon.hp) add(i, -5);
});
}
// A zero score would wrap to 255 on the first decrement in the ROM.
const effective = scores.slice(0, moves.length).map((score) => score || 256);
const minimum = Math.min(...effective);
+71
View File
@@ -574,6 +574,7 @@ export class Battle {
player: this.player, enemy: this.enemy,
data: this.options.data, battleData: this.data, weather: this.weather,
random: () => this.random(),
damage: (move) => this.enemyAIDamage(move),
});
this.enemy.moveNum = choice.index;
this.enemy.curMove = choice.move;
@@ -594,6 +595,76 @@ export class Battle {
throw new UnsupportedBattle("The battle did not end.");
}
/** AIDamageCalc: pre-variation damage used only for scoring. It omits
* accuracy and does not roll a critical hit, but, as in the ROM, a
* wCriticalHit left over from an earlier move still selects unboosted
* stats and doubles the estimate. */
private enemyAIDamage(moveId: number): number {
const move = this.options.data.move(moveId);
switch (move.effect) {
case C.EFFECT_SUPER_FANG:
return Math.max(1, this.player.mon.hp >> 1);
case C.EFFECT_STATIC_DAMAGE:
return move.power;
case C.EFFECT_LEVEL_DAMAGE:
return this.enemy.mon.level;
case C.EFFECT_PSYWAVE: {
const limit = this.enemy.mon.level + (this.enemy.mon.level >> 1);
for (let attempt = 0; attempt < 4096; attempt++) {
const damage = this.random();
if (damage && damage < limit) return damage;
}
throw new UnsupportedBattle("Trainer Psywave AI random source did not produce damage.");
}
}
if (!move.power) return 0;
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
const special = move.type >= C.SPECIAL,
attackStat = special ? C.SP_ATTACK : C.ATTACK,
defenseStat = special ? C.SP_DEFENSE : C.DEFENSE,
screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT;
const unboosted =
this.criticalHit && this.player.levels[defenseStat] >= this.enemy.levels[attackStat];
let attack: number, defense: number;
if (unboosted) {
attack = this.enemy.stats[attackStat];
defense = this.player.stats[defenseStat];
} else {
attack = this.enemy.mon.stats[attackStat];
defense = this.player.mon.stats[defenseStat];
if (this.player.screens & (1 << screen)) defense = (defense * 2) & 0xffff;
}
const boosted = special
? this.enemy.mon.species === C.PIKACHU && this.enemy.mon.item === C.LIGHT_BALL
: (this.enemy.mon.species === C.CUBONE || this.enemy.mon.species === C.MAROWAK) && this.enemy.mon.item === C.THICK_CLUB;
if (boosted) attack = (attack * 2) & 0xffff;
[attack, defense] = this.truncate(attack, defense);
if (this.player.mon.species === C.DITTO && this.player.mon.item === C.METAL_POWDER)
throw new UnsupportedBattle("Metal Powder is not ported yet.");
const held = this.data.heldEffect(this.enemy.mon.item),
typeBoost = this.data.typeBoost(held.effect),
boost = held.effect && typeBoost === move.type ? held.parameter : undefined,
baseDamage = calculateDamage({
power: move.power,
level: this.enemy.mon.level,
attack,
defense,
effect: move.effect,
critical: Boolean(this.criticalHit),
boost,
current: 0,
});
if (baseDamage === undefined) return 0;
return applyTypeMatchups(this.matchups, {
damage: baseDamage,
modifier: C.EFFECTIVE,
moveType: move.type,
userTypes: this.enemy.mon.types,
targetTypes: this.player.mon.types,
identified: this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
}).damage;
}
/** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if
* the battle ended from the menu. */
private *battleMenu(): Gen<boolean> {
+5
View File
@@ -15,7 +15,10 @@ export const C = {
EFFECT_MULTI_HIT: 29,
EFFECT_CONVERSION: 30,
EFFECT_TOXIC: 33,
EFFECT_OHKO: 38,
EFFECT_RAZOR_WIND: 39,
EFFECT_SUPER_FANG: 40,
EFFECT_STATIC_DAMAGE: 41,
EFFECT_DOUBLE_HIT: 44,
EFFECT_JUMP_KICK: 45,
EFFECT_ATTACK_DOWN_2: 58,
@@ -54,6 +57,8 @@ export const C = {
EFFECT_SUBSTITUTE: 79,
EFFECT_LEECH_SEED: 84,
EFFECT_DISABLE: 86,
EFFECT_LEVEL_DAMAGE: 87,
EFFECT_PSYWAVE: 88,
EFFECT_ENCORE: 90,
EFFECT_SLEEP_TALK: 97,
EFFECT_MEAN_LOOK: 106,
+3
View File
@@ -8,7 +8,10 @@ export const SYMBOLS = {
trainerClassAttributes: 0x3959c,
trainerClassDVs: 0x270d6,
statusOnlyEffects: 0x385db,
stallMoves: 0x39348,
recklessMoves: 0x393e2,
residualMoves: 0x39446,
riskyEffects: 0x394ff,
mapGroups: 0x94000,
tilesets: 0x4d596,
collisions: 0x4ce1f,