Files
Crystal/tests/battle-held-items.test.ts
kawaandClaude Opus 5.5 b5c2b19094
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s
Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:09:47 +02:00

299 lines
15 KiB
TypeScript

import { beforeAll, describe, expect, it, vi } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { TrainerData } from "../src/game/rom/trainers";
import { Battle, type BattleEffect, type BattleOptions } from "../src/game/battle/battle";
import { C, COMMAND } from "../src/game/battle/constants";
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 SPLASH = 150, TACKLE = 33, TRANSFORM = 144, MIMIC = 102, SMOKE_BALL = 0x6a;
describe.skipIf(!existsSync(romPath))("held items and obedience with the local ROM", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const mon = (species = 143, level = 30, moves = [SPLASH], extra: Partial<PartyMon> = {}) => {
const result = partyMon(assets, species, level);
result.moves = Array.from({ length: 4 }, (_, i) => moves[i] ?? 0) as PartyMon["moves"];
result.pp = result.moves.map((move) => move ? assets.pokemon.move(move).pp : 0) as PartyMon["pp"];
return Object.assign(result, extra);
};
const duel = (party: PartyMon[], enemy = [mon()], extra: Partial<BattleOptions> = {}) => new Battle({
...battleOptions(assets, trainers, party, { wild: { species: 129, level: 10 } }),
wild: undefined,
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, items: [0, 0], baseReward: 10, reward: 40 },
...extra,
});
const drain = (run: Generator<BattleEffect, unknown, unknown>) => {
const texts: string[] = [];
for (let next = run.next(); !next.done; next = run.next()) {
if (next.value.kind === "text") texts.push(next.value.text.replace(/\n/g, " "));
else expect(next.value.kind).toBe("hud");
}
return texts.join(" ");
};
// Direct routine access lets branch tests supply exactly the BattleRandom
// bytes consumed by CheckObedience, without menu/AI draws obscuring them.
const internal = (battle: Battle) => battle as unknown as {
turn: number; alreadyDisobeyed: number; attackMissed: number; effectFailed: number;
script: number[]; scriptIndex: number; enemySpecies: number;
checkObedience(): Generator<BattleEffect, void, unknown>;
updateMoveData(): void;
handleMysteryBerry(): Generator<BattleEffect, void, unknown>;
handleStatBoostingHeldItems(): Generator<BattleEffect, void, unknown>;
dittoMetalPowder(side: Battle["player"], attack: number, defense: number): [number, number];
data: { heldEffect(item: number): { effect: number; parameter: number } };
};
it("Smoke Ball escapes without a speed roll or consumption, after the trap checks", () => {
const holder = mon(143, 5, [SPLASH], { item: SMOKE_BALL });
const battle = duel([holder], [], { trainer: undefined, wild: { species: 129, level: 20 } });
const steps = battleSteps(battle);
battle.enemy.mon.stats[C.SPEED] = 999;
battle.enemy.set(5, C.SUBSTATUS_CANT_RUN);
steps.answer({ action: "run" });
expect(steps.done).toBe(false);
expect(steps.texts.join(" ")).toContain("Can't escape");
battle.enemy.reset(5, C.SUBSTATUS_CANT_RUN);
steps.answer({ action: "run" });
expect(steps.done).toBe(true);
expect(steps.texts.join(" ")).toContain("fled using a SMOKE BALL");
expect(steps.texts.join(" ")).toContain("Got away safely");
expect(holder.item).toBe(SMOKE_BALL);
});
it("Smoke Ball cannot escape a trainer", () => {
const battle = duel([mon(143, 30, [SPLASH], { item: SMOKE_BALL })]);
const steps = battleSteps(battle).answer({ action: "run" });
expect(steps.done).toBe(false);
expect(steps.texts.join(" ")).toContain("No! There's no running");
});
it.each([0, 1])("MysteryBerry replenishes the first exhausted stored move on side %i", (sideIndex) => {
const holder = mon(143, 30, [SPLASH, TACKLE], { item: C.MYSTERYBERRY, pp: [0x81, 0xc0, 0, 0] });
const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
const side = sideIndex ? battle.enemy : battle.player;
expect(holder.pp[0]).toBe(0x85);
expect(holder.pp[1]).toBe(0xc0);
expect(side.mon.pp[0]).toBe(0x85);
expect(holder.item).toBe(0);
expect(side.mon.item).toBe(0);
expect(steps.texts.join(" ")).toContain("recovered PP using");
});
it("MysteryBerry stays held when only a copied move has no PP", () => {
const holder = mon(132, 30, [TRANSFORM], { item: C.MYSTERYBERRY });
const battle = duel([holder]);
battleSteps(battle).answer({ action: "fight", move: 0 });
battle.player.mon.pp[0] = 0;
drain(internal(battle).handleMysteryBerry());
expect(holder.item).toBe(C.MYSTERYBERRY);
expect(battle.player.mon.pp[0]).toBe(0);
});
it.each([TRANSFORM, MIMIC])("MysteryBerry restores stored move %i without refilling its copy", (move) => {
const holder = mon(132, 30, [move], { item: C.MYSTERYBERRY, pp: [1, 0, 0, 0] });
const battle = duel([holder], [mon(135)]);
const steps = battleSteps(battle).answer({ action: "fight", move: 0 });
expect(steps.done).toBe(false);
expect(holder.pp[0]).toBe(5);
expect(holder.item).toBe(0);
expect(battle.player.mon.moves[0]).toBe(SPLASH);
expect(battle.player.mon.pp[0]).toBe(5);
});
it("MysteryBerry restores Sketch by one PP and preserves PP Ups", () => {
const holder = mon(235, 30, [SPLASH, C.SKETCH], { item: C.MYSTERYBERRY, pp: [10, 0xc0, 0, 0] });
const battle = duel([holder]);
battleSteps(battle).answer({ action: "fight", move: 0 });
expect(holder.pp[1]).toBe(0xc1);
expect(battle.player.mon.pp[1]).toBe(0xc1);
});
it.each([0xad, 0x53])("a trainer's consumed berry %i is removed from its stored party too", (item) => {
// Berry and Bitter Berry exercise ConsumeHeldItem and the separate
// UseConfusionHealingItem removal path.
const holder = mon(143, 30, [SPLASH], { item });
const battle = duel([mon()], [holder]);
const steps = battleSteps(battle);
if (item === 0xad) battle.enemy.mon.hp = 20;
else {
battle.enemy.set(3, C.SUBSTATUS_CONFUSED);
battle.enemy.confuseCount = 5;
}
steps.answer({ action: "fight", move: 0 });
expect(battle.enemy.mon.item).toBe(0);
expect(holder.item).toBe(0);
});
it.each([0, 1])("Berserk Gene activates before the menu on side %i, with the 256-turn confusion bug", (sideIndex) => {
const holder = mon(143, 30, [SPLASH], { item: C.BERSERK_GENE });
const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]);
const steps = battleSteps(battle), side = sideIndex ? battle.enemy : battle.player;
expect(holder.item).toBe(0);
expect(side.levels[C.ATTACK]).toBe(9);
expect(side.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
expect(side.confuseCount).toBe(0);
expect(steps.texts.join(" ")).toContain("BERSERK GENE activated");
steps.answer({ action: "fight", move: 0 });
expect(side.confuseCount).toBe(255);
expect(steps.texts.filter((text) => text.includes("BERSERK GENE activated"))).toHaveLength(1);
});
it("Metal Powder checks original species on either side and keeps the overflow quirk", () => {
const battle = duel([mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })], [mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })]);
battleSteps(battle);
for (const side of [battle.player, battle.enemy]) {
side.mon.species = 143;
expect(internal(battle).dittoMetalPowder(side, 101, 170)).toEqual([101, 255]);
expect(internal(battle).dittoMetalPowder(side, 101, 171)).toEqual([50, 128]);
expect(internal(battle).dittoMetalPowder(side, 1, 255)).toEqual([1, 191]);
}
});
it("unused held stat effects respect failure and consume both party and battle items on success", () => {
const holder = mon(143, 30, [SPLASH], { item: C.MYSTERYBERRY });
const battle = duel([holder]);
battleSteps(battle);
const state = internal(battle);
const spy = vi.spyOn(state.data, "heldEffect").mockImplementation((item) => ({ effect: item ? C.HELD_ATTACK_UP : 0, parameter: 0 }));
state.attackMissed = 1;
drain(state.handleStatBoostingHeldItems());
expect(holder.item).toBe(C.MYSTERYBERRY);
state.attackMissed = state.effectFailed = 0;
drain(state.handleStatBoostingHeldItems());
expect(holder.item).toBe(0);
expect(battle.player.mon.item).toBe(0);
expect(battle.player.levels[C.ATTACK]).toBe(8);
spy.mockRestore();
});
it.each([1, 2])("Exp. Share divides its half between %i healthy holders, excluding fainted holders", (count) => {
const fighter = mon(143, 50, [TACKLE]);
const holders = Array.from({ length: count }, () => mon(143, 30, [SPLASH], { item: C.EXP_SHARE }));
const fainted = mon(143, 30, [SPLASH], { item: C.EXP_SHARE, hp: 0 });
const party = [fighter, ...holders, fainted], before = party.map((p) => p.exp);
const battle = duel(party, [], { trainer: undefined, wild: { species: 16, level: 10 } });
expect(playBattle(battle).result).toBe(C.WIN);
const base = assets.pokemon.baseData(16);
expect(fighter.exp - before[0]).toBe(Math.floor((base.baseExp >> 1) * 10 / 7));
holders.forEach((holder, i) => {
expect(holder.exp - before[i + 1]).toBe(Math.floor(Math.floor((base.baseExp >> 1) / count) * 10 / 7));
expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => Math.floor((value >> 1) / count)));
});
expect(fainted.exp).toBe(before.at(-1));
expect(fainted.statExp).toEqual([0, 0, 0, 0, 0]);
});
it("an active Exp. Share holder receives both halves, including Pokérus stat experience", () => {
const holder = mon(143, 50, [TACKLE], { item: C.EXP_SHARE, pokerus: 1 });
const before = holder.exp;
const battle = duel([holder], [], { trainer: undefined, wild: { species: 16, level: 10 } });
const result = playBattle(battle);
const base = assets.pokemon.baseData(16);
expect(holder.exp - before).toBe(2 * Math.floor((base.baseExp >> 1) * 10 / 7));
expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => 4 * (value >> 1)));
expect(result.texts.filter((text) => text.includes("gained"))).toHaveLength(2);
});
const obedience = (bytes: number[], extra: Partial<BattleOptions> = {}, level = 50) => {
const holder = mon(143, level, [SPLASH, TACKLE], { otId: 4321 });
const battle = duel([holder], [mon()], extra);
battleSteps(battle);
const state = internal(battle);
state.turn = 0;
state.script = [COMMAND.checkobedience, COMMAND.usedmovetext, COMMAND.endmove];
state.scriptIndex = 1;
battle.player.curMove = SPLASH;
battle.player.moveNum = 0;
state.updateMoveData();
const random = vi.spyOn(battle, "random").mockImplementation(() => {
const byte = bytes.shift();
if (byte === undefined) throw new Error("Unexpected obedience RNG draw");
return byte;
});
return { battle, holder, state, random, texts: () => drain(state.checkObedience()) };
};
it.each([[0, 10], [2, 30], [8, 50], [0x20, 70], [0x80, 100]])("badge mask %i guarantees obedience through level %i without RNG", (johtoBadges, level) => {
const test = obedience([], { johtoBadges }, level);
expect(test.texts()).toBe("");
expect(test.random).not.toHaveBeenCalled();
});
it("owned Pokémon obey regardless of level, and foreign Pokémon can pass their first roll", () => {
const owned = obedience([], {}, 100);
owned.holder.otId = 1234;
expect(owned.texts()).toBe("");
expect(owned.random).not.toHaveBeenCalled();
const foreign = obedience([0xff, 0]); // reject 255, then obey
expect(foreign.texts()).toBe("");
expect(foreign.random).toHaveBeenCalledTimes(2);
});
it("disobedience loafs, clears Encore and remembered moves, and spends no PP", () => {
const test = obedience([1, 20, 0xff, 0]); // swap(1)=16: disobey; neither nap nor self-hit
test.battle.player.set(5, C.SUBSTATUS_ENCORED);
test.battle.player.encoreCount = 3;
test.battle.player.lastMove = test.battle.player.lastCounterMove = TACKLE;
const pp = [...test.holder.pp];
expect(test.texts()).toContain("loafing around");
expect(test.holder.pp).toEqual(pp);
expect(test.battle.player.lastMove).toBe(0);
expect(test.battle.player.lastCounterMove).toBe(0);
expect(test.battle.player.encoreCount).toBe(0);
expect(test.battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(false);
expect(test.state.script[1]).toBe(COMMAND.endmove);
});
it("disobedience can nap or inflict confusion damage without using PP", () => {
const nap = obedience([1, 20, 0, 0, 8]); // reject zero sleep, then one turn
expect(nap.texts()).toContain("began to nap");
expect(nap.battle.player.mon.status).toBe(1);
const hit = obedience([1, 20, 3]); // swap(3)=48, in [40,80)
const hp = hit.battle.player.mon.hp;
expect(hit.texts()).toContain("hurt itself");
expect(hit.battle.player.mon.hp).toBeLessThan(hp);
expect(hit.battle.player.mon.pp).toEqual(hit.holder.pp);
});
it("a sleeping foreign Pokémon ignores Snore before the second obedience roll", () => {
const test = obedience([1]);
test.battle.player.curMove = C.SNORE;
test.battle.player.mon.status = 3;
test.battle.player.lastMove = TACKLE;
test.state.updateMoveData();
expect(test.texts()).toContain("ignored orders");
expect(test.random).toHaveBeenCalledTimes(1);
expect(test.battle.player.lastMove).toBe(TACKLE); // IgnoreSleepOnly returns early
});
it("disobedience can use a different move, spending only that slot's PP", () => {
const test = obedience([1, 0, 0, 1, 255, 255, 0]);
// 16 fails obedience; 0 selects UseInstead; reject original slot 0,
// choose Tackle, then its critical, damage-variation and hit draws.
const pp = [...test.holder.pp], hp = test.battle.enemy.mon.hp;
expect(test.texts()).toContain("instead");
expect(test.battle.enemy.mon.hp).toBeLessThan(hp);
expect(test.holder.pp).toEqual([pp[0], pp[1] - 1, 0, 0]);
expect(test.battle.player.moveNum).toBe(0);
expect(test.state.alreadyDisobeyed).toBe(1);
});
it.each(["disabled", "no PP", "one move"])("disobedience loafs when an alternate move is unavailable: %s", (reason) => {
const test = obedience([1, 0, 0]);
if (reason === "disabled") test.battle.player.disabledMove = TACKLE;
else if (reason === "no PP") test.battle.player.mon.pp[1] = 0xc0;
else test.battle.player.mon.moves[1] = 0;
expect(test.texts()).toContain("loafing around");
expect(test.random).toHaveBeenCalledTimes(3);
expect(test.state.alreadyDisobeyed).toBe(0);
});
});