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.
299 lines
15 KiB
TypeScript
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, 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);
|
|
});
|
|
});
|