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 => { 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 = {}, ) => { 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(); 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(); 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(); 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(); 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"); }); });