- 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>
223 lines
8.0 KiB
TypeScript
223 lines
8.0 KiB
TypeScript
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!");
|
||
});
|
||
});
|