Port weather, copying moves, held items and obedience (#28)
Checks / web (push) Canceled after 0s
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:
1 parent
8f5b464efc
commit
32d736ecae
13 files changed
+1509
-75
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user