Checks / web (push) Canceled after 0s
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.
649 lines
26 KiB
TypeScript
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);
|
|
});
|