Add scripted trainer battle foundation and native move AI
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-24 09:33:33 +02:00
1 parent 6a74f59e46
commit 3e92936cb9
20 files changed
+1417 -32

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
import type { PokemonData } from "../rom/pokemon";
import { SYMBOLS } from "../rom/profile";
import { applyTypeMatchups, UnsupportedBattle, type BattleMon } from "./battle";
import type { BattleData } from "./data";
import { C } from "./constants";
/** 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"
>;
sub: readonly number[];
screens: number;
turnsTaken: number;
disableCount: number;
disabledMove: number;
}
export interface TrainerAIInput {
weights: number;
player: TrainerAISide;
enemy: TrainerAISide;
data: Pick<PokemonData, "move" | "baseData">;
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
weather: number;
random: () => number;
}
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
const SUPPORTED = 0xfd8f;
export function validateTrainerAI(weights: number): void {
if (
!Number.isInteger(weights) ||
weights < 0 ||
weights > 0xffff ||
weights & ~SUPPORTED
)
throw new UnsupportedBattle(
`Trainer AI layers 0x${weights.toString(16)} are not translated.`,
);
}
const has = (side: TrainerAISide, byte: number, bit: number) =>
Boolean(side.sub[byte] & (1 << bit));
const screen = (side: TrainerAISide, bit: number) =>
Boolean(side.screens & (1 << bit));
/** AI_Redundant, including the original Nightmare and Future Sight bugs. */
function redundant(effect: number, input: TrainerAIInput): boolean {
const { player: p, enemy: e, weather } = input;
switch (effect) {
case C.EFFECT_DREAM_EATER:
return !(p.mon.status & 7);
case C.EFFECT_HEAL:
case C.EFFECT_MORNING_SUN:
case C.EFFECT_SYNTHESIS:
case C.EFFECT_MOONLIGHT:
return e.mon.hp === e.mon.maxHp;
case C.EFFECT_LIGHT_SCREEN:
return screen(e, C.SCREENS_LIGHT_SCREEN);
case C.EFFECT_MIST:
return has(e, 4, C.SUBSTATUS_MIST);
case C.EFFECT_FOCUS_ENERGY:
return has(e, 4, C.SUBSTATUS_FOCUS_ENERGY);
case C.EFFECT_CONFUSE:
return has(p, 3, C.SUBSTATUS_CONFUSED) || screen(p, C.SCREENS_SAFEGUARD);
case C.EFFECT_TRANSFORM:
return has(e, 5, C.SUBSTATUS_TRANSFORMED);
case C.EFFECT_REFLECT:
return screen(e, C.SCREENS_REFLECT);
case C.EFFECT_SUBSTITUTE:
return has(e, 4, C.SUBSTATUS_SUBSTITUTE);
case C.EFFECT_LEECH_SEED:
return has(p, 4, C.SUBSTATUS_LEECH_SEED);
case C.EFFECT_DISABLE:
return p.disableCount !== 0;
case C.EFFECT_ENCORE:
return has(p, 5, C.SUBSTATUS_ENCORED);
case C.EFFECT_SNORE:
case C.EFFECT_SLEEP_TALK:
return !(e.mon.status & 7);
case C.EFFECT_MEAN_LOOK:
return has(e, 5, C.SUBSTATUS_CANT_RUN);
case C.EFFECT_NIGHTMARE:
return !p.mon.status || has(p, 1, C.SUBSTATUS_NIGHTMARE);
case C.EFFECT_SPIKES:
return screen(p, C.SCREENS_SPIKES);
case C.EFFECT_FORESIGHT:
return has(p, 1, C.SUBSTATUS_IDENTIFIED);
case C.EFFECT_PERISH_SONG:
return has(p, 1, C.SUBSTATUS_PERISH);
case C.EFFECT_SANDSTORM:
return weather === C.WEATHER_SANDSTORM;
case C.EFFECT_SAFEGUARD:
return screen(e, C.SCREENS_SAFEGUARD);
case C.EFFECT_RAIN_DANCE:
return weather === C.WEATHER_RAIN;
case C.EFFECT_SUNNY_DAY:
return weather === C.WEATHER_SUN;
case C.EFFECT_TELEPORT:
return true;
case C.EFFECT_SWAGGER:
return has(p, 3, C.SUBSTATUS_CONFUSED);
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;
if (!ratio) return 1;
if (ratio === 254) return 0;
const value =
((side.mon.dvs >> 8) & 0xf0) | ((side.mon.dvs >> 4) & 0xf);
return value > ratio ? 1 : 0;
};
const pg = gender(p),
eg = gender(e);
return pg < 0 || eg < 0 || pg === eg || has(p, 1, C.SUBSTATUS_IN_LOVE);
}
default:
return false;
}
}
/** AIChooseMove and its implemented scoring layers. Scores are the raw
* pre-normalization bytes. Ties use Random & 3 rejection sampling, preserving
* both slot fairness and the original number of random calls. */
export function chooseTrainerMove(input: TrainerAIInput): {
move: number;
index: number;
scores: number[];
} {
validateTrainerAI(input.weights);
const { player, enemy, data, battleData, weights, random } = input;
const moves = enemy.mon.moves.slice(0, 4);
const end = moves.indexOf(0);
if (end >= 0) moves.length = end;
if (!moves.length)
throw new UnsupportedBattle(
"Trainer move AI requires at least one nonzero move.",
);
const attributes = moves.map((move) => data.move(move));
const scores: number[] = Array.from({ length: 4 }, (_, i) =>
moves[i] === enemy.disabledMove || !(enemy.mon.pp[i] & 0x3f) ? 80 : 20,
);
const add = (i: number, value: number) => {
scores[i] = (scores[i] + value) & 255;
};
const matchups = battleData.typeMatchups();
const matchup = (i: number) =>
applyTypeMatchups(matchups, {
damage: 0,
modifier: 0,
moveType: attributes[i].type,
userTypes: enemy.mon.types,
targetTypes: player.mon.types,
identified: has(player, 1, C.SUBSTATUS_IDENTIFIED),
}).matchup;
if (weights & 1) {
const statusOnly = battleData.byteList(SYMBOLS.statusOnlyEffects);
attributes.forEach((move, i) => {
if (
redundant(move.effect, input) ||
(statusOnly.includes(move.effect) &&
(player.mon.status !== 0 || screen(player, C.SCREENS_SAFEGUARD)))
)
add(i, 10);
});
}
if (weights & 2)
attributes.forEach((move, i) => {
const effect = move.effect;
const up =
(effect >= C.EFFECT_ATTACK_UP && effect <= C.EFFECT_EVASION_UP) ||
(effect >= C.EFFECT_ATTACK_UP_2 && effect <= C.EFFECT_EVASION_UP_2);
const down =
(effect >= C.EFFECT_ATTACK_DOWN && effect <= C.EFFECT_EVASION_DOWN) ||
(effect >= C.EFFECT_ATTACK_DOWN_2 && effect <= C.EFFECT_EVASION_DOWN_2);
if (!up && !down) return;
const turns = up ? enemy.turnsTaken : player.turnsTaken;
if (!turns) {
if (random() >= 128) add(i, -2);
} else if (random() >= 30) add(i, 2);
});
if (weights & 4)
attributes.forEach((move, i) => {
const value = matchup(i);
if (!value) add(i, 10);
else if (value > 10 && move.power) add(i, -1);
else if (
value < 10 &&
attributes.some((other) => other.type !== move.type && other.power)
)
add(i, 1);
});
if (weights & 8)
attributes.forEach((move, i) => {
if (!move.power) add(i, 2);
});
if (weights & 128 && enemy.turnsTaken) {
const residual = battleData.byteList(SYMBOLS.residualMoves);
for (let i = 0; i < moves.length; i++) {
if (!residual.includes(moves[i])) continue;
// Original ret nc exits the entire layer, not just this move.
if (random() >= 230) break;
add(i, 1);
}
}
if (weights & 256)
attributes.forEach((move, i) => {
const poison =
move.effect === C.EFFECT_POISON || move.effect === C.EFFECT_TOXIC;
if (
poison ||
move.effect === C.EFFECT_SLEEP ||
move.effect === C.EFFECT_PARALYZE ||
move.power
) {
if ((poison && player.mon.types.includes(C.POISON)) || !matchup(i))
add(i, 10);
}
});
// A zero score would wrap to 255 on the first decrement in the ROM.
const effective = scores.slice(0, moves.length).map((score) => score || 256);
const minimum = Math.min(...effective);
for (let attempt = 0; attempt < 4096; attempt++) {
const index = random() & 3;
if (index < moves.length && effective[index] === minimum)
return { move: moves[index], index, scores };
}
throw new UnsupportedBattle(
"Trainer move selection random source did not produce a candidate.",
);
}
+151 -24
View File
@@ -5,6 +5,7 @@ import { calcStats, type PartyMon } from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
import { BattleData } from "./data";
import { C, COMMAND } from "./constants";
import { chooseTrainerMove, validateTrainerAI } from "./ai";
/** Raised for battle rules that have not been translated. The battle is
* discarded, as if it never started. */
@@ -91,7 +92,17 @@ export type BattleEffect =
| { kind: "stats"; labels: string[]; values: number[] };
export type BattleAction = { action: "fight"; move: number } | { action: "run" };
export type BattleInput = number | BattleAction | undefined;
export type BattleOutcome = { result: number };
export type BattleOutcome = { result: number; prizeMoney?: number };
export interface BattleTrainer {
name: string;
className: string;
party: PartyMon[];
aiFlags: number;
reward: number;
winText?: number;
lossText?: number;
}
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
@@ -104,7 +115,9 @@ export interface BattleOptions {
/** wTimeOfDay: morning 0, day 1, night 2. */
timeOfDay: number;
random: () => number;
wild: { species: number; level: number };
wild?: { species: number; level: number };
trainer?: BattleTrainer;
canLose?: boolean;
text: () => TextContext;
}
@@ -214,9 +227,9 @@ export function applyTypeMatchups(
return { damage, modifier: (modifier & C.STAB_DAMAGE) | matchup, matchup, missed };
}
/** A native translation of Crystal's battle engine for wild battles.
/** A native translation of Crystal's battle engine.
* The move effect scripts are the ROM's own; each battle command is a
* native translation. Link, trainer, contest and item paths are refused. */
* native translation. Unsupported AI, switching, contest and item paths are refused. */
export class Battle {
readonly data: BattleData;
readonly player = new Side();
@@ -255,6 +268,7 @@ export class Battle {
constructor(private options: BattleOptions) {
this.data = new BattleData(options.data.reader);
this.matchups = this.data.typeMatchups();
if (options.trainer) this.battleMode = C.TRAINER_BATTLE;
}
private get reader(): RomReader {
return this.options.data.reader;
@@ -278,6 +292,9 @@ export class Battle {
const base = this.options.text();
return {
...base,
trainer: this.options.trainer
? `${this.options.trainer.className} ${this.options.trainer.name}`.trim()
: base.trainer,
strings: {
...base.strings,
[RAM.battleMonNickname]: this.player.mon?.nickname ?? "",
@@ -358,6 +375,36 @@ export class Battle {
/** LoadEnemyMon for a wild Pokémon from a grass or water table. */
private loadEnemyMon() {
const trainer = this.options.trainer;
if (trainer) {
const mon = trainer.party[0],
base = this.options.data.baseData(mon.species);
this.enemy.mon = {
species: mon.species,
item: mon.item,
moves: [...mon.moves],
dvs: mon.dvs,
pp: [...mon.pp],
happiness: mon.happiness,
level: mon.level,
status: mon.status,
hp: mon.hp,
maxHp: mon.stats[0],
stats: mon.stats.slice(1),
types: [...base.types],
nickname: mon.nickname,
};
this.enemyBase = {
stats: base.stats.slice(0, 5),
catchRate: base.catchRate,
exp: base.baseExp,
};
this.enemy.stats = [...this.enemy.mon.stats];
this.applyStatusEffects(this.enemy);
this.options.pokedex.seen.add(mon.species);
return;
}
if (!this.options.wild) throw new UnsupportedBattle("Missing encounter.");
const { species, level } = this.options.wild;
if (species === C.UNOWN || species === C.MAGIKARP)
throw new UnsupportedBattle("Unown and Magikarp encounters are not ported yet.");
@@ -452,19 +499,65 @@ export class Battle {
// ---------------------------------------------------------------- flow
/** StartBattle through ExitBattle for a wild encounter. */
/** StartBattle through ExitBattle. */
*run(): Gen<BattleOutcome> {
const trainer = this.options.trainer;
if (trainer) {
validateTrainerAI(trainer.aiFlags);
if (trainer.party.length !== 1)
throw new UnsupportedBattle(
"Trainer party switching is not ported yet.",
);
if (
!Number.isInteger(trainer.reward) ||
trainer.reward < 0 ||
trainer.reward > C.MAX_MONEY
)
throw new UnsupportedBattle("Invalid trainer reward.");
}
this.loadEnemyMon();
this.curBattleMon = this.options.party.findIndex((mon) => mon.hp > 0);
if (this.curBattleMon < 0) throw new UnsupportedBattle("No Pokémon can fight.");
yield* this.say(BATTLE.WildPokemonAppearedText);
if (this.curBattleMon < 0)
throw new UnsupportedBattle("No Pokémon can fight.");
if (trainer) {
yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else yield* this.say(BATTLE.WildPokemonAppearedText);
this.participants = 1 << this.curBattleMon;
this.initBattleMon();
const amuletCoin =
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
this.newStatus(this.player);
this.newStatus(this.enemy);
yield* this.say(BATTLE.GoMonText);
yield this.hud();
yield* this.battleTurn();
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
if (this.options.canLose) {
for (const mon of this.options.party) {
mon.hp = mon.stats[0];
mon.status = 0;
mon.pp = mon.moves.map((move, slot) => {
if (!move) return 0;
const ups = mon.pp[slot] >> 6,
base = this.options.data.move(move).pp;
return (
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
);
}) as PartyMon["pp"];
}
this.initBattleMon();
yield this.hud();
}
if (trainer.winText) yield* this.say(trainer.winText);
const prizeMoney = trainer.reward * (amuletCoin ? 2 : 1);
this.numbers[RAM.battleReward] = prizeMoney;
yield* this.say(BATTLE.GotMoneyForWinningText);
return { result: this.battleResult, prizeMoney };
}
if (trainer && this.options.canLose && trainer.lossText)
yield* this.say(trainer.lossText);
return { result: this.battleResult };
}
@@ -474,6 +567,17 @@ export class Battle {
this.player.justGotFrozen = 0;
this.enemy.justGotFrozen = 0;
this.updateBattleMonInParty();
// AIChooseMove runs once before BattleMenu, even with exhausted PP.
if (this.options.trainer && !this.checkEnemyLockedIn()) {
const choice = chooseTrainerMove({
weights: this.options.trainer.aiFlags,
player: this.player, enemy: this.enemy,
data: this.options.data, battleData: this.data, weather: this.weather,
random: () => this.random(),
});
this.enemy.moveNum = choice.index;
this.enemy.curMove = choice.move;
}
if (!this.checkPlayerLockedIn()) {
for (;;) {
const escaped = yield* this.battleMenu();
@@ -574,27 +678,39 @@ export class Battle {
this.parseEnemyAction();
return true;
}
/** ParseEnemyAction for a wild Pokémon: a random usable move. */
/** ParseEnemyAction: use the trainer AI selection or a random wild move. */
private parseEnemyAction() {
const enemy = this.enemy,
mon = enemy.mon;
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
throw new UnsupportedBattle("Locked-in and encored wild moves are not ported yet.");
throw new UnsupportedBattle(
"Locked-in and encored enemy moves are not ported yet.",
);
let move: number = C.STRUGGLE;
for (let slot = 0; slot < 4; slot++) {
const candidate = mon.moves[slot];
if (!candidate) break;
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK)) continue;
for (let guard = 0; guard < 4096; guard++) {
const choice = this.random() & 3;
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
enemy.moveNum = choice;
move = mon.moves[choice];
const usable = mon.moves.some(
(candidate, slot) =>
candidate &&
candidate !== enemy.disabledMove &&
mon.pp[slot] & C.PP_MASK,
);
if (this.options.trainer && usable) {
move = enemy.curMove;
} else
for (let slot = 0; slot < 4; slot++) {
const candidate = mon.moves[slot];
if (!candidate) break;
if (candidate === enemy.disabledMove || !(mon.pp[slot] & C.PP_MASK))
continue;
for (let guard = 0; guard < 4096; guard++) {
const choice = this.random() & 3;
if (((enemy.disableCount >> 4) & 0xf) - 1 === choice) continue;
if (!mon.moves[choice] || !(mon.pp[choice] & C.PP_MASK)) continue;
enemy.moveNum = choice;
move = mon.moves[choice];
break;
}
break;
}
break;
}
enemy.curMove = move;
this.turn = 1;
this.updateMoveData();
@@ -640,9 +756,16 @@ export class Battle {
/** DetermineMoveOrder: true if the player moves first. */
private determineMoveOrder(): boolean {
if (this.battlePlayerAction) return true;
const player = this.data.priority(this.options.data.move(this.player.curMove).effect, C.BASE_PRIORITY);
const enemy = this.data.priority(this.options.data.move(this.enemy.curMove).effect, C.BASE_PRIORITY);
const priority = (move: number, value: number) => (move === C.VITAL_THROW ? 0 : value);
const player = this.data.priority(
this.options.data.move(this.player.curMove).effect,
C.BASE_PRIORITY,
);
const enemy = this.data.priority(
this.options.data.move(this.enemy.curMove).effect,
C.BASE_PRIORITY,
);
const priority = (move: number, value: number) =>
move === C.VITAL_THROW ? 0 : value;
const playerPriority = priority(this.player.curMove, player),
enemyPriority = priority(this.enemy.curMove, enemy);
if (playerPriority !== enemyPriority) return playerPriority > enemyPriority;
@@ -732,6 +855,10 @@ export class Battle {
/** TryToRunAwayFromBattle for a wild battle. Returns true on escape. */
private *tryToRunAway(): Gen<boolean> {
if (this.options.trainer) {
yield* this.say(BATTLE.BattleText_TheresNoEscapeFromTrainerBattle);
return false;
}
if (this.enemy.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.player.wrapCount) {
yield* this.say(BATTLE.BattleText_CantEscape);
return false;
+37
View File
@@ -43,6 +43,40 @@ export const C = {
EFFECT_BEAT_UP: 154,
EFFECT_FLY: 155,
EFFECT_SLEEP: 1,
EFFECT_HEAL: 32,
EFFECT_LIGHT_SCREEN: 35,
EFFECT_MIST: 46,
EFFECT_FOCUS_ENERGY: 47,
EFFECT_CONFUSE: 49,
EFFECT_TRANSFORM: 57,
EFFECT_REFLECT: 65,
EFFECT_SUBSTITUTE: 79,
EFFECT_LEECH_SEED: 84,
EFFECT_DISABLE: 86,
EFFECT_ENCORE: 90,
EFFECT_SLEEP_TALK: 97,
EFFECT_MEAN_LOOK: 106,
EFFECT_NIGHTMARE: 107,
EFFECT_SPIKES: 112,
EFFECT_FORESIGHT: 113,
EFFECT_PERISH_SONG: 114,
EFFECT_SANDSTORM: 115,
EFFECT_ATTRACT: 120,
EFFECT_SAFEGUARD: 124,
EFFECT_RAIN_DANCE: 136,
EFFECT_SUNNY_DAY: 137,
EFFECT_TELEPORT: 153,
EFFECT_MORNING_SUN: 132,
EFFECT_SYNTHESIS: 133,
EFFECT_MOONLIGHT: 134,
EFFECT_POISON: 66,
EFFECT_PARALYZE: 67,
EFFECT_ATTACK_UP: 10,
EFFECT_EVASION_UP: 16,
EFFECT_ATTACK_UP_2: 50,
EFFECT_EVASION_UP_2: 56,
// Moves (constants/move_constants.asm)
GUST: 16,
WHIRLWIND: 18,
@@ -175,6 +209,8 @@ export const C = {
SCREENS_SAFEGUARD: 2,
SCREENS_LIGHT_SCREEN: 3,
SCREENS_REFLECT: 4,
SCREENS_UNUSED: 5,
WEATHER_SANDSTORM: 3,
WEATHER_NONE: 0,
WEATHER_RAIN: 1,
WEATHER_SUN: 2,
@@ -189,6 +225,7 @@ export const C = {
// Battle modes, types and results
WILD_BATTLE: 1,
TRAINER_BATTLE: 2,
HELD_AMULET_COIN: 76,
BATTLETYPE_NORMAL: 0,
BATTLETYPE_DEBUG: 2,
BATTLETYPE_TUTORIAL: 3,