import { test, expect } from "@playwright/test"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { BattleData } from "../src/game/battle/data"; import { Battle, applyTypeMatchups, calculateDamage, restoreHealth, restoredPP, sendOutText, withdrawText, } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { NATIVE_MAPS } from "../src/game/rom/profile"; import { calcStat, maxPP } from "../src/game/party/pokemon"; import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture"; test("native decompression matches original GB assembly executing in the reference emulator", async ({ page, }) => { test.skip(!existsSync(romPath), "Requires the local user ROM."); await openReference(page); const result = await page.evaluate(async () => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { decompress } = await import("/src/game/rom/lz.ts"); const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts"); const file = ( document.querySelector("#reference-fixture") as HTMLInputElement ).files![0]; const bytes = new Uint8Array(await file.arrayBuffer()); const canvas = document.createElement("canvas"); const reference = new BinjgbEmulator(); const assets = new CrystalAssets(bytes); await reference.load(bytes, canvas); const comparisons: { map: string; bytes: number; equal: boolean; returned: boolean; }[] = []; for (const id of Object.keys(NATIVE_MAPS)) { const map = assets.map(id); const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15); const bank = Math.floor(pointer / 0x4000), address = 0x4000 + (pointer % 0x4000); const native = decompress(assets.reader, pointer); // Original test trampoline in WRAM: DI; SP=$c7fe; A=bank; // HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40); // write a sentinel and loop. The referenced routine is the user's ROM. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0x21, address & 255, address >> 8, 0x11, 0, 0xd0, 0xcd, 0x40, 0x0b, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; reference.writeMemory(0xff70, 6); reference.writeMemory(0xff9d, 1); reference.writeMemory(0xc6ff, 0); program.forEach((value, index) => reference.writeMemory(0xc000 + index, value), ); reference.setProgramCounter(0xc000); reference.advanceFrames(30); comparisons.push({ map: id, bytes: native.length, returned: reference.readMemory(0xc6ff) === 0x42, equal: native.every( (value, index) => reference.readMemory(0xd000 + index) === value, ), }); } reference.dispose(); return comparisons; }); expect(result.length).toBe(Object.keys(NATIVE_MAPS).length); for (const comparison of result) { expect( comparison.returned, `${comparison.map}: original routine returned`, ).toBe(true); expect( comparison.equal, `${comparison.map}: ${comparison.bytes} decompressed bytes match`, ).toBe(true); } }); test("native damage and type effectiveness match the original battle routines", async ({ page, }) => { test.skip( !existsSync(romPath) || !existsSync(symbolsPath), "Requires the local user ROM and the private PRET rebuild.", ); const { at, routine } = loadSymbols(); const bytes = new Uint8Array(readFileSync(romPath)); const assets = new CrystalAssets(bytes); const data = new BattleData(assets.reader); const matchups = data.typeMatchups(); // A deterministic generator keeps failures reproducible. let seed = 0x1234; const next = (limit: number) => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 8) % limit; }; const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27]; const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION]; const damageCases = Array.from({ length: 400 }, () => { const item = next(4) === 0 ? next(191) : 0; return { power: next(8) === 0 ? 0 : next(256), level: 1 + next(100), attack: next(256), defense: next(256), effect: effects[next(effects.length)], critical: next(2), current: next(5) === 0 ? next(1200) : 0, type: types[next(types.length)], item, }; }); const stabCases = Array.from({ length: 400 }, () => ({ move: 1 + next(164), type: types[next(types.length)], user: [types[next(types.length)], types[next(types.length)]], target: [types[next(types.length)], types[next(types.length)]], identified: next(4) === 0, damage: next(1000), })); await openReference(page); const addresses = { curDamage: at("wCurDamage"), critical: at("wCriticalHit"), turn: at("hBattleTurn"), playerMove: at("wPlayerMoveStruct"), battleItem: at("wBattleMonItem"), battleTypes: at("wBattleMonType1"), enemyTypes: at("wEnemyMonType1"), typeModifier: at("wTypeModifier"), attackMissed: at("wAttackMissed"), weather: at("wBattleWeather"), johto: at("wJohtoBadges"), kanto: at("wKantoBadges"), link: at("wLinkMode"), tower: at("wInBattleTowerBattle"), enemySub1: at("wEnemySubStatus1"), damageCalc: routine("BattleCommand_DamageCalc"), stab: routine("BattleCommand_Stab"), }; const results = await page.evaluate( async ({ addresses, damageCases, stabCases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0]; const reference = new BinjgbEmulator(); await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas")); const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff); const word = (address: number, value: number) => { write(address, value >> 8); write(address + 1, value); }; // Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E; // CALL the original routine; write a sentinel; loop. const call = (target: { bank: number; address: number }, registers: number[]) => { const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20, 0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3], 0xcd, target.address & 0xff, target.address >> 8, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; write(0xc6ff, 0); program.forEach((value, index) => write(0xc000 + index, value)); reference.setProgramCounter(0xc000); reference.advanceFrames(2); return reference.readMemory(0xc6ff) === 0x42; }; write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower]) write(address, 0); write(addresses.turn, 0); const damage = damageCases.map((input) => { write(addresses.playerMove + 1, input.effect); write(addresses.playerMove + 3, input.type); write(addresses.battleItem, input.item); write(addresses.critical, input.critical); word(addresses.curDamage, input.current); // B = attack, C = defense, D = power, E = level const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]); return { returned, damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), }; }); const stab = stabCases.map((input) => { write(addresses.playerMove, input.move); write(addresses.playerMove + 3, input.type); write(addresses.battleTypes, input.user[0]); write(addresses.battleTypes + 1, input.user[1]); write(addresses.enemyTypes, input.target[0]); write(addresses.enemyTypes + 1, input.target[1]); write(addresses.enemySub1, input.identified ? 1 << 3 : 0); write(addresses.typeModifier, 10); write(addresses.attackMissed, 0); word(addresses.curDamage, input.damage); const returned = call(addresses.stab, [0, 0, 0, 0]); return { returned, damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1), modifier: reference.readMemory(addresses.typeModifier), missed: reference.readMemory(addresses.attackMissed), }; }); reference.dispose(); return { damage, stab }; }, { addresses, damageCases, stabCases }, ); damageCases.forEach((input, index) => { const original = results.damage[index]; expect(original.returned, `damage case ${index} returned`).toBe(true); const held = data.heldEffect(input.item); const boost = held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined; const native = calculateDamage({ power: input.power, level: input.level, attack: input.attack, defense: input.defense, effect: input.effect, critical: Boolean(input.critical), boost, current: input.current, }) ?? input.current; expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage); }); stabCases.forEach((input, index) => { const original = results.stab[index]; expect(original.returned, `type case ${index} returned`).toBe(true); const native = applyTypeMatchups(matchups, { damage: input.damage, modifier: 10, moveType: input.type, userTypes: input.user, targetTypes: input.target, identified: input.identified, }); expect( [native.damage, native.modifier, Number(native.missed)], `type case ${index}: ${JSON.stringify(input)}`, ).toEqual([original.damage, original.modifier, original.missed]); }); }); test("native stats and experience curves match the original routines", async ({ page }) => { test.skip( !existsSync(romPath) || !existsSync(symbolsPath), "Requires the local user ROM and the private PRET rebuild.", ); const { at, routine } = loadSymbols(); const pokemon = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon; let seed = 0x5eed; const next = (limit: number) => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 8) % limit; }; // Legal stats stay below 999; levels above 100 reach the MAX_STAT_VALUE cap. const statCases = Array.from({ length: 300 }, () => ({ base: Array.from({ length: 6 }, () => (next(4) === 0 ? 200 + next(56) : 1 + next(255))), level: next(6) === 0 ? 101 + next(155) : next(5) === 0 ? 100 : 1 + next(100), dvs: next(0x10000), statExp: Array.from({ length: 5 }, () => (next(3) === 0 ? 0xffff - next(256) : next(0x10000))), useStatExp: next(4) !== 0, })); await openReference(page); const addresses = { baseStats: at("wBaseStats"), level: at("wCurPartyLevel"), growthRate: at("wBaseGrowthRate"), product: at("hProduct"), calcMonStats: routine("CalcMonStats"), calcExpAtLevel: routine("CalcExpAtLevel"), }; const results = await page.evaluate( async ({ addresses, statCases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0]; const reference = new BinjgbEmulator(); await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas")); const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff); const read = (address: number, length: number) => Array.from({ length }, (_, i) => reference.readMemory(address + i)); // Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC, DE // and HL; CALL the original routine; write a sentinel; loop. const call = ( target: { bank: number; address: number }, bc: number, de: number, hl: number, frames = 2, ) => { const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20, 0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0x21, hl & 0xff, hl >> 8, 0xcd, target.address & 0xff, target.address >> 8, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; write(0xc6ff, 0); program.forEach((value, index) => write(0xc000 + index, value)); reference.setProgramCounter(0xc000); reference.advanceFrames(frames); return reference.readMemory(0xc6ff) === 0x42; }; write(0xff70, 1); // WRAM bank 1 holds the base data and party level // A party struct at $c100 (stat experience at +11, DVs at +21) and // the six computed stats at $c140. const mon = 0xc100, out = 0xc140; const stats = statCases.map((input) => { input.base.forEach((value, i) => write(addresses.baseStats + i, value)); write(addresses.level, input.level); input.statExp.forEach((value, i) => { write(mon + 11 + i * 2, value >> 8); write(mon + 12 + i * 2, value); }); write(mon + 21, input.dvs >> 8); write(mon + 22, input.dvs); // B = stat experience flag; HL = MON_STAT_EXP - 1; DE = output. // GetSquareRoot counts up to 255 per stat, so allow more frames. const returned = call(addresses.calcMonStats, Number(input.useStatExp) << 8, out, mon + 10, 8); const bytes = read(out, 12); return { returned, stats: Array.from({ length: 6 }, (_, i) => (bytes[i * 2] << 8) | bytes[i * 2 + 1]) }; }); const experience: { returned: boolean; exp: number }[] = []; for (let growth = 0; growth < 6; growth++) for (let level = 1; level <= 100; level++) { write(addresses.growthRate, growth); // D = level const returned = call(addresses.calcExpAtLevel, 0, level << 8, 0); const [high, middle, low] = read(addresses.product + 1, 3); experience.push({ returned, exp: (high << 16) | (middle << 8) | low }); } reference.dispose(); return { stats, experience }; }, { addresses, statCases }, ); statCases.forEach((input, index) => { const original = results.stats[index]; expect(original.returned, `stat case ${index} returned`).toBe(true); const native = input.base.map((base, stat) => calcStat(base, input.dvs, stat, input.level, input.useStatExp ? input.statExp : undefined), ); expect(native, `stat case ${index}: ${JSON.stringify(input)}`).toEqual(original.stats); }); expect(results.stats.some(({ stats }) => stats.includes(999))).toBe(true); results.experience.forEach((original, index) => { const growth = Math.floor(index / 100), level = (index % 100) + 1; expect(original.returned, `experience ${growth}/${level} returned`).toBe(true); expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp); }); }); test("native switch texts, healing, PP, happiness and CalcPlayerStats match the original routines", async ({ page, }) => { test.skip( !existsSync(romPath) || !existsSync(symbolsPath), "Requires the local user ROM and the private PRET rebuild.", ); const { at, routine } = loadSymbols(); const bytes = new Uint8Array(readFileSync(romPath)); const assets = new CrystalAssets(bytes); const pokemon = assets.pokemon; let seed = 0xbeef; const next = (limit: number) => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 8) % limit; }; const maxHp = () => (next(4) === 0 ? 4 + next(60) : 4 + next(996)); const cheerCases = Array.from({ length: 300 }, () => { const max = maxHp(); return { max, hp: next(max + 1), atSwitch: next(max + 1) }; }); const healthCases = Array.from({ length: 300 }, () => { const max = 1 + next(999); const amounts = [10, 20, 50, 60, 80, 100, 200, 999, next(0x10000)]; return { max, hp: next(max + 1), amount: amounts[next(amounts.length)] }; }); const ppItems = [C.MAX_ELIXER, 0x3f, C.MAX_ETHER, C.ELIXER, C.MYSTERYBERRY]; const ppCases = Array.from({ length: 200 }, () => ({ move: 1 + next(251), pp: (next(4) << 6) | next(64), item: ppItems[next(ppItems.length)], })); const happinessCases = Array.from({ length: 300 }, () => ({ happiness: next(256), action: 1 + next(19), cursor: next(7), mon: next(6), egg: next(8) === 0, })); const statCases = Array.from({ length: 200 }, () => ({ levels: Array.from({ length: 5 }, () => 1 + next(13)), stats: Array.from({ length: 5 }, () => 1 + next(999)), status: [0, 1 << 6, 1 << 4, 1 << 3][next(4)], enemyStatus: [0, 1 << 6, 1 << 4][next(3)], enemyStats: Array.from({ length: 5 }, () => 1 + next(999)), turn: next(2), })); await openReference(page); const skipToTextbox = routine("SendOutMonText.skip_to_textbox"); const addresses = { // The ROM copy loaded in the page gets `ret` instead of SendOutMonText's // final `jp BattleTextbox`, so the routine returns its text in HL. patch: skipToTextbox.bank * 0x4000 + skipToTextbox.address - 0x4000, sendOut: routine("SendOutMonText"), withdraw: { ...routine("WithdrawMonText.WithdrawMonText"), address: at("WithdrawMonText.WithdrawMonText") + 5 }, restoreHealth: routine("RestoreHealth"), restorePP: routine("RestorePP"), changeHappiness: routine("ChangeHappiness"), calcPlayerStats: routine("CalcPlayerStats"), multiplicand: at("hMultiplicand"), enemyHP: at("wEnemyMonHP"), enemyMaxHP: at("wEnemyMonMaxHP"), hpAtSwitch: at("wEnemyHPAtTimeOfPlayerSwitch"), link: at("wLinkMode"), curPartyMon: at("wCurPartyMon"), partyMon1: at("wPartyMon1"), partySpecies: at("wPartySpecies"), partyCount: at("wPartyCount"), menuCursorY: at("wMenuCursorY"), hpBuffer1: at("wHPBuffer1"), ppItem: at("wTempRestorePPItem"), monType: at("wMonType"), battleMode: at("wBattleMode"), partyMenuCursor: at("wPartyMenuCursor"), battleMonHappiness: at("wBattleMonHappiness"), playerLevels: at("wPlayerAtkLevel"), playerStats: at("wPlayerStats"), battleMonStats: at("wBattleMonAttack"), battleMonStatus: at("wBattleMonStatus"), enemyStatus: at("wEnemyMonStatus"), enemyStats: at("wEnemyMonAttack"), johto: at("wJohtoBadges"), turn: at("hBattleTurn"), text: { GoMonText: at("GoMonText"), DoItMonText: at("DoItMonText"), GoForItMonText: at("GoForItMonText"), YourFoesWeakGetmMonText: at("YourFoesWeakGetmMonText"), ThatsEnoughComeBackText: at("ThatsEnoughComeBackText"), ComeBackText: at("ComeBackText"), OKComeBackText: at("OKComeBackText"), GoodComeBackText: at("GoodComeBackText"), }, }; const results = await page.evaluate( async ({ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0]; const rom = new Uint8Array(await file.arrayBuffer()); if (rom[addresses.patch] !== 0xc3) throw new Error("Unexpected SendOutMonText ending."); rom[addresses.patch] = 0xc9; const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff); const word = (address: number, value: number) => { write(address, value >> 8); write(address + 1, value); }; const readWord = (address: number) => (reference.readMemory(address) << 8) | reference.readMemory(address + 1); // Trampoline: DI; SP=$c7fe; switch banks; set BC and DE; CALL; store // HL at $c6fd; write a sentinel; loop. const call = (target: { bank: number; address: number }, bc = 0, de = 0) => { const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20, 0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0xcd, target.address & 0xff, target.address >> 8, 0x7d, 0xea, 0xfd, 0xc6, 0x7c, 0xea, 0xfe, 0xc6, 0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe, ]; write(0xc6ff, 0); program.forEach((value, index) => write(0xc000 + index, value)); reference.setProgramCounter(0xc000); reference.advanceFrames(2); if (reference.readMemory(0xc6ff) !== 0x42) throw new Error(`Routine at ${target.address.toString(16)} did not return.`); return reference.readMemory(0xc6fd) | (reference.readMemory(0xc6fe) << 8); }; write(0xff70, 1); write(addresses.link, 0); write(addresses.johto, 0); const textName = (hl: number) => Object.entries(addresses.text).find(([, address]) => address === hl)?.[0] ?? `0x${hl.toString(16)}`; const cheers = cheerCases.map((input) => { word(addresses.enemyHP, input.hp); word(addresses.enemyMaxHP, input.max); const sendOut = textName(call(addresses.sendOut)); word(addresses.hpAtSwitch, input.atSwitch); write(addresses.multiplicand, 0); const withdraw = textName(call(addresses.withdraw)); return { sendOut, withdraw }; }); const mon = addresses.partyMon1; write(addresses.curPartyMon, 0); const health = healthCases.map((input) => { word(mon + 34, input.hp); word(mon + 36, input.max); // ItemRestoreHP's IsMonFainted loads the maximum into wHPBuffer1 // (low byte first), which a full restore copies. write(addresses.hpBuffer1, input.max); write(addresses.hpBuffer1 + 1, input.max >> 8); call(addresses.restoreHealth, 0, input.amount); return readWord(mon + 34); }); const pp = ppCases.map((input) => { write(mon + 2, input.move); write(mon + 23, input.pp); write(addresses.menuCursorY, 0); write(addresses.ppItem, input.item); call(addresses.restorePP); return reference.readMemory(mon + 23); }); write(addresses.partyCount, 6); write(addresses.battleMode, 1); const happiness = happinessCases.map((input) => { for (let slot = 0; slot < 6; slot++) { write(addresses.partySpecies + slot, input.egg && slot === input.mon ? 0xfd : 155); write(mon + slot * 48 + 27, input.happiness); } write(addresses.curPartyMon, input.mon); write(addresses.partyMenuCursor, input.cursor); write(addresses.battleMonHappiness, 0x55); call(addresses.changeHappiness, input.action); return { party: reference.readMemory(mon + input.mon * 48 + 27), battle: reference.readMemory(addresses.battleMonHappiness), }; }); const stats = statCases.map((input) => { input.levels.forEach((level, i) => write(addresses.playerLevels + i, level)); input.stats.forEach((value, i) => word(addresses.playerStats + i * 2, value)); input.enemyStats.forEach((value, i) => word(addresses.enemyStats + i * 2, value)); write(addresses.battleMonStatus, input.status); write(addresses.enemyStatus, input.enemyStatus); write(addresses.turn, input.turn); call(addresses.calcPlayerStats); return { player: Array.from({ length: 5 }, (_, i) => readWord(addresses.battleMonStats + i * 2)), enemy: Array.from({ length: 5 }, (_, i) => readWord(addresses.enemyStats + i * 2)), turn: reference.readMemory(addresses.turn), }; }); reference.dispose(); return { cheers, health, pp, happiness, stats }; }, { addresses, cheerCases, healthCases, ppCases, happinessCases, statCases }, ); cheerCases.forEach((input, index) => { expect( { sendOut: sendOutText(input.hp, input.max), withdraw: withdrawText(input.atSwitch, input.hp, input.max) }, `cheer case ${index}: ${JSON.stringify(input)}`, ).toEqual(results.cheers[index]); }); healthCases.forEach((input, index) => expect(restoreHealth(input.hp, input.max, input.amount), `health case ${index}: ${JSON.stringify(input)}`).toBe( results.health[index], ), ); ppCases.forEach((input, index) => { const max = maxPP(pokemon, input.move, input.pp >> 6) & 63; expect(restoredPP(input.pp, max, input.item) ?? input.pp, `PP case ${index}: ${JSON.stringify(input)}`).toBe( results.pp[index], ); }); // ChangeHappiness and CalcPlayerStats run on a battle's own state. const battle = new Battle({ data: pokemon, party: [], pokedex: { seen: new Set(), caught: new Set() }, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => 0, text: () => ({}) as never, }); const internals = battle as unknown as { options: { party: { species: number; happiness: number }[] }; partyMenuCursor: number; turn: number; changeHappiness(index: number, action: number): void; calcPlayerStats(): void; }; happinessCases.forEach((input, index) => { internals.options.party = Array.from({ length: 6 }, (_, slot) => ({ species: input.egg && slot === input.mon ? C.EGG : 155, happiness: input.happiness, })); internals.partyMenuCursor = input.cursor; battle.player.mon = { happiness: 0x55 } as never; internals.changeHappiness(input.mon, input.action); expect( { party: internals.options.party[input.mon].happiness, battle: battle.player.mon.happiness }, `happiness case ${index}: ${JSON.stringify(input)}`, ).toEqual(results.happiness[index]); }); statCases.forEach((input, index) => { battle.player.levels = [...input.levels, 7, 7]; battle.player.stats = [...input.stats]; battle.player.mon = { stats: [0, 0, 0, 0, 0], status: input.status } as never; battle.enemy.mon = { stats: [...input.enemyStats], status: input.enemyStatus } as never; internals.turn = input.turn; internals.calcPlayerStats(); expect( { player: battle.player.mon.stats, enemy: battle.enemy.mon.stats, turn: internals.turn }, `stat case ${index}: ${JSON.stringify(input)}`, ).toEqual(results.stats[index]); }); });