Port the Smart AI effect handlers (#28)

Translate every AI_Smart_EffectHandlers entry, so trainers with the Smart layer are no longer refused. The AI's type checks keep the CheckTypeMatchup bug: they read the move type and Foresight flag through hBattleTurn, and Conversion2's write to the player's move type persists.

The reference emulator runs the unmodified AI_Smart routine over 6,024 states per browser, comparing scores, Random consumption, hBattleTurn and the player's move type. Validated with 579 unit tests, 151 browser tests, native/reference builds and reference/vendor/release checks. Kurt's balls, Park and Safari balls and special battle types remain on #28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 15:30:32 +02:00
1 parent 32d736ecae
commit 77704fe848
11 files changed
+682 -71

No files matched your search

+48 -37
View File
@@ -3,14 +3,27 @@ import { SYMBOLS } from "../rom/profile";
import { applyTypeMatchups, UnsupportedBattle, type BattleMon } from "./battle";
import type { BattleData } from "./data";
import { C } from "./constants";
import { scoreSmartMoves } from "./smart-ai";
/** Structural view of Side. The caller skips AI for lock-in. Struggle is
* decided later by ParseEnemyAction, after AI has consumed its random bytes. */
export interface TrainerAISide {
mon: Pick<
BattleMon,
"moves" | "pp" | "status" | "hp" | "maxHp" | "types" | "species" | "dvs"
"moves" | "pp" | "status" | "hp" | "maxHp" | "types" | "species" | "dvs" | "stats" | "level"
>;
levels: readonly number[];
/** The move struct; Smart AI reads and may write its type. */
move?: { type: number };
lastMove?: number;
lastCounterMove?: number;
furyCutterCount?: number;
rolloutCount?: number;
wrapCount?: number;
perishCount?: number;
rageCounter?: number;
protectCount?: number;
minimized?: number;
sub: readonly number[];
screens: number;
turnsTaken: number;
@@ -25,38 +38,33 @@ export interface TrainerAIInput {
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
weather: number;
random: () => number;
/** hBattleTurn before AIChooseMove; the AI layers may change it. */
turn?: 0 | 1;
/** AIDamageCalc: maximum pre-variation damage for an enemy move. */
damage: (move: number) => number;
playerUsedMoves?: readonly number[];
playerHasReserve?: boolean;
enemyHasReserve?: boolean;
enemyParty?: readonly { hp: number; status: number }[];
/** CheckPlayerMoveTypeMatchups, given what CheckTypeMatchup reads
* through hBattleTurn: a move type and the Foresight flag. */
switchScore?: (moveType: number, identified: boolean) => number;
conversion2Type?: number;
/** Original species/DVs used by CheckOppositeGender after Transform. */
genders?: readonly [number, number];
}
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
// Bits 10-15 point to AI_None in the original and are valid no-ops.
// AI_Smart_EffectHandlers has no entries for these effects: Tackle/Scratch,
// Growl and Leer therefore leave scores and the random stream unchanged.
// Other Smart effects remain refused until their dispatch is translated.
const SMART_NOOP_EFFECTS: ReadonlySet<number> = new Set([
C.EFFECT_NORMAL_HIT,
C.EFFECT_ATTACK_DOWN,
C.EFFECT_DEFENSE_DOWN,
]);
export function validateTrainerAI(
weights: number,
moveEffects: readonly number[] = [],
_moveEffects: readonly number[] = [],
): void {
if (!Number.isInteger(weights) || weights < 0 || weights > 0xffff)
throw new UnsupportedBattle(
`Trainer AI layers 0x${weights.toString(16)} are not translated.`,
);
if (weights & 16) {
const unsupported = moveEffects.find(
(effect) => !SMART_NOOP_EFFECTS.has(effect),
);
if (unsupported !== undefined)
throw new UnsupportedBattle(
`Trainer Smart AI for move effect ${unsupported} is not translated.`,
);
}
}
const has = (side: TrainerAISide, byte: number, bit: number) =>
Boolean(side.sub[byte] & (1 << bit));
@@ -122,15 +130,6 @@ function redundant(effect: number, input: TrainerAIInput): boolean {
case C.EFFECT_FUTURE_SIGHT:
return screen(e, C.SCREENS_UNUSED);
case C.EFFECT_ATTRACT: {
// CheckOppositeGender uses the original party species and backup DVs.
// These are not exposed by this view for transformed combatants.
if (
has(p, 5, C.SUBSTATUS_TRANSFORMED) ||
has(e, 5, C.SUBSTATUS_TRANSFORMED)
)
throw new UnsupportedBattle(
"Trainer Attract AI for transformed combatants is not translated.",
);
const gender = (side: TrainerAISide) => {
const ratio = input.data.baseData(side.mon.species).genderRatio;
if (ratio === 255) return -1;
@@ -140,8 +139,7 @@ function redundant(effect: number, input: TrainerAIInput): boolean {
((side.mon.dvs >> 8) & 0xf0) | ((side.mon.dvs >> 4) & 0xf);
return value > ratio ? 1 : 0;
};
const pg = gender(p),
eg = gender(e);
const [pg, eg] = input.genders ?? [gender(p), gender(e)];
return pg < 0 || eg < 0 || pg === eg || has(p, 1, C.SUBSTATUS_IN_LOVE);
}
default:
@@ -156,15 +154,17 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move: number;
index: number;
scores: number[];
/** Whether AI_Types, AI_Status or AIDamageCalc set hBattleTurn to the
* enemy, which the battle menu then still holds. */
/** Whether the AI layers left hBattleTurn on the enemy, which the battle
* menu then still holds. */
enemyTurn: boolean;
/** Whether Smart AI left it on the player (Conversion2). */
playerTurn?: true;
} {
validateTrainerAI(input.weights);
const { player, enemy, data, battleData, weights, random } = input;
let enemyTurn = false;
let turn: 0 | 1 | undefined;
const damage = (move: number) => {
enemyTurn = true;
turn = 1;
return input.damage(move);
};
const moves = enemy.mon.moves.slice(0, 4);
@@ -223,7 +223,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
});
if (weights & 4) {
// AI_Types sets hBattleTurn before each move's matchup.
enemyTurn = true;
turn = 1;
attributes.forEach((move, i) => {
const value = matchup(i);
if (!value) add(i, 10);
@@ -239,6 +239,11 @@ export function chooseTrainerMove(input: TrainerAIInput): {
attributes.forEach((move, i) => {
if (!move.power) add(i, 2);
});
if (weights & 16)
scoreSmartMoves(input, add, {
get: () => turn ?? input.turn ?? 1,
set: (value) => { turn = value; },
}, damage);
if (weights & 32) {
// AICheckEnemyHalfHP carries only when HP is strictly above half.
const hp = enemy.mon.hp,
@@ -302,7 +307,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
// hBattleTurn before BattleCheckTypeMatchup.
if (poison && player.mon.types.includes(C.POISON)) add(i, 10);
else {
enemyTurn = true;
turn = 1;
if (!matchup(i)) add(i, 10);
}
}
@@ -327,7 +332,13 @@ export function chooseTrainerMove(input: TrainerAIInput): {
for (let attempt = 0; attempt < 4096; attempt++) {
const index = random() & 3;
if (index < moves.length && effective[index] === minimum)
return { move: moves[index], index, scores, enemyTurn };
return {
move: moves[index],
index,
scores,
enemyTurn: turn === 1,
...(turn === 0 ? { playerTurn: true as const } : {}),
};
}
throw new UnsupportedBattle(
"Trainer move selection random source did not produce a candidate.",
+20 -6
View File
@@ -1036,11 +1036,23 @@ export class Battle {
player: this.player, enemy: this.enemy,
data: this.options.data, battleData: this.data, weather: this.weather,
random: () => this.random(),
turn: this.turn ? 1 : 0,
damage: (move) => this.enemyAIDamage(move),
playerUsedMoves: this.playerUsedMoves,
playerHasReserve: this.options.party.some((mon, i) => i !== this.curBattleMon && mon.hp > 0),
enemyHasReserve: this.options.trainer.party.some((mon, i) => i !== this.curOTMon && mon.hp > 0),
enemyParty: this.options.trainer.party,
switchScore: (type, identified) => this.checkPlayerMoveTypeMatchups(type, identified),
conversion2Type: this.reader.byte(SYMBOLS.moves + 255 * 7 + 3),
genders: [
this.options.data.gender(this.partyMon.species, this.partyMon.dvs),
this.options.data.gender(this.enemySpecies, this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED) ? this.enemy.backupDvs : this.enemy.mon.dvs),
].map((gender) => gender === undefined ? -1 : gender === "male" ? 1 : 0) as [number, number],
});
this.enemy.moveNum = choice.index;
this.enemy.curMove = choice.move;
if (choice.enemyTurn) this.turn = 1;
else if (choice.playerTurn) this.turn = 0;
}
if (!this.checkPlayerLockedIn()) {
for (;;) {
@@ -5578,12 +5590,14 @@ export class Battle {
return e === 2 ? score + 0x10 : 0;
}
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
* (10); lower means the enemy is worse off. Matchups use the enemy's move
* type (see checkAbleToSwitch). */
private checkPlayerMoveTypeMatchups(): number {
const data = this.options.data,
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
type = this.enemy.move.type;
* (10); lower means the enemy is worse off. CheckTypeMatchup reads the move
* type and Foresight flag through hBattleTurn; switching runs on the
* enemy's turn (see checkAbleToSwitch), Smart AI on either. */
private checkPlayerMoveTypeMatchups(
type = this.enemy.move.type,
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
): number {
const data = this.options.data;
const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10;
+27
View File
@@ -93,6 +93,33 @@ export const C = {
EFFECT_FAKE_OUT: 141,
EFFECT_MIRROR_COAT: 144,
EFFECT_ACCURACY_DOWN: 23,
EFFECT_BATON_PASS: 127,
EFFECT_BELLY_DRUM: 142,
EFFECT_CONVERSION2: 93,
EFFECT_CURSE: 109,
EFFECT_DEFROST_OPPONENT: 96,
EFFECT_DESTINY_BOND: 98,
EFFECT_EARTHQUAKE: 147,
EFFECT_FLAME_WHEEL: 108,
EFFECT_FORCE_SWITCH: 28,
EFFECT_GUST: 149,
EFFECT_HEAL_BELL: 102,
EFFECT_MAGNITUDE: 126,
EFFECT_MIMIC: 82,
EFFECT_MIRROR_MOVE: 9,
EFFECT_PAIN_SPLIT: 91,
EFFECT_PSYCH_UP: 143,
EFFECT_RAPID_SPIN: 129,
EFFECT_RESET_STATS: 25,
EFFECT_SPEED_DOWN_HIT: 70,
EFFECT_SPITE: 100,
EFFECT_SP_DEF_UP_2: 54,
EFFECT_STOMP: 150,
EFFECT_THIEF: 105,
EFFECT_TWISTER: 146,
EFFECT_UNUSED_2B: 43,
// Moves (constants/move_constants.asm)
RAZOR_WIND: 13,
GUST: 16,
+359
View File
@@ -0,0 +1,359 @@
import type { TrainerAIInput, TrainerAISide } from "./ai";
import { applyTypeMatchups } from "./battle";
import { BATTLE } from "../rom/profile";
import { C } from "./constants";
/** AI_Smart_EffectHandlers. Decisions follow the instructions rather than
* the sometimes inaccurate comments in scoring.asm. Random thresholds and
* byte score wrapping are observable battle behavior. */
export function scoreSmartMoves(
input: TrainerAIInput,
add: (slot: number, amount: number) => void,
turn: { get(): 0 | 1; set(turn: 0 | 1): void },
damage: (move: number) => number,
) {
const { player: p, enemy: e, data, battleData, random: rng } = input;
const moves = e.mon.moves.slice(0, e.mon.moves.indexOf(0) < 0 ? 4 : e.mon.moves.indexOf(0));
const used = input.playerUsedMoves ?? [];
const has = (s: TrainerAISide, byte: number, bit: number) => Boolean(s.sub[byte] & (1 << bit));
const level = (s: TrainerAISide, stat: number) => s.levels[stat];
const above = (s: TrainerAISide, divisor: number) => ((s.mon.hp * divisor) & 0xffff) > s.mon.maxHp;
const full = (s: TrainerAISide) => s.mon.hp === s.mon.maxHp;
const fast = () => e.mon.stats[C.SPEED] > p.mon.stats[C.SPEED];
const list = (address: number) => battleData.byteList(address);
const useful = (move: number) => list(BATTLE.UsefulMoves).includes(move);
const hasEffect = (effect: number) => moves.some((id) => data.move(id).effect === effect);
const reserve = (enemy: boolean) => enemy ? input.enemyHasReserve ?? false : input.playerHasReserve ?? false;
// CheckTypeMatchup ignores the type the AI loads into a: it reads the move
// struct and Foresight flag through hBattleTurn (docs/bugs_and_glitches.md).
// wEnemyMoveStruct holds the last move AIGetEnemyMove or HiddenPowerDamage
// loaded; Conversion2 writes the player's struct type, which persists.
let enemyMoveType = 0;
const playerMove = p.move ?? { type: 0 };
const moveType = () => (turn.get() ? enemyMoveType : playerMove.type);
const identified = () => has(turn.get() ? p : e, 1, C.SUBSTATUS_IDENTIFIED);
const checkTypeMatchup = (target: TrainerAISide) =>
applyTypeMatchups(battleData.typeMatchups(), {
damage: 0, modifier: 0, moveType: moveType(), userTypes: [],
targetTypes: target.mon.types, identified: identified(),
}).matchup;
// BattleCheckTypeMatchup targets the side opposite hBattleTurn.
const matchup = (side: 0 | 1) => {
turn.set(side);
return checkTypeMatchup(side ? p : e);
};
const loadMove = (move: number) => { enemyMoveType = data.move(move).type; };
const switchScore = () => input.switchScore!(moveType(), identified());
const counters = (s: TrainerAISide, key: "furyCutterCount" | "rolloutCount" | "wrapCount" | "perishCount" | "rageCounter" | "protectCount" | "minimized") => s[key] ?? 0;
const last = p.lastCounterMove ?? 0;
const evasive = () => has(p, 3, C.SUBSTATUS_FLYING) || has(p, 3, C.SUBSTATUS_UNDERGROUND);
moves.forEach((id, i) => {
const move = data.move(id), effect = move.effect;
enemyMoveType = move.type;
const change = (n: number) => add(i, n);
const chance = (threshold: number, n: number) => { if (rng() >= threshold) change(n); };
const rollout = () => {
if (has(e, 1, C.SUBSTATUS_IN_LOVE) || has(e, 3, C.SUBSTATUS_CONFUSED) ||
e.mon.status & (1 << C.PAR) || !above(e, 4) || level(e, C.ACCURACY) < 7 || level(p, C.EVASION) >= 8)
chance(50, 1);
else if (rng() < 200) change(-2);
};
const weather = (bad: number, good: number, address: number) => {
for (const type of p.mon.types) {
if (type === bad) return change(3);
if (type === good) {
if (above(p, 2) && (!p.turnsTaken || !e.turnsTaken)) change(-2);
return;
}
}
if (!list(address).some((m) => moves.includes(m)) || !above(p, 2)) return change(3);
chance(128, -1);
};
// EvasionUp and AccuracyDown share the latter part of their routine.
const accuracy = (evasion: boolean) => {
if (evasion && level(e, C.EVASION) >= 13) return change(10);
const s = evasion ? e : p;
if (full(s) && (evasion || above(e, 2))) {
if (has(p, 5, C.SUBSTATUS_TOXIC) || rng() < 178) return change(-2);
} else if (!above(s, 4)) change(2);
else {
if (rng() < 10) return change(-2);
if (above(s, 2)) { if (rng() < 50) return change(-2); }
else if (rng() >= 128) change(2);
}
if (has(p, 5, C.SUBSTATUS_TOXIC)) chance(80, -2);
else if (has(p, 4, C.SUBSTATUS_LEECH_SEED)) chance(128, -1);
else if (level(p, C.ACCURACY) < level(e, C.EVASION)) change(1);
else if (counters(p, "furyCutterCount") || has(p, 1, C.SUBSTATUS_ROLLOUT)) change(-2);
else change(1);
};
switch (effect) {
case C.EFFECT_SLEEP:
if (hasEffect(C.EFFECT_DREAM_EATER) || hasEffect(C.EFFECT_NIGHTMARE)) chance(128, -2);
return;
case C.EFFECT_LEECH_HIT: {
const value = matchup(1);
if (value < 10) chance(100, 1);
else if (value > 10 && !full(e)) chance(50, -1);
return;
}
case C.EFFECT_LOCK_ON:
if (has(p, 5, C.SUBSTATUS_LOCK_ON)) {
moves.forEach((m, slot) => { if (data.move(m).accuracy < 180) add(slot, -2); });
return change(10);
}
if (!above(e, 4) || (!above(e, 2) && !fast())) return change(1);
if (level(p, C.EVASION) >= 10) return chance(128, -2);
if (level(p, C.EVASION) >= 8) return;
if (level(e, C.ACCURACY) < 5) return chance(128, -2);
if (level(e, C.ACCURACY) < 7) return;
if (!moves.some((m) => { loadMove(m); return data.move(m).accuracy < 180 && matchup(1) >= 10; })) change(1);
return;
case C.EFFECT_SELFDESTRUCT:
if ((!reserve(true) && reserve(false)) || above(e, 2)) return change(3);
if (above(e, 4)) chance(20, 3);
return;
case C.EFFECT_DREAM_EATER: return chance(25, -3);
case C.EFFECT_EVASION_UP: return accuracy(true);
case C.EFFECT_ACCURACY_DOWN: return accuracy(false);
case C.EFFECT_ALWAYS_HIT:
if (level(e, C.ACCURACY) < 5 || level(p, C.EVASION) >= 10) chance(50, -2);
return;
case C.EFFECT_MIRROR_MOVE:
if (!last) { if (fast()) change(10); return; }
if (useful(last) && rng() >= 128) { change(-1); if (fast()) chance(25, -1); }
return;
case C.EFFECT_RESET_STATS:
// The original decrements its loop counter before loading: six stats.
if (e.levels.slice(0, 6).some((n) => n < 5) || p.levels.slice(0, 6).some((n) => n >= 10)) chance(40, -1);
else change(1);
return;
case C.EFFECT_BIDE: if (!full(e)) chance(25, 1); return;
case C.EFFECT_FORCE_SWITCH:
case C.EFFECT_BATON_PASS: if (switchScore() >= 10) change(1); return;
case C.EFFECT_HEAL:
case C.EFFECT_MORNING_SUN:
case C.EFFECT_SYNTHESIS:
case C.EFFECT_MOONLIGHT:
if (!above(e, 4)) chance(25, -2); else if (above(e, 2)) change(1);
return;
case C.EFFECT_TOXIC:
case C.EFFECT_LEECH_SEED: if (!above(p, 2)) change(1); return;
case C.EFFECT_LIGHT_SCREEN:
case C.EFFECT_REFLECT: if (!full(e)) chance(20, 1); return;
case C.EFFECT_OHKO:
if (e.mon.level < p.mon.level) change(10); else if (!above(p, 2)) change(1);
return;
case C.EFFECT_TRAP_TARGET:
if (counters(p, "wrapCount")) return chance(128, 1);
if (has(p, 5, C.SUBSTATUS_TOXIC) || (p.sub[1] & ((1 << C.SUBSTATUS_IN_LOVE) | (1 << C.SUBSTATUS_ROLLOUT) | (1 << C.SUBSTATUS_IDENTIFIED) | (1 << C.SUBSTATUS_NIGHTMARE))) || !p.turnsTaken) {
if (above(e, 4)) chance(128, -2);
} else chance(128, 1);
return;
case C.EFFECT_RAZOR_WIND:
case C.EFFECT_UNUSED_2B:
if (has(e, 1, C.SUBSTATUS_PERISH) && counters(e, "perishCount") < 3) return change(1);
if (used.filter(Boolean).some((m) => data.move(m).effect === C.EFFECT_PROTECT)) return change(6);
if (has(e, 3, C.SUBSTATUS_CONFUSED) || !above(e, 2)) chance(200, 1);
return;
case C.EFFECT_CONFUSE:
if (!above(p, 2)) { chance(25, 1); if (!above(p, 4)) change(1); }
return;
case C.EFFECT_SP_DEF_UP_2:
if (!above(e, 2) || level(e, C.SP_DEFENSE) >= 11) return change(1);
if (level(e, C.SP_DEFENSE) < 9 && p.mon.types.some((t) => t >= C.SPECIAL)) chance(50, -2);
return;
case C.EFFECT_FLY: if (evasive() && fast()) change(-3); return;
case C.EFFECT_SUPER_FANG: if (!above(p, 4)) change(1); return;
case C.EFFECT_PARALYZE:
if (!above(p, 4)) chance(128, 1); else if (!fast() && above(e, 4)) chance(50, -2);
return;
case C.EFFECT_SPEED_DOWN_HIT:
if (id === 196 && above(e, 4) && !p.turnsTaken && !fast()) chance(30, -2);
return;
case C.EFFECT_SUBSTITUTE: if (!above(e, 2)) change(10); return;
case C.EFFECT_HYPER_BEAM:
if (above(e, 2)) { if (rng() >= 40) { change(1); chance(128, 1); } }
else if (!above(e, 4)) chance(128, -1);
return;
case C.EFFECT_RAGE:
if (has(e, 4, C.SUBSTATUS_RAGE)) {
chance(128, -1);
if (counters(e, "rageCounter") >= 2) change(-1);
if (counters(e, "rageCounter") >= 3) change(-1);
} else if (!above(e, 2)) change(1); else if (rng() < 50) change(-1);
return;
case C.EFFECT_MIMIC:
if (!last) return change(fast() ? 10 : 1);
if (!above(e, 2)) return change(1);
loadMove(last);
{ const value = matchup(1);
if (value < 10) return change(1);
if (value > 10) chance(128, -1);
if (useful(last)) chance(128, -1);
}
return;
case C.EFFECT_COUNTER:
case C.EFFECT_MIRROR_COAT: {
const suitable = (m: number) => m && data.move(m).power && (data.move(m).type >= C.SPECIAL) === (effect === C.EFFECT_MIRROR_COAT);
const count = used.filter(suitable).length;
if (!count) change(1); else if (count >= 3 || suitable(last)) chance(100, -1);
return;
}
case C.EFFECT_ENCORE: {
if (!fast()) return change(3);
if (!p.lastMove) return change(10);
loadMove(p.lastMove);
if (data.move(p.lastMove).power) {
// CheckTypeMatchup against the enemy, on whichever turn is current.
const value = checkTypeMatchup(e);
if (value < 10) { if (!value) chance(70, -2); return; }
}
if (list(BATTLE.EncoreMoves).includes(last)) chance(70, -2); else change(3);
return;
}
case C.EFFECT_PAIN_SPLIT: if (((e.mon.hp * 2) & 0xffff) > p.mon.hp) change(1); return;
case C.EFFECT_SNORE:
case C.EFFECT_SLEEP_TALK: return change((e.mon.status & 7) === 1 ? 3 : -3);
case C.EFFECT_DEFROST_OPPONENT: if (e.mon.status & (1 << C.FRZ)) change(-3); return;
case C.EFFECT_SPITE: {
if (!last) { if (fast()) change(10); else chance(128, 1); return; }
const slot = p.mon.moves.indexOf(last);
if (slot < 0) return;
const pp = p.mon.pp[slot]; // Deliberately includes PP Ups.
if (pp < 6) chance(100, -2); else if (pp >= 15 || rng() < 100) change(1);
return;
}
case C.EFFECT_DESTINY_BOND:
case C.EFFECT_REVERSAL:
case C.EFFECT_SKULL_BASH: if (above(e, 4)) change(1); return;
case C.EFFECT_HEAL_BELL:
if (!(input.enemyParty ?? [e.mon]).some((m) => m.hp && m.status)) { if (!e.mon.status) change(10); return; }
if (e.mon.status) change(-1);
if (e.mon.status & ((1 << C.FRZ) | 7)) chance(128, -2);
return;
case C.EFFECT_PRIORITY_HIT:
if (fast()) return;
if (evasive()) return change(10);
if (damage(id) > p.mon.hp) change(-3);
return;
case C.EFFECT_THIEF: return change(30);
case C.EFFECT_CONVERSION2:
if (!p.lastMove) {
// The zero move underflows to record 256, a byte from the ROM.
playerMove.type = input.conversion2Type!;
const value = matchup(0);
if (value === 10) return;
if (value > 10) return chance(128, -1);
}
return chance(25, 1);
case C.EFFECT_DISABLE:
if (fast()) { if (useful(last)) return chance(100, -1); if (move.power) return; }
return chance(20, 1);
case C.EFFECT_MEAN_LOOK:
if (!above(e, 2)) return change(1);
if (!reserve(false)) return change(10);
// The original checks the enemy's Toxic flag, not the player's.
if (has(e, 5, C.SUBSTATUS_TOXIC) || (p.sub[1] & ((1 << C.SUBSTATUS_IN_LOVE) | (1 << C.SUBSTATUS_ROLLOUT) | (1 << C.SUBSTATUS_IDENTIFIED) | (1 << C.SUBSTATUS_NIGHTMARE)))) return chance(50, -3);
if (switchScore() < 11) change(1);
return;
case C.EFFECT_NIGHTMARE: return chance(128, -1);
case C.EFFECT_FLAME_WHEEL: if (e.mon.status & (1 << C.FRZ)) change(-5); return;
case C.EFFECT_CURSE:
if (!e.mon.types.includes(C.GHOST)) {
if (!above(e, 2) || level(e, C.ATTACK) >= 11) return change(1);
if (level(e, C.ATTACK) >= 9) return;
if (p.mon.types[0] === C.GHOST) return change(2);
if (p.mon.types.every((t) => t < C.SPECIAL)) chance(50, -2);
return;
}
if (has(p, 1, C.SUBSTATUS_CURSE)) return change(10);
if (!reserve(true) && reserve(false)) return change(4);
if (reserve(true) && !reserve(false)) return chance(128, -2);
if (!above(e, 4)) return change(4);
if (!above(e, 2)) return change(2);
if (full(e) && !p.turnsTaken) chance(128, -2);
return;
case C.EFFECT_PROTECT:
if (counters(e, "protectCount")) { change(1); return chance(20, 2); }
if (!has(p, 5, C.SUBSTATUS_LOCK_ON) && (counters(p, "furyCutterCount") >= 3 || has(p, 3, C.SUBSTATUS_CHARGED) ||
has(p, 5, C.SUBSTATUS_TOXIC) || has(p, 4, C.SUBSTATUS_LEECH_SEED) || has(p, 1, C.SUBSTATUS_CURSE) ||
(has(p, 1, C.SUBSTATUS_ROLLOUT) && counters(p, "rolloutCount") >= 3))) return chance(50, -1);
return chance(20, 2);
case C.EFFECT_FORESIGHT:
if (level(e, C.ACCURACY) < 5 || level(p, C.EVASION) >= 10 || p.mon.types.includes(C.GHOST)) chance(100, -2);
else chance(20, 1);
return;
case C.EFFECT_PERISH_SONG:
if (!reserve(true)) return change(5);
if (has(p, 5, C.SUBSTATUS_CANT_RUN)) return chance(128, -1);
if (switchScore() >= 10) chance(128, 1);
return;
case C.EFFECT_SANDSTORM:
if (p.mon.types.some((t) => t === C.ROCK || t === C.GROUND || t === C.STEEL)) return change(2);
if (!above(p, 2)) change(1); else chance(128, -1);
return;
case C.EFFECT_ENDURE:
if (counters(e, "protectCount") || full(e)) return change(2);
if (above(e, 4)) return change(1);
if (hasEffect(C.EFFECT_REVERSAL)) chance(50, -3);
else if (has(e, 5, C.SUBSTATUS_LOCK_ON)) chance(128, -2);
return;
case C.EFFECT_FURY_CUTTER:
if (counters(e, "furyCutterCount") >= 1) change(-1);
if (counters(e, "furyCutterCount") >= 2) change(-2);
if (counters(e, "furyCutterCount") >= 3) change(-3);
return rollout();
case C.EFFECT_ROLLOUT: return rollout();
case C.EFFECT_SWAGGER:
case C.EFFECT_ATTRACT: if (!p.turnsTaken) { if (rng() < 200) change(-1); } else chance(50, 1); return;
case C.EFFECT_SAFEGUARD: if (!above(p, 2)) chance(50, 1); return;
case C.EFFECT_MAGNITUDE:
case C.EFFECT_EARTHQUAKE:
case C.EFFECT_GUST:
case C.EFFECT_TWISTER: {
const dig = effect === C.EFFECT_MAGNITUDE || effect === C.EFFECT_EARTHQUAKE;
if (last !== (dig ? C.DIG : C.FLY)) return;
if (has(p, 3, dig ? C.SUBSTATUS_UNDERGROUND : C.SUBSTATUS_FLYING)) { if (fast()) change(-2); }
else if (!fast()) chance(128, -1);
return;
}
case C.EFFECT_PURSUIT: if (!above(p, 4)) chance(128, -2); else chance(50, 1); return;
case C.EFFECT_RAPID_SPIN:
if (counters(e, "wrapCount") || has(e, 4, C.SUBSTATUS_LEECH_SEED) || e.screens & (1 << C.SCREENS_SPIKES)) chance(50, -2);
return;
case C.EFFECT_HIDDEN_POWER: {
const ad = e.mon.dvs >> 8, ss = e.mon.dvs & 255;
const bits = ((ad >> 4) & 8) | ((ad & 8) >> 1) | ((ss >> 6) & 2) | ((ss & 8) >> 3);
const power = ((bits * 5 + (ss & 3)) >> 1) + 31;
let type = ((ad & 3) | (((ad >> 4) & 3) << 2)) + 1;
if (type >= C.BIRD) type++;
if (type >= C.UNUSED_TYPES) type += 10;
turn.set(1);
enemyMoveType = type;
const value = matchup(1);
if (value < 10 || power < 50) change(1); else if (value > 10 || power >= 70) change(-1);
return;
}
case C.EFFECT_RAIN_DANCE: return weather(21, C.FIRE, BATTLE.RainDanceMoves);
case C.EFFECT_SUNNY_DAY: return weather(C.FIRE, 21, BATTLE.SunnyDayMoves);
case C.EFFECT_BELLY_DRUM:
if (level(e, C.ATTACK) >= 10) return change(5);
if (!full(e)) { change(1); if (!above(e, 2)) change(5); }
return;
case C.EFFECT_PSYCH_UP:
if (e.levels.reduce((a, b) => a + b, 0) >= p.levels.reduce((a, b) => a + b, 0)) change(2);
else if (level(p, C.ACCURACY) >= 6 && level(e, C.EVASION) < 8) chance(50, -1);
return;
case C.EFFECT_FUTURE_SIGHT: if (fast() && evasive()) change(-2); return;
case C.EFFECT_STOMP: if (counters(p, "minimized")) chance(50, -1); return;
case C.EFFECT_SOLARBEAM:
if (input.weather === C.WEATHER_SUN) chance(50, -2);
else if (input.weather === C.WEATHER_RAIN) chance(25, 2);
return;
case C.EFFECT_THUNDER: if (input.weather === C.WEATHER_SUN) chance(25, 1); return;
// Effects absent from AI_Smart_EffectHandlers are deliberate no-ops.
}
});
}
+6
View File
@@ -584,6 +584,12 @@ export const MENU = {
/** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */
export const BATTLE = {
UsefulMoves: 0x39301,
EncoreMoves: 0x38c85,
RainDanceMoves: 0x390e7,
SunnyDayMoves: 0x39134,
AI_Smart_EffectHandlers: 0x386f2,
DownpourText: 0x817a3,
SunGotBrightText: 0x817b8,
SandstormBrewedText: 0x816e4,