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:
1 parent
eb03a20d8f
commit
f8e0f9c65a
20 files changed
+1902
-96
No files matched your search
@@ -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!");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user