Files
Crystal/tests/battle-switch.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 f8e0f9c65a 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>
2026-09-30 11:04:11 +02:00

223 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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!");
});
});