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

+1 -1
View File
@@ -98,7 +98,7 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party. `game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party.
`game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky layers. It preserves the original random tie selection, known redundancy quirks, HP thresholds, exclusions and the deterministic pre-variation damage estimate used by Aggressive and Risky. Smart runs only for move effects that have no `AI_Smart_EffectHandlers` entry (Normal Hit, Attack Down, Defense Down; enough for the first rival). Other effects are refused before any random draw. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending. `game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky layers. It preserves the original random tie selection, known redundancy quirks, HP thresholds, exclusions and the deterministic pre-variation damage estimate used by Aggressive and Risky. `game/battle/smart-ai.ts` translates every `AI_Smart_EffectHandlers` entry, including its random thresholds, byte score wrapping and the `CheckTypeMatchup` bug: the AI's type checks read the move struct and Foresight flag through `hBattleTurn`, so they can use the player's struct, and Conversion2's write to it persists. `tests/reference-smart-ai.spec.ts` runs the unmodified `AI_Smart` routine in the reference emulator for all 251 moves, comparing scores, Random consumption, `hBattleTurn` and the player's move type. Synthetic scoring vectors cover the other layers.
`loadtrainer`, `winlosstext` and `startbattle` run scripted trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script. `loadtrainer`, `winlosstext` and `startbattle` run scripted trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script.
+9 -2
View File
@@ -297,7 +297,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- Exp. Share halves experience and stat-experience bytes before splitting the participant and holder pools. Healthy holders receive the second pool, including a holder that also fought; fainted holders are excluded. Existing traded, trainer, Lucky Egg and Pokérus modifiers still apply to each award. - Exp. Share halves experience and stat-experience bytes before splitting the participant and holder pools. Healthy holders receive the second pool, including a holder that also fought; fainted holders are excluded. Existing traded, trainer, Lucky Egg and Pokérus modifiers still apply to each award.
- Traded Pokémon obey according to the world's Johto badges. Disobedience preserves the original random draws and can ignore orders, nap, hurt itself or use another move; it spends only the alternate move's PP and breaks Encore where the original does. - Traded Pokémon obey according to the world's Johto badges. Disobedience preserves the original random draws and can ignore orders, nap, hurt itself or use another move; it spends only the alternate move's PP and breaks Encore where the original does.
- Consumed healing and confusion berries are removed from a trainer's stored party too, so switching cannot restore them. - Consumed healing and confusion berries are removed from a trainer's stored party too, so switching cannot restore them.
- Local-ROM regressions cover these paths; the reference emulator compares Metal Powder over 320 cases in each browser. The other new routines have source review and unit coverage, without a direct emulator comparison yet. Smart AI handlers remain unfinished; card #28 is still in progress. - Local-ROM regressions cover these paths; the reference emulator compares Metal Powder over 320 cases in each browser. The other new routines have source review and unit coverage, without a direct emulator comparison yet.
## Implemented: Smart AI
- Every `AI_Smart_EffectHandlers` entry is translated, so trainers with the Smart layer are no longer refused. The handlers read battle history, stat levels, both parties' reserves, the switch score and the weather.
- The AI's type checks keep the original `CheckTypeMatchup` bug: the move type and Foresight flag come from whichever side `hBattleTurn` names, so Encore and the switch score can read the player's move struct. Conversion2's write to the player's move type persists, as in the original.
- `tests/reference-smart-ai.spec.ts` runs the unmodified `AI_Smart` routine in the reference emulator over 6,024 states (all 251 moves, random battle state, either starting turn), comparing scores, Random consumption, `hBattleTurn` and the player's move type. A one-off run over 18,072 more states also matched in both browsers.
- Card #28 stays in progress for Kurt's balls, the Park and Safari balls and the special battle types.
## Native battles: current state ## Native battles: current state
@@ -381,7 +388,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game. Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Next: the remaining battle commands, move effects and Smart AI handlers (board #28). Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial, the PC, the introduction's naming and clock screens, and switching and healing items in battle run now. The opening milestone is complete within the recorded acceptance boundaries. The start menu, party menu and pack now run outside battle, and Pokémon evolve. Field moves from the menu, SEL/Select and MOVE PKMN W/O MAIL run too, and the Pokégear's clock, map and phone cards, with calls to Mom and Elm, Mom's bank at home and her purchases, the trainer card with the play time, the options, Fly, Dig, Teleport and the Escape Rope, and Headbutt and Rock Smash with their encounters, every remaining field item, the fishing rods, MAIL, moving items with Select and the MOVE screen's other Pokémon. Next: Kurt's balls, the Park and Safari balls and the special battle types (board #28).
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior. Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
+26
View File
@@ -1224,3 +1224,29 @@ move list or loop indefinitely. Johto badge stat boosts are still absent;
the new badge option implements obedience only. Do not claim the battle the new badge option implements obedience only. Do not claim the battle
engine or the full card is complete. Delivery is tracked by the commits engine or the full card is complete. Delivery is tracked by the commits
referencing card #28; its commit list includes the recovered groups above. referencing card #28; its commit list includes the recovered groups above.
## Smart AI handlers: 2026-10-03
Codex resumed card #28 after `32d736e` and stopped at its usage limit at
13:21 UTC with an uncommitted Smart AI translation (`smart-ai.ts`), partial
battle wiring and a failing emulator spec. Claude Code took it over.
- The spec showed 35 of 6,024 states differing, in five handlers. Encore,
Roar/Whirlwind, Baton Pass and Perish Song came from `CheckTypeMatchup`
ignoring the type in `a`: it reads the move type and Foresight flag through
`hBattleTurn`. The AI now tracks the turn and both move structs' types;
`checkPlayerMoveTypeMatchups` takes the type and flag. Conversion2's write
to the player's move type persists in the battle.
- Psych Up sums `NUM_LEVEL_STATS` (8) bytes. The eighth is only ever reset to
7 or copied, so the 7-entry sum is equivalent; the spec now writes it as 7.
- The spec randomizes the starting turn and the player's move type and also
compares them afterwards. It passes 6,024 states per browser; a one-off
run with another seed passed 18,072 per browser.
Validation: 579 unit tests passed (3 skipped); without the ROM 138 passed,
444 skipped. Browser suite: 151 passed, 19 intentional skips. Both builds,
reference/release/vendor checks and `git diff --check` passed. No CI or
physical-device validation.
Next on #28: Kurt's balls, Park and Safari balls, and special battle types.
Other boundaries listed in the previous section are unchanged.
+48 -37
View File
@@ -3,14 +3,27 @@ import { SYMBOLS } from "../rom/profile";
import { applyTypeMatchups, UnsupportedBattle, type BattleMon } from "./battle"; import { applyTypeMatchups, UnsupportedBattle, type BattleMon } from "./battle";
import type { BattleData } from "./data"; import type { BattleData } from "./data";
import { C } from "./constants"; import { C } from "./constants";
import { scoreSmartMoves } from "./smart-ai";
/** Structural view of Side. The caller skips AI for lock-in. Struggle is /** Structural view of Side. The caller skips AI for lock-in. Struggle is
* decided later by ParseEnemyAction, after AI has consumed its random bytes. */ * decided later by ParseEnemyAction, after AI has consumed its random bytes. */
export interface TrainerAISide { export interface TrainerAISide {
mon: Pick< mon: Pick<
BattleMon, 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[]; sub: readonly number[];
screens: number; screens: number;
turnsTaken: number; turnsTaken: number;
@@ -25,38 +38,33 @@ export interface TrainerAIInput {
battleData: Pick<BattleData, "byteList" | "typeMatchups">; battleData: Pick<BattleData, "byteList" | "typeMatchups">;
weather: number; weather: number;
random: () => number; random: () => number;
/** hBattleTurn before AIChooseMove; the AI layers may change it. */
turn?: 0 | 1;
/** AIDamageCalc: maximum pre-variation damage for an enemy move. */ /** AIDamageCalc: maximum pre-variation damage for an enemy move. */
damage: (move: number) => number; 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, // AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries. // 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. // 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( export function validateTrainerAI(
weights: number, weights: number,
moveEffects: readonly number[] = [], _moveEffects: readonly number[] = [],
): void { ): void {
if (!Number.isInteger(weights) || weights < 0 || weights > 0xffff) if (!Number.isInteger(weights) || weights < 0 || weights > 0xffff)
throw new UnsupportedBattle( throw new UnsupportedBattle(
`Trainer AI layers 0x${weights.toString(16)} are not translated.`, `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) => const has = (side: TrainerAISide, byte: number, bit: number) =>
Boolean(side.sub[byte] & (1 << bit)); Boolean(side.sub[byte] & (1 << bit));
@@ -122,15 +130,6 @@ function redundant(effect: number, input: TrainerAIInput): boolean {
case C.EFFECT_FUTURE_SIGHT: case C.EFFECT_FUTURE_SIGHT:
return screen(e, C.SCREENS_UNUSED); return screen(e, C.SCREENS_UNUSED);
case C.EFFECT_ATTRACT: { 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 gender = (side: TrainerAISide) => {
const ratio = input.data.baseData(side.mon.species).genderRatio; const ratio = input.data.baseData(side.mon.species).genderRatio;
if (ratio === 255) return -1; 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); ((side.mon.dvs >> 8) & 0xf0) | ((side.mon.dvs >> 4) & 0xf);
return value > ratio ? 1 : 0; return value > ratio ? 1 : 0;
}; };
const pg = gender(p), const [pg, eg] = input.genders ?? [gender(p), gender(e)];
eg = gender(e);
return pg < 0 || eg < 0 || pg === eg || has(p, 1, C.SUBSTATUS_IN_LOVE); return pg < 0 || eg < 0 || pg === eg || has(p, 1, C.SUBSTATUS_IN_LOVE);
} }
default: default:
@@ -156,15 +154,17 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move: number; move: number;
index: number; index: number;
scores: number[]; scores: number[];
/** Whether AI_Types, AI_Status or AIDamageCalc set hBattleTurn to the /** Whether the AI layers left hBattleTurn on the enemy, which the battle
* enemy, which the battle menu then still holds. */ * menu then still holds. */
enemyTurn: boolean; enemyTurn: boolean;
/** Whether Smart AI left it on the player (Conversion2). */
playerTurn?: true;
} { } {
validateTrainerAI(input.weights); validateTrainerAI(input.weights);
const { player, enemy, data, battleData, weights, random } = input; const { player, enemy, data, battleData, weights, random } = input;
let enemyTurn = false; let turn: 0 | 1 | undefined;
const damage = (move: number) => { const damage = (move: number) => {
enemyTurn = true; turn = 1;
return input.damage(move); return input.damage(move);
}; };
const moves = enemy.mon.moves.slice(0, 4); const moves = enemy.mon.moves.slice(0, 4);
@@ -223,7 +223,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
}); });
if (weights & 4) { if (weights & 4) {
// AI_Types sets hBattleTurn before each move's matchup. // AI_Types sets hBattleTurn before each move's matchup.
enemyTurn = true; turn = 1;
attributes.forEach((move, i) => { attributes.forEach((move, i) => {
const value = matchup(i); const value = matchup(i);
if (!value) add(i, 10); if (!value) add(i, 10);
@@ -239,6 +239,11 @@ export function chooseTrainerMove(input: TrainerAIInput): {
attributes.forEach((move, i) => { attributes.forEach((move, i) => {
if (!move.power) add(i, 2); 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) { if (weights & 32) {
// AICheckEnemyHalfHP carries only when HP is strictly above half. // AICheckEnemyHalfHP carries only when HP is strictly above half.
const hp = enemy.mon.hp, const hp = enemy.mon.hp,
@@ -302,7 +307,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
// hBattleTurn before BattleCheckTypeMatchup. // hBattleTurn before BattleCheckTypeMatchup.
if (poison && player.mon.types.includes(C.POISON)) add(i, 10); if (poison && player.mon.types.includes(C.POISON)) add(i, 10);
else { else {
enemyTurn = true; turn = 1;
if (!matchup(i)) add(i, 10); if (!matchup(i)) add(i, 10);
} }
} }
@@ -327,7 +332,13 @@ export function chooseTrainerMove(input: TrainerAIInput): {
for (let attempt = 0; attempt < 4096; attempt++) { for (let attempt = 0; attempt < 4096; attempt++) {
const index = random() & 3; const index = random() & 3;
if (index < moves.length && effective[index] === minimum) 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( throw new UnsupportedBattle(
"Trainer move selection random source did not produce a candidate.", "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, player: this.player, enemy: this.enemy,
data: this.options.data, battleData: this.data, weather: this.weather, data: this.options.data, battleData: this.data, weather: this.weather,
random: () => this.random(), random: () => this.random(),
turn: this.turn ? 1 : 0,
damage: (move) => this.enemyAIDamage(move), 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.moveNum = choice.index;
this.enemy.curMove = choice.move; this.enemy.curMove = choice.move;
if (choice.enemyTurn) this.turn = 1; if (choice.enemyTurn) this.turn = 1;
else if (choice.playerTurn) this.turn = 0;
} }
if (!this.checkPlayerLockedIn()) { if (!this.checkPlayerLockedIn()) {
for (;;) { for (;;) {
@@ -5578,12 +5590,14 @@ export class Battle {
return e === 2 ? score + 0x10 : 0; return e === 2 ? score + 0x10 : 0;
} }
/** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE /** CheckPlayerMoveTypeMatchups: wEnemyAISwitchScore from BASE_AI_SWITCH_SCORE
* (10); lower means the enemy is worse off. Matchups use the enemy's move * (10); lower means the enemy is worse off. CheckTypeMatchup reads the move
* type (see checkAbleToSwitch). */ * type and Foresight flag through hBattleTurn; switching runs on the
private checkPlayerMoveTypeMatchups(): number { * enemy's turn (see checkAbleToSwitch), Smart AI on either. */
const data = this.options.data, private checkPlayerMoveTypeMatchups(
identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED), type = this.enemy.move.type,
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), const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified); vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10; let score = 10;
+27
View File
@@ -93,6 +93,33 @@ export const C = {
EFFECT_FAKE_OUT: 141, EFFECT_FAKE_OUT: 141,
EFFECT_MIRROR_COAT: 144, 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) // Moves (constants/move_constants.asm)
RAZOR_WIND: 13, RAZOR_WIND: 13,
GUST: 16, 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 /** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */ * \`npm run check:reference\` can verify each address by name. */
export const BATTLE = { export const BATTLE = {
UsefulMoves: 0x39301,
EncoreMoves: 0x38c85,
RainDanceMoves: 0x390e7,
SunnyDayMoves: 0x39134,
AI_Smart_EffectHandlers: 0x386f2,
DownpourText: 0x817a3, DownpourText: 0x817a3,
SunGotBrightText: 0x817b8, SunGotBrightText: 0x817b8,
SandstormBrewedText: 0x816e4, SandstormBrewedText: 0x816e4,
+49 -4
View File
@@ -25,7 +25,10 @@ const side = (): TrainerAISide => ({
types: [0, 0], types: [0, 0],
species: 1, species: 1,
dvs: 0x8888, dvs: 0x8888,
stats: [20, 20, 20, 20, 20],
level: 20,
}, },
levels: [7, 7, 7, 7, 7, 7, 7],
sub: [0, 0, 0, 0, 0, 0], sub: [0, 0, 0, 0, 0, 0],
screens: 0, screens: 0,
turnsTaken: 0, turnsTaken: 0,
@@ -98,14 +101,14 @@ describe("trainer AI native scoring", () => {
}); });
}, },
); );
it("refuses untranslated Smart effects before any earlier layer consumes randomness", () => { it("scores exhausted Smart moves along with the other layers", () => {
const input = fixture( const input = fixture(
[move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)], [move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)],
[], [0, 0, 0, 0],
); );
input.weights = 0x3d3; input.weights = 0x3d3;
input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation. input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation.
expect(() => chooseTrainerMove(input)).toThrow(/Smart AI for move effect/); expect(chooseTrainerMove(input).index).toBe(0);
}); });
it("validates Smart effects across a supplied full trainer party", () => { it("validates Smart effects across a supplied full trainer party", () => {
expect(() => expect(() =>
@@ -117,7 +120,7 @@ describe("trainer AI native scoring", () => {
).not.toThrow(); ).not.toThrow();
expect(() => expect(() =>
validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]), validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]),
).toThrow(/Smart AI/); ).not.toThrow();
}); });
it("uses masked random slots and retries blanks without first-slot tie bias", () => { it("uses masked random slots and retries blanks without first-slot tie bias", () => {
const input = fixture(undefined, [3, 6, 253]); const input = fixture(undefined, [3, 6, 253]);
@@ -338,4 +341,46 @@ describe("trainer AI native scoring", () => {
})(); })();
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([15, 20]); expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([15, 20]);
}); });
it("Smart Encore reads the move struct of hBattleTurn's side, not the player's last move", () => {
const input = fixture(
[move(1, C.EFFECT_ENCORE, 0), move(2), move(3, C.EFFECT_NORMAL_HIT, 40, 7)],
[100, 0],
);
input.weights = 16;
input.turn = 0;
input.enemy.mon.stats = [20, 20, 30, 20, 20];
input.player.lastMove = 3;
input.player.move = { type: 9 };
// Type 7 (the last move) would be neutral and discourage Encore; the
// player's struct type 9 is immune, so Encore is encouraged instead.
input.battleData.typeMatchups = () => [{ attacker: 9, defender: 0, multiplier: 0 }];
expect(chooseTrainerMove(input)).toEqual({
move: 1,
index: 0,
scores: [18, 20, 80, 80],
enemyTurn: false,
});
});
it("Smart Conversion2 writes the player's move struct type and leaves the player's turn", () => {
const input = fixture([move(1, C.EFFECT_CONVERSION2, 0), move(2)], [200, 0]);
input.weights = 16;
input.conversion2Type = 5;
input.player.move = { type: 9 };
input.enemy.sub[1] = 1 << C.SUBSTATUS_IDENTIFIED;
// Foresight on the enemy (the side opposite the player's turn) stops
// before the Ghost rows, so only the first row applies.
input.battleData.typeMatchups = () => [
{ attacker: 5, defender: 0, multiplier: 20 },
{ attacker: 0xfe, defender: 0xfe, multiplier: 0 },
{ attacker: 5, defender: 0, multiplier: 0 },
];
expect(chooseTrainerMove(input)).toEqual({
move: 1,
index: 0,
scores: [19, 20, 80, 80],
enemyTurn: false,
playerTurn: true,
});
expect(input.player.move.type).toBe(5);
});
}); });
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
test("all Smart AI move handlers match original scores and Random consumption", async ({ page }) => {
test.setTimeout(120000);
const { at, routine } = loadSymbols();
const names = ["hROMBank", "hBattleTurn", "wEnemyAIMoveScores", "wPartyCount", "wOTPartyCount", "wCurBattleMon", "wCurOTMon", "wBattleWeather", "wBattleMode", "wCriticalHit", "wPlayerUsedMoves", "wEnemyMoveStruct", "wPlayerMoveStruct", "wCurDamage", "wTypeModifier", "wAttackMissed", "wLinkMode", "wPartyMon1Species", "wOTPartyMon1Species", "wPartyMon2Species", "wOTPartyMon2Species", "wPartyMon1HP", "wOTPartyMon1HP", "wPartyMon2HP", "wOTPartyMon2HP", "wPartyMon1Status", "wOTPartyMon1Status", "wPartyMon2Status", "wOTPartyMon2Status", "wTempEnemyMonSpecies"];
for (const prefix of ["Player", "Enemy"]) names.push(...["SubStatus1", "StatLevels", "Stats", "Screens", "TurnsTaken", "FuryCutterCount", "RolloutCount", "WrapCount", "PerishCount", "RageCounter", "ProtectCount", "Minimized"].map((s) => `w${prefix}${s}`), `wLast${prefix}Move`, `wLast${prefix}CounterMove`);
names.push("wBattleMonSpecies", "wEnemyMonSpecies");
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
const commands = { smart: routine("AI_Smart"), random: routine("Random") };
await openReference(page);
const result = await page.evaluate(async ({ a, commands }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { Battle } = await import("/src/game/battle/battle.ts");
const { scoreSmartMoves } = await import("/src/game/battle/smart-ai.ts");
const { SYMBOLS } = await import("/src/game/rom/profile.ts");
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
const assets = new CrystalAssets(rom);
// Only Random is replaced: replay fixed bytes while retaining every
// scoring instruction and its ROM data. Stub and stream live in audio RAM.
const patched = rom.slice();
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
const reference = new BinjgbEmulator();
await reference.load(patched, document.createElement("canvas"));
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
const read = (p: number) => reference.readMemory(p);
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
stub.forEach((v, i) => write(0xc080 + i, v));
const { bank, address } = commands.smart;
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
let seed = 0x284ac;
const next = (limit = 256) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; };
const types = [0, 1, 2, 3, 4, 5, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
const failures: unknown[] = [];
let compared = 0;
for (let id = 1; id <= 251; id++) for (let variant = 0; variant < 24; variant++) {
const bytes = Array.from({ length: 256 }, () => next());
let draws = 0;
const party = [{ species: 143, dvs: 0x8888, hp: 100, status: 0 }, { species: 16, hp: next(2) ? 100 : 0, status: next(256) }];
const enemyParty = structuredClone(party);
enemyParty[1].hp = next(2) ? 100 : 0;
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, reward: 0 } });
const internal = battle as unknown as Record<string, any>;
internal.battleMode = 2;
internal.enemySpecies = 143;
const used = variant % 4 === 0 ? [0, 0, 0, 0] : Array.from({ length: 4 }, () => 1 + next(251));
internal.playerUsedMoves = used;
internal.criticalHit = next(2);
internal.weather = next(4);
const state: unknown[] = [];
for (const [sideIndex, side] of [battle.player, battle.enemy].entries()) {
const prefix = sideIndex ? "Enemy" : "Player";
const monAddr = a[sideIndex ? "wEnemyMonSpecies" : "wBattleMonSpecies"];
side.mon = { species: 143, item: 0, moves: sideIndex ? [id, 33, 0, 0] : [...used], pp: [next(), next(), next(), next()], dvs: next(65536), happiness: 70, level: 1 + next(100), status: next(256), hp: [25, 50, 100, 75][next(4)], maxHp: 100, stats: Array.from({ length: 5 }, () => 1 + next(400)), types: [types[next(types.length)], types[next(types.length)]], nickname: "" };
side.sub = [0, next(), next(), next(), next(), next()];
side.levels = Array.from({ length: 7 }, () => 1 + next(13));
side.stats = Array.from({ length: 5 }, () => 1 + next(400));
const fields = { screens: "Screens", turnsTaken: "TurnsTaken", furyCutterCount: "FuryCutterCount", rolloutCount: "RolloutCount", wrapCount: "WrapCount", perishCount: "PerishCount", rageCounter: "RageCounter", protectCount: "ProtectCount", minimized: "Minimized" };
for (const [key, suffix] of Object.entries(fields)) { (side as any)[key] = next(5); write(a[`w${prefix}${suffix}`], (side as any)[key]); }
side.lastMove = variant % 3 === 0 ? 0 : used[0];
side.lastCounterMove = variant % 5 === 0 ? 0 : used[0];
write(a[`wLast${prefix}Move`], side.lastMove);
write(a[`wLast${prefix}CounterMove`], side.lastCounterMove);
write(monAddr, side.mon.species); write(monAddr + 1, 0);
side.mon.moves.forEach((m, i) => write(monAddr + 2 + i, m));
word(monAddr + 6, side.mon.dvs);
side.mon.pp.forEach((p, i) => write(monAddr + 8 + i, p));
write(monAddr + 13, side.mon.level); write(monAddr + 14, side.mon.status);
word(monAddr + 16, side.mon.hp); word(monAddr + 18, side.mon.maxHp);
side.mon.stats.forEach((s, i) => word(monAddr + 20 + i * 2, s));
side.mon.types.forEach((t, i) => write(monAddr + 30 + i, t));
side.sub.slice(1).forEach((v, i) => write(a[`w${prefix}SubStatus1`] + i, v));
side.levels.forEach((v, i) => write(a[`w${prefix}StatLevels`] + i, v));
// The eighth NUM_LEVEL_STATS byte is only ever reset to 7 or copied.
write(a[`w${prefix}StatLevels`] + 7, 7);
side.stats.forEach((v, i) => word(a[`w${prefix}Stats`] + i * 2, v));
state.push(structuredClone(side));
}
for (const prefix of ["", "OT"]) {
const mons = prefix ? enemyParty : party;
write(a[`w${prefix}PartyCount`], 2);
mons.forEach((mon, i) => { write(a[`w${prefix}PartyMon${i + 1}Species`], mon.species); word(a[`w${prefix}PartyMon${i + 1}HP`], mon.hp); write(a[`w${prefix}PartyMon${i + 1}Status`], mon.status); });
}
write(a.wCurBattleMon, 0); write(a.wCurOTMon, 0); write(a.wTempEnemyMonSpecies, 143);
write(a.wBattleMode, 2); write(a.wBattleWeather, internal.weather); write(a.wCriticalHit, internal.criticalHit);
write(a.wLinkMode, 0); write(a.wTypeModifier, 10); write(a.wAttackMissed, 0); word(a.wCurDamage, 0);
used.forEach((v, i) => write(a.wPlayerUsedMoves + i, v));
const initialTurn = next(2) as 0 | 1;
write(a.hBattleTurn, initialTurn);
battle.player.move.type = types[next(types.length)];
write(a.wPlayerMoveStruct + 3, battle.player.move.type);
const scores = [20, 20, 80, 80];
scores.forEach((s, i) => write(a.wEnemyAIMoveScores + i, s));
const input = { weights: 16, player: battle.player, enemy: battle.enemy, data: assets.pokemon, battleData: internal.data, weather: internal.weather, random: () => bytes[draws++], playerUsedMoves: used, playerHasReserve: Boolean(party[1].hp), enemyHasReserve: Boolean(enemyParty[1].hp), enemyParty, conversion2Type: assets.reader.byte(SYMBOLS.moves + 255 * 7 + 3), damage: (move: number) => internal.enemyAIDamage(move), switchScore: (type: number, identified: boolean) => internal.checkPlayerMoveTypeMatchups(type, identified) };
let turn = initialTurn;
scoreSmartMoves(input, (i, n) => { scores[i] = (scores[i] + n) & 255; }, { get: () => turn, set: (v) => { turn = v; } }, (m) => { turn = 1; return input.damage(m); });
bytes.forEach((v, i) => write(0xc100 + i, v));
write(0xc0e0, 0); write(0xc0f0, 0);
program.forEach((v, i) => write(0xc000 + i, v));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw Error(`AI_Smart did not return for ${id}`);
const original = [0, 1, 2, 3].map((i) => read(a.wEnemyAIMoveScores + i));
const playerType = read(a.wPlayerMoveStruct + 3);
if (original.some((s, i) => s !== scores[i]) || read(0xc0e0) !== draws || read(a.hBattleTurn) !== turn || playerType !== battle.player.move.type)
failures.push({ id, variant, effect: assets.pokemon.move(id).effect, original, scores, originalDraws: read(0xc0e0), draws, originalTurn: read(a.hBattleTurn), turn, initialTurn, originalPlayerType: playerType, playerType: battle.player.move.type, state, used });
compared++;
}
reference.dispose();
return { compared, failures: failures.map(({ state, ...rest }: any) => rest), first: failures[0], count: failures.length };
}, { a: addresses, commands });
expect(result.compared).toBe(6024);
expect(result, JSON.stringify(result)).toMatchObject({ count: 0, failures: [] });
});
+18 -21
View File
@@ -283,26 +283,23 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
expect(result.texts).toContain("was defeated!"); expect(result.texts).toContain("was defeated!");
}, },
); );
it("refuses untranslated Smart effects, including reserves, and empty parties before mutation", () => { it("refuses an empty trainer party before mutation", () => {
for (const kind of ["empty", "ai", "reserve"] as const) { const { options } = setup();
const { options } = setup(); options.trainer!.party = [];
if (kind === "empty") options.trainer!.party = []; const before = structuredClone(options.party);
else { let draws = 0;
options.trainer!.aiFlags |= 1 << 4; options.random = () => { draws++; return 0; };
const unsupported = structuredClone(options.trainer!.party[0]); expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
unsupported.moves = [115, 0, 0, 0]; // Reflect has an untranslated Smart handler. expect(options.party).toEqual(before);
if (kind === "reserve") options.trainer!.party.push(unsupported); expect(options.pokedex.seen.size).toBe(0);
else options.trainer!.party[0] = unsupported; expect(draws).toBe(0);
} });
const before = structuredClone(options.party); it("completes a battle against Smart AI with a handled status move", () => {
const enemyBefore = structuredClone(options.trainer!.party); const { options } = setup();
let draws = 0; options.trainer!.aiFlags |= 1 << 4;
options.random = () => { draws++; return 0; }; options.trainer!.party[0].moves = [115, 33, 0, 0]; // Reflect, Tackle
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle); const result = finish(new Battle(options));
expect(options.party).toEqual(before); expect(result.result).toBe(0);
expect(options.trainer!.party).toEqual(enemyBefore); expect(result.texts).toContain("was defeated!");
expect(options.pokedex.seen.size).toBe(0);
expect(draws).toBe(0);
}
}); });
}); });