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 => { 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 => ({ 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 & { 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); });