Port the remaining battle items, the stats screen and hBattleTurn at the menu

- 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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:24:55 +02:00
1 parent cfd083c16d
commit b13e09ec20
22 files changed
+1547 -171

No files matched your search

+248 -7
View File
@@ -2,11 +2,16 @@ 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 { 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, partyMon, playBattle } from "./battle-driver";
import {
battleOptions,
battleSteps,
partyMon,
playBattle,
} from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
@@ -16,6 +21,18 @@ const PIDGEY = 16,
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,
@@ -196,13 +213,237 @@ describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
});
it("refuses items whose battle effects are not ported", () => {
const { battle } = setup(
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: X_ATTACK, quantity: 1 }],
[{ item: POKE_DOLL, quantity: 1 }],
);
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
UnsupportedBattle,
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
});
});