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

+91 -8
View File
@@ -15,16 +15,17 @@ import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
describe.skipIf(!existsSync(romPath))("trainer battles", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const setup = (level = 15) => {
const trainer = trainers.trainer(22, 1); // Youngster Joey, level 4 Rattata.
const setup = (level = 15, [classId, id] = [22, 1], species = 155) => {
// Youngster Joey, level 4 Rattata, unless another trainer is named.
const trainer = trainers.trainer(classId, id);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
species,
level,
item: 0,
otId: 1234,
@@ -50,6 +51,7 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
className: trainer.className,
party: trainers.createParty(trainer),
aiFlags: trainer.attributes.aiMoveFlags,
switchFlags: trainer.attributes.aiItemSwitchFlags,
reward: trainer.reward,
},
};
@@ -171,11 +173,92 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
expect(mon.status).toBe(0);
expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp);
});
it("refuses unported party switching and AI before mutating the party or Pokédex", () => {
for (const kind of ["party", "ai"] as const) {
/** Run to the first battle menu; the battle's internals for AI checks. */
const atMenu = (battle: Battle) => {
const run = battle.run();
for (let guard = 0; guard < 20; guard++) {
const next = run.next();
if (!next.done && next.value.kind === "action") break;
}
return {
run,
internals: battle as unknown as {
curOTMon: number;
playerUsedMoves: number[];
findMonInOTPartyToSwitchIntoBattle(): number;
checkAbleToSwitch(): number;
aiSwitchOrTryItem(): Generator<{ kind: string; text?: string }, boolean>;
},
};
};
it("sends out a trainer's next Pokémon with no random draw when a candidate is unthreatened", () => {
// Totodile's Water type is neutral against Bug Catcher Don's Caterpie.
const { options } = setup(15, [36, 1], 158);
const battle = new Battle(options),
{ internals } = atMenu(battle);
options.trainer!.party[0].hp = 0;
options.random = () => {
throw new Error("Unexpected random draw");
};
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
});
it("draws BattleRandom & 7 when the player's types threaten every candidate", () => {
// Cyndaquil's Fire type is super effective against the other Caterpie.
const { options } = setup(15, [36, 1], 155);
const battle = new Battle(options),
{ internals } = atMenu(battle);
options.trainer!.party[0].hp = 0;
const draws = [0x0d, 0x08, 0x02, 0x09];
options.random = () => draws.shift()!;
// 5 and 0 (the fainted current Pokémon) and 2 (past the party) retry.
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
expect(draws).toEqual([]);
});
it("switches out under SWITCH_SOMETIMES using the enemy move type for every matchup", () => {
const { options } = setup(15, [22, 2]); // Youngster Mikey: Pidgey, Rattata
const battle = new Battle(options),
{ internals } = atMenu(battle);
const WATER = 0x15, GROUND = 0x04, GRASS = 0x16;
// CheckTypeMatchup reads the enemy's move type (here Grass), so it is
// super effective against the enemy's Water type and the player's Water/Ground.
battle.player.mon.types = [WATER, GROUND];
battle.enemy.mon.types = [WATER, WATER];
battle.enemy.move = { ...battle.enemy.move, type: GRASS };
const rattata = options.trainer!.party[1];
// FindEnemyMonsWithAtLeastQuarterMaxHP's rotation: 16 HP gives 4, never
// above the maximum, while 1 HP gives $200.
rattata.hp = 16;
expect(internals.checkAbleToSwitch()).toBe(0);
rattata.hp = 1;
expect(internals.checkAbleToSwitch()).toBe(0x11);
battle.enemy.mon.hp = 3;
battle.enemy.mon.status = 1 << 3; // poisoned
const draws = [49];
options.random = () => draws.shift()!;
const texts: string[] = [];
const run = internals.aiSwitchOrTryItem();
let next = run.next();
for (; !next.done; next = run.next()) if (next.value.text) texts.push(next.value.text);
expect(next.value).toBe(true);
expect(texts.join(" ").replace(/\n/g, " ")).toMatch(/withdrew PIDGEY.*sent out RATTATA/);
expect(internals.curOTMon).toBe(1);
expect(battle.enemy.mon.hp).toBe(1);
// AI_Switch keeps the withdrawn Pokémon's HP and status in the party.
expect(options.trainer!.party[0]).toMatchObject({ hp: 3, status: 1 << 3 });
});
it("records the player's used moves for the switch AI", () => {
const { options } = setup(15, [22, 2]);
const battle = new Battle(options),
{ run, internals } = atMenu(battle);
expect(internals.playerUsedMoves).toEqual([0, 0, 0, 0]);
run.next({ action: "fight", move: 0 });
expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 0]);
});
it("refuses the untranslated Smart AI and empty parties before mutating the party or Pokédex", () => {
for (const kind of ["empty", "ai"] as const) {
const { options } = setup();
if (kind === "party")
options.trainer!.party.push(structuredClone(options.trainer!.party[0]));
if (kind === "empty") options.trainer!.party = [];
else options.trainer!.aiFlags |= 1 << 4;
const before = structuredClone(options.party);
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);