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:
1 parent
32d736ecae
commit
77704fe848
11 files changed
+682
-71
No files matched your search
+48
-37
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user