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,208 @@
|
||||
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, UnsupportedBattle } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import type { Bag } from "../src/game/party/bag";
|
||||
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,
|
||||
CYNDAQUIL = 155,
|
||||
TOTODILE = 158;
|
||||
const BRIGHTPOWDER = 0x03,
|
||||
ANTIDOTE = 0x09,
|
||||
PARLYZ_HEAL = 0x0d,
|
||||
MAX_POTION = 0x0f,
|
||||
POTION = 0x12,
|
||||
X_ATTACK = 0x31,
|
||||
PSNCUREBERRY = 0x4a,
|
||||
BERRY = 0xad;
|
||||
const PSN = 1 << 3,
|
||||
PAR = 1 << 6;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
|
||||
let assets: CrystalAssets, trainers: TrainerData;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
trainers = new TrainerData(assets.reader);
|
||||
});
|
||||
const setup = (party: PartyMon[], items: Bag["items"], level = 3) => {
|
||||
const bag: Bag = { items, balls: [], keyItems: [] };
|
||||
const battle = new Battle(
|
||||
battleOptions(
|
||||
assets,
|
||||
trainers,
|
||||
party,
|
||||
{ wild: { species: PIDGEY, level } },
|
||||
{ bag },
|
||||
),
|
||||
);
|
||||
return { battle, bag };
|
||||
};
|
||||
const use = (item: number, slot = 0) => ({
|
||||
action: "item" as const,
|
||||
item,
|
||||
slot,
|
||||
});
|
||||
|
||||
it("restores HP with a Potion, takes the turn and uses up the Potion", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = 5;
|
||||
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 2 }]);
|
||||
const { texts, prompts } = playBattle(battle, { action: [use(POTION)] });
|
||||
expect(prompts[1]).toEqual({
|
||||
kind: "party",
|
||||
text: "Use on which POKéMON?",
|
||||
menu: false,
|
||||
cancel: true,
|
||||
});
|
||||
const healed = texts.indexOf("CYNDAQUIL recovered 20HP!");
|
||||
expect(healed).toBeGreaterThan(0);
|
||||
// The item takes the turn: the wild Pokémon moves next.
|
||||
expect(texts[healed + 1]).toMatch(/^Enemy PIDGEY used /);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("stops at the maximum HP and reports only the HP actually restored", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = mon.stats[0] - 3;
|
||||
const { battle, bag } = setup([mon], [{ item: MAX_POTION, quantity: 1 }]);
|
||||
const { texts } = playBattle(battle, { action: [use(MAX_POTION)] });
|
||||
expect(texts).toContain("CYNDAQUIL recovered 3HP!");
|
||||
expect(bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("heals a Pokémon that is not out without changing the one in battle", () => {
|
||||
const out = partyMon(assets, CYNDAQUIL, 10),
|
||||
resting = partyMon(assets, TOTODILE, 10);
|
||||
resting.hp = 1;
|
||||
const { battle } = setup([out, resting], [{ item: BERRY, quantity: 1 }]);
|
||||
const { texts } = playBattle(battle, { action: [use(BERRY)], party: [1] });
|
||||
expect(texts).toContain("TOTODILE recovered 10HP!");
|
||||
expect(resting.hp).toBe(11);
|
||||
expect(battle.curBattleMon).toBe(0);
|
||||
});
|
||||
|
||||
it("does nothing at full HP or on a fainted Pokémon, and keeps the turn", () => {
|
||||
const out = partyMon(assets, CYNDAQUIL, 10),
|
||||
fainted = partyMon(assets, TOTODILE, 10);
|
||||
fainted.hp = 0;
|
||||
const { battle, bag } = setup(
|
||||
[out, fainted],
|
||||
[{ item: POTION, quantity: 1 }],
|
||||
);
|
||||
const { texts, prompts } = playBattle(battle, {
|
||||
action: [use(POTION), use(POTION)],
|
||||
party: [0, 1],
|
||||
});
|
||||
expect(
|
||||
texts.filter((text) => text === "It won't have any effect."),
|
||||
).toHaveLength(2);
|
||||
expect(prompts.slice(0, 5).map((prompt) => prompt.kind)).toEqual([
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
]);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("returns to the battle menu when the party menu is cancelled", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = 5;
|
||||
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 1 }]);
|
||||
const { prompts } = playBattle(battle, {
|
||||
action: [use(POTION)],
|
||||
party: [-1],
|
||||
});
|
||||
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
]);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("cures poison with an Antidote or a PSNCUREBERRY, and only poison", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.status = PSN;
|
||||
const { battle, bag } = setup(
|
||||
[mon],
|
||||
[
|
||||
{ item: PARLYZ_HEAL, quantity: 1 },
|
||||
{ item: ANTIDOTE, quantity: 1 },
|
||||
{ item: PSNCUREBERRY, quantity: 1 },
|
||||
],
|
||||
);
|
||||
const { texts } = playBattle(battle, {
|
||||
action: [use(PARLYZ_HEAL, 0), use(ANTIDOTE, 1)],
|
||||
});
|
||||
expect(texts[texts.indexOf("It won't have any effect.") + 1]).toBe(
|
||||
"CYNDAQUIL's cured of poison.",
|
||||
);
|
||||
expect(mon.status).toBe(0);
|
||||
expect(bag.items.map(({ item }) => item)).toEqual([
|
||||
PARLYZ_HEAL,
|
||||
PSNCUREBERRY,
|
||||
]);
|
||||
|
||||
const berry = partyMon(assets, CYNDAQUIL, 10);
|
||||
berry.status = PSN;
|
||||
const second = setup([berry], [{ item: PSNCUREBERRY, quantity: 1 }]);
|
||||
expect(
|
||||
playBattle(second.battle, { action: [use(PSNCUREBERRY)] }).texts,
|
||||
).toContain("CYNDAQUIL's cured of poison.");
|
||||
expect(second.bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("restores the paralysed Pokémon's Speed with a Parlyz Heal", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.status = PAR;
|
||||
const { battle } = setup([mon], [{ item: PARLYZ_HEAL, quantity: 1 }]);
|
||||
const speeds: number[] = [];
|
||||
const run = battle.run();
|
||||
let input: Parameters<typeof run.next>[0];
|
||||
for (let step = 0, actions = 0; step < 500; step++) {
|
||||
const next = run.next(input);
|
||||
if (next.done) break;
|
||||
input = undefined;
|
||||
const effect = next.value;
|
||||
if (effect.kind === "action") {
|
||||
speeds.push(battle.player.mon.stats[C.SPEED]);
|
||||
input = actions++ === 0 ? use(PARLYZ_HEAL) : { action: "run" };
|
||||
} else if (effect.kind === "party") input = 0;
|
||||
if (actions > 1 && effect.kind === "action") break;
|
||||
}
|
||||
expect(speeds[0]).toBe(Math.max(1, mon.stats[3] >> 2));
|
||||
expect(speeds[1]).toBe(mon.stats[3]);
|
||||
expect(battle.player.mon.status).toBe(0);
|
||||
});
|
||||
|
||||
it("answers an item with no battle use with Oak's warning", () => {
|
||||
const { battle, bag } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: BRIGHTPOWDER, quantity: 1 }],
|
||||
);
|
||||
const { texts } = playBattle(battle, { action: [use(BRIGHTPOWDER)] });
|
||||
expect(texts[texts.indexOf("Go! CYNDAQUIL!") + 1]).toBe(
|
||||
"OAK: CHRIS! This isn't the time to use that!",
|
||||
);
|
||||
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("refuses items whose battle effects are not ported", () => {
|
||||
const { battle } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: X_ATTACK, quantity: 1 }],
|
||||
);
|
||||
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
|
||||
UnsupportedBattle,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user