Files
Crystal/tests/reference-move-effects.spec.ts
kawaandClaude Opus 5.5 b5c2b19094
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s
Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:09:47 +02:00

651 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,
items: [0, 0],
baseReward: 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);
});