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
+59
-1
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user