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

+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;