Open Route 30 with map trainers and multi-Pokémon trainer battles

Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 14:36:54 +02:00
1 parent 0d1bedb3b1
commit e00dfab42e
18 files changed
+1156 -62

No files matched your search

+404 -16
View File
@@ -97,8 +97,12 @@ export type BattleOutcome = { result: number; prizeMoney?: number };
export interface BattleTrainer {
name: string;
className: string;
/** wOTPartyMons: HP and status change only when a Pokémon faints or is
* withdrawn, as in the original. */
party: PartyMon[];
aiFlags: number;
/** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */
switchFlags: number;
reward: number;
winText?: number;
lossText?: number;
@@ -128,6 +132,18 @@ const SUB1 = 1,
SUB5 = 5;
/** "n percent" in the original: n * $ff / 100, rounded down. */
const percent = (n: number) => Math.floor((n * 0xff) / 100);
/** TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm). */
const SWITCH_OFTEN = 1 << 0,
SWITCH_RARELY = 1 << 1,
SWITCH_SOMETIMES = 1 << 2;
/** SwitchOften/SwitchRarely/SwitchSometimes: for each wEnemySwitchMonParam
* tier ($10, $20, $30), a Random threshold and whether a roll below it
* switches (true) or keeps the Pokémon in (false). */
const SWITCH_CHANCES: readonly (readonly [number, boolean])[][] = [
[[percent(50) + 1, true], [percent(79) - 1, true], [percent(4), false]],
[[percent(8), true], [percent(12), true], [percent(79) - 1, false]],
[[percent(20) - 1, true], [percent(50) + 1, true], [percent(20) - 1, false]],
];
export interface TypeMatchup {
attacker: number;
@@ -248,6 +264,13 @@ export class Battle {
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
weather: number = C.WEATHER_NONE;
/** wCurOTMon: the trainer's party index in battle. */
curOTMon = 0;
/** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */
private enemySwitchMonIndex = 0;
private battleHasJustStarted = 0;
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
private playerUsedMoves = [0, 0, 0, 0];
private curDamage = 0;
private criticalHit = 0;
private attackMissed = 0;
@@ -323,6 +346,7 @@ export class Battle {
switch (offset) {
case at("UsedMoveText"): {
// Updates the last move, then picks the grammar for "used".
if (this.turn === 0) this.updateUsedMoves(this.player.move.anim);
const user = this.user;
if (!user.charging) {
user.lastCounterMove = user.move.anim;
@@ -373,11 +397,12 @@ export class Battle {
// --------------------------------------------------------------- setup
/** LoadEnemyMon for a wild Pokémon from a grass or water table. */
/** LoadEnemyMon: the trainer's wCurOTMon, or a wild Pokémon from a grass
* or water table. */
private loadEnemyMon() {
const trainer = this.options.trainer;
if (trainer) {
const mon = trainer.party[0],
const mon = trainer.party[this.curOTMon],
base = this.options.data.baseData(mon.species);
this.enemy.mon = {
species: mon.species,
@@ -476,6 +501,7 @@ export class Battle {
if (mon.status & (1 << C.BRN)) mon.stats[C.ATTACK] = Math.max(1, mon.stats[C.ATTACK] >> 1);
}
private newStatus(side: Side) {
if (side === this.player) this.playerUsedMoves = [0, 0, 0, 0];
side.sub = [0, 0, 0, 0, 0, 0];
side.levels = [7, 7, 7, 7, 7, 7, 7];
side.lastMove = 0;
@@ -504,10 +530,8 @@ export class Battle {
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 (trainer.party.length < 1 || trainer.party.length > 6)
throw new UnsupportedBattle("Invalid trainer party.");
if (
!Number.isInteger(trainer.reward) ||
trainer.reward < 0 ||
@@ -858,16 +882,20 @@ export class Battle {
private *battlePlayerFirst(): Gen {
this.enemyGoesFirst = 0;
this.turn = 1;
const switched = yield* this.aiSwitchOrTryItem();
yield* this.doTurnFor(0);
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
this.turn = 0;
if (!(yield* this.residualDamage())) return yield* this.handlePlayerMonFaint();
yield this.hud();
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (!switched) {
if (this.tryEnemyFlee()) return yield* this.wildFled();
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -875,10 +903,13 @@ export class Battle {
}
private *battleEnemyFirst(): Gen {
if (this.tryEnemyFlee()) return yield* this.wildFled();
this.turn = 1;
this.enemyGoesFirst = 1;
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
if (!(yield* this.aiSwitchOrTryItem())) {
yield* this.doTurnFor(1);
if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint();
if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint();
}
this.turn = 1;
if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint();
yield this.hud();
@@ -1991,7 +2022,358 @@ export class Battle {
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
yield this.hud();
this.battleEnded = true;
// A wild battle ends; so does a trainer's with no Pokémon left (WinTrainerBattle).
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
// AskUseNextPokemon and ForcePlayerMonChoice.
if (!this.player.mon.hp) throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
// HandleEnemySwitch: the player's Pokémon stays in.
yield* this.enemyPartyMonEntrance();
this.battlePlayerAction = 0;
}
/** CheckEnemyTrainerDefeated: every party HP is zero. */
private enemyTrainerDefeated() {
return this.options.trainer!.party.every((mon) => mon.hp === 0);
}
/** EnemyPartyMonEntrance after a faint, while the player's Pokémon stands. */
private *enemyPartyMonEntrance(): Gen {
this.enemySwitchMonIndex = 0;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; // ResetEnemyStatLevels
this.breakAttraction();
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
this.enemy.move.anim = 0;
}
/** EnemySwitch and EnemySwitch_SetMode. The Shift-mode offer to switch
* too (CheckWhetherToAskSwitch) needs player switching and is refused. */
private *enemySwitch(): Gen {
if (!this.battleHasJustStarted && this.options.party.length > 1 && this.partyMon.hp)
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
this.resetEnemyBattleVars();
// CheckWhetherSwitchmonIsPredetermined, else the type-matchup choice.
const index = this.enemySwitchMonIndex
? this.enemySwitchMonIndex - 1
: this.battleHasJustStarted
? 0
: this.findMonInOTPartyToSwitchIntoBattle();
if (!this.options.trainer!.party[index]?.hp)
throw new UnsupportedBattle("Invalid trainer switch.");
// LoadEnemyMonToSwitchTo: LoadEnemyMon records wCurOTMon.
this.curOTMon = index;
this.loadEnemyMon();
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
}
/** ResetEnemyBattleVars. wEnemyItemState is unused while trainer items are refused. */
private resetEnemyBattleVars() {
this.player.lastCounterMove = 0;
this.enemy.lastCounterMove = 0;
this.enemy.lastMove = 0;
this.enemy.curMove = 0;
this.player.wrapCount = 0;
}
/** NewEnemyMonStatus: also clears the player's counter move, both wrap
* counts and the player's inability to run. */
private newEnemyMonStatus() {
const enemy = this.enemy;
this.player.lastCounterMove = 0;
enemy.lastCounterMove = 0;
enemy.lastMove = 0;
enemy.sub = [0, 0, 0, 0, 0, 0];
enemy.disableCount = 0;
enemy.furyCutterCount = 0;
enemy.protectCount = 0;
enemy.rageCounter = 0;
enemy.disabledMove = 0;
enemy.minimized = 0;
this.player.wrapCount = 0;
enemy.wrapCount = 0;
enemy.turnsTaken = 0;
this.player.reset(SUB5, C.SUBSTATUS_CANT_RUN);
}
private breakAttraction() {
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
this.enemy.reset(SUB1, C.SUBSTATUS_IN_LOVE);
}
/** UpdateUsedMoves: append a new move, dropping the oldest when full. */
private updateUsedMoves(move: number) {
const used = this.playerUsedMoves;
if (used.includes(move) && move) return;
const empty = used.indexOf(0);
if (empty >= 0) used[empty] = move;
else this.playerUsedMoves = [used[1], used[2], used[3], move];
}
/** CheckTypeMatchup's wTypeMatchup (EFFECTIVE is 10). */
private typeMatchup(moveType: number, types: readonly number[], identified: boolean) {
return applyTypeMatchups(this.matchups, {
damage: 0,
modifier: 0,
moveType,
userTypes: [],
targetTypes: types,
identified,
}).matchup;
}
/** FindMonInOTPartyToSwitchIntoBattle and ScoreMonTypeMatchups: prefer
* the first Pokémon with a super-effective move that the player's types
* don't also threaten, then the first unthreatened one, else a random
* standing one (drawn with BattleRandom). */
private findMonInOTPartyToSwitchIntoBattle(): number {
const party = this.options.trainer!.party,
data = this.options.data,
count = party.length;
// wEnemyEffectivenessVsPlayerMons / wPlayerEffectivenessVsEnemyMons,
// each shifted left once per Pokémon from a sentinel bit.
let enemyBits = 1,
playerBits = 1;
for (let index = 0; index < count; index++) {
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
const mon = party[index];
if (index === this.curOTMon || !mon.hp) {
playerBits |= 1;
continue;
}
// LookUpTheEffectivenessOfEveryMove, on the enemy's turn.
for (const move of mon.moves) {
if (!move) break;
const identified = this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.typeMatchup(data.move(move).type, this.player.mon.types, identified) > C.EFFECTIVE) {
enemyBits |= 1;
break;
}
}
// IsThePlayerMonTypesEffectiveAgainstOTMon, on the player's turn.
const types = data.baseData(mon.species).types,
identified = this.enemy.has(SUB1, C.SUBSTATUS_IDENTIFIED);
if (this.player.mon.types.some((type) => this.typeMatchup(type, types, identified) > C.EFFECTIVE)) {
if (enemyBits & 1) enemyBits &= ~1;
else playerBits |= 1;
}
}
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
// Align the first Pokémon to bit 7 by shifting the sentinel out.
for (;;) {
const carry = playerBits & 0x80;
enemyBits = (enemyBits << 1) & 0xff;
playerBits = (playerBits << 1) & 0xff;
if (carry) break;
}
let discouraged = 0;
while (discouraged < count && playerBits & (0x80 >> discouraged)) discouraged++;
if (discouraged < count) {
const bits = enemyBits || (~playerBits & 0xff);
for (let index = 0; index < 8; index++) if (bits & (0x80 >> index)) return index;
}
for (let guard = 0; guard < 4096; guard++) {
const index = this.random() & 7;
if (index < count && index !== this.curOTMon && party[index].hp) return index;
}
throw new UnsupportedBattle("The trainer has no Pokémon to send out.");
}
/** AI_SwitchOrTryItem (engine/battle/ai/items.asm). Returns true when the
* enemy spent its turn. Trainers holding items are refused before battle,
* so AI_TryItem finds none. */
private *aiSwitchOrTryItem(): Gen<boolean> {
const trainer = this.options.trainer;
if (!trainer || this.checkEnemyLockedIn()) return false;
if (this.player.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.enemy.wrapCount) return false;
const style = [SWITCH_OFTEN, SWITCH_RARELY, SWITCH_SOMETIMES].findIndex(
(bit) => trainer.switchFlags & bit,
);
if (style < 0) return false;
const param = this.checkAbleToSwitch(),
tier = param & 0xf0;
if (!tier) return false;
const [threshold, below] = SWITCH_CHANCES[style][Math.min(tier >> 4, 3) - 1];
if (this.random() < threshold !== below) return false;
this.enemySwitchMonIndex = (param & 0x0f) + 1;
// AI_TrySwitch: the withdrawn Pokémon's stale party HP still counts.
if (trainer.party.filter((mon) => mon.hp).length < 2) return false;
yield* this.aiSwitch();
return true;
}
/** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */
private *aiSwitch(): Gen {
const trainer = this.options.trainer!;
this.enemyGoesFirst = 1;
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
this.turn = 0;
// PursuitSwitch reads wCurPlayerMove's effect.
if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT)
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
// Status and HP go back to the party (MON_STATUS through MON_HP).
const outgoing = trainer.party[this.curOTMon];
outgoing.status = this.enemy.mon.status;
outgoing.hp = this.enemy.mon.hp;
yield* this.say(BATTLE.EnemyWithdrewText);
this.battleHasJustStarted = 1;
this.newEnemyMonStatus();
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon;
this.battleHasJustStarted = 0;
}
/** CheckAbleToSwitch (engine/battle/ai/switch.asm): wEnemySwitchMonParam,
* a chance tier in the high nibble and a party index in the low one.
* CheckTypeMatchup reads BATTLE_VARS_MOVE_TYPE rather than the type its
* callers load, so with hBattleTurn on the enemy every matchup here uses
* the enemy's chosen move type (docs/bugs_and_glitches.md). */
private checkAbleToSwitch(): number {
const party = this.options.trainer!.party,
data = this.options.data;
const matchup = (types: readonly number[]) =>
this.typeMatchup(this.enemy.move.type, types, this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED));
const bit = (index: number) => 0x20 >> index;
const damaging = (move: number) => data.move(move).power !== 0;
const baseTypes = (index: number) => data.baseData(party[index].species).types;
// FindAliveEnemyMons: the others still standing.
const aliveOthers = () => {
let mask = 0;
if (party.length >= 2)
party.forEach((mon, index) => {
if (index !== this.curOTMon && mon.hp) mask |= bit(index);
});
return mask;
};
// FindEnemyMonsWithAtLeastQuarterMaxHP. BUG: srl c / rl b rotate the low
// HP byte into the high byte instead of multiplying the HP by four.
const quarterHP = (mask: number) => {
let chosen = 0;
party.forEach((mon, index) => {
const high = mon.hp >> 8,
low = mon.hp & 0xff;
const rotated = ((((high << 2) | ((low & 1) << 1) | ((low >> 1) & 1)) & 0xff) << 8) | (low >> 2);
if (rotated > mon.stats[0]) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsThatResistPlayer: every branch reduces to one matchup.
const resisting = (mask: number) => {
let chosen = 0;
party.forEach((_, index) => {
if (matchup(baseTypes(index)) <= C.EFFECTIVE) chosen |= bit(index);
});
return mask & chosen;
};
// FindEnemyMonsWithASuperEffectiveMove. `e` is not reset between
// Pokémon, so one neutral move encourages every later candidate.
const superEffective = (mask: number) => {
let e = 0,
encouraged = 0;
for (let index = 0; index < 6; index++) {
if (!(mask & bit(index))) continue;
for (const move of party[index].moves) {
if (!move) break;
if (!damaging(move)) continue;
const value = matchup(this.player.mon.types);
if (value < C.EFFECTIVE) continue;
e = 1;
if (value === C.EFFECTIVE) continue;
e = 2;
break;
}
if (e === 2) return { e, score: index };
if (e === 1) encouraged |= bit(index);
}
for (let index = 0; index < 6; index++) if (encouraged & bit(index)) return { e, score: index };
return { e, score: 0xff };
};
// FindAliveEnemyMonsWithASuperEffectiveMove: standing, including the
// current Pokémon.
const standingSuperEffective = (mask: number) => {
let standing = 0;
party.forEach((mon, index) => {
if (mon.hp) standing |= bit(index);
});
return superEffective(mask & standing);
};
if (!aliveOthers()) return 0;
if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH))
throw new UnsupportedBattle("Perish Song is not ported yet.");
if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0;
const lastCounter = this.player.lastCounterMove;
if (lastCounter) {
// FindEnemyMonsImmuneToLastCounterMove.
let immune = 0;
party.forEach((mon, index) => {
if (index === this.curOTMon || !mon.hp || !damaging(lastCounter)) return;
if (matchup(baseTypes(index)) === 0) immune |= bit(index);
});
if (immune) {
const { e, score } = superEffective(immune);
if (score === 0xff) return 0;
if (e !== 2) {
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
return score + 0x10;
}
return score + (this.checkPlayerMoveTypeMatchups() < C.EFFECTIVE ? 0x20 : 0x10);
}
}
if (this.checkPlayerMoveTypeMatchups() >= C.EFFECTIVE) return 0;
const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers())));
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;
const vsEnemy = () => this.typeMatchup(type, this.enemy.mon.types, identified),
vsPlayer = () => this.typeMatchup(type, this.player.mon.types, identified);
let score = 10;
const used = this.playerUsedMoves;
if (!used[0]) {
if (vsEnemy() > C.EFFECTIVE) score--;
if (this.player.mon.types[1] !== this.player.mon.types[0] && vsEnemy() > C.EFFECTIVE) score--;
} else {
let e = 0,
superEffective = false;
for (const move of used) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsEnemy();
if (value > C.EFFECTIVE) {
score--;
superEffective = true;
break;
}
if (!value) continue;
if (value === C.EFFECTIVE) e = 2;
else if (e < 1) e = 1;
}
if (!superEffective && e !== 2) score += e ? 1 : 2;
}
// .CheckEnemyMoveMatchups
let c = 0;
for (const move of this.enemy.mon.moves) {
if (!move) break;
if (!data.move(move).power) continue;
const value = vsPlayer();
if (!value) continue;
c++;
if (value < C.EFFECTIVE) continue;
c += 5;
if (value > C.EFFECTIVE) c = 100;
}
if (!c) score -= 2;
else if (c < 5) score--;
else if (c >= 100) score++;
return score & 0xff;
}
private *faintYourPokemon(): Gen {
yield* this.say(BATTLE.BattleText_MonFainted);
@@ -2003,8 +2385,10 @@ export class Battle {
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
if (!this.enemy.mon.hp) {
yield* this.updateStateAndExperienceAfterEnemyFaint(true);
this.battleEnded = true;
return;
if (!this.options.trainer || this.enemyTrainerDefeated()) {
this.battleEnded = true;
return;
}
}
throw new UnsupportedBattle("Switching Pokémon is not ported yet.");
}
@@ -2035,8 +2419,12 @@ export class Battle {
/** UpdateBattleStateAndExperienceAfterEnemyFaint and GiveExperiencePoints. */
private *updateStateAndExperienceAfterEnemyFaint(playerFaintedFirst: boolean): Gen {
this.updateBattleMonInParty();
const trainer = this.options.trainer;
if (trainer) trainer.party[this.curOTMon].hp = 0;
this.player.reset(SUB3, C.SUBSTATUS_IN_LOOP);
this.enemy.damageTaken = 0;
this.newEnemyMonStatus();
this.breakAttraction();
if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon();
if (!this.options.party.some((mon) => mon.hp > 0)) return;
this.battleResult &= 0xc0; // WIN