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

+1 -1
View File
@@ -58,7 +58,7 @@ async function finish(page: Page, action: "fight" | "run" = "fight") {
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
else {
await fight.click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM|SCRATCH/ }).click();
await menu.getByRole("button", { name: /TACKLE|PSYWAVE|SCRATCH/ }).click();
}
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
await menu.getByRole("button", { name: "YES", exact: true }).click();
+3 -1
View File
@@ -24,7 +24,9 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.exp = 178; // One battle away from level 6 and Smokescreen.
if (scenario === "loss") mon.hp = 1;
if (scenario === "unsupported") {
mon.moves = [144, 0, 0, 0]; // Transform is explicitly refused.
// Psywave at level 1 loops forever in the original, so it stays refused.
mon.level = 1;
mon.moves = [149, 0, 0, 0];
mon.pp = [10, 0, 0, 0];
}
world.state.party.push(mon);
+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);
});
});
+193 -2
View File
@@ -95,7 +95,25 @@ const DOUBLESLAP = 3,
RAPID_SPIN = 0xe5,
PSYCH_UP = 0xf4,
ANCIENTPOWER = 0xf6,
FUTURE_SIGHT = 0xf8;
FUTURE_SIGHT = 0xf8,
RAIN_DANCE = 0xf0,
SUNNY_DAY = 0xf1,
SANDSTORM = 0xc9,
SUBSTITUTE = 0xa4,
TRANSFORM = 0x90,
MIMIC = 0x66,
METRONOME = 0x76,
MIRROR_MOVE = 0x77,
SLEEP_TALK = 0xd6,
SNORE = 0xad,
CONVERSION = 0xa0,
CONVERSION2 = 0xb0,
SKETCH = 0xa6,
BATON_PASS = 0xe2,
ROAR = 0x2e,
TELEPORT = 0x64,
WATER_GUN = 0x37,
SYNTHESIS = 0xeb;
// Species and items
const PIDGEY = 16,
RATTATA = 19,
@@ -122,7 +140,10 @@ const PIDGEY = 16,
NIDORAN_F = 0x1d,
NIDORAN_M = 0x20,
STARMIE = 0x79,
FORRETRESS = 0xcd;
FORRETRESS = 0xcd,
DITTO = 0x84,
SMEARGLE = 0xeb,
PORYGON = 0x89;
const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92,
FOCUS_BAND = 0x77,
@@ -872,6 +893,176 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
});
});
describe("weather, substitutes and moves that copy or switch", () => {
it("makes it rain for five turns, strengthening Water moves", () => {
const damage = (rain: boolean) => {
const enemy = mon(SNORLAX, 60, [SPLASH]);
const battle = duel([mon(STARMIE, 40, [rain ? RAIN_DANCE : SPLASH, WATER_GUN])], [enemy], { seed: 3 });
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
return { taken: enemy.hp - battle.enemy.mon.hp, steps, battle };
};
const rain = damage(true),
dry = damage(false);
expect(rain.steps.texts.join(" ")).toContain("A downpour started!");
expect(rain.steps.texts.join(" ")).toContain("Rain continues to fall.");
expect(rain.taken).toBeGreaterThan(dry.taken * 1.3);
for (let turn = 0; turn < 3; turn++) rain.steps.answer({ action: "fight", move: 1 });
expect(rain.steps.texts).toContain("The rain stopped.");
expect(rain.battle.weather).toBe(C.WEATHER_NONE);
});
it("skips Solarbeam's charge and heals more with Synthesis in the sun", () => {
const player = mon(BULBASAUR, 60, [SUNNY_DAY, SOLARBEAM, SYNTHESIS]);
player.hp = 20;
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("The sunlight got bright!");
steps.answer({ action: "fight", move: 1 });
expect(steps.texts).not.toContain("BULBASAUR took in sunlight!");
expect(steps.texts).toContain("BULBASAUR used SOLARBEAM!");
const hp = battle.player.mon.hp;
steps.answer({ action: "fight", move: 2 });
// Synthesis gives half the maximum HP by day, all of it in the sun.
expect(battle.player.mon.hp).toBe(Math.min(player.stats[0], hp + player.stats[0]));
});
it("buffets Pokémon other than Rock, Ground and Steel types in a sandstorm", () => {
const battle = duel([mon(GEODUDE, 40, [SANDSTORM, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
expect(steps.texts).toContain("A SANDSTORM brewed!");
expect(steps.texts).toContain("The SANDSTORM hits Enemy SNORLAX!");
expect(steps.texts).not.toContain("The SANDSTORM hits GEODUDE!");
// Two turns' worth: the turn it started and the next.
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(((battle.enemy.mon.maxHp >> 2) >> 1) * 2);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("But it failed!");
});
it("makes a substitute that takes the hits and blocks status moves", () => {
const player = mon(SNORLAX, 60, [SUBSTITUTE, SPLASH]);
const battle = duel([player], [mon(PIDGEY, 30, [TACKLE])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
const quarter = player.stats[0] >> 2;
expect(steps.texts.join(" ")).toContain("made a SUBSTITUTE");
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
expect(steps.texts.join(" ")).toContain("The SUBSTITUTE took damage");
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
expect(battle.player.substituteHp).toBeLessThan(quarter);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("SNORLAX has a SUBSTITUTE!");
const status = firstTurn(duel([mon(SNORLAX, 60, [SUBSTITUTE])], [mon(PIDGEY, 30, [POISONPOWDER])]));
expect(status.texts).toContain("It didn't affect SNORLAX!");
const weak = mon(SNORLAX, 60, [SUBSTITUTE]);
weak.hp = weak.stats[0] >> 2;
expect(firstTurn(duel([weak], [mon(PIDGEY, 30, [SPLASH])])).texts.join(" ")).toContain("Too weak to make a SUBSTITUTE");
// A fast Jolteon's substitute, then a strong Tackle that breaks it.
const breaker = duel([mon(JOLTEON, 30, [SUBSTITUTE])], [mon(SNORLAX, 60, [TACKLE])]);
const broken = firstTurn(breaker);
expect(broken.texts.join(" ")).toContain("SUBSTITUTE faded");
expect(breaker.player.has(4, C.SUBSTATUS_SUBSTITUTE)).toBe(false);
});
it("transforms into the target with 5 PP per move", () => {
const battle = duel([mon(DITTO, 40, [TRANSFORM])], [mon(SNORLAX, 60, [SPLASH, TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("TRANSFORMED into SNORLAX");
expect(battle.player.mon.species).toBe(SNORLAX);
expect(battle.player.mon.moves.slice(0, 2)).toEqual([SPLASH, TACKLE]);
expect(battle.player.mon.pp.slice(0, 3)).toEqual([5, 5, 0]);
});
it("catches a transformed wild Pokémon as a Ditto with its own DVs", () => {
const party = [mon(SNORLAX, 40, [SPLASH])];
const bag = { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] };
const battle = duel(party, { wild: { species: DITTO, level: 10 } }, { bag, caughtData: () => 0 });
const steps = battleSteps(battle);
const dvs = battle.enemy.mon.dvs;
steps.answer({ action: "fight", move: 0 });
expect(battle.enemy.has(5, C.SUBSTATUS_TRANSFORMED)).toBe(true);
steps.answer({ action: "item", item: C.MASTER_BALL, slot: 0 });
while (!steps.done) steps.answer(steps.prompt?.kind === "yesno" ? 0 : undefined);
expect(party[1].species).toBe(DITTO);
expect(party[1].dvs).toBe(dvs);
expect(party[1].moves[0]).toBe(TRANSFORM);
});
it("mimics the target's last move for the battle", () => {
const player = mon(STARMIE, 60, [MIMIC]);
const battle = duel([player], [mon(SNORLAX, 30, [TACKLE])]);
const { texts } = turns(battle, [0, 0]);
expect(texts.join(" ")).toContain("learned TACKLE");
expect(battle.player.mon.moves[0]).toBe(TACKLE);
expect(player.moves[0]).toBe(MIMIC);
});
it("calls a random move with Metronome", () => {
const { texts } = firstTurn(duel([mon(STARMIE, 60, [METRONOME])], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts[0]).toBe("STARMIE used METRONOME!");
expect(texts[1]).toMatch(/^STARMIE used /);
expect(texts[1]).not.toBe("STARMIE used METRONOME!");
});
it("mirrors the target's last move", () => {
const battle = duel([mon(SNORLAX, 30, [MIRROR_MOVE])], [mon(STARMIE, 60, [TACKLE])]);
const { texts } = firstTurn(battle);
const used = texts.indexOf("SNORLAX used MIRROR MOVE!");
expect(texts[used + 1]).toBe("SNORLAX used TACKLE!");
const first = firstTurn(duel([mon(STARMIE, 60, [MIRROR_MOVE])], [mon(SNORLAX, 30, [TACKLE])]));
expect(first.texts.join(" ")).toContain("MIRROR MOVE failed");
});
it("talks in its sleep with another move, and snores only when asleep", () => {
const sleeper = mon(SNORLAX, 60, [SLEEP_TALK, TACKLE, SNORE], { status: 7 });
const { texts } = firstTurn(duel([sleeper], [mon(PIDGEY, 30, [SPLASH])]));
expect(texts).toContain("SNORLAX used SLEEP TALK!");
expect(texts.some((text) => text === "SNORLAX used TACKLE!" || text === "SNORLAX used SNORE!")).toBe(true);
const awake = firstTurn(duel([mon(SNORLAX, 60, [SNORE])], [mon(PIDGEY, 30, [SPLASH])]));
expect(awake.texts).toContain("But it failed!");
});
it("changes its type with Conversion and Conversion2", () => {
const battle = duel([mon(PORYGON, 40, [CONVERSION, WATER_GUN])], [mon(SNORLAX, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("transformed into the WATER-type");
expect(battle.player.mon.types).toEqual([21, 21]);
// Jolteon moves first, so its Tackle is the last move to resist.
const resist = duel([mon(PORYGON, 30, [CONVERSION2])], [mon(JOLTEON, 20, [TACKLE])]);
firstTurn(resist);
const [type] = resist.player.mon.types;
expect([5, 8, 9]).toContain(type); // Rock, Ghost or Steel
});
it("sketches the target's last move for good", () => {
// Jolteon moves first, so Sketch has a move to copy.
const player = mon(SMEARGLE, 20, [SKETCH]);
const battle = duel([player], [mon(JOLTEON, 30, [TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("SKETCHED TACKLE");
expect(player.moves[0]).toBe(TACKLE);
expect(player.pp[0]).toBe(assets.pokemon.move(TACKLE).pp);
});
it("passes stat changes and the substitute along with Baton Pass", () => {
const party = [mon(STARMIE, 60, [SWORDS_DANCE, BATON_PASS]), mon(SNORLAX, 30, [TACKLE])];
const battle = duel(party, [mon(PIDGEY, 30, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
expect(steps.prompt?.kind).toBe("party");
steps.answer(0).answer(1);
expect(steps.texts).toContain("STARMIE is already out.");
expect(battle.player.mon.species).toBe(SNORLAX);
expect(battle.player.levels[C.ATTACK]).toBe(9);
const [numerator, denominator] = [2, 1];
expect(battle.player.mon.stats[C.ATTACK]).toBe(Math.min(999, Math.floor((party[1].stats[1] * numerator) / denominator)));
});
it("ends a wild battle with Roar or Teleport, and drags out a trainer's Pokémon", () => {
const roar = duel([mon(SNORLAX, 60, [ROAR])], { wild: { species: PIDGEY, level: 5 } });
const roared = playBattle(roar);
expect(roared.result).toBe(C.DRAW);
expect(roared.texts).toContain("Enemy PIDGEY fled in fear!");
const teleport = duel([mon(STARMIE, 60, [TELEPORT])], { wild: { species: PIDGEY, level: 5 } });
const teleported = playBattle(teleport);
expect(teleported.result).toBe(C.DRAW);
expect(teleported.texts.join(" ")).toContain("fled from battle");
const trainer = duel([mon(SNORLAX, 30, [ROAR])], [mon(PIDGEY, 30, [SPLASH]), mon(RATTATA, 30, [SPLASH])]);
const dragged = firstTurn(trainer);
expect(dragged.texts.join(" ")).toContain("was dragged out");
expect(trainer.enemy.mon.species).toBe(RATTATA);
});
});
describe("Counter and Mirror Coat", () => {
it("returns twice a physical hit taken first", () => {
const player = mon(SNORLAX, 40, [COUNTER]);
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { C } from "../src/game/battle/constants";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs the private ROM and pinned reference rebuild.");
test("Metal Powder matches the original species check and byte overflow", async ({ page }) => {
const { at, routine } = loadSymbols();
const addresses = {
command: routine("DittoMetalPowder"),
romBank: at("hROMBank"),
turn: at("hBattleTurn"),
curBattleMon: at("wCurBattleMon"),
partySpecies: at("wPartyMon1Species"),
enemySpecies: at("wTempEnemyMonSpecies"),
playerItem: at("wBattleMonItem"),
enemyItem: at("wEnemyMonItem"),
};
await openReference(page);
const differences = await page.evaluate(async ({ addresses, ditto, powder }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { Battle } = await import("/src/game/battle/battle.ts");
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
const rom = new Uint8Array(await file.arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (address: number, value: number) => reference.writeMemory(address, value & 255);
const read = (address: number) => reference.readMemory(address);
const failures: unknown[] = [];
let compared = 0;
write(0xff70, 1);
write(addresses.curBattleMon, 0);
for (const turn of [0, 1]) for (const species of [ditto, 25]) for (const item of [powder, 0])
for (const attack of [1, 2, 101, 255]) for (const defense of [1, 2, 100, 169, 170, 171, 172, 200, 254, 255]) {
const battle = new Battle({
data: assets.pokemon, party: [{ species }] as never,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1, playerName: "CHRIS", timeOfDay: 1,
text: () => ({}) as never, random: () => 0,
});
const internal = battle as unknown as {
enemySpecies: number;
dittoMetalPowder(side: unknown, attack: number, defense: number): number[];
};
internal.enemySpecies = species;
const target = turn ? battle.player : battle.enemy;
target.mon = { species: 25, item } as never; // transformed battle species
const native = internal.dittoMetalPowder(target, attack, defense);
write(addresses.turn, turn);
write(addresses.partySpecies, species);
write(addresses.enemySpecies, species);
write(addresses.playerItem, item);
write(addresses.enemyItem, item);
const { bank, address } = addresses.command;
// DI; SP; ROM bank; BC; call the unmodified routine; save B/C;
// sentinel in audio RAM (no interrupts); loop.
const program = [
0xf3, 0x31, 0xfe, 0xc7,
0x3e, bank, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20,
0x06, attack, 0x0e, defense, 0xcd, address & 255, address >> 8,
0x78, 0xea, 0xe0, 0xc0, 0x79, 0xea, 0xe1, 0xc0,
0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe,
];
write(0xc0f0, 0);
program.forEach((byte, i) => write(0xc000 + i, byte));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw new Error("DittoMetalPowder did not return");
const original = [read(0xc0e0), read(0xc0e1)];
if (original.some((value, i) => value !== native[i]))
failures.push({ turn, species, item, attack, defense, original, native });
compared++;
}
reference.dispose();
return { compared, failures };
}, { addresses, ditto: C.DITTO, powder: C.METAL_POWDER });
expect(differences.compared).toBe(320);
expect(differences.failures).toEqual([]);
});
+25
View File
@@ -51,6 +51,7 @@ type Case = {
beatUpHit: number;
playerIsSwitching: number;
enemyIsSwitching: number;
weather?: number;
/** A battle script and the index of the command after the one run. */
script: number[];
scriptIndex: number;
@@ -142,6 +143,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
enemyGoesFirst: at("wEnemyGoesFirst"),
animParam: at("wBattleAnimParam"),
battleMode: at("wBattleMode"),
weather: at("wBattleWeather"),
partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"),
@@ -257,6 +259,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
beatUpHit: input.beatUpHit,
playerIsSwitching: input.playerIsSwitching,
enemyIsSwitching: input.enemyIsSwitching,
weather: input.weather ?? 0,
script: [...input.script],
scriptIndex: input.scriptIndex,
});
@@ -269,6 +272,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(addresses.enemyGoesFirst, input.enemyGoesFirst);
write(addresses.animParam, input.animParam);
write(addresses.battleMode, input.battleMode);
write(addresses.weather, input.weather ?? 0);
write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit);
@@ -621,3 +625,24 @@ test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
}
await compare(page, ["BattleCommand_CheckHit"], cases);
});
test("Stab matches weather, STAB and type effectiveness", async ({ page }) => {
const { make, next } = generator(0x57ab);
// Normal, Fighting, Flying, Ground, Ghost, Fire, Water, Grass, Electric, Psychic, Dark.
const types = [0, 1, 2, 4, 8, 20, 21, 22, 23, 24, 27];
const cases: Case[] = [];
for (let i = 0; i < 1000; i++) {
const input = make({
curDamage: [0, 1, 2, 3, 40, 997, 0x7fff, 0xaaaa, 0xffff][next(9)],
typeModifier: next(4) ? C.EFFECTIVE : 0,
});
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, type: types[next(types.length)], effect: next(4) ? 0 : C.EFFECT_SOLARBEAM };
user.types = [types[next(types.length)], types[next(types.length)]];
target.types = [types[next(types.length)], types[next(types.length)]];
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
cases.push({ ...input, weather: next(4) } as Case);
}
await compare(page, ["BattleCommand_Stab"], cases);
});
+3 -1
View File
@@ -130,7 +130,9 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
it("does not re-trigger a refused sighting until the player moves", () => {
const world = fixture({ x: 1, y: 11, direction: "up" });
world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused
// Psywave at level 1 loops forever in the original, so it stays refused.
world.state.party[0].level = 1;
world.state.party[0].moves = [149, 0, 0, 0];
world.state.party[0].pp = [10, 0, 0, 0];
const before = world.save();
world.move("up");