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>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { TrainerData } from "../src/game/rom/trainers";
|
|
import {
|
|
Battle,
|
|
UnsupportedBattle,
|
|
type BattleInput,
|
|
type BattleOptions,
|
|
} from "../src/game/battle/battle";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
|
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))("trainer battles", () => {
|
|
let assets: CrystalAssets, trainers: TrainerData;
|
|
beforeAll(() => {
|
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
trainers = new TrainerData(assets.reader);
|
|
});
|
|
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,
|
|
level,
|
|
item: 0,
|
|
otId: 1234,
|
|
ot: "CHRIS",
|
|
dvs: 0x823b,
|
|
caughtData: 0,
|
|
});
|
|
let seed = 1;
|
|
const options: BattleOptions = {
|
|
data: assets.pokemon,
|
|
party: [mon],
|
|
playerId: 1234,
|
|
playerName: "CHRIS",
|
|
timeOfDay: 1,
|
|
pokedex: { seen: new Set(), caught: new Set() },
|
|
random: () => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
},
|
|
text: () => defaultTextContext("CHRIS"),
|
|
trainer: {
|
|
name: trainer.name,
|
|
className: trainer.className,
|
|
party: trainers.createParty(trainer),
|
|
aiFlags: trainer.attributes.aiMoveFlags,
|
|
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
|
reward: trainer.reward,
|
|
},
|
|
};
|
|
return { mon, options };
|
|
};
|
|
const finish = (battle: Battle, runFirst = false) => {
|
|
const gen = battle.run();
|
|
const texts: string[] = [];
|
|
let input: BattleInput,
|
|
triedRun = false;
|
|
for (let guard = 0; guard < 2000; guard++) {
|
|
const next = gen.next(input);
|
|
if (next.done)
|
|
return { ...next.value, texts: texts.join(" ").replace(/\n/g, " ") };
|
|
const effect = next.value;
|
|
input = undefined;
|
|
if (effect.kind === "text") texts.push(effect.text);
|
|
if (effect.kind === "action") {
|
|
input =
|
|
runFirst && !triedRun
|
|
? { action: "run" }
|
|
: { action: "fight", move: 0 };
|
|
triedRun = true;
|
|
}
|
|
if (effect.kind === "yesno") input = 0;
|
|
if (effect.kind === "forget") input = 4;
|
|
}
|
|
throw new Error("Battle did not finish.");
|
|
};
|
|
it("announces the trainer, refuses escape without spending a turn, and awards trainer experience and money", () => {
|
|
const direct = setup(),
|
|
escape = setup();
|
|
const before = direct.mon.exp;
|
|
const won = finish(new Battle(direct.options));
|
|
const escaped = finish(new Battle(escape.options), true);
|
|
expect(won.result).toBe(0);
|
|
expect(won.prizeMoney).toBe(64);
|
|
expect(won.texts).toContain("YOUNGSTER JOEY wants to battle!");
|
|
expect(won.texts).toContain("sent out RATTATA!");
|
|
expect(won.texts).toContain("was defeated!");
|
|
expect(won.texts).toContain("CHRIS got ¥64");
|
|
expect(escaped.texts).toContain("trainer battle");
|
|
expect(escape.mon).toEqual(direct.mon);
|
|
expect(direct.mon.exp - before).toBe(48); // floor(57 * 4 / 7), then 1.5x.
|
|
expect(direct.options.pokedex.seen.has(19)).toBe(true);
|
|
});
|
|
it("doubles prize money when the active Pokémon holds an Amulet Coin", () => {
|
|
const { mon, options } = setup();
|
|
mon.item = 0x5b;
|
|
const result = finish(new Battle(options));
|
|
expect(result.prizeMoney).toBe(128);
|
|
expect(result.texts).toContain("CHRIS got ¥128");
|
|
});
|
|
it("sends a quarter of the prize to Mom while she saves some", () => {
|
|
const { mon, options } = setup();
|
|
mon.item = 0x5b;
|
|
const result = finish(new Battle({ ...options, mom: { saving: 1, money: 10 } }));
|
|
expect(result.prizeMoney).toBe(96);
|
|
expect(result.momMoney).toBe(32);
|
|
expect(result.texts).toContain("CHRIS got ¥128 for winning! Sent some to MOM!");
|
|
// A full account keeps the whole prize in the wallet.
|
|
const full = setup();
|
|
const kept = finish(
|
|
new Battle({ ...full.options, mom: { saving: 1, money: 999999 } }),
|
|
);
|
|
expect(kept.prizeMoney).toBe(64);
|
|
expect(kept.momMoney).toBeUndefined();
|
|
expect(kept.texts).toContain("CHRIS got ¥64 for winning!");
|
|
expect(kept.texts).not.toContain("MOM");
|
|
});
|
|
it("scores exhausted trainer moves before the menu, then uses Struggle", () => {
|
|
const { options } = setup();
|
|
options.trainer!.party[0].pp.fill(0);
|
|
let draws = 0;
|
|
options.random = () => { draws++; return 0; };
|
|
const battle = new Battle(options), run = battle.run();
|
|
for (let guard = 0; guard < 20; guard++) {
|
|
const next = run.next();
|
|
if (!next.done && next.value.kind === "action") break;
|
|
}
|
|
expect(draws).toBeGreaterThan(0);
|
|
// The AI selected a normal move despite exhausted PP; ParseEnemyAction
|
|
// replaces it before the first attack, as the cartridge does.
|
|
expect(battle.enemy.curMove).toBe(33);
|
|
run.next({ action: "fight", move: 0 });
|
|
expect(battle.enemy.curMove).toBe(165);
|
|
});
|
|
it("uses the native damage estimate for Aggressive move scoring", () => {
|
|
const { options } = setup();
|
|
const enemy = options.trainer!.party[0];
|
|
enemy.moves = [33, 98, 0, 0]; // Tackle, Quick Attack
|
|
enemy.pp = enemy.moves.map((move) => move ? assets.pokemon.move(move).pp : 0);
|
|
options.trainer!.aiFlags = 1 << 6;
|
|
const battle = new Battle(options), run = battle.run();
|
|
for (let guard = 0; guard < 20; guard++) {
|
|
const next = run.next();
|
|
if (!next.done && next.value.kind === "action") break;
|
|
}
|
|
expect(battle.enemy.curMove).toBe(98);
|
|
});
|
|
it("lets a leftover critical-hit flag alter the AI damage estimate", () => {
|
|
const { options } = setup();
|
|
const battle = new Battle(options), run = battle.run();
|
|
for (let guard = 0; guard < 20; guard++) {
|
|
const next = run.next();
|
|
if (!next.done && next.value.kind === "action") break;
|
|
}
|
|
const internals = battle as unknown as {
|
|
criticalHit: number;
|
|
enemyAIDamage(move: number): number;
|
|
};
|
|
const plain = internals.enemyAIDamage(33); // Tackle
|
|
battle.player.screens = 1 << C.SCREENS_REFLECT;
|
|
const screened = internals.enemyAIDamage(33);
|
|
// CheckDamageStatsCritical: equal stages select unboosted stats, which
|
|
// ignore Reflect; DamageCalc then doubles the estimate.
|
|
internals.criticalHit = 1;
|
|
const critical = internals.enemyAIDamage(33);
|
|
// Rattata L4 (Atk 10) Tackle into Cyndaquil (Def 18, 36 under Reflect).
|
|
expect([plain, screened, critical]).toEqual([4, 3, 6]);
|
|
});
|
|
it("loses without receiving prize money", () => {
|
|
const { mon, options } = setup(2);
|
|
mon.hp = 1;
|
|
const lost = finish(new Battle(options));
|
|
expect(lost.result).toBe(1);
|
|
expect(lost.prizeMoney).toBeUndefined();
|
|
expect(mon.hp).toBe(0);
|
|
});
|
|
it("heals the party after a CANLOSE victory", () => {
|
|
const { mon, options } = setup();
|
|
options.canLose = true;
|
|
mon.hp--;
|
|
finish(new Battle(options));
|
|
expect(mon.hp).toBe(mon.stats[0]);
|
|
expect(mon.status).toBe(0);
|
|
expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp);
|
|
});
|
|
/** 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.each([1, 2, 3])(
|
|
"completes first rival starter variant %i with its ROM AI flags",
|
|
(id) => {
|
|
const { options } = setup(15, [9, id]);
|
|
expect(options.trainer!.aiFlags).toBe(0x3d3);
|
|
const result = finish(new Battle(options));
|
|
expect(result.result).toBe(0);
|
|
expect(result.prizeMoney).toBe(300);
|
|
expect(result.texts).toContain("was defeated!");
|
|
},
|
|
);
|
|
it("refuses an empty trainer party before mutation", () => {
|
|
const { options } = setup();
|
|
options.trainer!.party = [];
|
|
const before = structuredClone(options.party);
|
|
let draws = 0;
|
|
options.random = () => { draws++; return 0; };
|
|
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
|
|
expect(options.party).toEqual(before);
|
|
expect(options.pokedex.seen.size).toBe(0);
|
|
expect(draws).toBe(0);
|
|
});
|
|
it("completes a battle against Smart AI with a handled status move", () => {
|
|
const { options } = setup();
|
|
options.trainer!.aiFlags |= 1 << 4;
|
|
options.trainer!.party[0].moves = [115, 33, 0, 0]; // Reflect, Tackle
|
|
const result = finish(new Battle(options));
|
|
expect(result.result).toBe(0);
|
|
expect(result.texts).toContain("was defeated!");
|
|
});
|
|
});
|