Files
Crystal/tests/reference-parity.spec.ts
T

641 lines
26 KiB
TypeScript

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]);
});
});