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

+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> {