Translate Opportunist, Aggressive and Risky trainer AI
Add the AI_Opportunist, AI_Aggressive and AI_Risky scoring layers with the ROM's stall, reckless and risky tables, HP thresholds, last-wins ties and strict knockout test. AIDamageCalc is ported as a pre-variation damage estimate, including a critical-hit flag left over from an earlier move, which the original also reads. Smart remains the only refused layer, so the rival stays gated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e92936cb9
commit
0d1bedb3b1
10 files changed
+294
-11
No files matched your search
+1
-1
@@ -71,7 +71,7 @@ Native saves are version 3. Version 2 added scene values by owning map, phone co
|
|||||||
|
|
||||||
`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party.
|
`game/rom/trainers.ts` decodes `TrainerGroups`, `TrainerClassAttributes`, `TrainerClassNames`, `TrainerClassDVs` and map trainer headers. Tests cover all 542 ROM records across 67 classes; CAL2 construction refuses its SRAM-dependent Mystery Gift party.
|
||||||
|
|
||||||
`game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Cautious and Status layers. It preserves the original random tie selection and known redundancy quirks. Smart, Opportunist, Aggressive and Risky remain refused, including the rival's AI. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending.
|
`game/battle/ai.ts` translates `AIChooseMove`, `AI_Redundant` and the Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky layers. It preserves the original random tie selection, known redundancy quirks, HP thresholds, exclusions and the deterministic pre-variation damage estimate used by Aggressive and Risky. Smart remains refused, including the rival's AI. Synthetic scoring vectors establish the tested branches; original-engine AI execution parity is still pending.
|
||||||
|
|
||||||
`loadtrainer`, `winlosstext` and `startbattle` now run single-Pokémon trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script. Production map gates are unchanged. Trainer items, party switching, sight lines, trainer-object interaction and the rival round trip remain unported.
|
`loadtrainer`, `winlosstext` and `startbattle` now run single-Pokémon trainer battles through the existing battle UI. Tests cover Joey's ROM party/text/reward, RUN refusal, trainer experience, Amulet Coin, CANLOSE behavior, money limits, transactional rollback and midbattle restore. Chromium and WebKit exercise the UI with a test-only script. Production map gates are unchanged. Trainer items, party switching, sight lines, trainer-object interaction and the rival round trip remain unported.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -62,10 +62,10 @@ The emulator-backed prototype is now at `/reference.html`. The default applicati
|
|||||||
## Implemented: first scripted trainer battle slice
|
## Implemented: first scripted trainer battle slice
|
||||||
|
|
||||||
- ROM decoder for 542 trainer records across 67 classes, all four party formats, class names, DVs, rewards and map trainer headers. SRAM-dependent Mystery Gift parties remain refused.
|
- ROM decoder for 542 trainer records across 67 classes, all four party formats, class names, DVs, rewards and map trainer headers. SRAM-dependent Mystery Gift parties remain refused.
|
||||||
- Native move scoring for Basic, Setup, Types, Offensive, Cautious and Status, with deterministic tie selection. Other scoring layers remain refused.
|
- Native move scoring for Basic, Setup, Types, Offensive, Opportunist, Aggressive, Cautious, Status and Risky, with deterministic tie selection and the original pre-variation damage estimate. Smart remains refused.
|
||||||
- Scripted single-Pokémon trainer battles support the challenge, sending out, RUN refusal, experience, win/loss text and prize money, including Amulet Coin. Script results, party changes and rewards commit together; interrupted saves replay from committed state.
|
- Scripted single-Pokémon trainer battles support the challenge, sending out, RUN refusal, experience, win/loss text and prize money, including Amulet Coin. Script results, party changes and rewards commit together; interrupted saves replay from committed state.
|
||||||
- Local-ROM tests and Chromium/WebKit tests exercise Youngster Joey through a test-only script. No new maps or trainer encounters are enabled in normal play.
|
- Local-ROM tests and Chromium/WebKit tests exercise Youngster Joey through a test-only script. No new maps or trainer encounters are enabled in normal play.
|
||||||
- Next: advanced AI, trainer party switching/items, trainer sight lines and normal trainer-object scripts, then the rival and Route 30 story. The rival remains refused because its AI is not yet translated.
|
- Next: Smart AI, trainer party switching/items, trainer sight lines and normal trainer-object scripts, then the rival and Route 30 story. The rival remains refused because Smart AI is not yet translated.
|
||||||
|
|
||||||
## In progress: native battles
|
## In progress: native battles
|
||||||
|
|
||||||
|
|||||||
@@ -157,3 +157,41 @@ Mystery Egg), Route 30/46. NPCs do not roam yet.
|
|||||||
Next: NPC roaming movement (`map_objects.asm` random walk/spin, radius and
|
Next: NPC roaming movement (`map_objects.asm` random walk/spin, radius and
|
||||||
visibility rules, deterministic RNG in tests), trainer sight lines and trainer
|
visibility rules, deterministic RNG in tests), trainer sight lines and trainer
|
||||||
battles, Route 30 and Mr. Pokémon, capture and the catching tutorial.
|
battles, Route 30 and Mr. Pokémon, capture and the catching tutorial.
|
||||||
|
|
||||||
|
## Trainer AI continuation: 2026-09-24
|
||||||
|
|
||||||
|
The trainer foundation was subsequently committed through `3e92936`. The next
|
||||||
|
AI increment translates Opportunist, Aggressive and Risky scoring, including
|
||||||
|
the ROM's stall/reckless/risky tables and Aggressive/Risky's deterministic
|
||||||
|
pre-variation damage estimate. Smart is now the only refused move-scoring layer,
|
||||||
|
so the rival remains gated. Continue with Smart before the rival, or with
|
||||||
|
trainer sight lines and normal trainer-object scripts for the already-supported
|
||||||
|
Youngster and Bug Catcher classes on Route 30.
|
||||||
|
|
||||||
|
## AI layer review: 2026-09-24 (resumed from Codex)
|
||||||
|
|
||||||
|
Claude Code checked Codex's uncommitted Opportunist, Aggressive and Risky layers
|
||||||
|
against `engine/battle/ai/scoring.asm`. The HP thresholds, `AI_50_50` (≥128),
|
||||||
|
`79 percent - 1` (200), last-wins ties, the power < 2 and reckless exclusions,
|
||||||
|
the strict-borrow KO test and the constant-damage paths all match.
|
||||||
|
|
||||||
|
One fix: `AIDamageCalc` goes through `EnemyAttackDamage` and
|
||||||
|
`BattleCommand_DamageCalc`, and both read `wCriticalHit`. A flag left over from
|
||||||
|
an earlier move therefore selects unboosted stats (ignoring screens) and
|
||||||
|
doubles the estimate. `enemyAIDamage` now reads the native `criticalHit`
|
||||||
|
instead of hard-coding `false`. It also reuses `truncate`. A local-ROM test
|
||||||
|
fixes the estimate at 4, 3 (Reflect) and 6 (leftover critical).
|
||||||
|
|
||||||
|
Known side effects of `AIDamageCalc` that are not modelled: it overwrites
|
||||||
|
`wCurDamage`, `wTypeModifier`, `wAttackMissed` and `hBattleTurn`. `CheckTurn`
|
||||||
|
resets the modifier and miss flag before each move. Whether the `wCurDamage`
|
||||||
|
overwrite can reach Counter/Mirror Coat still needs checking before those
|
||||||
|
effects are ported.
|
||||||
|
|
||||||
|
Final checks: 147 unit tests with the local ROM; 42 Chromium/WebKit browser
|
||||||
|
tests; native and reference builds; reference, release and vendor checks;
|
||||||
|
`git diff --check`. Not committed.
|
||||||
|
|
||||||
|
Next (unchanged): Smart AI before the rival, or trainer sight lines and
|
||||||
|
normal trainer-object scripts for Route 30's Youngster and Bug Catcher.
|
||||||
|
|
||||||
@@ -43,7 +43,10 @@ const names = {
|
|||||||
trainerClassAttributes: "TrainerClassAttributes",
|
trainerClassAttributes: "TrainerClassAttributes",
|
||||||
trainerClassDVs: "TrainerClassDVs",
|
trainerClassDVs: "TrainerClassDVs",
|
||||||
statusOnlyEffects: "StatusOnlyEffects",
|
statusOnlyEffects: "StatusOnlyEffects",
|
||||||
|
stallMoves: "StallMoves",
|
||||||
|
recklessMoves: "RecklessMoves",
|
||||||
residualMoves: "ResidualMoves",
|
residualMoves: "ResidualMoves",
|
||||||
|
riskyEffects: "RiskyEffects",
|
||||||
mapGroups: "MapGroupPointers",
|
mapGroups: "MapGroupPointers",
|
||||||
tilesets: "Tilesets",
|
tilesets: "Tilesets",
|
||||||
collisions: "CollisionPermissionTable",
|
collisions: "CollisionPermissionTable",
|
||||||
|
|||||||
+59
-1
@@ -25,11 +25,15 @@ export interface TrainerAIInput {
|
|||||||
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
|
battleData: Pick<BattleData, "byteList" | "typeMatchups">;
|
||||||
weather: number;
|
weather: number;
|
||||||
random: () => number;
|
random: () => number;
|
||||||
|
/** AIDamageCalc: maximum pre-variation damage for an enemy move. */
|
||||||
|
damage: (move: number) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
|
// AIScoringPointers: Basic, Setup, Types, Offensive, Smart, Opportunist,
|
||||||
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
|
// Aggressive, Cautious, Status, Risky, then six actual AI_None entries.
|
||||||
const SUPPORTED = 0xfd8f;
|
// Smart (bit 4) is the only translated pointer still refused. Bits 10-15
|
||||||
|
// point to AI_None in the original and are valid no-ops.
|
||||||
|
const SUPPORTED = 0xffef;
|
||||||
export function validateTrainerAI(weights: number): void {
|
export function validateTrainerAI(weights: number): void {
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(weights) ||
|
!Number.isInteger(weights) ||
|
||||||
@@ -207,6 +211,46 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
attributes.forEach((move, i) => {
|
attributes.forEach((move, i) => {
|
||||||
if (!move.power) add(i, 2);
|
if (!move.power) add(i, 2);
|
||||||
});
|
});
|
||||||
|
if (weights & 32) {
|
||||||
|
// AICheckEnemyHalfHP carries only when HP is strictly above half.
|
||||||
|
const hp = enemy.mon.hp,
|
||||||
|
maxHp = enemy.mon.maxHp;
|
||||||
|
if (hp * 2 <= maxHp) {
|
||||||
|
// At or below one quarter the layer always runs. Between one quarter
|
||||||
|
// and one half it runs only on the upper half of Random's byte range.
|
||||||
|
const discourage = hp * 4 <= maxHp || random() >= 128;
|
||||||
|
if (discourage) {
|
||||||
|
const stall = battleData.byteList(SYMBOLS.stallMoves);
|
||||||
|
moves.forEach((move, i) => {
|
||||||
|
if (stall.includes(move)) add(i, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (weights & 64) {
|
||||||
|
// Ties replace the current candidate, so the last strongest slot wins.
|
||||||
|
let strongest = -1,
|
||||||
|
highest = 0;
|
||||||
|
attributes.forEach((move, i) => {
|
||||||
|
if (!move.power) return;
|
||||||
|
const damage = input.damage(moves[i]);
|
||||||
|
if (damage >= highest) {
|
||||||
|
highest = damage;
|
||||||
|
strongest = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (strongest >= 0) {
|
||||||
|
const reckless = battleData.byteList(SYMBOLS.recklessMoves);
|
||||||
|
attributes.forEach((move, i) => {
|
||||||
|
if (
|
||||||
|
i !== strongest &&
|
||||||
|
move.power >= 2 &&
|
||||||
|
!reckless.includes(move.effect)
|
||||||
|
)
|
||||||
|
add(i, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (weights & 128 && enemy.turnsTaken) {
|
if (weights & 128 && enemy.turnsTaken) {
|
||||||
const residual = battleData.byteList(SYMBOLS.residualMoves);
|
const residual = battleData.byteList(SYMBOLS.residualMoves);
|
||||||
for (let i = 0; i < moves.length; i++) {
|
for (let i = 0; i < moves.length; i++) {
|
||||||
@@ -230,6 +274,20 @@ export function chooseTrainerMove(input: TrainerAIInput): {
|
|||||||
add(i, 10);
|
add(i, 10);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (weights & 512) {
|
||||||
|
const risky = battleData.byteList(SYMBOLS.riskyEffects);
|
||||||
|
attributes.forEach((move, i) => {
|
||||||
|
if (!move.power) return;
|
||||||
|
if (risky.includes(move.effect)) {
|
||||||
|
if (enemy.mon.hp === enemy.mon.maxHp) return;
|
||||||
|
// `79 percent - 1` is 200. Bytes below it exclude the move.
|
||||||
|
if (random() < 200) return;
|
||||||
|
}
|
||||||
|
// The original subtracts damage from HP and requires a borrow, so exact
|
||||||
|
// damage equality is not treated as a knockout here.
|
||||||
|
if (input.damage(moves[i]) > player.mon.hp) add(i, -5);
|
||||||
|
});
|
||||||
|
}
|
||||||
// A zero score would wrap to 255 on the first decrement in the ROM.
|
// A zero score would wrap to 255 on the first decrement in the ROM.
|
||||||
const effective = scores.slice(0, moves.length).map((score) => score || 256);
|
const effective = scores.slice(0, moves.length).map((score) => score || 256);
|
||||||
const minimum = Math.min(...effective);
|
const minimum = Math.min(...effective);
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ export class Battle {
|
|||||||
player: this.player, enemy: this.enemy,
|
player: this.player, enemy: this.enemy,
|
||||||
data: this.options.data, battleData: this.data, weather: this.weather,
|
data: this.options.data, battleData: this.data, weather: this.weather,
|
||||||
random: () => this.random(),
|
random: () => this.random(),
|
||||||
|
damage: (move) => this.enemyAIDamage(move),
|
||||||
});
|
});
|
||||||
this.enemy.moveNum = choice.index;
|
this.enemy.moveNum = choice.index;
|
||||||
this.enemy.curMove = choice.move;
|
this.enemy.curMove = choice.move;
|
||||||
@@ -594,6 +595,76 @@ export class Battle {
|
|||||||
throw new UnsupportedBattle("The battle did not end.");
|
throw new UnsupportedBattle("The battle did not end.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AIDamageCalc: pre-variation damage used only for scoring. It omits
|
||||||
|
* accuracy and does not roll a critical hit, but, as in the ROM, a
|
||||||
|
* wCriticalHit left over from an earlier move still selects unboosted
|
||||||
|
* stats and doubles the estimate. */
|
||||||
|
private enemyAIDamage(moveId: number): number {
|
||||||
|
const move = this.options.data.move(moveId);
|
||||||
|
switch (move.effect) {
|
||||||
|
case C.EFFECT_SUPER_FANG:
|
||||||
|
return Math.max(1, this.player.mon.hp >> 1);
|
||||||
|
case C.EFFECT_STATIC_DAMAGE:
|
||||||
|
return move.power;
|
||||||
|
case C.EFFECT_LEVEL_DAMAGE:
|
||||||
|
return this.enemy.mon.level;
|
||||||
|
case C.EFFECT_PSYWAVE: {
|
||||||
|
const limit = this.enemy.mon.level + (this.enemy.mon.level >> 1);
|
||||||
|
for (let attempt = 0; attempt < 4096; attempt++) {
|
||||||
|
const damage = this.random();
|
||||||
|
if (damage && damage < limit) return damage;
|
||||||
|
}
|
||||||
|
throw new UnsupportedBattle("Trainer Psywave AI random source did not produce damage.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!move.power) return 0;
|
||||||
|
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
|
||||||
|
const special = move.type >= C.SPECIAL,
|
||||||
|
attackStat = special ? C.SP_ATTACK : C.ATTACK,
|
||||||
|
defenseStat = special ? C.SP_DEFENSE : C.DEFENSE,
|
||||||
|
screen = special ? C.SCREENS_LIGHT_SCREEN : C.SCREENS_REFLECT;
|
||||||
|
const unboosted =
|
||||||
|
this.criticalHit && this.player.levels[defenseStat] >= this.enemy.levels[attackStat];
|
||||||
|
let attack: number, defense: number;
|
||||||
|
if (unboosted) {
|
||||||
|
attack = this.enemy.stats[attackStat];
|
||||||
|
defense = this.player.stats[defenseStat];
|
||||||
|
} else {
|
||||||
|
attack = this.enemy.mon.stats[attackStat];
|
||||||
|
defense = this.player.mon.stats[defenseStat];
|
||||||
|
if (this.player.screens & (1 << screen)) defense = (defense * 2) & 0xffff;
|
||||||
|
}
|
||||||
|
const boosted = special
|
||||||
|
? this.enemy.mon.species === C.PIKACHU && this.enemy.mon.item === C.LIGHT_BALL
|
||||||
|
: (this.enemy.mon.species === C.CUBONE || this.enemy.mon.species === C.MAROWAK) && this.enemy.mon.item === C.THICK_CLUB;
|
||||||
|
if (boosted) attack = (attack * 2) & 0xffff;
|
||||||
|
[attack, defense] = this.truncate(attack, defense);
|
||||||
|
if (this.player.mon.species === C.DITTO && this.player.mon.item === C.METAL_POWDER)
|
||||||
|
throw new UnsupportedBattle("Metal Powder is not ported yet.");
|
||||||
|
const held = this.data.heldEffect(this.enemy.mon.item),
|
||||||
|
typeBoost = this.data.typeBoost(held.effect),
|
||||||
|
boost = held.effect && typeBoost === move.type ? held.parameter : undefined,
|
||||||
|
baseDamage = calculateDamage({
|
||||||
|
power: move.power,
|
||||||
|
level: this.enemy.mon.level,
|
||||||
|
attack,
|
||||||
|
defense,
|
||||||
|
effect: move.effect,
|
||||||
|
critical: Boolean(this.criticalHit),
|
||||||
|
boost,
|
||||||
|
current: 0,
|
||||||
|
});
|
||||||
|
if (baseDamage === undefined) return 0;
|
||||||
|
return applyTypeMatchups(this.matchups, {
|
||||||
|
damage: baseDamage,
|
||||||
|
modifier: C.EFFECTIVE,
|
||||||
|
moveType: move.type,
|
||||||
|
userTypes: this.enemy.mon.types,
|
||||||
|
targetTypes: this.player.mon.types,
|
||||||
|
identified: this.player.has(SUB1, C.SUBSTATUS_IDENTIFIED),
|
||||||
|
}).damage;
|
||||||
|
}
|
||||||
|
|
||||||
/** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if
|
/** BattleMenu: FIGHT chooses a move; RUN tries to flee. Returns true if
|
||||||
* the battle ended from the menu. */
|
* the battle ended from the menu. */
|
||||||
private *battleMenu(): Gen<boolean> {
|
private *battleMenu(): Gen<boolean> {
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export const C = {
|
|||||||
EFFECT_MULTI_HIT: 29,
|
EFFECT_MULTI_HIT: 29,
|
||||||
EFFECT_CONVERSION: 30,
|
EFFECT_CONVERSION: 30,
|
||||||
EFFECT_TOXIC: 33,
|
EFFECT_TOXIC: 33,
|
||||||
|
EFFECT_OHKO: 38,
|
||||||
EFFECT_RAZOR_WIND: 39,
|
EFFECT_RAZOR_WIND: 39,
|
||||||
|
EFFECT_SUPER_FANG: 40,
|
||||||
|
EFFECT_STATIC_DAMAGE: 41,
|
||||||
EFFECT_DOUBLE_HIT: 44,
|
EFFECT_DOUBLE_HIT: 44,
|
||||||
EFFECT_JUMP_KICK: 45,
|
EFFECT_JUMP_KICK: 45,
|
||||||
EFFECT_ATTACK_DOWN_2: 58,
|
EFFECT_ATTACK_DOWN_2: 58,
|
||||||
@@ -54,6 +57,8 @@ export const C = {
|
|||||||
EFFECT_SUBSTITUTE: 79,
|
EFFECT_SUBSTITUTE: 79,
|
||||||
EFFECT_LEECH_SEED: 84,
|
EFFECT_LEECH_SEED: 84,
|
||||||
EFFECT_DISABLE: 86,
|
EFFECT_DISABLE: 86,
|
||||||
|
EFFECT_LEVEL_DAMAGE: 87,
|
||||||
|
EFFECT_PSYWAVE: 88,
|
||||||
EFFECT_ENCORE: 90,
|
EFFECT_ENCORE: 90,
|
||||||
EFFECT_SLEEP_TALK: 97,
|
EFFECT_SLEEP_TALK: 97,
|
||||||
EFFECT_MEAN_LOOK: 106,
|
EFFECT_MEAN_LOOK: 106,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export const SYMBOLS = {
|
|||||||
trainerClassAttributes: 0x3959c,
|
trainerClassAttributes: 0x3959c,
|
||||||
trainerClassDVs: 0x270d6,
|
trainerClassDVs: 0x270d6,
|
||||||
statusOnlyEffects: 0x385db,
|
statusOnlyEffects: 0x385db,
|
||||||
|
stallMoves: 0x39348,
|
||||||
|
recklessMoves: 0x393e2,
|
||||||
residualMoves: 0x39446,
|
residualMoves: 0x39446,
|
||||||
|
riskyEffects: 0x394ff,
|
||||||
mapGroups: 0x94000,
|
mapGroups: 0x94000,
|
||||||
tilesets: 0x4d596,
|
tilesets: 0x4d596,
|
||||||
collisions: 0x4ce1f,
|
collisions: 0x4ce1f,
|
||||||
|
|||||||
+77
-7
@@ -39,6 +39,9 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
|
|||||||
player: side(),
|
player: side(),
|
||||||
enemy: side(),
|
enemy: side(),
|
||||||
weather: 0,
|
weather: 0,
|
||||||
|
damage: () => {
|
||||||
|
throw new Error("Unexpected damage estimate");
|
||||||
|
},
|
||||||
random: () => {
|
random: () => {
|
||||||
if (cursor >= bytes.length)
|
if (cursor >= bytes.length)
|
||||||
throw new Error("Unexpected extra random call");
|
throw new Error("Unexpected extra random call");
|
||||||
@@ -51,20 +54,35 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
battleData: {
|
battleData: {
|
||||||
byteList: (address) =>
|
byteList: (address) => {
|
||||||
address === SYMBOLS.statusOnlyEffects
|
if (address === SYMBOLS.statusOnlyEffects)
|
||||||
? [C.EFFECT_SLEEP, C.EFFECT_TOXIC, C.EFFECT_POISON, C.EFFECT_PARALYZE]
|
return [
|
||||||
: [1, 2],
|
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: () => [],
|
typeMatchups: () => [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("trainer AI native scoring", () => {
|
describe("trainer AI native scoring", () => {
|
||||||
it("rejects untranslated layers, including Rival1, before consuming randomness", () => {
|
it("rejects the untranslated Smart layer, including Rival1, before consuming randomness", () => {
|
||||||
for (const weights of [16, 32, 64, 512, 0x3d3, -1, 65536])
|
for (const weights of [16, 0x3d3, -1, 65536])
|
||||||
expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
|
expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
|
||||||
expect(() => validateTrainerAI(0xfd8f)).not.toThrow();
|
expect(() => validateTrainerAI(0xffef)).not.toThrow();
|
||||||
});
|
});
|
||||||
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
|
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
|
||||||
const input = fixture(undefined, [3, 6, 253]);
|
const input = fixture(undefined, [3, 6, 253]);
|
||||||
@@ -204,4 +222,56 @@ describe("trainer AI native scoring", () => {
|
|||||||
input.weights = 8;
|
input.weights = 8;
|
||||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([22, 20]);
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type BattleInput,
|
type BattleInput,
|
||||||
type BattleOptions,
|
type BattleOptions,
|
||||||
} from "../src/game/battle/battle";
|
} from "../src/game/battle/battle";
|
||||||
|
import { C } from "../src/game/battle/constants";
|
||||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||||
import { defaultTextContext } from "../src/game/scripts/text";
|
import { defaultTextContext } from "../src/game/scripts/text";
|
||||||
|
|
||||||
@@ -119,6 +120,40 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
|
|||||||
run.next({ action: "fight", move: 0 });
|
run.next({ action: "fight", move: 0 });
|
||||||
expect(battle.enemy.curMove).toBe(165);
|
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", () => {
|
it("loses without receiving prize money", () => {
|
||||||
const { mon, options } = setup(2);
|
const { mon, options } = setup(2);
|
||||||
mon.hp = 1;
|
mon.hp = 1;
|
||||||
|
|||||||
Reference in new issue
Block a user