Files
Crystal/tests/battle-switch.test.ts
Sébastien QUEROLandClaude Opus 5.5 c4cc0e756c
Checks / web (push) Canceled after 0s
Port the options screen
OPTION in the start menu runs _Option with the ROM's labels and settings,
OptionsControl's cursor wrapping and each option's bit handling. The four
option bytes live in the script state and saves, defaulting to
DefaultOptions. The battle style now decides whether a trainer's next
Pokémon brings an offer to switch, and the menu account whether the start
menu describes its items; the other settings are kept for later.

Closes #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:47:11 +02:00

235 lines
8.5 KiB
TypeScript
Raw Permalink 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("makes no offer in Set mode", () => {
const battle = new Battle({
...options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
shift: false,
});
const { texts } = play(battle, {});
expect(texts).toContain("YOUNGSTER MIKEY sent out RATTATA!");
expect(texts.some((text) => text.includes("is about to use"))).toBe(false);
expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false);
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!");
});
});