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