Port weather, copying moves, held items and obedience (#28)
Checks / web (push) Canceled after 0s

Complete the weather, Substitute, Transform, called-move and forced-switch effects. Add held-item behavior, Exp. Share awards and badge-based traded Pokemon obedience, and persist trainer berry consumption.

Validated with 576 passing unit tests and 149 browser tests, native/reference builds, reference/vendor/release checks and 320 Metal Powder emulator comparisons per browser. Smart AI and special balls/battle types remain on #28.
This commit is contained in:
Sébastien QUEROL committed 2026-10-03 15:03:19 +02:00
1 parent 8f5b464efc
commit 32d736ecae
13 files changed
+1509 -75

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
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, 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);
});
});