Port the Smart AI effect handlers (#28)
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>
This commit is contained in:
1 parent
32d736ecae
commit
77704fe848
11 files changed
+682
-71
No files matched your search
+49
-4
@@ -25,7 +25,10 @@ const side = (): TrainerAISide => ({
|
||||
types: [0, 0],
|
||||
species: 1,
|
||||
dvs: 0x8888,
|
||||
stats: [20, 20, 20, 20, 20],
|
||||
level: 20,
|
||||
},
|
||||
levels: [7, 7, 7, 7, 7, 7, 7],
|
||||
sub: [0, 0, 0, 0, 0, 0],
|
||||
screens: 0,
|
||||
turnsTaken: 0,
|
||||
@@ -98,14 +101,14 @@ describe("trainer AI native scoring", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
it("refuses untranslated Smart effects before any earlier layer consumes randomness", () => {
|
||||
it("scores exhausted Smart moves along with the other layers", () => {
|
||||
const input = fixture(
|
||||
[move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)],
|
||||
[],
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
input.weights = 0x3d3;
|
||||
input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation.
|
||||
expect(() => chooseTrainerMove(input)).toThrow(/Smart AI for move effect/);
|
||||
expect(chooseTrainerMove(input).index).toBe(0);
|
||||
});
|
||||
it("validates Smart effects across a supplied full trainer party", () => {
|
||||
expect(() =>
|
||||
@@ -117,7 +120,7 @@ describe("trainer AI native scoring", () => {
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]),
|
||||
).toThrow(/Smart AI/);
|
||||
).not.toThrow();
|
||||
});
|
||||
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
|
||||
const input = fixture(undefined, [3, 6, 253]);
|
||||
@@ -338,4 +341,46 @@ describe("trainer AI native scoring", () => {
|
||||
})();
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([15, 20]);
|
||||
});
|
||||
it("Smart Encore reads the move struct of hBattleTurn's side, not the player's last move", () => {
|
||||
const input = fixture(
|
||||
[move(1, C.EFFECT_ENCORE, 0), move(2), move(3, C.EFFECT_NORMAL_HIT, 40, 7)],
|
||||
[100, 0],
|
||||
);
|
||||
input.weights = 16;
|
||||
input.turn = 0;
|
||||
input.enemy.mon.stats = [20, 20, 30, 20, 20];
|
||||
input.player.lastMove = 3;
|
||||
input.player.move = { type: 9 };
|
||||
// Type 7 (the last move) would be neutral and discourage Encore; the
|
||||
// player's struct type 9 is immune, so Encore is encouraged instead.
|
||||
input.battleData.typeMatchups = () => [{ attacker: 9, defender: 0, multiplier: 0 }];
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [18, 20, 80, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
});
|
||||
it("Smart Conversion2 writes the player's move struct type and leaves the player's turn", () => {
|
||||
const input = fixture([move(1, C.EFFECT_CONVERSION2, 0), move(2)], [200, 0]);
|
||||
input.weights = 16;
|
||||
input.conversion2Type = 5;
|
||||
input.player.move = { type: 9 };
|
||||
input.enemy.sub[1] = 1 << C.SUBSTATUS_IDENTIFIED;
|
||||
// Foresight on the enemy (the side opposite the player's turn) stops
|
||||
// before the Ghost rows, so only the first row applies.
|
||||
input.battleData.typeMatchups = () => [
|
||||
{ attacker: 5, defender: 0, multiplier: 20 },
|
||||
{ attacker: 0xfe, defender: 0xfe, multiplier: 0 },
|
||||
{ attacker: 5, defender: 0, multiplier: 0 },
|
||||
];
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [19, 20, 80, 80],
|
||||
enemyTurn: false,
|
||||
playerTurn: true,
|
||||
});
|
||||
expect(input.player.move.type).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
test("all Smart AI move handlers match original scores and Random consumption", async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const names = ["hROMBank", "hBattleTurn", "wEnemyAIMoveScores", "wPartyCount", "wOTPartyCount", "wCurBattleMon", "wCurOTMon", "wBattleWeather", "wBattleMode", "wCriticalHit", "wPlayerUsedMoves", "wEnemyMoveStruct", "wPlayerMoveStruct", "wCurDamage", "wTypeModifier", "wAttackMissed", "wLinkMode", "wPartyMon1Species", "wOTPartyMon1Species", "wPartyMon2Species", "wOTPartyMon2Species", "wPartyMon1HP", "wOTPartyMon1HP", "wPartyMon2HP", "wOTPartyMon2HP", "wPartyMon1Status", "wOTPartyMon1Status", "wPartyMon2Status", "wOTPartyMon2Status", "wTempEnemyMonSpecies"];
|
||||
for (const prefix of ["Player", "Enemy"]) names.push(...["SubStatus1", "StatLevels", "Stats", "Screens", "TurnsTaken", "FuryCutterCount", "RolloutCount", "WrapCount", "PerishCount", "RageCounter", "ProtectCount", "Minimized"].map((s) => `w${prefix}${s}`), `wLast${prefix}Move`, `wLast${prefix}CounterMove`);
|
||||
names.push("wBattleMonSpecies", "wEnemyMonSpecies");
|
||||
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
|
||||
const commands = { smart: routine("AI_Smart"), random: routine("Random") };
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, commands }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||
const { scoreSmartMoves } = await import("/src/game/battle/smart-ai.ts");
|
||||
const { SYMBOLS } = await import("/src/game/rom/profile.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
// Only Random is replaced: replay fixed bytes while retaining every
|
||||
// scoring instruction and its ROM data. Stub and stream live in audio RAM.
|
||||
const patched = rom.slice();
|
||||
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(patched, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
|
||||
stub.forEach((v, i) => write(0xc080 + i, v));
|
||||
const { bank, address } = commands.smart;
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
let seed = 0x284ac;
|
||||
const next = (limit = 256) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; };
|
||||
const types = [0, 1, 2, 3, 4, 5, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
for (let id = 1; id <= 251; id++) for (let variant = 0; variant < 24; variant++) {
|
||||
const bytes = Array.from({ length: 256 }, () => next());
|
||||
let draws = 0;
|
||||
const party = [{ species: 143, dvs: 0x8888, hp: 100, status: 0 }, { species: 16, hp: next(2) ? 100 : 0, status: next(256) }];
|
||||
const enemyParty = structuredClone(party);
|
||||
enemyParty[1].hp = next(2) ? 100 : 0;
|
||||
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, reward: 0 } });
|
||||
const internal = battle as unknown as Record<string, any>;
|
||||
internal.battleMode = 2;
|
||||
internal.enemySpecies = 143;
|
||||
const used = variant % 4 === 0 ? [0, 0, 0, 0] : Array.from({ length: 4 }, () => 1 + next(251));
|
||||
internal.playerUsedMoves = used;
|
||||
internal.criticalHit = next(2);
|
||||
internal.weather = next(4);
|
||||
const state: unknown[] = [];
|
||||
for (const [sideIndex, side] of [battle.player, battle.enemy].entries()) {
|
||||
const prefix = sideIndex ? "Enemy" : "Player";
|
||||
const monAddr = a[sideIndex ? "wEnemyMonSpecies" : "wBattleMonSpecies"];
|
||||
side.mon = { species: 143, item: 0, moves: sideIndex ? [id, 33, 0, 0] : [...used], pp: [next(), next(), next(), next()], dvs: next(65536), happiness: 70, level: 1 + next(100), status: next(256), hp: [25, 50, 100, 75][next(4)], maxHp: 100, stats: Array.from({ length: 5 }, () => 1 + next(400)), types: [types[next(types.length)], types[next(types.length)]], nickname: "" };
|
||||
side.sub = [0, next(), next(), next(), next(), next()];
|
||||
side.levels = Array.from({ length: 7 }, () => 1 + next(13));
|
||||
side.stats = Array.from({ length: 5 }, () => 1 + next(400));
|
||||
const fields = { screens: "Screens", turnsTaken: "TurnsTaken", furyCutterCount: "FuryCutterCount", rolloutCount: "RolloutCount", wrapCount: "WrapCount", perishCount: "PerishCount", rageCounter: "RageCounter", protectCount: "ProtectCount", minimized: "Minimized" };
|
||||
for (const [key, suffix] of Object.entries(fields)) { (side as any)[key] = next(5); write(a[`w${prefix}${suffix}`], (side as any)[key]); }
|
||||
side.lastMove = variant % 3 === 0 ? 0 : used[0];
|
||||
side.lastCounterMove = variant % 5 === 0 ? 0 : used[0];
|
||||
write(a[`wLast${prefix}Move`], side.lastMove);
|
||||
write(a[`wLast${prefix}CounterMove`], side.lastCounterMove);
|
||||
write(monAddr, side.mon.species); write(monAddr + 1, 0);
|
||||
side.mon.moves.forEach((m, i) => write(monAddr + 2 + i, m));
|
||||
word(monAddr + 6, side.mon.dvs);
|
||||
side.mon.pp.forEach((p, i) => write(monAddr + 8 + i, p));
|
||||
write(monAddr + 13, side.mon.level); write(monAddr + 14, side.mon.status);
|
||||
word(monAddr + 16, side.mon.hp); word(monAddr + 18, side.mon.maxHp);
|
||||
side.mon.stats.forEach((s, i) => word(monAddr + 20 + i * 2, s));
|
||||
side.mon.types.forEach((t, i) => write(monAddr + 30 + i, t));
|
||||
side.sub.slice(1).forEach((v, i) => write(a[`w${prefix}SubStatus1`] + i, v));
|
||||
side.levels.forEach((v, i) => write(a[`w${prefix}StatLevels`] + i, v));
|
||||
// The eighth NUM_LEVEL_STATS byte is only ever reset to 7 or copied.
|
||||
write(a[`w${prefix}StatLevels`] + 7, 7);
|
||||
side.stats.forEach((v, i) => word(a[`w${prefix}Stats`] + i * 2, v));
|
||||
state.push(structuredClone(side));
|
||||
}
|
||||
for (const prefix of ["", "OT"]) {
|
||||
const mons = prefix ? enemyParty : party;
|
||||
write(a[`w${prefix}PartyCount`], 2);
|
||||
mons.forEach((mon, i) => { write(a[`w${prefix}PartyMon${i + 1}Species`], mon.species); word(a[`w${prefix}PartyMon${i + 1}HP`], mon.hp); write(a[`w${prefix}PartyMon${i + 1}Status`], mon.status); });
|
||||
}
|
||||
write(a.wCurBattleMon, 0); write(a.wCurOTMon, 0); write(a.wTempEnemyMonSpecies, 143);
|
||||
write(a.wBattleMode, 2); write(a.wBattleWeather, internal.weather); write(a.wCriticalHit, internal.criticalHit);
|
||||
write(a.wLinkMode, 0); write(a.wTypeModifier, 10); write(a.wAttackMissed, 0); word(a.wCurDamage, 0);
|
||||
used.forEach((v, i) => write(a.wPlayerUsedMoves + i, v));
|
||||
const initialTurn = next(2) as 0 | 1;
|
||||
write(a.hBattleTurn, initialTurn);
|
||||
battle.player.move.type = types[next(types.length)];
|
||||
write(a.wPlayerMoveStruct + 3, battle.player.move.type);
|
||||
const scores = [20, 20, 80, 80];
|
||||
scores.forEach((s, i) => write(a.wEnemyAIMoveScores + i, s));
|
||||
const input = { weights: 16, player: battle.player, enemy: battle.enemy, data: assets.pokemon, battleData: internal.data, weather: internal.weather, random: () => bytes[draws++], playerUsedMoves: used, playerHasReserve: Boolean(party[1].hp), enemyHasReserve: Boolean(enemyParty[1].hp), enemyParty, conversion2Type: assets.reader.byte(SYMBOLS.moves + 255 * 7 + 3), damage: (move: number) => internal.enemyAIDamage(move), switchScore: (type: number, identified: boolean) => internal.checkPlayerMoveTypeMatchups(type, identified) };
|
||||
let turn = initialTurn;
|
||||
scoreSmartMoves(input, (i, n) => { scores[i] = (scores[i] + n) & 255; }, { get: () => turn, set: (v) => { turn = v; } }, (m) => { turn = 1; return input.damage(m); });
|
||||
bytes.forEach((v, i) => write(0xc100 + i, v));
|
||||
write(0xc0e0, 0); write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error(`AI_Smart did not return for ${id}`);
|
||||
const original = [0, 1, 2, 3].map((i) => read(a.wEnemyAIMoveScores + i));
|
||||
const playerType = read(a.wPlayerMoveStruct + 3);
|
||||
if (original.some((s, i) => s !== scores[i]) || read(0xc0e0) !== draws || read(a.hBattleTurn) !== turn || playerType !== battle.player.move.type)
|
||||
failures.push({ id, variant, effect: assets.pokemon.move(id).effect, original, scores, originalDraws: read(0xc0e0), draws, originalTurn: read(a.hBattleTurn), turn, initialTurn, originalPlayerType: playerType, playerType: battle.player.move.type, state, used });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures: failures.map(({ state, ...rest }: any) => rest), first: failures[0], count: failures.length };
|
||||
}, { a: addresses, commands });
|
||||
expect(result.compared).toBe(6024);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0, failures: [] });
|
||||
});
|
||||
@@ -283,26 +283,23 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
expect(result.texts).toContain("was defeated!");
|
||||
},
|
||||
);
|
||||
it("refuses untranslated Smart effects, including reserves, and empty parties before mutation", () => {
|
||||
for (const kind of ["empty", "ai", "reserve"] as const) {
|
||||
const { options } = setup();
|
||||
if (kind === "empty") options.trainer!.party = [];
|
||||
else {
|
||||
options.trainer!.aiFlags |= 1 << 4;
|
||||
const unsupported = structuredClone(options.trainer!.party[0]);
|
||||
unsupported.moves = [115, 0, 0, 0]; // Reflect has an untranslated Smart handler.
|
||||
if (kind === "reserve") options.trainer!.party.push(unsupported);
|
||||
else options.trainer!.party[0] = unsupported;
|
||||
}
|
||||
const before = structuredClone(options.party);
|
||||
const enemyBefore = structuredClone(options.trainer!.party);
|
||||
let draws = 0;
|
||||
options.random = () => { draws++; return 0; };
|
||||
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
|
||||
expect(options.party).toEqual(before);
|
||||
expect(options.trainer!.party).toEqual(enemyBefore);
|
||||
expect(options.pokedex.seen.size).toBe(0);
|
||||
expect(draws).toBe(0);
|
||||
}
|
||||
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!");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user