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

+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!");
});
});