Finish the opening milestone's switching, items and acceptance run

- Decode evolution lists from the ROM; all 251 match the pinned PRET source.
- Compare CalcMonStats and CalcExpAtLevel against the original routines in
  the reference emulator.
- Add a continuous opening playthrough from the intro to the catching
  tutorial, reloading a JSON save after every stage.
- Port player switching in battle: the PKMN menu, forced choices after a
  faint, Shift mode's offer and double faints, with the original texts.
- Port HP and status healing items in battle through the ROM's ItemEffects,
  HealingHPAmounts and StatusHealingActions tables.
- Add the battle party menu and the Items pocket to the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:04:11 +02:00
1 parent eb03a20d8f
commit f8e0f9c65a
20 files changed
+1902 -96

No files matched your search

+64 -1
View File
@@ -58,7 +58,7 @@ async function finish(page: Page, action: "fight" | "run" = "fight") {
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
else {
await fight.click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM/ }).click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM|SCRATCH/ }).click();
}
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
await menu.getByRole("button", { name: "YES", exact: true }).click();
@@ -115,6 +115,8 @@ test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
// A real pointer press on the on-screen D-pad (synthetic events cannot be captured).
await page.locator('[data-game-button="down"]').click();
await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/);
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("button", { name: "PACK", exact: true })).toHaveClass(/battle-selected/);
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
@@ -178,6 +180,7 @@ test("battle PACK throws Poké Balls, names the catch and saves it", async ({ pa
named = true;
} else if (await pack.count()) {
await pack.click();
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
const ball = menu.getByRole("button", { name: /POKé BALL/ });
await expect(ball).toContainText("×");
await ball.click();
@@ -206,7 +209,67 @@ test("battle PACK closes with B and returns to the command menu", async ({ page
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PACK", exact: true }).click();
// BattlePack opens on the Items pocket first.
await expect(menu.getByRole("button", { name: "ITEM POCKET", exact: true })).toHaveAttribute("aria-pressed", "true");
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
await expect(menu.getByRole("button", { name: /POKé BALL/ })).toBeVisible();
await page.keyboard.press("KeyZ");
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
});
test("battle PKMN switches through the party menu and its submenu, then shares the experience", async ({ page }) => {
await start(page, "switch");
const menu = page.locator("#battle-menu");
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", exact: true }).count()); tick++) {
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PKMN", exact: true }).click();
await expect(page.locator("#battle-text")).toHaveText("Choose a POKéMON.");
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
// BattleMonMenu: B returns to the party list.
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeDisabled();
await page.keyboard.press("KeyZ");
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
// The withdrawal and send-out texts continue on their own.
const shown: string[] = [];
for (let tick = 0; tick < 20 && (await page.locator("#battle-player-name").innerText()) !== "TOTODILE"; tick++) {
shown.push(await page.locator("#battle-text").innerText());
await page.clock.runFor(300);
}
await expect(page.locator("#battle-player-name")).toHaveText("TOTODILE");
expect(shown.join("\n").replace(/\n/g, " ")).toContain("CYNDAQUIL, that's enough! Come back!");
const messages = await finish(page);
// Pidgey's 23 experience points are halved for the two participants.
expect(messages.replace(/\n/g, " ")).toContain("CYNDAQUIL gained 11 EXP. Points!");
expect(messages.replace(/\n/g, " ")).toContain("TOTODILE gained 11 EXP. Points!");
const { state } = await exportState(page);
expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([155, 158]);
expect(state.party[1].exp).toBe(135 + 11);
});
test("battle PACK uses a Potion from the Items pocket on the Pokémon out", async ({ page }) => {
await start(page, "potion");
const menu = page.locator("#battle-menu");
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PACK", exact: true }).count()); tick++) {
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
await page.clock.runFor(1200);
}
await menu.getByRole("button", { name: "PACK", exact: true }).click();
await menu.getByRole("button", { name: /^POTION/ }).click();
await expect(page.locator("#battle-text")).toHaveText("Use on which POKéMON?");
await menu.getByRole("button", { name: /^CYNDAQUIL/ }).click();
const shown: string[] = [];
for (let tick = 0; tick < 20 && !shown.some((text) => text.includes("recovered")); tick++) {
shown.push((await page.locator("#battle-text").innerText()).replace(/\n/g, " "));
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
else await page.clock.runFor(300);
}
// From 3 HP, a Potion's 20 stops at the level 5 maximum of 19.
expect(shown).toContain("CYNDAQUIL recovered 16HP!");
await finish(page, "run");
const { state } = await exportState(page);
expect(state.bag.items).toEqual([]);
});
+103
View File
@@ -0,0 +1,103 @@
import type { CrystalAssets } from "../src/game/rom/assets";
import type { TrainerData } from "../src/game/rom/trainers";
import type {
Battle,
BattleEffect,
BattleInput,
BattleOptions,
} from "../src/game/battle/battle";
import type { Bag } from "../src/game/party/bag";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
export const partyMon = (
assets: CrystalAssets,
species: number,
level: number,
): PartyMon =>
generatePartyMon(assets.pokemon, {
species,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
/** Battle options for a wild Pokémon or a ROM trainer, with a seeded
* stand-in for the hardware Random routine. */
export function battleOptions(
assets: CrystalAssets,
trainers: TrainerData,
party: PartyMon[],
enemy:
| { wild: { species: number; level: number } }
| { trainer: [number, number] },
{ seed = 1, bag }: { seed?: number; bag?: Bag } = {},
): BattleOptions {
const base = {
data: assets.pokemon,
party,
bag,
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
pokedex: { seen: new Set<number>(), caught: new Set<number>() },
random: () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
text: () => defaultTextContext("CHRIS"),
};
if ("wild" in enemy) return { ...base, wild: enemy.wild };
const trainer = trainers.trainer(...enemy.trainer);
return {
...base,
trainer: {
name: trainer.name,
className: trainer.className,
party: trainers.createParty(trainer),
aiFlags: trainer.attributes.aiMoveFlags,
switchFlags: trainer.attributes.aiItemSwitchFlags,
reward: trainer.reward,
},
};
}
/** Run a battle. Menus take queued answers per prompt kind, then the
* defaults: the first move, YES, cancelling a move to forget and the
* first party slot. */
export function playBattle(
battle: Battle,
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
) {
const texts: string[] = [],
prompts: BattleEffect[] = [];
const run = battle.run();
let input: BattleInput;
for (let step = 0; step < 3000; step++) {
const next = run.next(input);
if (next.done) return { texts, prompts, result: next.value.result };
const effect = next.value;
input = undefined;
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
else if (effect.kind !== "hud") {
prompts.push(effect);
const answer = queued[effect.kind]?.shift();
input =
answer !== undefined
? answer
: effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: effect.kind === "forget"
? 4
: effect.kind === "party"
? 0
: undefined;
}
}
throw new Error("Battle did not end.");
}
+10 -1
View File
@@ -2,7 +2,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
export type BattleScenario = "win" | "loss" | "unsupported" | "catch";
export type BattleScenario = "win" | "loss" | "unsupported" | "catch" | "switch" | "potion";
/** Test-only encounters on an existing floor: production maps stay gated.
* Everything after selection runs through the ROM's real wild-battle script. */
@@ -28,7 +28,16 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.pp = [10, 0, 0, 0];
}
world.state.party.push(mon);
if (scenario === "switch")
world.state.party.push(generatePartyMon(assets.pokemon, {
species: 158, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}));
if (scenario === "catch") world.state.bag.balls = [{ item: 5, quantity: 10 }]; // POKé BALL
if (scenario === "potion") {
mon.hp = 3;
world.state.bag.items = [{ item: 18, quantity: 1 }]; // POTION
}
world.state.pokedex.seen.add(155);
world.state.pokedex.caught.add(155);
world.state.money = 3001;
+208
View File
@@ -0,0 +1,208 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { TrainerData } from "../src/game/rom/trainers";
import { Battle, UnsupportedBattle } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import type { Bag } from "../src/game/party/bag";
import type { PartyMon } from "../src/game/party/pokemon";
import { battleOptions, partyMon, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PIDGEY = 16,
CYNDAQUIL = 155,
TOTODILE = 158;
const BRIGHTPOWDER = 0x03,
ANTIDOTE = 0x09,
PARLYZ_HEAL = 0x0d,
MAX_POTION = 0x0f,
POTION = 0x12,
X_ATTACK = 0x31,
PSNCUREBERRY = 0x4a,
BERRY = 0xad;
const PSN = 1 << 3,
PAR = 1 << 6;
describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const setup = (party: PartyMon[], items: Bag["items"], level = 3) => {
const bag: Bag = { items, balls: [], keyItems: [] };
const battle = new Battle(
battleOptions(
assets,
trainers,
party,
{ wild: { species: PIDGEY, level } },
{ bag },
),
);
return { battle, bag };
};
const use = (item: number, slot = 0) => ({
action: "item" as const,
item,
slot,
});
it("restores HP with a Potion, takes the turn and uses up the Potion", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = 5;
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 2 }]);
const { texts, prompts } = playBattle(battle, { action: [use(POTION)] });
expect(prompts[1]).toEqual({
kind: "party",
text: "Use on which POKéMON?",
menu: false,
cancel: true,
});
const healed = texts.indexOf("CYNDAQUIL recovered 20HP!");
expect(healed).toBeGreaterThan(0);
// The item takes the turn: the wild Pokémon moves next.
expect(texts[healed + 1]).toMatch(/^Enemy PIDGEY used /);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("stops at the maximum HP and reports only the HP actually restored", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = mon.stats[0] - 3;
const { battle, bag } = setup([mon], [{ item: MAX_POTION, quantity: 1 }]);
const { texts } = playBattle(battle, { action: [use(MAX_POTION)] });
expect(texts).toContain("CYNDAQUIL recovered 3HP!");
expect(bag.items).toEqual([]);
});
it("heals a Pokémon that is not out without changing the one in battle", () => {
const out = partyMon(assets, CYNDAQUIL, 10),
resting = partyMon(assets, TOTODILE, 10);
resting.hp = 1;
const { battle } = setup([out, resting], [{ item: BERRY, quantity: 1 }]);
const { texts } = playBattle(battle, { action: [use(BERRY)], party: [1] });
expect(texts).toContain("TOTODILE recovered 10HP!");
expect(resting.hp).toBe(11);
expect(battle.curBattleMon).toBe(0);
});
it("does nothing at full HP or on a fainted Pokémon, and keeps the turn", () => {
const out = partyMon(assets, CYNDAQUIL, 10),
fainted = partyMon(assets, TOTODILE, 10);
fainted.hp = 0;
const { battle, bag } = setup(
[out, fainted],
[{ item: POTION, quantity: 1 }],
);
const { texts, prompts } = playBattle(battle, {
action: [use(POTION), use(POTION)],
party: [0, 1],
});
expect(
texts.filter((text) => text === "It won't have any effect."),
).toHaveLength(2);
expect(prompts.slice(0, 5).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
"party",
"action",
]);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("returns to the battle menu when the party menu is cancelled", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.hp = 5;
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 1 }]);
const { prompts } = playBattle(battle, {
action: [use(POTION)],
party: [-1],
});
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
]);
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
});
it("cures poison with an Antidote or a PSNCUREBERRY, and only poison", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.status = PSN;
const { battle, bag } = setup(
[mon],
[
{ item: PARLYZ_HEAL, quantity: 1 },
{ item: ANTIDOTE, quantity: 1 },
{ item: PSNCUREBERRY, quantity: 1 },
],
);
const { texts } = playBattle(battle, {
action: [use(PARLYZ_HEAL, 0), use(ANTIDOTE, 1)],
});
expect(texts[texts.indexOf("It won't have any effect.") + 1]).toBe(
"CYNDAQUIL's cured of poison.",
);
expect(mon.status).toBe(0);
expect(bag.items.map(({ item }) => item)).toEqual([
PARLYZ_HEAL,
PSNCUREBERRY,
]);
const berry = partyMon(assets, CYNDAQUIL, 10);
berry.status = PSN;
const second = setup([berry], [{ item: PSNCUREBERRY, quantity: 1 }]);
expect(
playBattle(second.battle, { action: [use(PSNCUREBERRY)] }).texts,
).toContain("CYNDAQUIL's cured of poison.");
expect(second.bag.items).toEqual([]);
});
it("restores the paralysed Pokémon's Speed with a Parlyz Heal", () => {
const mon = partyMon(assets, CYNDAQUIL, 10);
mon.status = PAR;
const { battle } = setup([mon], [{ item: PARLYZ_HEAL, quantity: 1 }]);
const speeds: number[] = [];
const run = battle.run();
let input: Parameters<typeof run.next>[0];
for (let step = 0, actions = 0; step < 500; step++) {
const next = run.next(input);
if (next.done) break;
input = undefined;
const effect = next.value;
if (effect.kind === "action") {
speeds.push(battle.player.mon.stats[C.SPEED]);
input = actions++ === 0 ? use(PARLYZ_HEAL) : { action: "run" };
} else if (effect.kind === "party") input = 0;
if (actions > 1 && effect.kind === "action") break;
}
expect(speeds[0]).toBe(Math.max(1, mon.stats[3] >> 2));
expect(speeds[1]).toBe(mon.stats[3]);
expect(battle.player.mon.status).toBe(0);
});
it("answers an item with no battle use with Oak's warning", () => {
const { battle, bag } = setup(
[partyMon(assets, CYNDAQUIL, 10)],
[{ item: BRIGHTPOWDER, quantity: 1 }],
);
const { texts } = playBattle(battle, { action: [use(BRIGHTPOWDER)] });
expect(texts[texts.indexOf("Go! CYNDAQUIL!") + 1]).toBe(
"OAK: CHRIS! This isn't the time to use that!",
);
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
});
it("refuses items whose battle effects are not ported", () => {
const { battle } = setup(
[partyMon(assets, CYNDAQUIL, 10)],
[{ item: X_ATTACK, quantity: 1 }],
);
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
UnsupportedBattle,
);
});
});
+222
View File
@@ -0,0 +1,222 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { TrainerData } from "../src/game/rom/trainers";
import { Battle } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import type { PartyMon } from "../src/game/party/pokemon";
import { battleOptions, partyMon, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const PIDGEY = 16,
RATTATA = 19,
CYNDAQUIL = 155,
TOTODILE = 158;
describe.skipIf(!existsSync(romPath))("switching Pokémon in battle", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const mon = (species: number, level: number) =>
partyMon(assets, species, level);
const options = (
party: PartyMon[],
enemy: Parameters<typeof battleOptions>[3],
seed = 1,
) => battleOptions(assets, trainers, party, enemy, { seed });
const play = playBattle;
it("switches from the PKMN menu: the enemy still attacks and both share the experience", () => {
const first = mon(CYNDAQUIL, 10),
second = mon(TOTODILE, 10);
const battle = new Battle(
options([first, second], { wild: { species: PIDGEY, level: 3 } }),
);
const { texts, prompts } = play(battle, {
action: [{ action: "pkmn" }],
party: [0, 1],
});
expect(prompts[1]).toEqual({
kind: "party",
text: "Choose a POKéMON.",
menu: true,
cancel: true,
});
const switched = texts.indexOf("CYNDAQUIL is already out.");
expect(switched).toBeGreaterThan(0);
// The enemy's HP is untouched, so the withdrawal is "that's enough".
expect(texts.slice(switched + 1, switched + 4)).toEqual([
"CYNDAQUIL, that's enough! Come back!",
"Go! TOTODILE!",
expect.stringMatching(/^Enemy PIDGEY used /),
]);
expect(battle.curBattleMon).toBe(1);
expect(battle.lastPlayerMon).toBe(0);
// Base experience 55 × level 3 ÷ 7 = 23, halved for two participants.
expect(texts).toContain("CYNDAQUIL gained 11 EXP. Points!");
expect(texts).toContain("TOTODILE gained 11 EXP. Points!");
});
it("goes back to the battle menu when the party menu is cancelled", () => {
const battle = new Battle(
options([mon(CYNDAQUIL, 10), mon(TOTODILE, 10)], {
wild: { species: PIDGEY, level: 3 },
}),
);
const { prompts, texts } = play(battle, {
action: [{ action: "pkmn" }],
party: [-1],
});
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
"action",
"party",
"action",
]);
expect(texts).toContain("CYNDAQUIL gained 23 EXP. Points!");
expect(battle.curBattleMon).toBe(0);
});
it("measures the withdrawal against the enemy's HP when the Pokémon went out", () => {
const battle = new Battle(
options(
[mon(CYNDAQUIL, 12), mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 12 } },
3,
),
);
// Tackle once, then switch: the lost HP picks the text.
const { texts } = play(battle, {
action: [{ action: "fight", move: 0 }, { action: "pkmn" }],
party: [1],
});
const withdrawal = texts.find((text) => text.startsWith("CYNDAQUIL, "))!;
expect([
"CYNDAQUIL, come back!",
"CYNDAQUIL, OK! Come back!",
"CYNDAQUIL, good! Come back!",
]).toContain(withdrawal);
});
it("asks for the next Pokémon after a faint and refuses a fainted choice", () => {
const first = mon(CYNDAQUIL, 5),
second = mon(TOTODILE, 10),
fainted = mon(PIDGEY, 5);
first.hp = 1;
fainted.hp = 0;
const battle = new Battle(
options(
[first, fainted, second],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
const { texts, prompts } = play(battle, { party: [1, 2] });
expect(texts).toContain("CYNDAQUIL fainted!");
expect(texts).toContain("Use next POKéMON?");
const forced = prompts.find((prompt) => prompt.kind === "party");
expect(forced).toEqual({
kind: "party",
text: "Which POKéMON?",
menu: false,
cancel: false,
});
expect(texts).toContain("There's no will to battle!");
const sentOut = texts.findIndex((text) => text.endsWith("TOTODILE!"));
expect(texts[sentOut]).toMatch(
/^(Go!|Do it!|Go for it,|Your foe's weak! Get'm,) TOTODILE!$/,
);
expect(battle.curBattleMon).toBe(2);
// The fainted Pokémon did not share the experience.
expect(first.exp).toBe(assets.pokemon.expAtLevel(3, 5));
});
it("keeps asking for a Pokémon when the forced party menu is cancelled", () => {
const first = mon(CYNDAQUIL, 5);
first.hp = 1;
const battle = new Battle(
options(
[first, mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
const { prompts } = play(battle, { party: [-1, -1, 1] });
expect(prompts.filter((prompt) => prompt.kind === "party")).toHaveLength(3);
expect(battle.curBattleMon).toBe(1);
});
it("runs after declining the next Pokémon, with the first party Pokémon's Speed", () => {
const first = mon(CYNDAQUIL, 5);
first.hp = 1;
const battle = new Battle(
options(
[first, mon(TOTODILE, 10)],
{ wild: { species: RATTATA, level: 8 } },
5,
),
);
// The party's stored Speed, not the fainted battle Pokémon's, decides.
first.stats[3] = 999;
const { texts, result } = play(battle, { yesno: [0] });
expect(texts.slice(-2)).toEqual(["Use next POKéMON?", "Got away safely!"]);
expect(result).toBe(C.DRAW);
});
it("offers a switch in Shift mode when a trainer sends out the next Pokémon", () => {
// Youngster Mikey: Pidgey, then Rattata.
const first = mon(CYNDAQUIL, 15),
second = mon(TOTODILE, 15);
const battle = new Battle(options([first, second], { trainer: [22, 2] }));
const { texts } = play(battle, { yesno: [1], party: [1] });
const offer = texts.indexOf("YOUNGSTER MIKEY is about to use RATTATA.");
expect(offer).toBeGreaterThan(0);
expect(texts.slice(offer + 1, offer + 5)).toEqual([
"Will CHRIS change POKéMON?",
"YOUNGSTER MIKEY sent out RATTATA!",
expect.stringMatching(/^CYNDAQUIL, /),
"Go! TOTODILE!",
]);
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
});
it("keeps the Pokémon in when the offer is declined", () => {
const battle = new Battle(
options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
);
const { texts } = play(battle, { yesno: [0] });
const offer = texts.findIndex((text) => text.includes("Will CHRIS change"));
expect(texts[offer + 1]).toBe("YOUNGSTER MIKEY sent out RATTATA!");
expect(texts.some((text) => text.startsWith("CYNDAQUIL, "))).toBe(false);
expect(battle.curBattleMon).toBe(0);
});
it("sends out both sides without an offer after a double faint", () => {
// Struggle's recoil faints the attacker as the trainer's Pidgey faints.
const first = mon(CYNDAQUIL, 15);
first.hp = 1;
first.pp = [0, 0, 0, 0];
const battle = new Battle(
options([first, mon(TOTODILE, 15)], { trainer: [22, 2] }),
);
const { texts, prompts } = play(battle, { party: [1] });
const fainted = texts.indexOf("Enemy PIDGEY fainted!");
expect(fainted).toBeGreaterThan(0);
// The fainted Pokémon is no longer a participant, so nobody gains
// experience for the Pidgey.
expect(texts.slice(fainted, fainted + 4)).toEqual([
"Enemy PIDGEY fainted!",
"CYNDAQUIL fainted!",
"Go! TOTODILE!",
"YOUNGSTER MIKEY sent out RATTATA!",
]);
expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false);
expect(prompts.find((prompt) => prompt.kind === "party")).toMatchObject({
cancel: false,
});
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
});
});
+103
View File
@@ -12,6 +12,7 @@ import {
runScript,
} from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Evolution } from "../src/game/rom/pokemon";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
@@ -613,6 +614,108 @@ describe.skipIf(!existsSync(romPath))(
);
},
);
it("decodes evolution lists, including stat and happiness triggers", () => {
const data = load().pokemon;
expect(data.evolutions(155)).toEqual([
{ method: "level", level: 14, species: 156 },
]);
expect(data.evolutions(133).map((e) => e.method)).toEqual([
"item",
"item",
"item",
"happiness",
"happiness",
]);
expect(data.evolutions(133)[4]).toEqual({
method: "happiness",
time: "nite",
species: 197,
});
expect(data.evolutions(61)).toContainEqual({
method: "trade",
item: 82, // KING'S ROCK
species: 186,
});
expect(data.evolutions(64)).toEqual([
{ method: "trade", item: null, species: 65 },
]);
expect(data.evolutions(236)).toEqual([
{ method: "stat", level: 20, attack: "lt", species: 107 },
{ method: "stat", level: 20, attack: "gt", species: 106 },
{ method: "stat", level: 20, attack: "eq", species: 237 },
]);
expect(data.evolutions(251)).toEqual([]);
// The four-byte stat records must not shift the learnset.
expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 });
});
it.skipIf(
!existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"),
)("matches every evolution list against the pinned PRET source", () => {
const data = load().pokemon;
const root = ".reference/pokecrystal";
const constants = (file: string, first: number) => {
const ids = new Map<string, number>();
let value = first;
for (const line of readFileSync(`${root}/${file}`, "utf8").split(
"\n",
)) {
if (/^DEF NUM_/.test(line)) break;
const name = /^\s*const (\w+)/.exec(line)?.[1];
if (name) ids.set(name, value++);
}
return ids;
};
const species = constants("constants/pokemon_constants.asm", 1),
items = constants("constants/item_constants.asm", 0);
const id = (map: Map<string, number>, name: string) => {
const value = map.get(name);
if (value === undefined) throw new Error(`Unknown ${name}`);
return value;
};
const lists: Evolution[][] = [];
let current: Evolution[] | null = null;
for (const line of readFileSync(
`${root}/data/pokemon/evos_attacks.asm`,
"utf8",
).split("\n")) {
if (/^\w+EvosAttacks:/.test(line)) lists.push((current = []));
const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line);
if (!args || !current) {
if (/^\s*db 0 ; no more evolutions/.test(line)) current = null;
continue;
}
const [a, b, c] = args[2].split(",").map((s) => s.trim());
if (args[1] === "EVOLVE_LEVEL")
current.push({ method: "level", level: +a, species: id(species, b) });
else if (args[1] === "EVOLVE_ITEM")
current.push({
method: "item",
item: id(items, a),
species: id(species, b),
});
else if (args[1] === "EVOLVE_TRADE")
current.push({
method: "trade",
item: a === "-1" ? null : id(items, a),
species: id(species, b),
});
else if (args[1] === "EVOLVE_HAPPINESS")
current.push({
method: "happiness",
time: a.slice(3).toLowerCase() as "anytime",
species: id(species, b),
});
else
current.push({
method: "stat",
level: +a,
attack: b.slice(4, 6).toLowerCase() as "gt",
species: id(species, c),
});
}
expect(lists).toHaveLength(251);
lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list));
});
it("gives the chosen starter and the aide's Potion, then saves them", () => {
let seed = 7;
const random = () => (seed = (seed * 73 + 41) & 255);
+321
View File
@@ -0,0 +1,321 @@
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK = "24:4",
ELMS_LAB = "24:5",
ROUTE_29 = "24:3",
CHERRYGROVE = "26:3",
ROUTE_30 = "26:1",
MR_POKEMONS_HOUSE = "26:10";
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
* without fixtures or story flags set by hand. Every stage ends with a
* save serialized to JSON and loaded into a fresh world. The stages share
* one world and run in order, so run the file rather than a single stage. */
describe.skipIf(!existsSync(romPath))(
"a continuous opening playthrough",
() => {
const bytes = existsSync(romPath)
? new Uint8Array(readFileSync(romPath))
: new Uint8Array();
const now = () => new Date("2026-09-22T12:00:00");
let seed = 0x2a;
const random = () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
let world: GameWorld;
const log: string[] = [];
/** Run scripts and battles until the player is free again. Battles use
* the first move with PP; a wild battle below half HP is run from, as
* a cautious player would. New moves are learned when there is room,
* and other questions take the queued answers or their defaults. */
const play = (answers: (number | string)[] = []) => {
for (let tick = 0; tick < 100_000; tick++) {
expect(world.notice, world.diagnostic).toBe("");
const battle = world.battle;
if (battle) {
if (battle.message && log.at(-1) !== battle.message.text)
log.push(battle.message.text);
const prompt = battle.prompt;
if (prompt?.kind === "action") {
const { engine } = battle,
mon = engine.player.mon;
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else
world.battleAction({
action: "fight",
move: Math.max(
0,
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
),
});
} else if (prompt?.kind === "yesno") world.battleChoose(1);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || battle.message?.prompt)
world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
world.choose(typeof answer === "string" ? answer : "");
else
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) {
world.tick(); // scene scripts and phone calls start on idle ticks
if (!world.busy) return;
continue;
}
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Walk to a tile on the current map, replanning after ledges, roaming
* NPCs and scenes that move the player. A warp target leaves the map. */
const walk = (x: number, y: number) => {
const map = world.mapId,
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
for (let attempt = 0; attempt < 40; attempt++) {
play();
if (warp && world.mapId !== map) return;
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y)) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
}
}
throw new Error(
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`,
);
};
/** Step across a map connection from the nearest usable edge tile. */
const cross = (direction: Direction, target: string) => {
const map = world.mapId;
const [dx, dy] = STEP[direction];
const edge = Array.from(
{ length: dx ? world.map.height : world.map.width },
(_, i) =>
dx
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
try {
return [{ x, y, length: findPath(world, x, y).length }];
} catch {
return [];
}
});
edge.sort((a, b) => a.length - b.length);
for (const { x, y } of edge) {
walk(x, y);
if (world.move(direction)) {
play();
if (world.mapId === target) return;
}
if (world.mapId !== map) break;
}
throw new Error(
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
);
};
/** Stand in front of a warp leading to `target` and take it. */
const enter = (target: string, direction: Direction) => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
expect(warp, `a warp to ${target}`).toBeDefined();
const [dx, dy] = STEP[direction];
walk(warp.x - dx, warp.y - dy);
expect(world.move(direction)).toBe(true);
play();
expect(world.mapId).toBe(target);
};
/** Leave through an exit mat, which needs the exit direction pressed. */
const exit = (target: string, direction: Direction = "down") => {
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
walk(warp.x, warp.y);
if (world.mapId !== target) {
expect(world.move(direction)).toBe(true);
play();
}
expect(world.mapId).toBe(target);
};
const reload = () => {
const saved = JSON.parse(JSON.stringify(world.save()));
world = new GameWorld(assets!, now, random);
world.restore(saved);
expect(world.save()).toEqual(saved);
};
beforeEach(() => {
log.length = 0;
});
const traceFailures = () =>
onTestFailed(() => {
console.log(
`${world.mapId} ${world.x},${world.y}; party ${JSON.stringify(
world.state.party.map(({ species, level, hp }) => ({
species,
level,
hp,
})),
)}\n${log.slice(-30).join("\n---\n")}`,
);
});
it("plays the introduction and Mom's scene", () => {
world = new GameWorld(assets!, now, random);
traceFailures();
world.beginIntro();
play([0, "", 1, "", 1, 0, "KAWA"]);
expect(world.intro).toBe(false);
expect(world.name).toBe("KAWA");
reload();
walk(7, 0);
expect(world.mapId).toBe("24:6");
walk(9, 3);
expect(world.move("down")).toBe(true);
play([1, 1, 1, 1, 1]);
expect(log).toContain("KAWA received\nPOKéGEAR.");
reload();
});
it("meets Elm, takes Cyndaquil and the aide's Potion", () => {
traceFailures();
walk(6, 7);
expect(world.move("down")).toBe(true);
play();
expect(world.mapId).toBe(NEW_BARK);
enter(ELMS_LAB, "up");
play([0, 1]);
walk(6, 4);
world.move("up");
world.interact();
play([1, 1, ""]);
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
walk(4, 7);
expect(world.move("down")).toBe(true);
play();
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
reload();
});
it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
reload();
cross("up", ROUTE_30);
enter(MR_POKEMONS_HOUSE, "up");
play();
expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
expect(world.state.flags.has(11)).toBe(true); // the Pokédex
reload();
});
it("takes Elm's call outside and meets the rival in Cherrygrove", () => {
traceFailures();
exit(ROUTE_30);
expect(log.join("\n")).toContain("It's a disaster!");
expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
reload();
cross("down", CHERRYGROVE);
// The rival's scene blocks the way east to Route 29.
cross("right", ROUTE_29);
const rival = log.join("\n");
expect(rival).toContain("RIVAL ?");
expect(rival).toContain("My name's ???.");
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
reload();
});
it("names the rival for the officer and returns the Egg to Elm", () => {
traceFailures();
cross("right", NEW_BARK);
enter(ELMS_LAB, "up");
walk(4, 6);
expect(world.move("up")).toBe(true);
play(["SILVER"]);
expect(world.state.rivalName).toBe("SILVER");
reload();
walk(5, 3);
world.move("up"); // Elm stands at 5,2: this only turns the player.
world.interact();
play();
expect(world.state.bag.keyItems).toEqual([]);
walk(4, 8);
expect(world.state.bag.balls).toEqual([
{ item: C.POKE_BALL, quantity: 5 },
]);
reload();
});
it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
const pages = log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("show you how to catch POKéMON?");
expect(pages).toContain("That's how you do it");
expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
expect(world.state.scenes[ROUTE_29]).toBe(0);
reload();
const save = world.save();
expect(save.party[0].species).toBe(155);
expect(save.pokedex.caught).toContain(155);
expect(save.rivalName).toBe("SILVER");
expect(save.intro).toBe(false);
});
},
);
+143 -27
View File
@@ -1,23 +1,41 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { BattleData } from "../src/game/battle/data";
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { calcStat } from "../src/game/party/pokemon";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
/** Banked addresses from the private PRET rebuild's symbol file. */
function loadSymbols() {
const symbols = new Map(
readFileSync(symbolsPath, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
return match
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
: [];
}),
);
return {
at: (name: string) => symbols.get(name)!.address,
routine: (name: string) => symbols.get(name)!,
};
}
/** Open the reference page and hand it the ROM through an isolated file
* input, without an HTTP ROM endpoint. */
async function openReference(page: Page) {
await page.goto("/reference.html");
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
@@ -25,6 +43,13 @@ test("native decompression matches original GB assembly executing in the referen
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
}
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");
@@ -113,23 +138,11 @@ test("native decompression matches original GB assembly executing in the referen
test("native damage and type effectiveness match the original battle routines", async ({
page,
}) => {
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
const symbols = new Map(
readFileSync(symbolsPath, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
return match
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
: [];
}),
);
const at = (name: string) => symbols.get(name)!.address;
const routine = (name: string) => symbols.get(name)!;
const { at, routine } = loadSymbols();
const bytes = new Uint8Array(readFileSync(romPath));
const assets = new CrystalAssets(bytes);
const data = new BattleData(assets.reader);
@@ -164,14 +177,7 @@ test("native damage and type effectiveness match the original battle routines",
identified: next(4) === 0,
damage: next(1000),
}));
await page.goto("/reference.html");
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
await openReference(page);
const addresses = {
curDamage: at("wCurDamage"),
critical: at("wCriticalHit"),
@@ -294,3 +300,113 @@ test("native damage and type effectiveness match the original battle routines",
).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);
});
});