Toxic, Leech Seed, Nightmare and Curse hurt in ResidualDamage's order. Protect, Detect, Endure, Destiny Bond, Spite, Heal Bell, Disable, Encore, Attract, Mist, Focus Energy, Haze, the healing moves and Rest, Mean Look, Pain Split, Belly Drum, Psych Up, Safeguard, Perish Song, Spikes, Rapid Spin, AncientPower and Future Sight now run, with Future Sight, Perish Song, Safeguard and Encore handled between turns and Spikes on entry. A trainer's party struct now follows its Pokémon where the original updates it, and the enemy's moves spend PP there or in the wild Pokémon's kept PP, so a caught Pokémon keeps the PP it used. Part of #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1045 lines
47 KiB
TypeScript
1045 lines
47 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;
|
|
// 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;
|
|
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("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");
|
|
});
|
|
});
|