Files
Crystal/tests/move-effects.test.ts
T
Sébastien QUEROL 32d736ecae
Checks / web (push) Canceled after 0s
Port weather, copying moves, held items and obedience (#28)
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.
2026-10-03 15:03:19 +02:00

1236 lines
57 KiB
TypeScript

import { beforeAll, describe, expect, it } 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 BattleOptions } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { battleOptions, battleSteps, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// constants/move_constants.asm
const DOUBLESLAP = 3,
PAY_DAY = 6,
STOMP = 23,
DOUBLE_KICK = 24,
JUMP_KICK = 26,
FURY_ATTACK = 31,
HORN_DRILL = 32,
TACKLE = 33,
TWINEEDLE = 41,
SONICBOOM = 49,
EMBER = 52,
COUNTER = 68,
SEISMIC_TOSS = 69,
DRAGON_RAGE = 82,
CONFUSION = 93,
HYPNOSIS = 95,
NIGHT_SHADE = 101,
MINIMIZE = 107,
SELFDESTRUCT = 120,
DREAM_EATER = 138,
PSYWAVE = 149,
SPLASH = 150,
TRI_ATTACK = 161,
SUPER_FANG = 162,
TRIPLE_KICK = 167,
THIEF = 168,
FLAME_WHEEL = 172,
FLAIL = 175,
FALSE_SWIPE = 206,
SWAGGER = 207,
RETURN = 216,
PRESENT = 217,
FRUSTRATION = 218,
MAGNITUDE = 222,
HIDDEN_POWER = 237,
MIRROR_COAT = 243,
BEAT_UP = 251,
ICE_BEAM = 58,
RAZOR_WIND = 13,
GUST = 16,
FLY = 19,
WRAP = 35,
THRASH = 37,
HYPER_BEAM = 63,
SOLARBEAM = 76,
EARTHQUAKE = 89,
DIG = 91,
DEFENSE_CURL = 111,
BIDE = 117,
SKULL_BASH = 130,
MIND_READER = 170,
ROLLOUT = 205,
FURY_CUTTER = 210,
PURSUIT = 228,
SWORDS_DANCE = 0x0e,
GROWL = 0x2d,
DISABLE = 0x32,
MIST = 0x36,
LEECH_SEED = 0x49,
POISONPOWDER = 0x4d,
TOXIC = 0x5c,
RECOVER = 0x69,
HAZE = 0x72,
FOCUS_ENERGY = 0x74,
REST = 0x9c,
NIGHTMARE = 0xab,
CURSE = 0xae,
SPITE = 0xb4,
PROTECT = 0xb6,
BELLY_DRUM = 0xbb,
SPIKES = 0xbf,
DESTINY_BOND = 0xc2,
PERISH_SONG = 0xc3,
ENDURE = 0xcb,
MEAN_LOOK = 0xd4,
ATTRACT = 0xd5,
HEAL_BELL = 0xd7,
SAFEGUARD = 0xdb,
PAIN_SPLIT = 0xdc,
ENCORE = 0xe3,
RAPID_SPIN = 0xe5,
PSYCH_UP = 0xf4,
ANCIENTPOWER = 0xf6,
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,
GEODUDE = 74,
GASTLY = 92,
SNORLAX = 143,
JOLTEON = 135,
ABRA = 63,
MEOWTH = 52,
HITMONLEE = 106,
LAPRAS = 131,
CYNDAQUIL = 155,
TOTODILE = 158,
PIDGEOT = 18,
ARBOK = 24,
SANDSLASH = 28,
DIGLETT = 50,
ONIX = 95,
SCYTHER = 123,
TAUROS = 128,
HOUNDOUR = 228,
BULBASAUR = 1,
GENGAR = 0x5e,
NIDORAN_F = 0x1d,
NIDORAN_M = 0x20,
STARMIE = 0x79,
FORRETRESS = 0xcd,
DITTO = 0x84,
SMEARGLE = 0xeb,
PORYGON = 0x89;
const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92,
FOCUS_BAND = 0x77,
FLOWER_MAIL = 0x9e;
const FRZ = 1 << C.FRZ,
PSN = 1 << C.PSN;
describe.skipIf(!existsSync(romPath))("move effects 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: number,
level: number,
moves: number[],
extra: Partial<PartyMon> = {},
): PartyMon => {
const result = generatePartyMon(assets.pokemon, {
species,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
result.moves = [0, 1, 2, 3].map((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);
};
/** A battle against a trainer with this party and no AI layers, so a
* Pokémon with one move always uses it. */
const duel = (
party: PartyMon[],
enemy: PartyMon[] | { wild: { species: number; level: number } },
{ seed = 1, ...options }: { seed?: number } & Partial<BattleOptions> = {},
) => {
const base = battleOptions(
assets,
trainers,
party,
{ wild: { species: PIDGEY, level: 2 } },
{ seed },
);
return new Battle({
...base,
...(Array.isArray(enemy)
? {
wild: undefined,
trainer: {
name: "JOEY",
className: "YOUNGSTER",
party: enemy,
aiFlags: 0,
switchFlags: 0,
reward: 40,
},
}
: enemy),
...options,
});
};
/** Play the first turn: the texts from FIGHT with the first move until
* the next battle menu or the end. */
const firstTurn = (battle: Battle) => {
const steps = battleSteps(battle);
const before = steps.texts.length;
steps.answer({ action: "fight", move: 0 });
return { steps, texts: steps.texts.slice(before) };
};
/** Play turns with FIGHT and these move slots; a locked-in Pokémon
* plays on without a menu. Returns the texts after the first menu. */
const turns = (battle: Battle, moves: number[]) => {
const steps = battleSteps(battle);
const before = steps.texts.length;
for (const move of moves) {
if (steps.done) break;
expect(steps.prompt?.kind).toBe("action");
steps.answer({ action: "fight", move });
}
return { steps, texts: steps.texts.slice(before) };
};
/** The first seed whose first turn passes `test`. */
const findSeed = (
make: (seed: number) => Battle,
test: (texts: string[], battle: Battle) => boolean,
) => {
for (let seed = 1; seed < 500; seed++) {
const battle = make(seed);
const { texts, steps } = firstTurn(battle);
if (test(texts, battle)) return { seed, battle, texts, steps };
}
throw new Error("No seed found.");
};
describe("multi-hit moves", () => {
it("hits two to five times and says how often", () => {
const counts = new Set<number>();
for (let seed = 1; seed < 60; seed++) {
const battle = duel([mon(CYNDAQUIL, 30, [FURY_ATTACK])], [mon(SNORLAX, 50, [SPLASH])], { seed });
const { texts } = firstTurn(battle);
const missed = texts.some((text) => text.includes("attack missed"));
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
if (missed) {
expect(hit).toBeUndefined();
continue;
}
const n = Number(hit![1]);
counts.add(n);
expect(n).toBeGreaterThanOrEqual(2);
expect(n).toBeLessThanOrEqual(5);
// The loop counter and the hit count are spent; the high byte of
// the user's damage taken is cleared again.
expect(battle.player.rolloutCount).toBe(0);
expect(battle.player.damageTaken >> 8).toBe(0);
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
}
expect([...counts].sort()).toEqual([2, 3, 4, 5]);
});
it("hits twice with Double Kick and Twineedle, and only says effectiveness once", () => {
for (const move of [DOUBLE_KICK, TWINEEDLE]) {
const { texts } = findSeed(
(seed) => duel([mon(HITMONLEE, 30, [move])], [mon(GEODUDE, 50, [SPLASH])], { seed }),
(texts) => texts.includes("Hit 2 times!"),
);
const effectiveness = texts.filter((text) => /effective/.test(text));
// Double Kick is super effective against Geodude; Twineedle is
// neutral. Either way the loop's later hits say nothing.
expect(effectiveness).toHaveLength(move === DOUBLE_KICK ? 1 : 0);
}
});
it("stops without the hit count when the target faints mid-loop", () => {
const enemy = mon(RATTATA, 2, [SPLASH]);
enemy.hp = 1;
const battle = duel([mon(CYNDAQUIL, 30, [DOUBLESLAP])], [enemy]);
const { texts } = playBattle(battle);
expect(texts.join(" ")).toContain("fainted");
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
});
it("kicks up to three times with growing damage", () => {
const counts = new Set<number>();
for (let seed = 1; seed < 40; seed++) {
const battle = duel([mon(HITMONLEE, 30, [TRIPLE_KICK])], [mon(SNORLAX, 60, [SPLASH])], { seed });
const { texts } = firstTurn(battle);
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
if (hit) counts.add(Number(hit[1]));
}
expect([...counts].sort()).toEqual([1, 2, 3]);
});
it("beats up with each healthy member, by nickname, skipping fainted and statused ones", () => {
const party = [
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
mon(TOTODILE, 20, [TACKLE], { nickname: "BRAVO", hp: 0 }),
mon(PIDGEY, 20, [TACKLE], { nickname: "CHARLIE", status: PSN }),
mon(RATTATA, 20, [TACKLE], { nickname: "DELTA" }),
];
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
const attacks = texts.filter((text) => text.endsWith("'s attack!"));
expect(attacks).toEqual(["ALPHA's attack!", "DELTA's attack!"]);
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
});
it("lets a lone Pokémon beat up once, then ends the move", () => {
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
expect(texts).not.toContain("But it failed!");
});
it("names a trainer's members by species", () => {
const enemy = [mon(SNORLAX, 60, [BEAT_UP]), mon(GEODUDE, 20, [SPLASH])];
const { texts } = firstTurn(duel([mon(PIDGEY, 60, [SPLASH])], enemy));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["SNORLAX's attack!", "GEODUDE's attack!"]);
});
});
describe("fixed and special damage", () => {
const damageTo = (move: number, enemy: PartyMon, level = 30) => {
const battle = duel([mon(ABRA, level, [move])], [enemy]);
const before = enemy.hp;
const { texts } = firstTurn(battle);
return { damage: before - battle.enemy.mon.hp, texts, battle };
};
it("deals the level, 20 or 40 HP, or half the target's HP", () => {
expect(damageTo(SEISMIC_TOSS, mon(SNORLAX, 50, [SPLASH]), 37).damage).toBe(37);
expect(damageTo(NIGHT_SHADE, mon(GEODUDE, 50, [SPLASH]), 41).damage).toBe(41);
expect(damageTo(SONICBOOM, mon(SNORLAX, 50, [SPLASH])).damage).toBe(20);
expect(damageTo(DRAGON_RAGE, mon(SNORLAX, 50, [SPLASH])).damage).toBe(40);
const snorlax = mon(SNORLAX, 50, [SPLASH]);
const half = snorlax.hp >> 1;
expect(damageTo(SUPER_FANG, snorlax).damage).toBe(half);
});
it("misses an immune type", () => {
const normal = damageTo(NIGHT_SHADE, mon(SNORLAX, 50, [SPLASH]));
expect(normal.damage).toBe(0);
expect(normal.texts.join(" ")).toContain("doesn't affect");
const ghost = damageTo(SEISMIC_TOSS, mon(GASTLY, 50, [SPLASH]));
expect(ghost.damage).toBe(0);
expect(ghost.texts.join(" ")).toContain("doesn't affect");
});
it("rolls Psywave below one and a half times the level", () => {
for (let seed = 1; seed < 20; seed++) {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 40, [PSYWAVE])], [enemy], { seed });
firstTurn(battle);
const damage = enemy.hp - battle.enemy.mon.hp;
if (!damage) continue;
expect(damage).toBeGreaterThan(0);
expect(damage).toBeLessThan(60);
}
});
it("hits harder with Flail at low HP and resets its power", () => {
const full = damageTo(FLAIL, mon(SNORLAX, 50, [SPLASH]));
const low = (() => {
const user = mon(ABRA, 30, [FLAIL]);
user.hp = 1;
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([user], [enemy]);
firstTurn(battle);
return { damage: enemy.hp - battle.enemy.mon.hp, battle };
})();
expect(low.damage).toBeGreaterThan(full.damage * 4);
expect(low.battle.player.move.power).toBe(1);
});
it("knocks out in one hit or is unaffected against a higher level", () => {
const { texts, battle } = findSeed(
(seed) => duel([mon(SNORLAX, 60, [HORN_DRILL])], [mon(LAPRAS, 20, [SPLASH])], { seed }),
(texts) => texts.includes("It's a one-hit KO!"),
);
expect(battle.enemy.mon.hp).toBe(0);
expect(texts).toContain("It's a one-hit KO!");
const higher = firstTurn(duel([mon(SNORLAX, 20, [HORN_DRILL])], [mon(LAPRAS, 60, [SPLASH])]));
expect(higher.texts.join(" ")).toContain("unaffected");
});
it("leaves 1 HP with False Swipe", () => {
const enemy = mon(RATTATA, 3, [SPLASH]);
const battle = duel([mon(SNORLAX, 60, [FALSE_SWIPE])], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBe(1);
});
it("crashes on a missed Jump Kick for an eighth of the damage", () => {
const { texts, battle } = findSeed(
(seed) => duel([mon(HITMONLEE, 30, [JUMP_KICK])], [mon(SNORLAX, 50, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("crashed")),
);
expect(battle.player.mon.hp).toBeLessThan(battle.player.mon.maxHp);
expect(texts.join(" ")).toContain("missed");
});
it("doubles Stomp's damage against a minimized target", () => {
const battle = duel([mon(SNORLAX, 40, [STOMP])], [mon(GASTLY, 50, [MINIMIZE])]);
firstTurn(battle);
expect(battle.enemy.minimized).toBe(1);
});
it("sets Hidden Power's type and power from the DVs", () => {
// DVs $823b: Poison, 55 power.
const battle = duel([mon(ABRA, 30, [HIDDEN_POWER])], [mon(GEODUDE, 50, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(battle.player.move.type).toBe(C.POISON);
expect(texts.join(" ")).toContain("not very effective");
});
it("deals no damage with Return at very low happiness or Frustration at very high", () => {
for (const [move, happiness] of [[RETURN, 2], [FRUSTRATION, 255]]) {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 30, [move], { happiness })], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBe(enemy.hp);
}
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 30, [RETURN], { happiness: 255 })], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
});
it("announces Magnitude 4 to 10", () => {
const seen = new Set<string>();
for (let seed = 1; seed < 60; seed++) {
const { texts } = firstTurn(duel([mon(GEODUDE, 30, [MAGNITUDE])], [mon(SNORLAX, 50, [SPLASH])], { seed }));
const text = texts.find((t) => t.startsWith("Magnitude "));
if (text) seen.add(text);
}
expect([...seen].sort()).toEqual(
["Magnitude 10!", "Magnitude 4!", "Magnitude 5!", "Magnitude 6!", "Magnitude 7!", "Magnitude 8!", "Magnitude 9!"],
);
});
it("heals the target with a Present, which a full target refuses only with animations off", () => {
const healed = findSeed(
(seed) => {
const enemy = mon(SNORLAX, 50, [SPLASH]);
enemy.hp = 20;
return duel([mon(ABRA, 30, [PRESENT])], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("regained health")),
);
const quarter = healed.battle.enemy.mon.maxHp >> 2;
expect(healed.battle.enemy.mon.hp).toBe(20 + quarter);
expect(healed.texts.join(" ")).toContain("SNORLAX regained health");
const refused = (battleScene: boolean) =>
firstTurn(duel([mon(ABRA, 30, [PRESENT])], [mon(SNORLAX, 50, [SPLASH])], { seed: healed.seed, battleScene })).texts;
expect(refused(false).join(" ")).toContain("refused the gift");
expect(refused(true).join(" ")).not.toContain("refused the gift");
});
});
describe("multi-turn moves", () => {
it("charges Solarbeam on the first turn and fires it on the second, without a menu", () => {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 40, [SOLARBEAM])], [enemy]);
const { texts, steps } = turns(battle, [0]);
expect(texts[0]).toBe("ABRA took in sunlight!");
expect(texts).toContain("ABRA used SOLARBEAM!");
expect(texts.filter((text) => text === "Enemy SNORLAX used SPLASH!")).toHaveLength(2);
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
expect(steps.prompt?.kind).toBe("action");
expect(battle.player.mon.pp[0]).toBe(assets.pokemon.move(SOLARBEAM).pp - 1);
});
it("lowers Skull Bash's head with a Defense raise, then attacks", () => {
const { texts, battle } = (() => {
const battle = duel([mon(ONIX, 40, [SKULL_BASH])], [mon(SNORLAX, 50, [SPLASH])]);
return { ...turns(battle, [0]), battle };
})();
expect(texts.slice(0, 2)).toEqual(["ONIX lowered its head!", "ONIX's DEFENSE went up!"]);
expect(texts).toContain("ONIX used SKULL BASH!");
expect(battle.player.levels[C.DEFENSE]).toBe(8);
});
it("flies out of reach of most moves, but not Gust", () => {
const tackled = turns(duel([mon(PIDGEOT, 50, [FLY])], [mon(SNORLAX, 40, [TACKLE])]), [0]);
expect(tackled.texts[0]).toBe("PIDGEOT flew up high!");
expect(tackled.texts[1]).toBe("Enemy SNORLAX used TACKLE!");
expect(tackled.texts[2]).toContain("attack missed");
expect(tackled.texts).toContain("PIDGEOT used FLY!");
const player = mon(PIDGEOT, 50, [FLY]);
const gusted = duel([player], [mon(PIDGEOT, 30, [GUST])]);
turns(gusted, [0]);
expect(gusted.player.mon.hp).toBeLessThan(player.stats[0]);
});
it("digs out of reach, where Earthquake hits twice as hard", () => {
const quake = (dig: boolean) => {
const player = mon(SANDSLASH, 60, [dig ? DIG : SPLASH]);
const battle = duel([player], [mon(GEODUDE, 20, [EARTHQUAKE])]);
firstTurn(battle);
return player.stats[0] - battle.player.mon.hp;
};
const dug = quake(true),
plain = quake(false);
expect(dug).toBeGreaterThan(plain * 1.5);
});
it("recharges after Hyper Beam", () => {
const { texts, steps } = turns(duel([mon(TAUROS, 50, [HYPER_BEAM])], [mon(SNORLAX, 60, [SPLASH])]), [0]);
expect(texts).toContain("TAUROS used HYPER BEAM!");
expect(texts).toContain("TAUROS must recharge!");
expect(steps.prompt?.kind).toBe("action");
});
it("thrashes for two or three turns, then is confused", () => {
const battle = duel([mon(TAUROS, 50, [THRASH])], [mon(SNORLAX, 70, [SPLASH])]);
const { texts } = turns(battle, [0]);
const thrashes = texts.filter((text) => text === "TAUROS used THRASH!").length;
expect(thrashes === 2 || thrashes === 3).toBe(true);
expect(battle.player.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
expect(battle.player.has(3, C.SUBSTATUS_RAMPAGE)).toBe(false);
});
it("rolls out with doubling damage for up to five turns", () => {
const enemy = mon(SNORLAX, 100, [SPLASH]);
const battle = duel([mon(SANDSLASH, 30, [ROLLOUT])], [enemy]);
const steps = battleSteps(battle);
const hp: number[] = [battle.enemy.mon.hp];
steps.answer({ action: "fight", move: 0 });
// The next menu comes after the rollout ends.
expect(steps.texts.filter((text) => text === "SANDSLASH used ROLLOUT!").length).toBeGreaterThan(1);
hp.push(battle.enemy.mon.hp);
expect(hp[1]).toBeLessThan(hp[0]);
expect(battle.player.has(1, C.SUBSTATUS_ROLLOUT)).toBe(false);
});
it("doubles Fury Cutter for each hit in a row", () => {
for (let seed = 1; seed < 30; seed++) {
const battle = duel([mon(SCYTHER, 30, [FURY_CUTTER])], [mon(SNORLAX, 100, [SPLASH])], { seed });
const steps = battleSteps(battle);
const damage: number[] = [];
let hp = battle.enemy.mon.hp;
for (let turn = 0; turn < 3; turn++) {
steps.answer({ action: "fight", move: 0 });
damage.push(hp - battle.enemy.mon.hp);
hp = battle.enemy.mon.hp;
}
const critical = steps.texts.some((text) => text.includes("critical"));
if (damage.every(Boolean)) {
expect(battle.player.furyCutterCount).toBe(3);
// Four times the first hit, give or take the damage variation.
if (!critical) expect(damage[2]).toBeGreaterThan(damage[0] * 3);
} else if (!damage[2]) expect(battle.player.furyCutterCount).toBe(0);
}
});
it("bides its time and unleashes twice the damage taken", () => {
const player = mon(SNORLAX, 60, [BIDE]);
const enemy = mon(TAUROS, 40, [TACKLE]);
const battle = duel([player], [enemy]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
// Bide starts counting after the hit taken before its first turn.
const before = battle.player.mon.hp;
// FIGHT keeps the move while biding.
while (battle.player.has(3, C.SUBSTATUS_BIDE)) {
expect(battle.fightKeepsMove).toBe(true);
steps.answer({ action: "fight", move: 3 });
}
const texts = steps.texts;
expect(texts).toContain("SNORLAX is storing energy!");
expect(texts).toContain("SNORLAX unleashed energy!");
// The faster Tauros tackles before each of Snorlax's turns, so all
// the damage taken is unleashed.
const taken = before - battle.player.mon.hp;
expect(texts.lastIndexOf("Enemy TAUROS used TACKLE!")).toBeLessThan(texts.indexOf("SNORLAX unleashed energy!"));
expect(enemy.hp - battle.enemy.mon.hp).toBe(Math.min(enemy.hp, taken * 2));
expect(battle.player.damageTaken).toBe(0);
});
it("wraps the target, hurting it each turn until it is released", () => {
const enemy = mon(SNORLAX, 60, [SPLASH]);
const { battle, texts } = findSeed(
(seed) => duel([mon(ARBOK, 30, [WRAP])], [enemy], { seed }),
(texts) => texts.some((text) => text.includes("was WRAPPED by")),
);
expect(battle.enemy.trappingMove).toBe(WRAP);
expect(texts.at(-1)).toBe("Enemy SNORLAX's hurt by WRAP!");
// The wrap's own damage, then a sixteenth of the maximum HP.
const hurt = texts.indexOf("Enemy SNORLAX's hurt by WRAP!");
expect(hurt).toBeGreaterThan(texts.findIndex((text) => text.includes("was WRAPPED by")));
expect(battle.enemy.wrapCount).toBeGreaterThanOrEqual(2);
// Keep wrapping until it is released.
const steps = battleSteps(battle);
void steps;
});
it("cannot miss after Mind Reader", () => {
for (let seed = 1; seed < 15; seed++) {
const battle = duel([mon(SNORLAX, 60, [MIND_READER, HORN_DRILL])], [mon(PIDGEY, 30, [SPLASH])], { seed });
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("SNORLAX took aim!");
expect(battle.enemy.has(5, C.SUBSTATUS_LOCK_ON)).toBe(true);
steps.answer({ action: "fight", move: 1 });
expect(steps.texts).toContain("It's a one-hit KO!");
}
});
it("hits a withdrawn Pokémon with Pursuit before the switch, at double power", () => {
const first = mon(ABRA, 30, [SPLASH]);
const battle = duel([first, mon(SNORLAX, 30, [SPLASH])], [mon(HOUNDOUR, 40, [PURSUIT])]);
const steps = battleSteps(battle);
steps.answer({ action: "pkmn" }).answer(1);
const texts = steps.texts;
const used = texts.indexOf("Enemy HOUNDOUR used PURSUIT!");
expect(used).toBeGreaterThan(-1);
expect(used).toBeLessThan(texts.findIndex((text) => text.startsWith("Go! SNORLAX")));
expect(texts.filter((text) => text.includes("used PURSUIT"))).toHaveLength(1);
expect(first.hp).toBeLessThan(first.stats[0]);
expect(battle.player.mon.species).toBe(SNORLAX);
});
});
describe("status and field moves", () => {
/** Play turns choosing these move slots, returning the texts. */
const play = (battle: Battle, moves: number[]) => {
const steps = battleSteps(battle);
for (const move of moves) {
if (steps.done || steps.prompt?.kind !== "action") break;
steps.answer({ action: "fight", move });
}
return steps;
};
it("badly poisons with Toxic, one more sixteenth each turn", () => {
const enemy = mon(SNORLAX, 60, [SPLASH]);
const { battle, steps } = findSeed(
(seed) => duel([mon(GENGAR, 40, [TOXIC, SPLASH])], [enemy], { seed }),
(texts) => texts.some((text) => text.includes("badly poisoned")),
);
const sixteenth = (enemy.stats[0] >> 2) >> 2;
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth);
steps.answer({ action: "fight", move: 1 });
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 3);
steps.answer({ action: "fight", move: 1 });
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 6);
expect(battle.enemy.toxicCount).toBe(3);
});
it("seeds the target, draining an eighth to the seeder each turn, but not a Grass type", () => {
const { battle, texts } = findSeed(
(seed) => {
const player = mon(BULBASAUR, 60, [LEECH_SEED]);
player.hp = 10;
return duel([player], [mon(SNORLAX, 30, [SPLASH])], { seed });
},
(texts) => texts.some((text) => text.includes("was seeded")),
);
const eighth = (battle.enemy.mon.maxHp >> 2) >> 1;
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(eighth);
expect(battle.player.mon.hp).toBe(10 + eighth);
expect(texts).toContain("LEECH SEED saps Enemy SNORLAX!");
const grass = firstTurn(duel([mon(BULBASAUR, 40, [LEECH_SEED])], [mon(BULBASAUR, 40, [SPLASH])]));
expect(grass.texts.join(" ")).toContain("doesn't affect");
});
it("gives a sleeping target a nightmare that costs a quarter each turn", () => {
const enemy = mon(SNORLAX, 60, [SPLASH], { status: 5 });
const battle = duel([mon(GENGAR, 40, [NIGHTMARE])], [enemy]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("started to have a NIGHTMARE");
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2));
const awake = firstTurn(duel([mon(GENGAR, 40, [NIGHTMARE])], [mon(SNORLAX, 60, [SPLASH])]));
expect(awake.texts).toContain("But it failed!");
});
it("curses differently for Ghost types", () => {
const ghost = mon(GENGAR, 40, [CURSE]);
const enemy = mon(SNORLAX, 60, [SPLASH]);
const battle = duel([ghost], [enemy]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("put a CURSE");
expect(ghost.hp).toBe(ghost.stats[0] - (ghost.stats[0] >> 1));
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2));
const other = duel([mon(SNORLAX, 40, [CURSE])], [mon(SNORLAX, 60, [SPLASH])]);
const curse = firstTurn(other);
expect(other.player.levels.slice(0, 3)).toEqual([8, 8, 6]);
const used = curse.texts.indexOf("SNORLAX used CURSE!");
expect(curse.texts.slice(used + 1, used + 4)).toEqual(["SNORLAX's SPEED fell!", "SNORLAX's ATTACK went up!", "SNORLAX's DEFENSE went up!"]);
});
it("takes the attacker down with Destiny Bond", () => {
// Starmie moves first, so its Destiny Bond holds through Snorlax's hit.
const player = mon(STARMIE, 20, [DESTINY_BOND]);
const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]);
const { texts } = playBattle(battle);
expect(texts.join(" ")).toContain("took down with it");
expect(battle.enemy.mon.hp).toBe(0);
});
it("protects against a move, with less chance on each use in a row", () => {
const battle = duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts).toContain("STARMIE PROTECTED itself!");
expect(texts).toContain("STARMIE's PROTECTING itself!");
let failures = 0;
for (let seed = 1; seed < 40; seed++) {
const steps = play(duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])], { seed }), [0, 0, 0]);
failures += steps.texts.filter((text) => text === "But it failed!").length;
}
expect(failures).toBeGreaterThan(0);
});
it("endures a knockout hit with 1 HP", () => {
const player = mon(RATTATA, 10, [ENDURE]);
const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("braced itself");
expect(texts.join(" ")).toContain("ENDURED the hit");
expect(battle.player.mon.hp).toBe(1);
});
it("spites the last move's PP, in the trainer's party struct too", () => {
const enemy = mon(SNORLAX, 60, [TACKLE]);
const battle = duel([mon(GENGAR, 40, [SPITE])], [enemy]);
const { texts } = turns(battle, [0, 0]);
const spite = texts.find((text) => text.includes("was reduced by"));
const amount = Number(/reduced by (\d)/.exec(spite!)![1]);
expect(amount).toBeGreaterThanOrEqual(2);
// Two Tackles and the Spite in between, in the party struct too.
expect(battle.enemy.mon.pp[0]).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount);
expect(enemy.pp[0] & 63).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount);
});
it("cures the whole party with Heal Bell", () => {
const party = [mon(CYNDAQUIL, 40, [HEAL_BELL], { status: PSN }), mon(PIDGEY, 10, [TACKLE], { status: 1 << C.PAR })];
const battle = duel(party, [mon(SNORLAX, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("A bell chimed!");
expect(party.map((member) => member.status)).toEqual([0, 0]);
});
it("disables the target's last move, leaving it to Struggle", () => {
const battle = duel([mon(GENGAR, 60, [DISABLE])], [mon(SNORLAX, 20, [TACKLE])]);
// Disable fails before Snorlax has moved; the Tackle it already chose
// is then stopped, and Struggle follows.
const { texts } = turns(battle, [0, 0, 0]);
expect(texts).toContain("Enemy SNORLAX's TACKLE was DISABLED!");
expect(texts).toContain("Enemy SNORLAX's TACKLE is DISABLED!");
expect(texts).toContain("Enemy SNORLAX used STRUGGLE!");
});
it("encores the target into repeating its last move", () => {
const enemy = mon(SNORLAX, 30, [SPLASH, TACKLE]);
const battle = duel([mon(STARMIE, 60, [ENCORE, SWORDS_DANCE])], [enemy]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 1 });
const lastMove = battle.enemy.lastMove;
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("got an ENCORE");
expect(battle.enemy.has(5, C.SUBSTATUS_ENCORED)).toBe(true);
steps.answer({ action: "fight", move: 1 });
const used = steps.texts.filter((text) => text.startsWith("Enemy SNORLAX used")).slice(-1)[0];
expect(used).toBe(`Enemy SNORLAX used ${assets.pokemon.moveName(lastMove)}!`);
});
it("keeps the player's encored move whatever FIGHT chooses", () => {
const battle = duel([mon(SNORLAX, 30, [TACKLE, SPLASH])], [mon(STARMIE, 60, [ENCORE])]);
// Starmie's first Encore fails, as Snorlax has not moved yet.
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 1 }).answer({ action: "fight", move: 1 });
expect(battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(true);
expect(battle.fightKeepsMove).toBe(true);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.filter((text) => text === "SNORLAX used SPLASH!")).toHaveLength(3);
expect(steps.texts).not.toContain("SNORLAX used TACKLE!");
});
it("makes the opposite gender fall in love, which may stop it", () => {
const { battle } = findSeed(
(seed) => duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("fell in love")),
);
expect(battle.enemy.has(1, C.SUBSTATUS_IN_LOVE)).toBe(true);
const same = firstTurn(duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_M, 40, [SPLASH])]));
expect(same.texts).toContain("But it failed!");
const immobile = findSeed(
(seed) => duel([mon(NIDORAN_M, 40, [ATTRACT, SPLASH])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("infatuation kept it from attacking")),
);
expect(immobile.texts.join(" ")).toContain("is in love with");
});
it("shrouds the user in Mist against stat drops, and pumps it up with Focus Energy", () => {
const battle = duel([mon(STARMIE, 60, [MIST, FOCUS_ENERGY])], [mon(SNORLAX, 30, [GROWL])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("shrouded in MIST");
expect(steps.texts.join(" ")).toContain("protected by MIST");
expect(battle.player.levels[C.ATTACK]).toBe(7);
steps.answer({ action: "fight", move: 1 });
expect(steps.texts.join(" ")).toContain("getting pumped");
expect(battle.player.has(4, C.SUBSTATUS_FOCUS_ENERGY)).toBe(true);
});
it("eliminates all stat changes with Haze", () => {
const battle = duel([mon(STARMIE, 60, [SWORDS_DANCE, HAZE])], [mon(SNORLAX, 30, [GROWL])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
steps.answer({ action: "fight", move: 1 });
expect(steps.texts.join(" ")).toContain("All stat changes were eliminated!");
expect(battle.player.levels).toEqual([7, 7, 7, 7, 7, 7, 7]);
});
it("recovers half its HP, or all of it with Rest and two turns of sleep", () => {
const player = mon(STARMIE, 60, [RECOVER, REST]);
player.hp = 20;
const battle = duel([player], [mon(SNORLAX, 10, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(battle.player.mon.hp).toBe(20 + (player.stats[0] >> 1));
steps.answer({ action: "fight", move: 1 });
expect(steps.texts.join(" ")).toContain("STARMIE went to sleep!");
expect(battle.player.mon.hp).toBe(player.stats[0]);
expect(battle.player.mon.status).toBe(C.REST_SLEEP_TURNS + 1);
const full = firstTurn(duel([mon(STARMIE, 60, [RECOVER])], [mon(SNORLAX, 10, [SPLASH])]));
expect(full.texts).toContain("STARMIE's HP is full!");
});
it("stops the player running after Mean Look", () => {
const battle = duel([mon(STARMIE, 60, [SPLASH])], { wild: { species: GENGAR, level: 5 } });
const internal = battle as unknown as { enemy: { mon: { moves: number[] } } };
void internal;
const steps = battleSteps(battle);
battle.enemy.set(5, C.SUBSTATUS_CANT_RUN);
steps.answer({ action: "run" });
expect(steps.texts).toContain("Can't escape!");
});
it("shares pain to the average HP", () => {
const player = mon(GENGAR, 40, [PAIN_SPLIT]);
player.hp = 10;
const enemy = mon(SNORLAX, 60, [SPLASH]);
const battle = duel([player], [enemy]);
firstTurn(battle);
const average = (10 + enemy.stats[0]) >> 1;
expect(battle.enemy.mon.hp).toBe(average);
expect(battle.player.mon.hp).toBe(Math.min(average, player.stats[0]));
});
it("drums up the highest Attack for half the HP, keeping two stages when it fails", () => {
const drummer = mon(SNORLAX, 40, [BELLY_DRUM]);
const battle = duel([drummer], [mon(PIDGEY, 10, [SPLASH])]);
firstTurn(battle);
expect(battle.player.levels[C.ATTACK]).toBe(13);
expect(drummer.hp).toBe(drummer.stats[0] - (drummer.stats[0] >> 1));
const weak = mon(SNORLAX, 40, [BELLY_DRUM]);
weak.hp = 10;
const failed = duel([weak], [mon(PIDGEY, 10, [SPLASH])]);
expect(firstTurn(failed).texts).toContain("But it failed!");
expect(failed.player.levels[C.ATTACK]).toBe(9);
});
it("copies the target's stat changes with Psych Up", () => {
const battle = duel([mon(STARMIE, 60, [PSYCH_UP])], [mon(SNORLAX, 30, [SWORDS_DANCE])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("But it failed!");
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("copied the stat changes of Enemy SNORLAX");
expect(battle.player.levels[C.ATTACK]).toBe(9);
});
it("guards against status with Safeguard for five turns", () => {
const battle = duel([mon(STARMIE, 60, [SAFEGUARD, SPLASH])], [mon(SNORLAX, 30, [POISONPOWDER])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("covered by a veil");
expect(steps.texts.join(" ")).toContain("protected by SAFEGUARD");
for (let turn = 0; turn < 4; turn++) steps.answer({ action: "fight", move: 1 });
expect(steps.texts.join(" ")).toContain("SAFEGUARD faded");
});
it("counts Perish Song down until both faint", () => {
const battle = duel([mon(STARMIE, 60, [PERISH_SONG, SPLASH]), mon(PIDGEY, 10, [TACKLE])], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
for (let turn = 0; turn < 3 && steps.prompt?.kind === "action"; turn++)
steps.answer({ action: "fight", move: 1 });
const counts = steps.texts.filter((text) => text.includes("PERISH count is"));
expect(counts.slice(0, 2)).toEqual(["STARMIE's PERISH count is 3!", "Enemy SNORLAX's PERISH count is 3!"]);
expect(steps.texts.join(" ")).toContain("PERISH count is 0!");
expect(battle.enemy.mon.hp).toBe(0);
});
it("hurts the next Pokémon sent onto Spikes, which Rapid Spin blows away", () => {
const lead = mon(PIDGEY, 5, [TACKLE]);
lead.hp = 1;
const next = mon(SNORLAX, 30, [SPLASH]);
const battle = duel([mon(FORRETRESS, 60, [SPIKES, TACKLE])], [lead, next]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("SPIKES scattered all around Enemy PIDGEY");
steps.answer({ action: "fight", move: 1 });
expect(steps.texts.join(" ")).toContain("hurt by SPIKES");
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe((next.stats[0] >> 2) >> 1);
const spinner = duel([mon(FORRETRESS, 60, [RAPID_SPIN])], [mon(FORRETRESS, 60, [SPIKES])]);
const spun = turns(spinner, [0, 0]);
expect(spun.texts.join(" ")).toContain("blew away SPIKES");
});
it("may raise every stat with AncientPower", () => {
const { battle } = findSeed(
(seed) => duel([mon(ONIX, 60, [ANCIENTPOWER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("SPCL.DEF went up")),
);
expect(battle.player.levels.slice(0, 5)).toEqual([8, 8, 8, 8, 8]);
});
it("hits two turns later with Future Sight", () => {
const battle = duel([mon(STARMIE, 60, [FUTURE_SIGHT, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("foresaw an attack");
const full = battle.enemy.mon.hp;
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("But it failed!");
steps.answer({ action: "fight", move: 1 });
expect(steps.texts.at(-1)).toBe("Enemy SNORLAX was hit by FUTURE SIGHT!");
expect(battle.enemy.mon.hp).toBeLessThan(full);
});
});
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]);
const enemy = mon(JOLTEON, 40, [TACKLE]);
const battle = duel([player], [enemy]);
firstTurn(battle);
const taken = player.stats[0] - battle.player.mon.hp;
expect(taken).toBeGreaterThan(0);
expect(enemy.hp - battle.enemy.mon.hp).toBe(taken * 2);
});
it("fails against special moves, and Mirror Coat the other way round", () => {
const counter = duel([mon(SNORLAX, 40, [COUNTER])], [mon(JOLTEON, 40, [EMBER])]);
expect(firstTurn(counter).texts.join(" ")).toContain("missed");
const enemy = mon(JOLTEON, 40, [EMBER]);
const mirror = duel([mon(SNORLAX, 40, [MIRROR_COAT])], [enemy]);
firstTurn(mirror);
const taken = mirror.player.mon.maxHp - mirror.player.mon.hp;
expect(enemy.hp - mirror.enemy.mon.hp).toBe(taken * 2);
});
});
describe("statuses", () => {
it("freezes with an ice move, and the frozen Pokémon cannot move", () => {
const { battle, texts } = findSeed(
(seed) => duel([mon(LAPRAS, 40, [ICE_BEAM])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("frozen solid")),
);
expect(battle.enemy.mon.status & FRZ).toBeTruthy();
expect(texts.join(" ")).not.toContain("SNORLAX used");
});
it("thaws a frozen target with a fire move, whatever the burn chance", () => {
const enemy = mon(SNORLAX, 60, [SPLASH], { status: FRZ });
const battle = duel([mon(CYNDAQUIL, 30, [EMBER])], [enemy]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("was defrosted");
expect(battle.enemy.mon.status).toBe(0);
expect(enemy.status).toBe(0);
});
it("thaws a frozen user of Flame Wheel", () => {
const player = mon(CYNDAQUIL, 30, [FLAME_WHEEL], { status: FRZ });
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("was defrosted");
expect(player.status).toBe(0);
});
it("may paralyze, freeze or burn with Tri Attack", () => {
const seen = new Set<string>();
for (let seed = 1; seed < 400 && seen.size < 3; seed++) {
const { texts } = firstTurn(duel([mon(ABRA, 40, [TRI_ATTACK])], [mon(SNORLAX, 60, [SPLASH])], { seed }));
for (const [pattern, name] of [[/paralyzed/, "par"], [/frozen solid/, "frz"], [/burned/, "brn"]] as const)
if (texts.some((text) => pattern.test(text))) seen.add(name);
}
expect([...seen].sort()).toEqual(["brn", "frz", "par"]);
});
it("confuses with Confusion's added effect", () => {
const { battle } = findSeed(
(seed) => duel([mon(ABRA, 40, [CONFUSION])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("became confused")),
);
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
});
it("eats the dream of a sleeping target only", () => {
const asleep = mon(SNORLAX, 60, [SPLASH], { status: 3 });
const user = mon(ABRA, 40, [DREAM_EATER]);
user.hp = 10;
const battle = duel([user], [asleep]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("dream was eaten");
expect(battle.player.mon.hp).toBeGreaterThan(10);
const awake = firstTurn(duel([mon(ABRA, 40, [DREAM_EATER])], [mon(SNORLAX, 60, [SPLASH])]));
expect(awake.texts.join(" ")).not.toContain("dream was eaten");
});
it("raises the target's Attack and confuses it with Swagger", () => {
const { battle, texts } = findSeed(
(seed) => duel([mon(ABRA, 40, [SWAGGER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("became confused")),
);
expect(battle.enemy.levels[C.ATTACK]).toBe(9);
expect(texts.join(" ")).toContain("ATTACK went way up");
});
it("sleeps with Hypnosis and the target stays asleep", () => {
const { battle } = findSeed(
(seed) => duel([mon(ABRA, 40, [HYPNOSIS])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("fell asleep")),
);
expect(battle.enemy.mon.status & C.SLP_MASK).toBeTruthy();
});
});
describe("items and money", () => {
it("steals the target's item with Thief, but not MAIL", () => {
const player = mon(MEOWTH, 30, [THIEF]);
const enemy = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
const { battle, texts } = findSeed(
(seed) => {
player.item = 0;
enemy.item = LEFTOVERS;
return duel([player], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("stole")),
);
expect(texts.join(" ")).toContain("stole LEFTOVERS");
expect(player.item).toBe(LEFTOVERS);
expect(battle.player.mon.item).toBe(LEFTOVERS);
expect(battle.enemy.mon.item).toBe(0);
expect(enemy.item).toBe(0);
const mail = mon(SNORLAX, 60, [SPLASH], { item: FLOWER_MAIL });
const thief = mon(MEOWTH, 30, [THIEF]);
firstTurn(duel([thief], [mail]));
expect(thief.item).toBe(0);
});
it("loses the player's item to a trainer's Thief for good", () => {
const player = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
const { battle } = findSeed(
(seed) => {
player.item = LEFTOVERS;
return duel([player], [mon(MEOWTH, 30, [THIEF])], { seed });
},
(texts) => texts.some((text) => text.includes("stole")),
);
expect(player.item).toBe(0);
expect(battle.enemy.mon.item).toBe(LEFTOVERS);
});
it("scatters coins with Pay Day and pays them out after a win, doubled by an Amulet Coin", () => {
for (const item of [0, AMULET_COIN]) {
const enemy = mon(SNORLAX, 30, [SPLASH]);
enemy.hp = 1;
const battle = duel([mon(MEOWTH, 20, [PAY_DAY], { item })], [enemy]);
const { texts } = playBattle(battle);
expect(texts).toContain("Coins scattered everywhere!");
const picked = texts.find((text) => text.includes("picked up"));
expect(picked).toBe(`CHRIS picked up ¥${item ? 80 : 40}!`);
}
});
it("checks the Amulet Coin on every send-out", () => {
const enemy = mon(SNORLAX, 30, [SPLASH]);
enemy.hp = 1;
const fainted = mon(PIDGEY, 5, [TACKLE], { hp: 0 });
const battle = duel([mon(PIDGEY, 20, [SPLASH]), mon(MEOWTH, 20, [PAY_DAY], { item: AMULET_COIN }), fainted], [enemy]);
const { texts } = playBattle(battle, {
action: [{ action: "pkmn" }, { action: "fight", move: 0 }],
party: [1],
});
expect(texts.find((text) => text.includes("picked up"))).toBe("CHRIS picked up ¥80!");
});
});
describe("Selfdestruct", () => {
it("faints the user even if it misses", () => {
const player = mon(GEODUDE, 30, [SELFDESTRUCT]);
const battle = duel([player, mon(PIDGEY, 30, [TACKLE])], [mon(GASTLY, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("doesn't affect");
expect(player.hp).toBe(0);
});
});
it("holds on at 1 HP with a Focus Band", () => {
const { texts, battle } = findSeed(
(seed) => {
const enemy = mon(SNORLAX, 60, [SPLASH], { item: FOCUS_BAND });
enemy.hp = 1;
return duel([mon(JOLTEON, 40, [TACKLE])], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("hung on")),
);
expect(battle.enemy.mon.hp).toBe(1);
expect(texts.join(" ")).toContain("FOCUS BAND");
});
});