Files
Crystal/tests/reference-move-effects.spec.ts
T
Sébastien QUEROL 32d736ecae
Checks / web (push) Canceled after 0s
Port weather, copying moves, held items and obedience (#28)
Complete the weather, Substitute, Transform, called-move and forced-switch effects. Add held-item behavior, Exp. Share awards and badge-based traded Pokemon obedience, and persist trainer berry consumption.

Validated with 576 passing unit tests and 149 browser tests, native/reference builds, reference/vendor/release checks and 320 Metal Powder emulator comparisons per browser. Smart AI and special balls/battle types remain on #28.
2026-10-03 15:03:19 +02:00

649 lines
26 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;
weather?: 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"),
weather: at("wBattleWeather"),
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,
weather: input.weather ?? 0,
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.weather, input.weather ?? 0);
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);
});
test("Stab matches weather, STAB and type effectiveness", async ({ page }) => {
const { make, next } = generator(0x57ab);
// Normal, Fighting, Flying, Ground, Ghost, Fire, Water, Grass, Electric, Psychic, Dark.
const types = [0, 1, 2, 4, 8, 20, 21, 22, 23, 24, 27];
const cases: Case[] = [];
for (let i = 0; i < 1000; i++) {
const input = make({
curDamage: [0, 1, 2, 3, 40, 997, 0x7fff, 0xaaaa, 0xffff][next(9)],
typeModifier: next(4) ? C.EFFECTIVE : 0,
});
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, type: types[next(types.length)], effect: next(4) ? 0 : C.EFFECT_SOLARBEAM };
user.types = [types[next(types.length)], types[next(types.length)]];
target.types = [types[next(types.length)], types[next(types.length)]];
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
cases.push({ ...input, weather: next(4) } as Case);
}
await compare(page, ["BattleCommand_Stab"], cases);
});