Checks / web (push) Canceled after 0s
Mr. Pokémon's ROM scene gives the Mystery Egg and Pokédex, heals the party, sets the Cherrygrove spawn with blackoutmod and queues Elm's call. Cherrygrove's rival scene runs both triggers and all three RIVAL1_1 parties as a CANLOSE battle; reloadmap returns to the script. Smart AI is accepted for effects with no AI_Smart_EffectHandlers entry (Normal Hit, Attack Down, Defense Down) and still refuses others before any random draw, including for reserve Pokémon. Special calls read SpecialPhoneCallList: the entry's condition decides, and the ROM's CheckSpecialPhoneCall.script and Script_ReceivePhoneCall run its caller script through memcall, ending with HangUp's timed "Click!". Ringing and the caller box are not presented yet. In Elm's lab, NameRival shows the ROM's "RIVAL'S NAME?" title (the prompt previously had no page and soft-locked) and applies InitName's default to blank names. Handing over the Egg and the aide's Poké Balls run from the ROM. Saves gain rivalName and specialPhoneCall. The quest fixtures use a seeded RNG (a constant byte never ends the damage-variation loop) and no longer carry the house's moved objects into Cherrygrove. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
chooseTrainerMove,
|
|
validateTrainerAI,
|
|
type TrainerAIInput,
|
|
type TrainerAISide,
|
|
} from "../src/game/battle/ai";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { SYMBOLS } from "../src/game/rom/profile";
|
|
import type { MoveData } from "../src/game/rom/pokemon";
|
|
|
|
const move = (
|
|
id: number,
|
|
effect = C.EFFECT_NORMAL_HIT,
|
|
power = 40,
|
|
type = 0,
|
|
): MoveData => ({ id, effect, power, type, accuracy: 255, pp: 30, chance: 0 });
|
|
const side = (): TrainerAISide => ({
|
|
mon: {
|
|
moves: [1, 2, 0, 0],
|
|
pp: [30, 30, 0, 0],
|
|
status: 0,
|
|
hp: 20,
|
|
maxHp: 20,
|
|
types: [0, 0],
|
|
species: 1,
|
|
dvs: 0x8888,
|
|
},
|
|
sub: [0, 0, 0, 0, 0, 0],
|
|
screens: 0,
|
|
turnsTaken: 0,
|
|
disableCount: 0,
|
|
disabledMove: 0,
|
|
});
|
|
function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
|
|
let cursor = 0;
|
|
return {
|
|
weights: 0,
|
|
player: side(),
|
|
enemy: side(),
|
|
weather: 0,
|
|
damage: () => {
|
|
throw new Error("Unexpected damage estimate");
|
|
},
|
|
random: () => {
|
|
if (cursor >= bytes.length)
|
|
throw new Error("Unexpected extra random call");
|
|
return bytes[cursor++];
|
|
},
|
|
data: {
|
|
move: (id) => moves.find((m) => m.id === id)!,
|
|
baseData: () => {
|
|
throw new Error("Unexpected base data lookup");
|
|
},
|
|
},
|
|
battleData: {
|
|
byteList: (address) => {
|
|
if (address === SYMBOLS.statusOnlyEffects)
|
|
return [
|
|
C.EFFECT_SLEEP,
|
|
C.EFFECT_TOXIC,
|
|
C.EFFECT_POISON,
|
|
C.EFFECT_PARALYZE,
|
|
];
|
|
if (address === SYMBOLS.recklessMoves)
|
|
return [
|
|
C.EFFECT_SELFDESTRUCT,
|
|
C.EFFECT_RAMPAGE,
|
|
C.EFFECT_MULTI_HIT,
|
|
C.EFFECT_DOUBLE_HIT,
|
|
];
|
|
if (address === SYMBOLS.riskyEffects)
|
|
return [C.EFFECT_SELFDESTRUCT, C.EFFECT_OHKO];
|
|
return [1, 2];
|
|
},
|
|
typeMatchups: () => [],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("trainer AI native scoring", () => {
|
|
it("accepts all defined layer bits and rejects invalid layer words", () => {
|
|
for (const weights of [-1, 65536, 1.5, NaN])
|
|
expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
|
|
for (const weights of [16, 0x3d3, 0xffff])
|
|
expect(() => validateTrainerAI(weights)).not.toThrow();
|
|
});
|
|
it.each([C.EFFECT_ATTACK_DOWN, C.EFFECT_DEFENSE_DOWN])(
|
|
"Smart leaves the first rival's attack and stat-lowering effect %i unchanged",
|
|
(effect) => {
|
|
const input = fixture([move(1), move(2, effect, 0)], [3, 1]);
|
|
input.weights = 16;
|
|
expect(chooseTrainerMove(input)).toEqual({
|
|
move: 2,
|
|
index: 1,
|
|
scores: [20, 20, 80, 80],
|
|
});
|
|
},
|
|
);
|
|
it("refuses untranslated Smart effects before any earlier layer consumes randomness", () => {
|
|
const input = fixture(
|
|
[move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 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/);
|
|
});
|
|
it("validates Smart effects across a supplied full trainer party", () => {
|
|
expect(() =>
|
|
validateTrainerAI(0x3d3, [
|
|
C.EFFECT_NORMAL_HIT,
|
|
C.EFFECT_ATTACK_DOWN,
|
|
C.EFFECT_DEFENSE_DOWN,
|
|
]),
|
|
).not.toThrow();
|
|
expect(() =>
|
|
validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]),
|
|
).toThrow(/Smart AI/);
|
|
});
|
|
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
|
|
const input = fixture(undefined, [3, 6, 253]);
|
|
expect(chooseTrainerMove(input)).toEqual({
|
|
move: 2,
|
|
index: 1,
|
|
scores: [20, 20, 80, 80],
|
|
});
|
|
});
|
|
it("refuses a random source that never produces a candidate", () => {
|
|
const input = fixture();
|
|
input.random = () => 3;
|
|
expect(() => chooseTrainerMove(input)).toThrow(/random source/);
|
|
});
|
|
it("masks PP Ups and discourages disabled/exhausted moves", () => {
|
|
const input = fixture([move(1), move(2), move(3)], [0, 1, 2]);
|
|
input.enemy.mon.moves = [1, 2, 3, 0];
|
|
input.enemy.mon.pp = [30, 0xc0, 0xc1, 0];
|
|
input.enemy.disabledMove = 1;
|
|
expect(chooseTrainerMove(input)).toEqual({
|
|
move: 3,
|
|
index: 2,
|
|
scores: [80, 80, 20, 80],
|
|
});
|
|
});
|
|
it("still scores exhausted moves and consumes randomness before Struggle", () => {
|
|
const input = fixture(
|
|
[move(1, C.EFFECT_ATTACK_UP, 0), move(2)],
|
|
[128, 3, 0],
|
|
);
|
|
input.weights = 2;
|
|
input.enemy.mon.pp.fill(0);
|
|
expect(chooseTrainerMove(input)).toEqual({
|
|
move: 1,
|
|
index: 0,
|
|
scores: [78, 80, 80, 80],
|
|
});
|
|
});
|
|
it("still selects when the only move is disabled", () => {
|
|
const input = fixture([move(1)], [3, 0]);
|
|
input.enemy.mon.moves = [1, 0, 0, 0];
|
|
input.enemy.disabledMove = 1;
|
|
expect(chooseTrainerMove(input)).toEqual({
|
|
move: 1,
|
|
index: 0,
|
|
scores: [80, 20, 80, 80],
|
|
});
|
|
});
|
|
it("refuses empty move lists instead of the cartridge's infinite loop", () => {
|
|
const input = fixture();
|
|
input.enemy.mon.moves.fill(0);
|
|
expect(() => chooseTrainerMove(input)).toThrow(/nonzero move/);
|
|
});
|
|
it("Basic discourages status on a statused or safeguarded target exactly once", () => {
|
|
const input = fixture([move(1, C.EFFECT_SLEEP, 0), move(2)], [1]);
|
|
input.weights = 1;
|
|
input.player.mon.status = 8;
|
|
input.player.screens = 1 << C.SCREENS_SAFEGUARD;
|
|
expect(chooseTrainerMove(input).scores).toEqual([30, 20, 80, 80]);
|
|
});
|
|
it("Basic preserves Nightmare and Future Sight's cartridge redundancy bugs", () => {
|
|
const input = fixture(
|
|
[move(1, C.EFFECT_NIGHTMARE, 0), move(2, C.EFFECT_FUTURE_SIGHT)],
|
|
[0],
|
|
);
|
|
input.weights = 1;
|
|
input.player.mon.status = 8;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
input.random = () => 1;
|
|
input.enemy.screens = 1 << C.SCREENS_UNUSED;
|
|
input.player.mon.status = 0;
|
|
input.random = () => 0;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]);
|
|
});
|
|
it("Setup uses each side's first turn and exact 128/30 byte thresholds", () => {
|
|
const input = fixture(
|
|
[move(1, C.EFFECT_ATTACK_UP, 0), move(2, C.EFFECT_ATTACK_DOWN, 0)],
|
|
[128, 30, 0],
|
|
);
|
|
input.weights = 2;
|
|
input.player.turnsTaken = 1;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([18, 22]);
|
|
input.random = (() => {
|
|
const bytes = [127, 29, 1];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
});
|
|
it("Types discourages resistance only when another damaging type exists", () => {
|
|
const input = fixture([move(1, 0, 40, 20), move(2, 0, 0, 21)], [0]);
|
|
input.weights = 4;
|
|
input.battleData.typeMatchups = () => [
|
|
{ attacker: 20, defender: 0, multiplier: 5 },
|
|
];
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
input.data.move = (id) =>
|
|
id === 1 ? move(1, 0, 40, 20) : move(2, 0, 40, 21);
|
|
input.random = () => 1;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]);
|
|
});
|
|
it("Types encourages damaging super-effective moves and respects Foresight", () => {
|
|
const input = fixture([move(1, 0, 40, 0), move(2, 0, 40, 20)], [1]);
|
|
input.weights = 4;
|
|
input.player.mon.types = [8, 8];
|
|
input.battleData.typeMatchups = () => [
|
|
{ attacker: 20, defender: 8, multiplier: 20 },
|
|
{ attacker: 0xfe, defender: 0, multiplier: 0 },
|
|
{ attacker: 0, defender: 8, multiplier: 0 },
|
|
];
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 19]);
|
|
input.player.sub = [0, 1 << C.SUBSTATUS_IDENTIFIED, 0, 0, 0, 0];
|
|
input.random = () => 1;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 19]);
|
|
});
|
|
it("Status discourages poison against Poison and damaging immunities", () => {
|
|
const input = fixture([move(1, C.EFFECT_POISON, 0), move(2)], [0]);
|
|
input.weights = 256;
|
|
input.player.mon.types = [3, 3];
|
|
input.battleData.typeMatchups = () => [
|
|
{ attacker: 0, defender: 3, multiplier: 0 },
|
|
];
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]);
|
|
});
|
|
it("Cautious stops the entire layer on its first failed random check", () => {
|
|
const input = fixture(undefined, [230, 1]);
|
|
input.weights = 128;
|
|
input.enemy.turnsTaken = 1;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
input.random = (() => {
|
|
const bytes = [229, 230, 1];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]);
|
|
});
|
|
it("Offensive penalizes non-damaging moves by two", () => {
|
|
const input = fixture([move(1, C.EFFECT_ATTACK_DOWN, 0), move(2)], [1]);
|
|
input.weights = 8;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([22, 20]);
|
|
});
|
|
it("Opportunist discourages stall moves at low HP with the exact half thresholds", () => {
|
|
const input = fixture(undefined, [0]);
|
|
input.weights = 32;
|
|
input.enemy.mon.hp = 11;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
|
|
input.enemy.mon.hp = 5;
|
|
input.random = () => 0;
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 21]);
|
|
|
|
input.enemy.mon.hp = 8;
|
|
input.random = (() => {
|
|
const bytes = [127, 0];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
input.random = (() => {
|
|
const bytes = [128, 0];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 21]);
|
|
});
|
|
it("Aggressive keeps the last strongest move and spares reckless alternatives", () => {
|
|
const input = fixture(
|
|
[move(1), move(2, C.EFFECT_SELFDESTRUCT), move(3)],
|
|
[1],
|
|
);
|
|
input.weights = 64;
|
|
input.enemy.mon.moves = [1, 2, 3, 0];
|
|
input.enemy.mon.pp = [30, 30, 30, 0];
|
|
input.damage = (id) => (id === 1 ? 5 : 10);
|
|
expect(chooseTrainerMove(input).scores.slice(0, 3)).toEqual([21, 20, 20]);
|
|
});
|
|
it("Risky uses strict KO damage and applies its max-HP and random exclusions", () => {
|
|
const input = fixture([move(1, C.EFFECT_SELFDESTRUCT), move(2)], [1]);
|
|
input.weights = 512;
|
|
input.player.mon.hp = 10;
|
|
input.damage = (id) => (id === 1 ? 100 : 10);
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
|
|
input.enemy.mon.hp = 19;
|
|
input.random = (() => {
|
|
const bytes = [199, 1];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
|
input.random = (() => {
|
|
const bytes = [200, 1];
|
|
return () => bytes.shift()!;
|
|
})();
|
|
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([15, 20]);
|
|
});
|
|
});
|