Files
Crystal/tests/reference-move-effects.spec.ts
T
Sébastien QUEROLandClaude Opus 5.5 c53edb77e5 Port the moves that span several turns
A recharging, charged, rampaging or rolling Pokémon plays on without the
battle menu on either side, and a biding one uses FIGHT without the move
list. Razor Wind, Solarbeam, Skull Bash, Sky Attack, Fly and Dig charge
first; flying and digging Pokémon can only be hit by the original's
exceptions. Hyper Beam recharges, Thrash and its kind end in confusion,
Rollout and Fury Cutter double up, and Bide returns twice the damage it
took.

The trapping moves hurt their target between turns until it is released,
Mind Reader and Lock-On make the next move hit, and Pursuit strikes a
Pokémon being withdrawn first, on either side.

The new commands are compared with the original in the reference
emulator. Its fixture no longer zeroes wPlayerScreens with its sentinel.

Part of #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:57:46 +02:00

624 lines
25 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test";
import { existsSync } from "node:fs";
import { C, COMMAND } from "../src/game/battle/constants";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
/** One side's battle state: its battle_struct, move struct, substatuses,
* stat levels, unboosted stats, screens and counters. */
type Side = {
species: number;
item: number;
dvs: number;
happiness: number;
level: number;
status: number;
hp: number;
maxHp: number;
stats: number[];
types: [number, number];
move: { anim: number; effect: number; power: number; type: number; accuracy: number };
sub: number[];
levels: number[];
unboosted: number[];
screens: number;
rollout: number;
damageTaken: number;
lastCounterMove: number;
furyCutter: number;
confuse: number;
wrap: number;
trappingMove: number;
};
type Case = {
turn: number;
seeds: number[];
count: number;
sides: [Side, Side];
curDamage: number;
criticalHit: number;
attackMissed: number;
typeModifier: number;
enemyGoesFirst: number;
animParam: number;
battleMode: number;
partyCount: number;
otPartyCount: number;
beatUpHit: number;
playerIsSwitching: number;
enemyIsSwitching: number;
/** A battle script and the index of the command after the one run. */
script: number[];
scriptIndex: number;
};
function generator(start: number) {
let seed = start;
const next = (limit: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return (seed >>> 16) % limit;
};
const side = (overrides: Partial<Side> = {}): Side => {
const maxHp = 20 + next(300);
return {
species: 155,
item: 0,
dvs: next(0x10000),
happiness: next(256),
level: 5 + next(95),
status: 0,
hp: 1 + next(maxHp),
maxHp,
stats: Array.from({ length: 5 }, () => 5 + next(400)),
types: [C.NORMAL, C.NORMAL],
move: { anim: 33, effect: 0, power: 40, type: C.NORMAL, accuracy: 255 },
sub: [0, 0, 0, 0, 0],
levels: [7, 7, 7, 7, 7, 7, 7],
unboosted: Array.from({ length: 5 }, () => 5 + next(400)),
screens: 0,
rollout: 0,
damageTaken: 0,
lastCounterMove: 0,
furyCutter: 0,
confuse: 0,
wrap: 0,
trappingMove: 0,
...overrides,
};
};
const make = (overrides: Partial<Case> = {}): Case => ({
turn: next(2),
seeds: Array.from({ length: 10 }, () => next(256)),
count: next(9),
sides: [side(), side()],
curDamage: next(1000),
criticalHit: 0,
attackMissed: 0,
typeModifier: C.EFFECTIVE,
enemyGoesFirst: next(2),
animParam: 0,
battleMode: C.TRAINER_BATTLE,
partyCount: 1 + next(6),
otPartyCount: 1 + next(6),
beatUpHit: 1,
playerIsSwitching: 0,
enemyIsSwitching: 0,
script: [COMMAND.critical, COMMAND.endmove],
scriptIndex: 1,
...overrides,
});
return { next, side, make };
}
/** Run the unpatched commands in the reference emulator with the Link RNG,
* and the native commands on the same state; compare what they leave. */
async function compare(page: Page, routines: string[], cases: Case[]) {
const { at, routine } = loadSymbols();
const commands = routines.map((name) => {
const symbol = routine(name);
if (symbol.bank !== 0x0d) throw new Error(`${name} is not in bank $0d.`);
return {
address: symbol.address,
command: COMMAND[
name.replace("BattleCommand_", "").toLowerCase() as keyof typeof COMMAND
],
};
});
const addresses = {
romBank: at("hROMBank"),
turn: at("hBattleTurn"),
link: at("wLinkMode"),
seeds: at("wLinkBattleRNs"),
count: at("wLinkBattleRNCount"),
damage: at("wCurDamage"),
critical: at("wCriticalHit"),
missed: at("wAttackMissed"),
typeModifier: at("wTypeModifier"),
typeMatchup: at("wTypeMatchup"),
enemyGoesFirst: at("wEnemyGoesFirst"),
animParam: at("wBattleAnimParam"),
battleMode: at("wBattleMode"),
partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"),
playerIsSwitching: at("wPlayerIsSwitching"),
enemyIsSwitching: at("wEnemyIsSwitching"),
scriptBuffer: at("wBattleScriptBuffer"),
scriptAddress: at("wBattleScriptBufferAddress"),
sides: [
{
mon: at("wBattleMonSpecies"),
move: at("wPlayerMoveStruct"),
sub: at("wPlayerSubStatus1"),
levels: at("wPlayerStatLevels"),
stats: at("wPlayerStats"),
screens: at("wPlayerScreens"),
rollout: at("wPlayerRolloutCount"),
damageTaken: at("wPlayerDamageTaken"),
lastCounterMove: at("wLastPlayerCounterMove"),
furyCutter: at("wPlayerFuryCutterCount"),
confuse: at("wPlayerConfuseCount"),
wrap: at("wPlayerWrapCount"),
trappingMove: at("wPlayerTrappingMove"),
},
{
mon: at("wEnemyMonSpecies"),
move: at("wEnemyMoveStruct"),
sub: at("wEnemySubStatus1"),
levels: at("wEnemyStatLevels"),
stats: at("wEnemyStats"),
screens: at("wEnemyScreens"),
rollout: at("wEnemyRolloutCount"),
damageTaken: at("wEnemyDamageTaken"),
lastCounterMove: at("wLastEnemyCounterMove"),
furyCutter: at("wEnemyFuryCutterCount"),
confuse: at("wEnemyConfuseCount"),
wrap: at("wEnemyWrapCount"),
trappingMove: at("wEnemyTrappingMove"),
},
],
};
await openReference(page);
const results = await page.evaluate(
async ({ addresses, commands, cases }) => {
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 file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const rom = new Uint8Array(await file.arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (address: number, value: number) =>
reference.writeMemory(address, value & 255);
const word = (address: number, value: number) => {
write(address, value >> 8);
write(address + 1, value);
};
const read = (address: number) => reference.readMemory(address);
const readWord = (address: number) => (read(address) << 8) | read(address + 1);
// DI; SP=$c7fe; select bank $0d; CALL each command; sentinel; loop.
// The program and its sentinel ($c0f0) sit in audio RAM, which
// nothing reads with interrupts off.
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, 0x0d, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20];
for (const { address } of commands) program.push(0xcd, address & 255, address >> 8);
program.push(0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe);
write(0xff70, 1);
write(addresses.link, 1);
const results = cases.map((input, index) => {
let seeds = [...input.seeds],
count = input.count,
draws = 0;
const party = Array.from({ length: input.partyCount }, () => ({}));
const battle = new Battle({
data: assets.pokemon,
party: party as never,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1,
playerName: "CHRIS",
timeOfDay: 1,
text: () => ({}) as never,
trainer: {
name: "",
className: "",
party: Array.from({ length: input.otPartyCount }, () => ({})) as never,
aiFlags: 0,
switchFlags: 0,
reward: 0,
},
random: () => {
if (++draws > 1024) throw new Error(`Unbounded RNG in case ${index}`);
const value = seeds[count++];
if (count >= 9) {
count = 0;
seeds = seeds.map((value) => (value * 5 + 1) & 255);
}
return value;
},
});
const internal = battle as unknown as Record<string, unknown> & {
command(id: number): Generator;
};
Object.assign(internal, {
turn: input.turn,
curDamage: input.curDamage,
criticalHit: input.criticalHit,
attackMissed: input.attackMissed,
typeModifier: input.typeModifier,
enemyGoesFirst: input.enemyGoesFirst,
animParam: input.animParam,
battleMode: input.battleMode,
beatUpHit: input.beatUpHit,
playerIsSwitching: input.playerIsSwitching,
enemyIsSwitching: input.enemyIsSwitching,
script: [...input.script],
scriptIndex: input.scriptIndex,
});
write(addresses.turn, input.turn);
word(addresses.damage, input.curDamage);
write(addresses.critical, input.criticalHit);
write(addresses.missed, input.attackMissed);
write(addresses.typeModifier, input.typeModifier);
write(addresses.typeMatchup, 10);
write(addresses.enemyGoesFirst, input.enemyGoesFirst);
write(addresses.animParam, input.animParam);
write(addresses.battleMode, input.battleMode);
write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit);
write(addresses.playerIsSwitching, input.playerIsSwitching);
write(addresses.enemyIsSwitching, input.enemyIsSwitching);
write(addresses.count, input.count);
input.seeds.forEach((value, i) => write(addresses.seeds + i, value));
input.script.forEach((value, i) => write(addresses.scriptBuffer + i, value));
const scriptAddress = addresses.scriptBuffer + input.scriptIndex;
write(addresses.scriptAddress, scriptAddress);
write(addresses.scriptAddress + 1, scriptAddress >> 8);
[battle.player, battle.enemy].forEach((side, i) => {
const s = input.sides[i],
a = addresses.sides[i];
side.mon = {
species: s.species,
item: s.item,
moves: [s.move.anim, 0, 0, 0],
dvs: s.dvs,
pp: [10, 0, 0, 0],
happiness: s.happiness,
level: s.level,
status: s.status,
hp: s.hp,
maxHp: s.maxHp,
stats: [...s.stats],
types: [...s.types],
nickname: "",
} as never;
side.move = { ...s.move, pp: 10, chance: 0 };
side.sub = [0, ...s.sub];
side.levels = [...s.levels];
side.stats = [...s.unboosted];
side.screens = s.screens;
side.rolloutCount = s.rollout;
side.damageTaken = s.damageTaken;
side.lastCounterMove = s.lastCounterMove;
side.furyCutterCount = s.furyCutter;
side.confuseCount = s.confuse;
side.wrapCount = s.wrap;
side.trappingMove = s.trappingMove;
const mon = a.mon;
write(mon, s.species);
write(mon + 1, s.item);
word(mon + 6, s.dvs);
write(mon + 12, s.happiness);
write(mon + 13, s.level);
write(mon + 14, s.status);
word(mon + 16, s.hp);
word(mon + 18, s.maxHp);
s.stats.forEach((value, j) => word(mon + 20 + j * 2, value));
write(mon + 30, s.types[0]);
write(mon + 31, s.types[1]);
[s.move.anim, s.move.effect, s.move.power, s.move.type, s.move.accuracy, 10, 0].forEach(
(value, j) => write(a.move + j, value),
);
s.sub.forEach((value, j) => write(a.sub + j, value));
s.levels.forEach((value, j) => write(a.levels + j, value));
s.unboosted.forEach((value, j) => word(a.stats + j * 2, value));
write(a.screens, s.screens);
write(a.rollout, s.rollout);
word(a.damageTaken, s.damageTaken);
write(a.lastCounterMove, s.lastCounterMove);
write(a.furyCutter, s.furyCutter);
write(a.confuse, s.confuse);
write(a.wrap, s.wrap);
write(a.trappingMove, s.trappingMove);
});
for (const { command } of commands) {
const run = internal.command(command);
if (!run.next().done) throw new Error(`Case ${index} printed a text.`);
}
write(0xc0f0, 0);
program.forEach((value, i) => write(0xc000 + i, value));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw new Error(`Case ${index} did not return`);
const native = {
damage: internal.curDamage,
critical: internal.criticalHit,
missed: internal.attackMissed,
typeModifier: internal.typeModifier,
typeMatchup: internal.matchup,
animParam: internal.animParam,
script: (internal.script as number[]).slice(0, input.script.length),
scriptIndex: internal.scriptIndex,
sides: [battle.player, battle.enemy].map((side) => ({
power: side.move.power,
type: side.move.type,
accuracy: side.move.accuracy,
sub: side.sub.slice(1),
rollout: side.rolloutCount,
damageTaken: side.damageTaken,
furyCutter: side.furyCutterCount,
confuse: side.confuseCount,
wrap: side.wrapCount,
})),
count,
seeds,
};
const original = {
damage: readWord(addresses.damage),
critical: read(addresses.critical),
missed: read(addresses.missed),
typeModifier: read(addresses.typeModifier),
typeMatchup: read(addresses.typeMatchup),
animParam: read(addresses.animParam),
script: input.script.map((_, i) => read(addresses.scriptBuffer + i)),
scriptIndex:
((read(addresses.scriptAddress + 1) << 8) | read(addresses.scriptAddress)) -
addresses.scriptBuffer,
sides: addresses.sides.map((a) => ({
power: read(a.move + 2),
type: read(a.move + 3),
accuracy: read(a.move + 4),
sub: [0, 1, 2, 3, 4].map((j) => read(a.sub + j)),
rollout: read(a.rollout),
damageTaken: readWord(a.damageTaken),
furyCutter: read(a.furyCutter),
confuse: read(a.confuse),
wrap: read(a.wrap),
})),
count: read(addresses.count),
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
};
return { native, original, draws };
});
reference.dispose();
return results;
},
{ addresses, commands, cases },
);
results.forEach((result, index) =>
expect(result.native, `case ${index}: ${JSON.stringify(cases[index])}`).toEqual(result.original),
);
return results;
}
test.beforeEach(() => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
});
const GHOST = 8,
PSYCHIC_TYPE = 24,
DARK = 27,
FIGHTING = 1,
WATER = 21;
test("ConstantDamage matches level, fixed, Psywave, Super Fang and Reversal damage", async ({ page }) => {
const { make, side, next } = generator(0x5eed);
const cases: Case[] = [];
const effects = [C.EFFECT_LEVEL_DAMAGE, C.EFFECT_STATIC_DAMAGE, C.EFFECT_PSYWAVE, C.EFFECT_SUPER_FANG, C.EFFECT_REVERSAL];
for (const effect of effects)
for (let i = 0; i < 160; i++) {
const input = make();
const user = input.sides[input.turn];
user.move = { ...user.move, effect, power: [1, 20, 40, 100][next(4)], type: [C.NORMAL, FIGHTING, WATER][next(3)] };
if (effect === C.EFFECT_PSYWAVE && user.level < 2) user.level = 2;
if (effect === C.EFFECT_REVERSAL) {
// HP bar boundaries and maxima above 255, with stats up to 999 so
// TruncateHL_BC scales them.
user.maxHp = [48, 200, 255, 256, 700, 999][next(6)];
user.hp = i < 48 ? Math.max(1, Math.floor((user.maxHp * i) / 48)) : 1 + next(user.maxHp);
user.stats = user.stats.map(() => 1 + next(999));
input.sides[input.turn ^ 1].stats = input.sides[0].stats.map(() => 1 + next(999));
}
if (effect === C.EFFECT_SUPER_FANG) input.sides[input.turn ^ 1].hp = [1, 2, 3, 255, 256, 511, 999][next(7)];
cases.push(input);
}
// Psywave's rejection loop through a regenerated stream.
cases.push(make({ turn: 0, sides: [side({ level: 10, move: { anim: 149, effect: C.EFFECT_PSYWAVE, power: 1, type: 24, accuracy: 255 } }), side()], seeds: Array(10).fill(200) }));
await compare(page, ["BattleCommand_ConstantDamage"], cases);
});
test("OHKO matches level checks, accuracy bonus and one-hit KO damage", async ({ page }) => {
const { make, next } = generator(0x0b0e);
const cases: Case[] = [];
for (let i = 0; i < 600; i++) {
const input = make({ typeModifier: next(4) ? C.EFFECTIVE : 0 });
const user = input.sides[input.turn];
user.move = { ...user.move, effect: C.EFFECT_OHKO, power: 1, accuracy: [0, 76, 178, 255][next(4)] };
if (next(3)) input.sides[input.turn ^ 1].level = Math.max(1, user.level - next(100));
cases.push(input);
}
await compare(page, ["BattleCommand_OHKO"], cases);
});
test("Counter and Mirror Coat match their move, type, order and damage checks", async ({ page }) => {
const { make, next } = generator(0xc0de);
const cases: Case[] = [];
// Physical, special, status, Counter, Mirror Coat, no move.
const lastMoves = [33, 52, 45, 68, 243, 0, 89, 94, 149];
for (const [routine, effect, type] of [
["BattleCommand_Counter", C.EFFECT_COUNTER, FIGHTING],
["BattleCommand_MirrorCoat", C.EFFECT_MIRROR_COAT, PSYCHIC_TYPE],
] as const) {
const batch: Case[] = [];
for (let i = 0; i < 400; i++) {
const input = make({ curDamage: [0, 1, 55, 997, 0x7fff, 0x8000, 0xffff][next(7)] });
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, effect, type, power: 1 };
target.lastCounterMove = lastMoves[next(lastMoves.length)];
target.types = [[C.NORMAL, GHOST, DARK, PSYCHIC_TYPE][next(4)], [C.NORMAL, GHOST, DARK][next(3)]] as [number, number];
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
batch.push(input);
}
await compare(page, [routine], batch);
cases.push(...batch);
}
expect(cases.length).toBe(800);
});
test("EndLoop matches the hit count draws, Beat Up counts and the loop back", async ({ page }) => {
const { make, next } = generator(0x100b);
const cases: Case[] = [];
// checkhit, critical, damagestats, damagecalc, endloop | raisesub, endmove
const script = [COMMAND.checkhit, COMMAND.critical, COMMAND.damagestats, COMMAND.damagecalc, COMMAND.endloop, COMMAND.raisesub, COMMAND.endmove, 0, 0];
const effects = [C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP];
for (const effect of effects)
for (let i = 0; i < 200; i++) {
const input = make({ script, scriptIndex: 5, battleMode: next(3) ? C.TRAINER_BATTLE : C.WILD_BATTLE });
const user = input.sides[input.turn];
user.move = { ...user.move, effect };
user.damageTaken = next(0x10000);
if (i % 2) {
// A later pass that loops back again.
user.sub[2] = 1 << C.SUBSTATUS_IN_LOOP;
user.rollout = 2 + next(4);
}
// A single Triple Kick, or a final pass, prints "Hit n times!"; the
// fixture cannot answer texts, so those draws are moved on.
if (effect === C.EFFECT_TRIPLE_KICK && !(i % 2)) {
let p = input.count;
while (!(input.seeds[p] & 3) && p < 8) p++;
if ((input.seeds[p] & 3) === 1) input.seeds[p] |= 2;
if (p === 8 && !(input.seeds[p] & 3)) input.seeds[p] = 2;
}
cases.push(input);
}
await compare(page, ["BattleCommand_EndLoop"], cases);
});
test("Triple Kick, Hidden Power and False Swipe match their damage changes", async ({ page }) => {
const { make, next } = generator(0x3ce);
const kicks: Case[] = [];
for (let i = 0; i < 200; i++)
kicks.push(make({ animParam: next(4), curDamage: [0, 1, 300, 997, 0x5555, 0x8000, 0xffff][next(7)] }));
await compare(page, ["BattleCommand_TripleKick"], kicks);
// Hidden Power's type and power, then the damage they give.
const hidden: Case[] = [];
for (let i = 0; i < 600; i++) {
const input = make({ curDamage: 0 });
const user = input.sides[input.turn];
user.dvs = i < 256 ? (i << 8) | (i ^ 0x5a) : next(0x10000);
user.move = { ...user.move, effect: C.EFFECT_HIDDEN_POWER, power: 1, anim: 237 };
hidden.push(input);
}
await compare(page, ["BattleCommand_HiddenPower", "BattleCommand_DamageCalc"], hidden);
const swipes: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ criticalHit: [0, 1, 2][next(3)], curDamage: next(600) });
input.sides[input.turn ^ 1].hp = [1, 2, 255, 256, next(600)][next(5)];
swipes.push(input);
}
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
});
test("Rollout, Fury Cutter, rampages, charges and Pursuit match their counters and damage", async ({ page }) => {
const { make, next } = generator(0x2011);
const damage = () => [0, 1, 40, 997, 0x3fff, 0x8000, 0xffff][next(7)];
const rollout: Case[] = [];
for (let i = 0; i < 400; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
user.status = next(5) ? 0 : 2;
if (next(2)) user.sub[1] = 1 << C.SUBSTATUS_CURLED;
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
rollout.push(input);
}
await compare(page, ["BattleCommand_RolloutPower"], rollout);
const fury: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
input.sides[input.turn].furyCutter = next(8);
fury.push(input);
}
await compare(page, ["BattleCommand_FuryCutter"], fury);
// checkrampage, checkobedience, doturn, rampage | usedmovetext, endmove
const rampageScript = [COMMAND.checkrampage, COMMAND.checkobedience, COMMAND.doturn, COMMAND.rampage, COMMAND.usedmovetext, COMMAND.endmove];
const rampage: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ script: rampageScript, scriptIndex: 1 });
const user = input.sides[input.turn];
if (next(4)) user.sub[2] = 1 << C.SUBSTATUS_RAMPAGE;
user.rollout = 1 + next(3);
user.confuse = next(6);
if (next(3) === 0) user.screens = 1 << C.SCREENS_SAFEGUARD;
if (next(3) === 0) input.sides[input.turn ^ 1].screens = 1 << C.SCREENS_SAFEGUARD;
rampage.push(input);
}
await compare(page, ["BattleCommand_CheckRampage"], rampage);
const start: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make();
input.sides[input.turn].status = next(3) ? 0 : 3;
start.push(input);
}
await compare(page, ["BattleCommand_Rampage"], start);
// checkrollout, checkobedience, doturn | usedmovetext, endmove
const checkRollout: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkrollout, COMMAND.checkobedience, COMMAND.doturn, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
checkRollout.push(input);
}
await compare(page, ["BattleCommand_CheckRollout"], checkRollout);
// checkcharge, checkobedience, doturn, charge | usedmovetext, endmove
const checkCharge: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkcharge, COMMAND.checkobedience, COMMAND.doturn, COMMAND.charge, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
input.sides[input.turn].sub[2] = next(256) & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND) | 1);
checkCharge.push(input);
}
await compare(page, ["BattleCommand_CheckCharge"], checkCharge);
const pursuit: Case[] = [];
for (let i = 0; i < 100; i++)
pursuit.push(make({ curDamage: damage(), playerIsSwitching: next(2), enemyIsSwitching: next(2) }));
await compare(page, ["BattleCommand_Pursuit"], pursuit);
});
test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
const { make, next } = generator(0xf1d);
const moves = [16, 18, 87, 239, 89, 90, 222, 33]; // Gust, Whirlwind, Thunder, Twister, Earthquake, Fissure, Magnitude, Tackle
const cases: Case[] = [];
for (let i = 0; i < 800; i++) {
const input = make({ curDamage: next(1000) });
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, anim: moves[next(moves.length)], accuracy: [0, 128, 255][next(3)], effect: next(4) ? 0 : C.EFFECT_JUMP_KICK };
target.sub[2] = [0, 1 << C.SUBSTATUS_FLYING, 1 << C.SUBSTATUS_UNDERGROUND, (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)][next(4)];
if (next(2)) target.sub[4] = 1 << C.SUBSTATUS_LOCK_ON;
cases.push(input);
}
await compare(page, ["BattleCommand_CheckHit"], cases);
});