- Run every non-ball item effect usable in battle from the ROM's ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. - Read happiness changes from HappinessChanges, including the larger level-up gain where a Pokémon was caught. - Model hBattleTurn at the battle menu, including the trainer AI's writes, and drop the status-heal refusal. - Port the stats screen for battles and Bill's PC. - Compare the send-out and withdraw texts, RestoreHealth, RestorePP, ChangeHappiness and CalcPlayerStats with the original routines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
16 KiB
TypeScript
450 lines
16 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 { Bag } from "../src/game/party/bag";
|
|
import type { PartyMon } from "../src/game/party/pokemon";
|
|
import {
|
|
battleOptions,
|
|
battleSteps,
|
|
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,
|
|
FULL_RESTORE = 0x0e,
|
|
MAX_ELIXER = 0x15,
|
|
X_ACCURACY = 0x21,
|
|
POKE_DOLL = 0x25,
|
|
REVIVE = 0x27,
|
|
GUARD_SPEC = 0x29,
|
|
DIRE_HIT = 0x2c,
|
|
ETHER = 0x3f,
|
|
BITTER_BERRY = 0x53,
|
|
ENERGYPOWDER = 0x79,
|
|
REVIVAL_HERB = 0x7c,
|
|
MYSTERYBERRY = 0x96,
|
|
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("raises a stat with an X item, even past the limit, and uses the turn", () => {
|
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
|
mon.happiness = 70;
|
|
const { battle, bag } = setup([mon], [{ item: X_ATTACK, quantity: 2 }]);
|
|
const steps = battleSteps(battle).answer(use(X_ATTACK));
|
|
expect(steps.texts.slice(2, 5)).toEqual([
|
|
"CHRIS used the X ATTACK.",
|
|
"CYNDAQUIL's ATTACK went up!",
|
|
"Enemy PIDGEY used TACKLE!",
|
|
]);
|
|
expect(battle.player.levels[C.ATTACK]).toBe(8);
|
|
// HappinessChanges' X item row: +1 below 100.
|
|
expect(mon.happiness).toBe(71);
|
|
battle.player.levels[C.ATTACK] = 13;
|
|
steps.answer(use(X_ATTACK));
|
|
expect(steps.texts).toContain("CYNDAQUIL's ATTACK won't rise anymore!");
|
|
expect(bag.items).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
[X_ACCURACY, C.SUBSTATUS_X_ACCURACY],
|
|
[GUARD_SPEC, C.SUBSTATUS_MIST],
|
|
[DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY],
|
|
])(
|
|
"sets item %i's flag once, then keeps the item and the turn",
|
|
(item, bit) => {
|
|
const { battle, bag } = setup(
|
|
[partyMon(assets, CYNDAQUIL, 10)],
|
|
[{ item, quantity: 2 }],
|
|
);
|
|
const steps = battleSteps(battle).answer(use(item));
|
|
expect(battle.player.sub[4] & (1 << bit)).toBeTruthy();
|
|
const before = steps.texts.length;
|
|
steps.answer(use(item));
|
|
expect(steps.texts.slice(before)).toEqual(["It won't have any effect."]);
|
|
expect(steps.prompt?.kind).toBe("action");
|
|
expect(bag.items).toEqual([{ item, quantity: 1 }]);
|
|
},
|
|
);
|
|
|
|
it("ends a wild battle with a Poké Doll, and warns in a trainer battle", () => {
|
|
const wild = setup(
|
|
[partyMon(assets, CYNDAQUIL, 10)],
|
|
[{ item: POKE_DOLL, quantity: 1 }],
|
|
);
|
|
const { texts, result } = playBattle(wild.battle, {
|
|
action: [use(POKE_DOLL)],
|
|
});
|
|
expect(texts.at(-1)).toBe("CHRIS used the POKé DOLL.");
|
|
expect(result).toBe(C.DRAW);
|
|
expect(wild.bag.items).toEqual([]);
|
|
|
|
const bag: Bag = {
|
|
items: [{ item: POKE_DOLL, quantity: 1 }],
|
|
balls: [],
|
|
keyItems: [],
|
|
};
|
|
const trainer = new Battle(
|
|
battleOptions(
|
|
assets,
|
|
trainers,
|
|
[partyMon(assets, CYNDAQUIL, 15)],
|
|
{ trainer: [22, 1] },
|
|
{ bag },
|
|
),
|
|
);
|
|
const steps = battleSteps(trainer).answer(use(POKE_DOLL));
|
|
expect(steps.texts.at(-1)).toBe(
|
|
"OAK: CHRIS! This isn't the time to use that!",
|
|
);
|
|
expect(bag.items).toEqual([{ item: POKE_DOLL, quantity: 1 }]);
|
|
});
|
|
|
|
it("restores HP and status with a Full Restore, or only the status at full HP", () => {
|
|
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
|
hurt.hp = 3;
|
|
hurt.status = PSN;
|
|
const first = setup([hurt], [{ item: FULL_RESTORE, quantity: 1 }]);
|
|
const steps = battleSteps(first.battle).answer(use(FULL_RESTORE)).answer(0);
|
|
expect(steps.texts).toContain(
|
|
`CYNDAQUIL recovered ${hurt.stats[0] - 3}HP!`,
|
|
);
|
|
expect(first.battle.player.mon.status).toBe(0);
|
|
|
|
const healthy = partyMon(assets, CYNDAQUIL, 10);
|
|
healthy.status = PSN;
|
|
const second = setup([healthy], [{ item: FULL_RESTORE, quantity: 1 }]);
|
|
expect(
|
|
playBattle(second.battle, { action: [use(FULL_RESTORE)] }).texts,
|
|
).toContain("CYNDAQUIL's health returned.");
|
|
expect(healthy.status).toBe(0);
|
|
});
|
|
|
|
it("revives a fainted Pokémon, which shares the experience again", () => {
|
|
// As in the forced switch test, the Rattata faints the first Pokémon.
|
|
const first = partyMon(assets, CYNDAQUIL, 5),
|
|
second = partyMon(assets, TOTODILE, 10);
|
|
first.hp = 1;
|
|
const bag: Bag = {
|
|
items: [{ item: REVIVE, quantity: 1 }],
|
|
balls: [],
|
|
keyItems: [],
|
|
};
|
|
const battle = new Battle(
|
|
battleOptions(
|
|
assets,
|
|
trainers,
|
|
[first, second],
|
|
{ wild: { species: 19, level: 8 } },
|
|
{ seed: 5, bag },
|
|
),
|
|
);
|
|
const { texts } = playBattle(battle, {
|
|
party: [1, 0],
|
|
action: [{ action: "fight", move: 0 }, use(REVIVE)],
|
|
});
|
|
expect(texts).toContain("CYNDAQUIL fainted!");
|
|
expect(texts).toContain("CYNDAQUIL is revitalized.");
|
|
// ReviveHalfHP: half the maximum, rounded down.
|
|
expect(first.hp).toBeLessThanOrEqual(first.stats[0] >> 1);
|
|
expect(texts.some((text) => text.startsWith("CYNDAQUIL gained"))).toBe(
|
|
true,
|
|
);
|
|
expect(bag.items).toEqual([]);
|
|
});
|
|
|
|
it("makes bitter medicine cost happiness", () => {
|
|
const fainted = partyMon(assets, TOTODILE, 10),
|
|
out = partyMon(assets, CYNDAQUIL, 10);
|
|
fainted.hp = 0;
|
|
fainted.happiness = 70;
|
|
const herb = setup([out, fainted], [{ item: REVIVAL_HERB, quantity: 1 }]);
|
|
const { texts } = playBattle(herb.battle, {
|
|
action: [use(REVIVAL_HERB)],
|
|
party: [1],
|
|
});
|
|
expect(texts).toContain("It looks bitter…");
|
|
expect(fainted.hp).toBe(fainted.stats[0]);
|
|
expect(fainted.happiness).toBe(55); // -15 below 100
|
|
|
|
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
|
hurt.hp = 3;
|
|
hurt.happiness = 150;
|
|
const powder = setup([hurt], [{ item: ENERGYPOWDER, quantity: 1 }]);
|
|
expect(
|
|
playBattle(powder.battle, { action: [use(ENERGYPOWDER)] }).texts,
|
|
).toContain("It looks bitter…");
|
|
expect(hurt.happiness).toBe(145); // -5 below 200
|
|
});
|
|
|
|
it("cures confusion with a Bitter Berry, and does nothing otherwise", () => {
|
|
const { battle, bag } = setup(
|
|
[partyMon(assets, CYNDAQUIL, 10)],
|
|
[{ item: BITTER_BERRY, quantity: 2 }],
|
|
);
|
|
const steps = battleSteps(battle);
|
|
steps.answer(use(BITTER_BERRY));
|
|
expect(steps.texts).toContain("It won't have any effect.");
|
|
battle.player.sub[3] |= 1 << C.SUBSTATUS_CONFUSED;
|
|
steps.answer(use(BITTER_BERRY));
|
|
expect(steps.texts).toContain("CHRIS used the BITTER BERRY.");
|
|
expect(steps.texts.some((text) => text.includes("confused"))).toBe(true);
|
|
expect(battle.player.sub[3] & (1 << C.SUBSTATUS_CONFUSED)).toBe(0);
|
|
expect(bag.items).toEqual([{ item: BITTER_BERRY, quantity: 1 }]);
|
|
});
|
|
|
|
it("restores PP with Ethers, Elixers and the MysteryBerry", () => {
|
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
|
mon.pp[0] = (1 << 6) | 1; // One PP Up: TACKLE's maximum is 42.
|
|
mon.pp[1] = 2;
|
|
const { battle, bag } = setup(
|
|
[mon],
|
|
[
|
|
{ item: ETHER, quantity: 1 },
|
|
{ item: MYSTERYBERRY, quantity: 1 },
|
|
{ item: MAX_ELIXER, quantity: 1 },
|
|
],
|
|
);
|
|
const steps = battleSteps(battle).answer(use(ETHER)).answer(0);
|
|
expect(steps.prompt).toMatchObject({
|
|
kind: "move",
|
|
text: "Restore the PP of\nwhich move?",
|
|
});
|
|
// Going back from the move list reopens the party menu.
|
|
steps.answer(-1);
|
|
expect(steps.prompt?.kind).toBe("party");
|
|
steps.answer(0).answer(0);
|
|
expect(steps.texts).toContain("PP was restored.");
|
|
expect(mon.pp[0]).toBe((1 << 6) | 11);
|
|
// BattleRestorePP: the Pokémon out takes the new PP.
|
|
expect(battle.player.mon.pp[0] & 63).toBe(11);
|
|
steps.answer(use(MYSTERYBERRY, 0)).answer(0).answer(1);
|
|
expect(mon.pp[1]).toBe(7);
|
|
steps.answer(use(MAX_ELIXER, 0)).answer(0);
|
|
expect(mon.pp[0]).toBe((1 << 6) | 42);
|
|
expect(mon.pp[1] & 63).toBe(assets.pokemon.move(mon.moves[1]).pp);
|
|
expect(bag.items).toEqual([]);
|
|
});
|
|
|
|
it("reapplies the paralysed enemy's Speed penalty when a heal follows a full turn", () => {
|
|
// After HandleHealingItems, hBattleTurn holds the enemy, so
|
|
// CalcPlayerStats penalises the enemy again (the original's behaviour).
|
|
const mon = partyMon(assets, CYNDAQUIL, 10);
|
|
mon.status = PSN;
|
|
const { battle } = setup([mon], [{ item: ANTIDOTE, quantity: 1 }], 5);
|
|
const steps = battleSteps(battle).answer({ action: "fight", move: 1 });
|
|
battle.enemy.mon.status = PAR;
|
|
const speed = battle.enemy.mon.stats[C.SPEED];
|
|
steps.answer(use(ANTIDOTE)).answer(0);
|
|
expect(steps.texts).toContain("CYNDAQUIL's cured of poison.");
|
|
expect(battle.enemy.mon.stats[C.SPEED]).toBe(Math.max(1, speed >> 2));
|
|
});
|
|
|
|
it("raises happiness more when leveling up where the Pokémon was caught", () => {
|
|
const level = (landmark: number) => {
|
|
const mon = partyMon(assets, CYNDAQUIL, 5);
|
|
mon.exp = 178; // One Pidgey from level 6
|
|
mon.happiness = 70;
|
|
mon.caughtData = (mon.caughtData & ~0x7f) | 3;
|
|
const bag: Bag = { items: [], balls: [], keyItems: [] };
|
|
const options = battleOptions(
|
|
assets,
|
|
trainers,
|
|
[mon],
|
|
{ wild: { species: PIDGEY, level: 3 } },
|
|
{ bag },
|
|
);
|
|
playBattle(new Battle({ ...options, landmark }));
|
|
return mon.happiness;
|
|
};
|
|
expect(level(3)).toBe(80); // HAPPINESS_GAINLEVELATHOME: +10
|
|
expect(level(4)).toBe(75); // HAPPINESS_GAINLEVEL: +5
|
|
});
|
|
});
|