- Day-Care and breeding: deposits, withdrawal pricing and growth, compatibility, egg species, DVs and moves, DayCareStep, egg collection and the Odd Egg, compared with the cartridge's routines. - CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep in the original's order; HatchEggs with the ROM's hatch animation, compared frame by frame with the original. - MAIL: all ten stationery types and the Portrait MAIL picture, compared with the cartridge's tilemaps, graphics and palettes. - Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio take the original's absent-printer path. - Hall of Fame records (saved apart from the League flag, ready for the League to call) and the Hall of Fame PC, compared with the cartridge's _HallOfFamePC on 255 pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1248 lines
58 KiB
TypeScript
1248 lines
58 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { TrainerData } from "../src/game/rom/trainers";
|
|
import { Battle, type BattleOptions } from "../src/game/battle/battle";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
|
|
import { battleOptions, battleSteps, playBattle } from "./battle-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
|
|
// constants/move_constants.asm
|
|
const DOUBLESLAP = 3,
|
|
PAY_DAY = 6,
|
|
STOMP = 23,
|
|
DOUBLE_KICK = 24,
|
|
JUMP_KICK = 26,
|
|
FURY_ATTACK = 31,
|
|
HORN_DRILL = 32,
|
|
TACKLE = 33,
|
|
TWINEEDLE = 41,
|
|
SONICBOOM = 49,
|
|
EMBER = 52,
|
|
COUNTER = 68,
|
|
SEISMIC_TOSS = 69,
|
|
DRAGON_RAGE = 82,
|
|
CONFUSION = 93,
|
|
HYPNOSIS = 95,
|
|
NIGHT_SHADE = 101,
|
|
MINIMIZE = 107,
|
|
SELFDESTRUCT = 120,
|
|
DREAM_EATER = 138,
|
|
PSYWAVE = 149,
|
|
SPLASH = 150,
|
|
TRI_ATTACK = 161,
|
|
SUPER_FANG = 162,
|
|
TRIPLE_KICK = 167,
|
|
THIEF = 168,
|
|
FLAME_WHEEL = 172,
|
|
FLAIL = 175,
|
|
FALSE_SWIPE = 206,
|
|
SWAGGER = 207,
|
|
RETURN = 216,
|
|
PRESENT = 217,
|
|
FRUSTRATION = 218,
|
|
MAGNITUDE = 222,
|
|
HIDDEN_POWER = 237,
|
|
MIRROR_COAT = 243,
|
|
BEAT_UP = 251,
|
|
ICE_BEAM = 58,
|
|
RAZOR_WIND = 13,
|
|
GUST = 16,
|
|
FLY = 19,
|
|
WRAP = 35,
|
|
THRASH = 37,
|
|
HYPER_BEAM = 63,
|
|
SOLARBEAM = 76,
|
|
EARTHQUAKE = 89,
|
|
DIG = 91,
|
|
DEFENSE_CURL = 111,
|
|
BIDE = 117,
|
|
SKULL_BASH = 130,
|
|
MIND_READER = 170,
|
|
ROLLOUT = 205,
|
|
FURY_CUTTER = 210,
|
|
PURSUIT = 228,
|
|
SWORDS_DANCE = 0x0e,
|
|
GROWL = 0x2d,
|
|
DISABLE = 0x32,
|
|
MIST = 0x36,
|
|
LEECH_SEED = 0x49,
|
|
POISONPOWDER = 0x4d,
|
|
TOXIC = 0x5c,
|
|
RECOVER = 0x69,
|
|
HAZE = 0x72,
|
|
FOCUS_ENERGY = 0x74,
|
|
REST = 0x9c,
|
|
NIGHTMARE = 0xab,
|
|
CURSE = 0xae,
|
|
SPITE = 0xb4,
|
|
PROTECT = 0xb6,
|
|
BELLY_DRUM = 0xbb,
|
|
SPIKES = 0xbf,
|
|
DESTINY_BOND = 0xc2,
|
|
PERISH_SONG = 0xc3,
|
|
ENDURE = 0xcb,
|
|
MEAN_LOOK = 0xd4,
|
|
ATTRACT = 0xd5,
|
|
HEAL_BELL = 0xd7,
|
|
SAFEGUARD = 0xdb,
|
|
PAIN_SPLIT = 0xdc,
|
|
ENCORE = 0xe3,
|
|
RAPID_SPIN = 0xe5,
|
|
PSYCH_UP = 0xf4,
|
|
ANCIENTPOWER = 0xf6,
|
|
FUTURE_SIGHT = 0xf8,
|
|
RAIN_DANCE = 0xf0,
|
|
SUNNY_DAY = 0xf1,
|
|
SANDSTORM = 0xc9,
|
|
SUBSTITUTE = 0xa4,
|
|
TRANSFORM = 0x90,
|
|
MIMIC = 0x66,
|
|
METRONOME = 0x76,
|
|
MIRROR_MOVE = 0x77,
|
|
SLEEP_TALK = 0xd6,
|
|
SNORE = 0xad,
|
|
CONVERSION = 0xa0,
|
|
CONVERSION2 = 0xb0,
|
|
SKETCH = 0xa6,
|
|
BATON_PASS = 0xe2,
|
|
ROAR = 0x2e,
|
|
TELEPORT = 0x64,
|
|
WATER_GUN = 0x37,
|
|
SYNTHESIS = 0xeb;
|
|
// Species and items
|
|
const PIDGEY = 16,
|
|
RATTATA = 19,
|
|
GEODUDE = 74,
|
|
GASTLY = 92,
|
|
SNORLAX = 143,
|
|
JOLTEON = 135,
|
|
ABRA = 63,
|
|
MEOWTH = 52,
|
|
HITMONLEE = 106,
|
|
LAPRAS = 131,
|
|
CYNDAQUIL = 155,
|
|
TOTODILE = 158,
|
|
PIDGEOT = 18,
|
|
ARBOK = 24,
|
|
SANDSLASH = 28,
|
|
DIGLETT = 50,
|
|
ONIX = 95,
|
|
SCYTHER = 123,
|
|
TAUROS = 128,
|
|
HOUNDOUR = 228,
|
|
BULBASAUR = 1,
|
|
GENGAR = 0x5e,
|
|
NIDORAN_F = 0x1d,
|
|
NIDORAN_M = 0x20,
|
|
STARMIE = 0x79,
|
|
FORRETRESS = 0xcd,
|
|
DITTO = 0x84,
|
|
SMEARGLE = 0xeb,
|
|
PORYGON = 0x89;
|
|
const AMULET_COIN = 0x5b,
|
|
LEFTOVERS = 0x92,
|
|
FOCUS_BAND = 0x77,
|
|
FLOWER_MAIL = 0x9e;
|
|
const FRZ = 1 << C.FRZ,
|
|
PSN = 1 << C.PSN;
|
|
|
|
describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
|
let assets: CrystalAssets, trainers: TrainerData;
|
|
beforeAll(() => {
|
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
trainers = new TrainerData(assets.reader);
|
|
});
|
|
const mon = (
|
|
species: number,
|
|
level: number,
|
|
moves: number[],
|
|
extra: Partial<PartyMon> = {},
|
|
): PartyMon => {
|
|
const result = generatePartyMon(assets.pokemon, {
|
|
species,
|
|
level,
|
|
item: 0,
|
|
otId: 1234,
|
|
ot: "CHRIS",
|
|
dvs: 0x823b,
|
|
caughtData: 0,
|
|
});
|
|
result.moves = [0, 1, 2, 3].map((i) => moves[i] ?? 0) as PartyMon["moves"];
|
|
result.pp = result.moves.map((move) =>
|
|
move ? assets.pokemon.move(move).pp : 0,
|
|
) as PartyMon["pp"];
|
|
return Object.assign(result, extra);
|
|
};
|
|
/** A battle against a trainer with this party and no AI layers, so a
|
|
* Pokémon with one move always uses it. */
|
|
const duel = (
|
|
party: PartyMon[],
|
|
enemy: PartyMon[] | { wild: { species: number; level: number } },
|
|
{ seed = 1, ...options }: { seed?: number } & Partial<BattleOptions> = {},
|
|
) => {
|
|
const base = battleOptions(
|
|
assets,
|
|
trainers,
|
|
party,
|
|
{ wild: { species: PIDGEY, level: 2 } },
|
|
{ seed },
|
|
);
|
|
return new Battle({
|
|
...base,
|
|
...(Array.isArray(enemy)
|
|
? {
|
|
wild: undefined,
|
|
trainer: {
|
|
name: "JOEY",
|
|
className: "YOUNGSTER",
|
|
party: enemy,
|
|
aiFlags: 0,
|
|
switchFlags: 0,
|
|
items: [0, 0],
|
|
baseReward: 10,
|
|
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 an Egg with stored HP join Beat Up, but skips a zero-HP Egg", () => {
|
|
const party = [
|
|
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
|
|
mon(TOTODILE, 5, [TACKLE], { nickname: "EGG", egg: true }),
|
|
mon(PIDGEY, 5, [TACKLE], { nickname: "EGG", egg: true, hp: 0 }),
|
|
];
|
|
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
|
|
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!", "EGG's attack!"]);
|
|
});
|
|
|
|
it("lets a lone Pokémon beat up once, then ends the move", () => {
|
|
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
|
|
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
|
|
expect(texts).not.toContain("But it failed!");
|
|
});
|
|
|
|
it("names a trainer's members by species", () => {
|
|
const enemy = [mon(SNORLAX, 60, [BEAT_UP]), mon(GEODUDE, 20, [SPLASH])];
|
|
const { texts } = firstTurn(duel([mon(PIDGEY, 60, [SPLASH])], enemy));
|
|
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["SNORLAX's attack!", "GEODUDE's attack!"]);
|
|
});
|
|
});
|
|
|
|
describe("fixed and special damage", () => {
|
|
const damageTo = (move: number, enemy: PartyMon, level = 30) => {
|
|
const battle = duel([mon(ABRA, level, [move])], [enemy]);
|
|
const before = enemy.hp;
|
|
const { texts } = firstTurn(battle);
|
|
return { damage: before - battle.enemy.mon.hp, texts, battle };
|
|
};
|
|
it("deals the level, 20 or 40 HP, or half the target's HP", () => {
|
|
expect(damageTo(SEISMIC_TOSS, mon(SNORLAX, 50, [SPLASH]), 37).damage).toBe(37);
|
|
expect(damageTo(NIGHT_SHADE, mon(GEODUDE, 50, [SPLASH]), 41).damage).toBe(41);
|
|
expect(damageTo(SONICBOOM, mon(SNORLAX, 50, [SPLASH])).damage).toBe(20);
|
|
expect(damageTo(DRAGON_RAGE, mon(SNORLAX, 50, [SPLASH])).damage).toBe(40);
|
|
const snorlax = mon(SNORLAX, 50, [SPLASH]);
|
|
const half = snorlax.hp >> 1;
|
|
expect(damageTo(SUPER_FANG, snorlax).damage).toBe(half);
|
|
});
|
|
it("misses an immune type", () => {
|
|
const normal = damageTo(NIGHT_SHADE, mon(SNORLAX, 50, [SPLASH]));
|
|
expect(normal.damage).toBe(0);
|
|
expect(normal.texts.join(" ")).toContain("doesn't affect");
|
|
const ghost = damageTo(SEISMIC_TOSS, mon(GASTLY, 50, [SPLASH]));
|
|
expect(ghost.damage).toBe(0);
|
|
expect(ghost.texts.join(" ")).toContain("doesn't affect");
|
|
});
|
|
it("rolls Psywave below one and a half times the level", () => {
|
|
for (let seed = 1; seed < 20; seed++) {
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
const battle = duel([mon(ABRA, 40, [PSYWAVE])], [enemy], { seed });
|
|
firstTurn(battle);
|
|
const damage = enemy.hp - battle.enemy.mon.hp;
|
|
if (!damage) continue;
|
|
expect(damage).toBeGreaterThan(0);
|
|
expect(damage).toBeLessThan(60);
|
|
}
|
|
});
|
|
it("hits harder with Flail at low HP and resets its power", () => {
|
|
const full = damageTo(FLAIL, mon(SNORLAX, 50, [SPLASH]));
|
|
const low = (() => {
|
|
const user = mon(ABRA, 30, [FLAIL]);
|
|
user.hp = 1;
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
const battle = duel([user], [enemy]);
|
|
firstTurn(battle);
|
|
return { damage: enemy.hp - battle.enemy.mon.hp, battle };
|
|
})();
|
|
expect(low.damage).toBeGreaterThan(full.damage * 4);
|
|
expect(low.battle.player.move.power).toBe(1);
|
|
});
|
|
it("knocks out in one hit or is unaffected against a higher level", () => {
|
|
const { texts, battle } = findSeed(
|
|
(seed) => duel([mon(SNORLAX, 60, [HORN_DRILL])], [mon(LAPRAS, 20, [SPLASH])], { seed }),
|
|
(texts) => texts.includes("It's a one-hit KO!"),
|
|
);
|
|
expect(battle.enemy.mon.hp).toBe(0);
|
|
expect(texts).toContain("It's a one-hit KO!");
|
|
const higher = firstTurn(duel([mon(SNORLAX, 20, [HORN_DRILL])], [mon(LAPRAS, 60, [SPLASH])]));
|
|
expect(higher.texts.join(" ")).toContain("unaffected");
|
|
});
|
|
it("leaves 1 HP with False Swipe", () => {
|
|
const enemy = mon(RATTATA, 3, [SPLASH]);
|
|
const battle = duel([mon(SNORLAX, 60, [FALSE_SWIPE])], [enemy]);
|
|
firstTurn(battle);
|
|
expect(battle.enemy.mon.hp).toBe(1);
|
|
});
|
|
it("crashes on a missed Jump Kick for an eighth of the damage", () => {
|
|
const { texts, battle } = findSeed(
|
|
(seed) => duel([mon(HITMONLEE, 30, [JUMP_KICK])], [mon(SNORLAX, 50, [SPLASH])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("crashed")),
|
|
);
|
|
expect(battle.player.mon.hp).toBeLessThan(battle.player.mon.maxHp);
|
|
expect(texts.join(" ")).toContain("missed");
|
|
});
|
|
it("doubles Stomp's damage against a minimized target", () => {
|
|
const battle = duel([mon(SNORLAX, 40, [STOMP])], [mon(GASTLY, 50, [MINIMIZE])]);
|
|
firstTurn(battle);
|
|
expect(battle.enemy.minimized).toBe(1);
|
|
});
|
|
it("sets Hidden Power's type and power from the DVs", () => {
|
|
// DVs $823b: Poison, 55 power.
|
|
const battle = duel([mon(ABRA, 30, [HIDDEN_POWER])], [mon(GEODUDE, 50, [SPLASH])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(battle.player.move.type).toBe(C.POISON);
|
|
expect(texts.join(" ")).toContain("not very effective");
|
|
});
|
|
it("deals no damage with Return at very low happiness or Frustration at very high", () => {
|
|
for (const [move, happiness] of [[RETURN, 2], [FRUSTRATION, 255]]) {
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
const battle = duel([mon(ABRA, 30, [move], { happiness })], [enemy]);
|
|
firstTurn(battle);
|
|
expect(battle.enemy.mon.hp).toBe(enemy.hp);
|
|
}
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
const battle = duel([mon(ABRA, 30, [RETURN], { happiness: 255 })], [enemy]);
|
|
firstTurn(battle);
|
|
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
|
|
});
|
|
it("announces Magnitude 4 to 10", () => {
|
|
const seen = new Set<string>();
|
|
for (let seed = 1; seed < 60; seed++) {
|
|
const { texts } = firstTurn(duel([mon(GEODUDE, 30, [MAGNITUDE])], [mon(SNORLAX, 50, [SPLASH])], { seed }));
|
|
const text = texts.find((t) => t.startsWith("Magnitude "));
|
|
if (text) seen.add(text);
|
|
}
|
|
expect([...seen].sort()).toEqual(
|
|
["Magnitude 10!", "Magnitude 4!", "Magnitude 5!", "Magnitude 6!", "Magnitude 7!", "Magnitude 8!", "Magnitude 9!"],
|
|
);
|
|
});
|
|
it("heals the target with a Present, which a full target refuses only with animations off", () => {
|
|
const healed = findSeed(
|
|
(seed) => {
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
enemy.hp = 20;
|
|
return duel([mon(ABRA, 30, [PRESENT])], [enemy], { seed });
|
|
},
|
|
(texts) => texts.some((text) => text.includes("regained health")),
|
|
);
|
|
const quarter = healed.battle.enemy.mon.maxHp >> 2;
|
|
expect(healed.battle.enemy.mon.hp).toBe(20 + quarter);
|
|
expect(healed.texts.join(" ")).toContain("SNORLAX regained health");
|
|
const refused = (battleScene: boolean) =>
|
|
firstTurn(duel([mon(ABRA, 30, [PRESENT])], [mon(SNORLAX, 50, [SPLASH])], { seed: healed.seed, battleScene })).texts;
|
|
expect(refused(false).join(" ")).toContain("refused the gift");
|
|
expect(refused(true).join(" ")).not.toContain("refused the gift");
|
|
});
|
|
});
|
|
|
|
|
|
describe("multi-turn moves", () => {
|
|
it("charges Solarbeam on the first turn and fires it on the second, without a menu", () => {
|
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
|
const battle = duel([mon(ABRA, 40, [SOLARBEAM])], [enemy]);
|
|
const { texts, steps } = turns(battle, [0]);
|
|
expect(texts[0]).toBe("ABRA took in sunlight!");
|
|
expect(texts).toContain("ABRA used SOLARBEAM!");
|
|
expect(texts.filter((text) => text === "Enemy SNORLAX used SPLASH!")).toHaveLength(2);
|
|
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
|
|
expect(steps.prompt?.kind).toBe("action");
|
|
expect(battle.player.mon.pp[0]).toBe(assets.pokemon.move(SOLARBEAM).pp - 1);
|
|
});
|
|
it("lowers Skull Bash's head with a Defense raise, then attacks", () => {
|
|
const { texts, battle } = (() => {
|
|
const battle = duel([mon(ONIX, 40, [SKULL_BASH])], [mon(SNORLAX, 50, [SPLASH])]);
|
|
return { ...turns(battle, [0]), battle };
|
|
})();
|
|
expect(texts.slice(0, 2)).toEqual(["ONIX lowered its head!", "ONIX's DEFENSE went up!"]);
|
|
expect(texts).toContain("ONIX used SKULL BASH!");
|
|
expect(battle.player.levels[C.DEFENSE]).toBe(8);
|
|
});
|
|
it("flies out of reach of most moves, but not Gust", () => {
|
|
const tackled = turns(duel([mon(PIDGEOT, 50, [FLY])], [mon(SNORLAX, 40, [TACKLE])]), [0]);
|
|
expect(tackled.texts[0]).toBe("PIDGEOT flew up high!");
|
|
expect(tackled.texts[1]).toBe("Enemy SNORLAX used TACKLE!");
|
|
expect(tackled.texts[2]).toContain("attack missed");
|
|
expect(tackled.texts).toContain("PIDGEOT used FLY!");
|
|
const player = mon(PIDGEOT, 50, [FLY]);
|
|
const gusted = duel([player], [mon(PIDGEOT, 30, [GUST])]);
|
|
turns(gusted, [0]);
|
|
expect(gusted.player.mon.hp).toBeLessThan(player.stats[0]);
|
|
});
|
|
it("digs out of reach, where Earthquake hits twice as hard", () => {
|
|
const quake = (dig: boolean) => {
|
|
const player = mon(SANDSLASH, 60, [dig ? DIG : SPLASH]);
|
|
const battle = duel([player], [mon(GEODUDE, 20, [EARTHQUAKE])]);
|
|
firstTurn(battle);
|
|
return player.stats[0] - battle.player.mon.hp;
|
|
};
|
|
const dug = quake(true),
|
|
plain = quake(false);
|
|
expect(dug).toBeGreaterThan(plain * 1.5);
|
|
});
|
|
it("recharges after Hyper Beam", () => {
|
|
const { texts, steps } = turns(duel([mon(TAUROS, 50, [HYPER_BEAM])], [mon(SNORLAX, 60, [SPLASH])]), [0]);
|
|
expect(texts).toContain("TAUROS used HYPER BEAM!");
|
|
expect(texts).toContain("TAUROS must recharge!");
|
|
expect(steps.prompt?.kind).toBe("action");
|
|
});
|
|
it("thrashes for two or three turns, then is confused", () => {
|
|
const battle = duel([mon(TAUROS, 50, [THRASH])], [mon(SNORLAX, 70, [SPLASH])]);
|
|
const { texts } = turns(battle, [0]);
|
|
const thrashes = texts.filter((text) => text === "TAUROS used THRASH!").length;
|
|
expect(thrashes === 2 || thrashes === 3).toBe(true);
|
|
expect(battle.player.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
|
|
expect(battle.player.has(3, C.SUBSTATUS_RAMPAGE)).toBe(false);
|
|
});
|
|
it("rolls out with doubling damage for up to five turns", () => {
|
|
const enemy = mon(SNORLAX, 100, [SPLASH]);
|
|
const battle = duel([mon(SANDSLASH, 30, [ROLLOUT])], [enemy]);
|
|
const steps = battleSteps(battle);
|
|
const hp: number[] = [battle.enemy.mon.hp];
|
|
steps.answer({ action: "fight", move: 0 });
|
|
// The next menu comes after the rollout ends.
|
|
expect(steps.texts.filter((text) => text === "SANDSLASH used ROLLOUT!").length).toBeGreaterThan(1);
|
|
hp.push(battle.enemy.mon.hp);
|
|
expect(hp[1]).toBeLessThan(hp[0]);
|
|
expect(battle.player.has(1, C.SUBSTATUS_ROLLOUT)).toBe(false);
|
|
});
|
|
it("doubles Fury Cutter for each hit in a row", () => {
|
|
for (let seed = 1; seed < 30; seed++) {
|
|
const battle = duel([mon(SCYTHER, 30, [FURY_CUTTER])], [mon(SNORLAX, 100, [SPLASH])], { seed });
|
|
const steps = battleSteps(battle);
|
|
const damage: number[] = [];
|
|
let hp = battle.enemy.mon.hp;
|
|
for (let turn = 0; turn < 3; turn++) {
|
|
steps.answer({ action: "fight", move: 0 });
|
|
damage.push(hp - battle.enemy.mon.hp);
|
|
hp = battle.enemy.mon.hp;
|
|
}
|
|
const critical = steps.texts.some((text) => text.includes("critical"));
|
|
if (damage.every(Boolean)) {
|
|
expect(battle.player.furyCutterCount).toBe(3);
|
|
// Four times the first hit, give or take the damage variation.
|
|
if (!critical) expect(damage[2]).toBeGreaterThan(damage[0] * 3);
|
|
} else if (!damage[2]) expect(battle.player.furyCutterCount).toBe(0);
|
|
}
|
|
});
|
|
it("bides its time and unleashes twice the damage taken", () => {
|
|
const player = mon(SNORLAX, 60, [BIDE]);
|
|
const enemy = mon(TAUROS, 40, [TACKLE]);
|
|
const battle = duel([player], [enemy]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
// Bide starts counting after the hit taken before its first turn.
|
|
const before = battle.player.mon.hp;
|
|
// FIGHT keeps the move while biding.
|
|
while (battle.player.has(3, C.SUBSTATUS_BIDE)) {
|
|
expect(battle.fightKeepsMove).toBe(true);
|
|
steps.answer({ action: "fight", move: 3 });
|
|
}
|
|
const texts = steps.texts;
|
|
expect(texts).toContain("SNORLAX is storing energy!");
|
|
expect(texts).toContain("SNORLAX unleashed energy!");
|
|
// The faster Tauros tackles before each of Snorlax's turns, so all
|
|
// the damage taken is unleashed.
|
|
const taken = before - battle.player.mon.hp;
|
|
expect(texts.lastIndexOf("Enemy TAUROS used TACKLE!")).toBeLessThan(texts.indexOf("SNORLAX unleashed energy!"));
|
|
expect(enemy.hp - battle.enemy.mon.hp).toBe(Math.min(enemy.hp, taken * 2));
|
|
expect(battle.player.damageTaken).toBe(0);
|
|
});
|
|
it("wraps the target, hurting it each turn until it is released", () => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
|
const { battle, texts } = findSeed(
|
|
(seed) => duel([mon(ARBOK, 30, [WRAP])], [enemy], { seed }),
|
|
(texts) => texts.some((text) => text.includes("was WRAPPED by")),
|
|
);
|
|
expect(battle.enemy.trappingMove).toBe(WRAP);
|
|
expect(texts.at(-1)).toBe("Enemy SNORLAX's hurt by WRAP!");
|
|
// The wrap's own damage, then a sixteenth of the maximum HP.
|
|
const hurt = texts.indexOf("Enemy SNORLAX's hurt by WRAP!");
|
|
expect(hurt).toBeGreaterThan(texts.findIndex((text) => text.includes("was WRAPPED by")));
|
|
expect(battle.enemy.wrapCount).toBeGreaterThanOrEqual(2);
|
|
// Keep wrapping until it is released.
|
|
const steps = battleSteps(battle);
|
|
void steps;
|
|
});
|
|
it("cannot miss after Mind Reader", () => {
|
|
for (let seed = 1; seed < 15; seed++) {
|
|
const battle = duel([mon(SNORLAX, 60, [MIND_READER, HORN_DRILL])], [mon(PIDGEY, 30, [SPLASH])], { seed });
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts).toContain("SNORLAX took aim!");
|
|
expect(battle.enemy.has(5, C.SUBSTATUS_LOCK_ON)).toBe(true);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts).toContain("It's a one-hit KO!");
|
|
}
|
|
});
|
|
it("hits a withdrawn Pokémon with Pursuit before the switch, at double power", () => {
|
|
const first = mon(ABRA, 30, [SPLASH]);
|
|
const battle = duel([first, mon(SNORLAX, 30, [SPLASH])], [mon(HOUNDOUR, 40, [PURSUIT])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "pkmn" }).answer(1);
|
|
const texts = steps.texts;
|
|
const used = texts.indexOf("Enemy HOUNDOUR used PURSUIT!");
|
|
expect(used).toBeGreaterThan(-1);
|
|
expect(used).toBeLessThan(texts.findIndex((text) => text.startsWith("Go! SNORLAX")));
|
|
expect(texts.filter((text) => text.includes("used PURSUIT"))).toHaveLength(1);
|
|
expect(first.hp).toBeLessThan(first.stats[0]);
|
|
expect(battle.player.mon.species).toBe(SNORLAX);
|
|
});
|
|
});
|
|
|
|
describe("status and field moves", () => {
|
|
/** Play turns choosing these move slots, returning the texts. */
|
|
const play = (battle: Battle, moves: number[]) => {
|
|
const steps = battleSteps(battle);
|
|
for (const move of moves) {
|
|
if (steps.done || steps.prompt?.kind !== "action") break;
|
|
steps.answer({ action: "fight", move });
|
|
}
|
|
return steps;
|
|
};
|
|
it("badly poisons with Toxic, one more sixteenth each turn", () => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
|
const { battle, steps } = findSeed(
|
|
(seed) => duel([mon(GENGAR, 40, [TOXIC, SPLASH])], [enemy], { seed }),
|
|
(texts) => texts.some((text) => text.includes("badly poisoned")),
|
|
);
|
|
const sixteenth = (enemy.stats[0] >> 2) >> 2;
|
|
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 3);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 6);
|
|
expect(battle.enemy.toxicCount).toBe(3);
|
|
});
|
|
it("seeds the target, draining an eighth to the seeder each turn, but not a Grass type", () => {
|
|
const { battle, texts } = findSeed(
|
|
(seed) => {
|
|
const player = mon(BULBASAUR, 60, [LEECH_SEED]);
|
|
player.hp = 10;
|
|
return duel([player], [mon(SNORLAX, 30, [SPLASH])], { seed });
|
|
},
|
|
(texts) => texts.some((text) => text.includes("was seeded")),
|
|
);
|
|
const eighth = (battle.enemy.mon.maxHp >> 2) >> 1;
|
|
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(eighth);
|
|
expect(battle.player.mon.hp).toBe(10 + eighth);
|
|
expect(texts).toContain("LEECH SEED saps Enemy SNORLAX!");
|
|
const grass = firstTurn(duel([mon(BULBASAUR, 40, [LEECH_SEED])], [mon(BULBASAUR, 40, [SPLASH])]));
|
|
expect(grass.texts.join(" ")).toContain("doesn't affect");
|
|
});
|
|
it("gives a sleeping target a nightmare that costs a quarter each turn", () => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH], { status: 5 });
|
|
const battle = duel([mon(GENGAR, 40, [NIGHTMARE])], [enemy]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("started to have a NIGHTMARE");
|
|
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2));
|
|
const awake = firstTurn(duel([mon(GENGAR, 40, [NIGHTMARE])], [mon(SNORLAX, 60, [SPLASH])]));
|
|
expect(awake.texts).toContain("But it failed!");
|
|
});
|
|
it("curses differently for Ghost types", () => {
|
|
const ghost = mon(GENGAR, 40, [CURSE]);
|
|
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
|
const battle = duel([ghost], [enemy]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("put a CURSE");
|
|
expect(ghost.hp).toBe(ghost.stats[0] - (ghost.stats[0] >> 1));
|
|
expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2));
|
|
const other = duel([mon(SNORLAX, 40, [CURSE])], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const curse = firstTurn(other);
|
|
expect(other.player.levels.slice(0, 3)).toEqual([8, 8, 6]);
|
|
const used = curse.texts.indexOf("SNORLAX used CURSE!");
|
|
expect(curse.texts.slice(used + 1, used + 4)).toEqual(["SNORLAX's SPEED fell!", "SNORLAX's ATTACK went up!", "SNORLAX's DEFENSE went up!"]);
|
|
});
|
|
it("takes the attacker down with Destiny Bond", () => {
|
|
// Starmie moves first, so its Destiny Bond holds through Snorlax's hit.
|
|
const player = mon(STARMIE, 20, [DESTINY_BOND]);
|
|
const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]);
|
|
const { texts } = playBattle(battle);
|
|
expect(texts.join(" ")).toContain("took down with it");
|
|
expect(battle.enemy.mon.hp).toBe(0);
|
|
});
|
|
it("protects against a move, with less chance on each use in a row", () => {
|
|
const battle = duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts).toContain("STARMIE PROTECTED itself!");
|
|
expect(texts).toContain("STARMIE's PROTECTING itself!");
|
|
let failures = 0;
|
|
for (let seed = 1; seed < 40; seed++) {
|
|
const steps = play(duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])], { seed }), [0, 0, 0]);
|
|
failures += steps.texts.filter((text) => text === "But it failed!").length;
|
|
}
|
|
expect(failures).toBeGreaterThan(0);
|
|
});
|
|
it("endures a knockout hit with 1 HP", () => {
|
|
const player = mon(RATTATA, 10, [ENDURE]);
|
|
const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("braced itself");
|
|
expect(texts.join(" ")).toContain("ENDURED the hit");
|
|
expect(battle.player.mon.hp).toBe(1);
|
|
});
|
|
it("spites the last move's PP, in the trainer's party struct too", () => {
|
|
const enemy = mon(SNORLAX, 60, [TACKLE]);
|
|
const battle = duel([mon(GENGAR, 40, [SPITE])], [enemy]);
|
|
const { texts } = turns(battle, [0, 0]);
|
|
const spite = texts.find((text) => text.includes("was reduced by"));
|
|
const amount = Number(/reduced by (\d)/.exec(spite!)![1]);
|
|
expect(amount).toBeGreaterThanOrEqual(2);
|
|
// Two Tackles and the Spite in between, in the party struct too.
|
|
expect(battle.enemy.mon.pp[0]).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount);
|
|
expect(enemy.pp[0] & 63).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount);
|
|
});
|
|
it("cures the whole party with Heal Bell", () => {
|
|
const party = [mon(CYNDAQUIL, 40, [HEAL_BELL], { status: PSN }), mon(PIDGEY, 10, [TACKLE], { status: 1 << C.PAR })];
|
|
const battle = duel(party, [mon(SNORLAX, 60, [SPLASH])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("A bell chimed!");
|
|
expect(party.map((member) => member.status)).toEqual([0, 0]);
|
|
});
|
|
it("disables the target's last move, leaving it to Struggle", () => {
|
|
const battle = duel([mon(GENGAR, 60, [DISABLE])], [mon(SNORLAX, 20, [TACKLE])]);
|
|
// Disable fails before Snorlax has moved; the Tackle it already chose
|
|
// is then stopped, and Struggle follows.
|
|
const { texts } = turns(battle, [0, 0, 0]);
|
|
expect(texts).toContain("Enemy SNORLAX's TACKLE was DISABLED!");
|
|
expect(texts).toContain("Enemy SNORLAX's TACKLE is DISABLED!");
|
|
expect(texts).toContain("Enemy SNORLAX used STRUGGLE!");
|
|
});
|
|
it("encores the target into repeating its last move", () => {
|
|
const enemy = mon(SNORLAX, 30, [SPLASH, TACKLE]);
|
|
const battle = duel([mon(STARMIE, 60, [ENCORE, SWORDS_DANCE])], [enemy]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
const lastMove = battle.enemy.lastMove;
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("got an ENCORE");
|
|
expect(battle.enemy.has(5, C.SUBSTATUS_ENCORED)).toBe(true);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
const used = steps.texts.filter((text) => text.startsWith("Enemy SNORLAX used")).slice(-1)[0];
|
|
expect(used).toBe(`Enemy SNORLAX used ${assets.pokemon.moveName(lastMove)}!`);
|
|
});
|
|
it("keeps the player's encored move whatever FIGHT chooses", () => {
|
|
const battle = duel([mon(SNORLAX, 30, [TACKLE, SPLASH])], [mon(STARMIE, 60, [ENCORE])]);
|
|
// Starmie's first Encore fails, as Snorlax has not moved yet.
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 1 }).answer({ action: "fight", move: 1 });
|
|
expect(battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(true);
|
|
expect(battle.fightKeepsMove).toBe(true);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.filter((text) => text === "SNORLAX used SPLASH!")).toHaveLength(3);
|
|
expect(steps.texts).not.toContain("SNORLAX used TACKLE!");
|
|
});
|
|
it("makes the opposite gender fall in love, which may stop it", () => {
|
|
const { battle } = findSeed(
|
|
(seed) => duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("fell in love")),
|
|
);
|
|
expect(battle.enemy.has(1, C.SUBSTATUS_IN_LOVE)).toBe(true);
|
|
const same = firstTurn(duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_M, 40, [SPLASH])]));
|
|
expect(same.texts).toContain("But it failed!");
|
|
const immobile = findSeed(
|
|
(seed) => duel([mon(NIDORAN_M, 40, [ATTRACT, SPLASH])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("infatuation kept it from attacking")),
|
|
);
|
|
expect(immobile.texts.join(" ")).toContain("is in love with");
|
|
});
|
|
it("shrouds the user in Mist against stat drops, and pumps it up with Focus Energy", () => {
|
|
const battle = duel([mon(STARMIE, 60, [MIST, FOCUS_ENERGY])], [mon(SNORLAX, 30, [GROWL])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("shrouded in MIST");
|
|
expect(steps.texts.join(" ")).toContain("protected by MIST");
|
|
expect(battle.player.levels[C.ATTACK]).toBe(7);
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.join(" ")).toContain("getting pumped");
|
|
expect(battle.player.has(4, C.SUBSTATUS_FOCUS_ENERGY)).toBe(true);
|
|
});
|
|
it("eliminates all stat changes with Haze", () => {
|
|
const battle = duel([mon(STARMIE, 60, [SWORDS_DANCE, HAZE])], [mon(SNORLAX, 30, [GROWL])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.join(" ")).toContain("All stat changes were eliminated!");
|
|
expect(battle.player.levels).toEqual([7, 7, 7, 7, 7, 7, 7]);
|
|
});
|
|
it("recovers half its HP, or all of it with Rest and two turns of sleep", () => {
|
|
const player = mon(STARMIE, 60, [RECOVER, REST]);
|
|
player.hp = 20;
|
|
const battle = duel([player], [mon(SNORLAX, 10, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(battle.player.mon.hp).toBe(20 + (player.stats[0] >> 1));
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.join(" ")).toContain("STARMIE went to sleep!");
|
|
expect(battle.player.mon.hp).toBe(player.stats[0]);
|
|
expect(battle.player.mon.status).toBe(C.REST_SLEEP_TURNS + 1);
|
|
const full = firstTurn(duel([mon(STARMIE, 60, [RECOVER])], [mon(SNORLAX, 10, [SPLASH])]));
|
|
expect(full.texts).toContain("STARMIE's HP is full!");
|
|
});
|
|
it("stops the player running after Mean Look", () => {
|
|
const battle = duel([mon(STARMIE, 60, [SPLASH])], { wild: { species: GENGAR, level: 5 } });
|
|
const internal = battle as unknown as { enemy: { mon: { moves: number[] } } };
|
|
void internal;
|
|
const steps = battleSteps(battle);
|
|
battle.enemy.set(5, C.SUBSTATUS_CANT_RUN);
|
|
steps.answer({ action: "run" });
|
|
expect(steps.texts).toContain("Can't escape!");
|
|
});
|
|
it("shares pain to the average HP", () => {
|
|
const player = mon(GENGAR, 40, [PAIN_SPLIT]);
|
|
player.hp = 10;
|
|
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
|
const battle = duel([player], [enemy]);
|
|
firstTurn(battle);
|
|
const average = (10 + enemy.stats[0]) >> 1;
|
|
expect(battle.enemy.mon.hp).toBe(average);
|
|
expect(battle.player.mon.hp).toBe(Math.min(average, player.stats[0]));
|
|
});
|
|
it("drums up the highest Attack for half the HP, keeping two stages when it fails", () => {
|
|
const drummer = mon(SNORLAX, 40, [BELLY_DRUM]);
|
|
const battle = duel([drummer], [mon(PIDGEY, 10, [SPLASH])]);
|
|
firstTurn(battle);
|
|
expect(battle.player.levels[C.ATTACK]).toBe(13);
|
|
expect(drummer.hp).toBe(drummer.stats[0] - (drummer.stats[0] >> 1));
|
|
const weak = mon(SNORLAX, 40, [BELLY_DRUM]);
|
|
weak.hp = 10;
|
|
const failed = duel([weak], [mon(PIDGEY, 10, [SPLASH])]);
|
|
expect(firstTurn(failed).texts).toContain("But it failed!");
|
|
expect(failed.player.levels[C.ATTACK]).toBe(9);
|
|
});
|
|
it("copies the target's stat changes with Psych Up", () => {
|
|
const battle = duel([mon(STARMIE, 60, [PSYCH_UP])], [mon(SNORLAX, 30, [SWORDS_DANCE])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts).toContain("But it failed!");
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("copied the stat changes of Enemy SNORLAX");
|
|
expect(battle.player.levels[C.ATTACK]).toBe(9);
|
|
});
|
|
it("guards against status with Safeguard for five turns", () => {
|
|
const battle = duel([mon(STARMIE, 60, [SAFEGUARD, SPLASH])], [mon(SNORLAX, 30, [POISONPOWDER])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("covered by a veil");
|
|
expect(steps.texts.join(" ")).toContain("protected by SAFEGUARD");
|
|
for (let turn = 0; turn < 4; turn++) steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.join(" ")).toContain("SAFEGUARD faded");
|
|
});
|
|
it("counts Perish Song down until both faint", () => {
|
|
const battle = duel([mon(STARMIE, 60, [PERISH_SONG, SPLASH]), mon(PIDGEY, 10, [TACKLE])], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
for (let turn = 0; turn < 3 && steps.prompt?.kind === "action"; turn++)
|
|
steps.answer({ action: "fight", move: 1 });
|
|
const counts = steps.texts.filter((text) => text.includes("PERISH count is"));
|
|
expect(counts.slice(0, 2)).toEqual(["STARMIE's PERISH count is 3!", "Enemy SNORLAX's PERISH count is 3!"]);
|
|
expect(steps.texts.join(" ")).toContain("PERISH count is 0!");
|
|
expect(battle.enemy.mon.hp).toBe(0);
|
|
});
|
|
it("hurts the next Pokémon sent onto Spikes, which Rapid Spin blows away", () => {
|
|
const lead = mon(PIDGEY, 5, [TACKLE]);
|
|
lead.hp = 1;
|
|
const next = mon(SNORLAX, 30, [SPLASH]);
|
|
const battle = duel([mon(FORRETRESS, 60, [SPIKES, TACKLE])], [lead, next]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("SPIKES scattered all around Enemy PIDGEY");
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.join(" ")).toContain("hurt by SPIKES");
|
|
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe((next.stats[0] >> 2) >> 1);
|
|
const spinner = duel([mon(FORRETRESS, 60, [RAPID_SPIN])], [mon(FORRETRESS, 60, [SPIKES])]);
|
|
const spun = turns(spinner, [0, 0]);
|
|
expect(spun.texts.join(" ")).toContain("blew away SPIKES");
|
|
});
|
|
it("may raise every stat with AncientPower", () => {
|
|
const { battle } = findSeed(
|
|
(seed) => duel([mon(ONIX, 60, [ANCIENTPOWER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("SPCL.DEF went up")),
|
|
);
|
|
expect(battle.player.levels.slice(0, 5)).toEqual([8, 8, 8, 8, 8]);
|
|
});
|
|
it("hits two turns later with Future Sight", () => {
|
|
const battle = duel([mon(STARMIE, 60, [FUTURE_SIGHT, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("foresaw an attack");
|
|
const full = battle.enemy.mon.hp;
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts).toContain("But it failed!");
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts.at(-1)).toBe("Enemy SNORLAX was hit by FUTURE SIGHT!");
|
|
expect(battle.enemy.mon.hp).toBeLessThan(full);
|
|
});
|
|
});
|
|
|
|
describe("weather, substitutes and moves that copy or switch", () => {
|
|
it("makes it rain for five turns, strengthening Water moves", () => {
|
|
const damage = (rain: boolean) => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
|
const battle = duel([mon(STARMIE, 40, [rain ? RAIN_DANCE : SPLASH, WATER_GUN])], [enemy], { seed: 3 });
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
|
|
return { taken: enemy.hp - battle.enemy.mon.hp, steps, battle };
|
|
};
|
|
const rain = damage(true),
|
|
dry = damage(false);
|
|
expect(rain.steps.texts.join(" ")).toContain("A downpour started!");
|
|
expect(rain.steps.texts.join(" ")).toContain("Rain continues to fall.");
|
|
expect(rain.taken).toBeGreaterThan(dry.taken * 1.3);
|
|
for (let turn = 0; turn < 3; turn++) rain.steps.answer({ action: "fight", move: 1 });
|
|
expect(rain.steps.texts).toContain("The rain stopped.");
|
|
expect(rain.battle.weather).toBe(C.WEATHER_NONE);
|
|
});
|
|
it("skips Solarbeam's charge and heals more with Synthesis in the sun", () => {
|
|
const player = mon(BULBASAUR, 60, [SUNNY_DAY, SOLARBEAM, SYNTHESIS]);
|
|
player.hp = 20;
|
|
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts.join(" ")).toContain("The sunlight got bright!");
|
|
steps.answer({ action: "fight", move: 1 });
|
|
expect(steps.texts).not.toContain("BULBASAUR took in sunlight!");
|
|
expect(steps.texts).toContain("BULBASAUR used SOLARBEAM!");
|
|
const hp = battle.player.mon.hp;
|
|
steps.answer({ action: "fight", move: 2 });
|
|
// Synthesis gives half the maximum HP by day, all of it in the sun.
|
|
expect(battle.player.mon.hp).toBe(Math.min(player.stats[0], hp + player.stats[0]));
|
|
});
|
|
it("buffets Pokémon other than Rock, Ground and Steel types in a sandstorm", () => {
|
|
const battle = duel([mon(GEODUDE, 40, [SANDSTORM, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
|
|
expect(steps.texts).toContain("A SANDSTORM brewed!");
|
|
expect(steps.texts).toContain("The SANDSTORM hits Enemy SNORLAX!");
|
|
expect(steps.texts).not.toContain("The SANDSTORM hits GEODUDE!");
|
|
// Two turns' worth: the turn it started and the next.
|
|
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(((battle.enemy.mon.maxHp >> 2) >> 1) * 2);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts).toContain("But it failed!");
|
|
});
|
|
it("makes a substitute that takes the hits and blocks status moves", () => {
|
|
const player = mon(SNORLAX, 60, [SUBSTITUTE, SPLASH]);
|
|
const battle = duel([player], [mon(PIDGEY, 30, [TACKLE])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
const quarter = player.stats[0] >> 2;
|
|
expect(steps.texts.join(" ")).toContain("made a SUBSTITUTE");
|
|
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
|
|
expect(steps.texts.join(" ")).toContain("The SUBSTITUTE took damage");
|
|
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
|
|
expect(battle.player.substituteHp).toBeLessThan(quarter);
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(steps.texts).toContain("SNORLAX has a SUBSTITUTE!");
|
|
const status = firstTurn(duel([mon(SNORLAX, 60, [SUBSTITUTE])], [mon(PIDGEY, 30, [POISONPOWDER])]));
|
|
expect(status.texts).toContain("It didn't affect SNORLAX!");
|
|
const weak = mon(SNORLAX, 60, [SUBSTITUTE]);
|
|
weak.hp = weak.stats[0] >> 2;
|
|
expect(firstTurn(duel([weak], [mon(PIDGEY, 30, [SPLASH])])).texts.join(" ")).toContain("Too weak to make a SUBSTITUTE");
|
|
// A fast Jolteon's substitute, then a strong Tackle that breaks it.
|
|
const breaker = duel([mon(JOLTEON, 30, [SUBSTITUTE])], [mon(SNORLAX, 60, [TACKLE])]);
|
|
const broken = firstTurn(breaker);
|
|
expect(broken.texts.join(" ")).toContain("SUBSTITUTE faded");
|
|
expect(breaker.player.has(4, C.SUBSTATUS_SUBSTITUTE)).toBe(false);
|
|
});
|
|
it("transforms into the target with 5 PP per move", () => {
|
|
const battle = duel([mon(DITTO, 40, [TRANSFORM])], [mon(SNORLAX, 60, [SPLASH, TACKLE])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("TRANSFORMED into SNORLAX");
|
|
expect(battle.player.mon.species).toBe(SNORLAX);
|
|
expect(battle.player.mon.moves.slice(0, 2)).toEqual([SPLASH, TACKLE]);
|
|
expect(battle.player.mon.pp.slice(0, 3)).toEqual([5, 5, 0]);
|
|
});
|
|
it("catches a transformed wild Pokémon as a Ditto with its own DVs", () => {
|
|
const party = [mon(SNORLAX, 40, [SPLASH])];
|
|
const bag = { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] };
|
|
const battle = duel(party, { wild: { species: DITTO, level: 10 } }, { bag, caughtData: () => 0 });
|
|
const steps = battleSteps(battle);
|
|
const dvs = battle.enemy.mon.dvs;
|
|
steps.answer({ action: "fight", move: 0 });
|
|
expect(battle.enemy.has(5, C.SUBSTATUS_TRANSFORMED)).toBe(true);
|
|
steps.answer({ action: "item", item: C.MASTER_BALL, slot: 0 });
|
|
while (!steps.done) steps.answer(steps.prompt?.kind === "yesno" ? 0 : undefined);
|
|
expect(party[1].species).toBe(DITTO);
|
|
expect(party[1].dvs).toBe(dvs);
|
|
expect(party[1].moves[0]).toBe(TRANSFORM);
|
|
});
|
|
it("mimics the target's last move for the battle", () => {
|
|
const player = mon(STARMIE, 60, [MIMIC]);
|
|
const battle = duel([player], [mon(SNORLAX, 30, [TACKLE])]);
|
|
const { texts } = turns(battle, [0, 0]);
|
|
expect(texts.join(" ")).toContain("learned TACKLE");
|
|
expect(battle.player.mon.moves[0]).toBe(TACKLE);
|
|
expect(player.moves[0]).toBe(MIMIC);
|
|
});
|
|
it("calls a random move with Metronome", () => {
|
|
const { texts } = firstTurn(duel([mon(STARMIE, 60, [METRONOME])], [mon(SNORLAX, 60, [SPLASH])]));
|
|
expect(texts[0]).toBe("STARMIE used METRONOME!");
|
|
expect(texts[1]).toMatch(/^STARMIE used /);
|
|
expect(texts[1]).not.toBe("STARMIE used METRONOME!");
|
|
});
|
|
it("mirrors the target's last move", () => {
|
|
const battle = duel([mon(SNORLAX, 30, [MIRROR_MOVE])], [mon(STARMIE, 60, [TACKLE])]);
|
|
const { texts } = firstTurn(battle);
|
|
const used = texts.indexOf("SNORLAX used MIRROR MOVE!");
|
|
expect(texts[used + 1]).toBe("SNORLAX used TACKLE!");
|
|
const first = firstTurn(duel([mon(STARMIE, 60, [MIRROR_MOVE])], [mon(SNORLAX, 30, [TACKLE])]));
|
|
expect(first.texts.join(" ")).toContain("MIRROR MOVE failed");
|
|
});
|
|
it("talks in its sleep with another move, and snores only when asleep", () => {
|
|
const sleeper = mon(SNORLAX, 60, [SLEEP_TALK, TACKLE, SNORE], { status: 7 });
|
|
const { texts } = firstTurn(duel([sleeper], [mon(PIDGEY, 30, [SPLASH])]));
|
|
expect(texts).toContain("SNORLAX used SLEEP TALK!");
|
|
expect(texts.some((text) => text === "SNORLAX used TACKLE!" || text === "SNORLAX used SNORE!")).toBe(true);
|
|
const awake = firstTurn(duel([mon(SNORLAX, 60, [SNORE])], [mon(PIDGEY, 30, [SPLASH])]));
|
|
expect(awake.texts).toContain("But it failed!");
|
|
});
|
|
it("changes its type with Conversion and Conversion2", () => {
|
|
const battle = duel([mon(PORYGON, 40, [CONVERSION, WATER_GUN])], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("transformed into the WATER-type");
|
|
expect(battle.player.mon.types).toEqual([21, 21]);
|
|
// Jolteon moves first, so its Tackle is the last move to resist.
|
|
const resist = duel([mon(PORYGON, 30, [CONVERSION2])], [mon(JOLTEON, 20, [TACKLE])]);
|
|
firstTurn(resist);
|
|
const [type] = resist.player.mon.types;
|
|
expect([5, 8, 9]).toContain(type); // Rock, Ghost or Steel
|
|
});
|
|
it("sketches the target's last move for good", () => {
|
|
// Jolteon moves first, so Sketch has a move to copy.
|
|
const player = mon(SMEARGLE, 20, [SKETCH]);
|
|
const battle = duel([player], [mon(JOLTEON, 30, [TACKLE])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("SKETCHED TACKLE");
|
|
expect(player.moves[0]).toBe(TACKLE);
|
|
expect(player.pp[0]).toBe(assets.pokemon.move(TACKLE).pp);
|
|
});
|
|
it("passes stat changes and the substitute along with Baton Pass", () => {
|
|
const party = [mon(STARMIE, 60, [SWORDS_DANCE, BATON_PASS]), mon(SNORLAX, 30, [TACKLE])];
|
|
const battle = duel(party, [mon(PIDGEY, 30, [SPLASH])]);
|
|
const steps = battleSteps(battle);
|
|
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
|
|
expect(steps.prompt?.kind).toBe("party");
|
|
steps.answer(0).answer(1);
|
|
expect(steps.texts).toContain("STARMIE is already out.");
|
|
expect(battle.player.mon.species).toBe(SNORLAX);
|
|
expect(battle.player.levels[C.ATTACK]).toBe(9);
|
|
const [numerator, denominator] = [2, 1];
|
|
expect(battle.player.mon.stats[C.ATTACK]).toBe(Math.min(999, Math.floor((party[1].stats[1] * numerator) / denominator)));
|
|
});
|
|
it("ends a wild battle with Roar or Teleport, and drags out a trainer's Pokémon", () => {
|
|
const roar = duel([mon(SNORLAX, 60, [ROAR])], { wild: { species: PIDGEY, level: 5 } });
|
|
const roared = playBattle(roar);
|
|
expect(roared.result).toBe(C.DRAW);
|
|
expect(roared.texts).toContain("Enemy PIDGEY fled in fear!");
|
|
const teleport = duel([mon(STARMIE, 60, [TELEPORT])], { wild: { species: PIDGEY, level: 5 } });
|
|
const teleported = playBattle(teleport);
|
|
expect(teleported.result).toBe(C.DRAW);
|
|
expect(teleported.texts.join(" ")).toContain("fled from battle");
|
|
const trainer = duel([mon(SNORLAX, 30, [ROAR])], [mon(PIDGEY, 30, [SPLASH]), mon(RATTATA, 30, [SPLASH])]);
|
|
const dragged = firstTurn(trainer);
|
|
expect(dragged.texts.join(" ")).toContain("was dragged out");
|
|
expect(trainer.enemy.mon.species).toBe(RATTATA);
|
|
});
|
|
});
|
|
|
|
describe("Counter and Mirror Coat", () => {
|
|
it("returns twice a physical hit taken first", () => {
|
|
const player = mon(SNORLAX, 40, [COUNTER]);
|
|
const enemy = mon(JOLTEON, 40, [TACKLE]);
|
|
const battle = duel([player], [enemy]);
|
|
firstTurn(battle);
|
|
const taken = player.stats[0] - battle.player.mon.hp;
|
|
expect(taken).toBeGreaterThan(0);
|
|
expect(enemy.hp - battle.enemy.mon.hp).toBe(taken * 2);
|
|
});
|
|
it("fails against special moves, and Mirror Coat the other way round", () => {
|
|
const counter = duel([mon(SNORLAX, 40, [COUNTER])], [mon(JOLTEON, 40, [EMBER])]);
|
|
expect(firstTurn(counter).texts.join(" ")).toContain("missed");
|
|
const enemy = mon(JOLTEON, 40, [EMBER]);
|
|
const mirror = duel([mon(SNORLAX, 40, [MIRROR_COAT])], [enemy]);
|
|
firstTurn(mirror);
|
|
const taken = mirror.player.mon.maxHp - mirror.player.mon.hp;
|
|
expect(enemy.hp - mirror.enemy.mon.hp).toBe(taken * 2);
|
|
});
|
|
});
|
|
|
|
describe("statuses", () => {
|
|
it("freezes with an ice move, and the frozen Pokémon cannot move", () => {
|
|
const { battle, texts } = findSeed(
|
|
(seed) => duel([mon(LAPRAS, 40, [ICE_BEAM])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("frozen solid")),
|
|
);
|
|
expect(battle.enemy.mon.status & FRZ).toBeTruthy();
|
|
expect(texts.join(" ")).not.toContain("SNORLAX used");
|
|
});
|
|
it("thaws a frozen target with a fire move, whatever the burn chance", () => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH], { status: FRZ });
|
|
const battle = duel([mon(CYNDAQUIL, 30, [EMBER])], [enemy]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("was defrosted");
|
|
expect(battle.enemy.mon.status).toBe(0);
|
|
expect(enemy.status).toBe(0);
|
|
});
|
|
it("thaws a frozen user of Flame Wheel", () => {
|
|
const player = mon(CYNDAQUIL, 30, [FLAME_WHEEL], { status: FRZ });
|
|
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("was defrosted");
|
|
expect(player.status).toBe(0);
|
|
});
|
|
it("may paralyze, freeze or burn with Tri Attack", () => {
|
|
const seen = new Set<string>();
|
|
for (let seed = 1; seed < 400 && seen.size < 3; seed++) {
|
|
const { texts } = firstTurn(duel([mon(ABRA, 40, [TRI_ATTACK])], [mon(SNORLAX, 60, [SPLASH])], { seed }));
|
|
for (const [pattern, name] of [[/paralyzed/, "par"], [/frozen solid/, "frz"], [/burned/, "brn"]] as const)
|
|
if (texts.some((text) => pattern.test(text))) seen.add(name);
|
|
}
|
|
expect([...seen].sort()).toEqual(["brn", "frz", "par"]);
|
|
});
|
|
it("confuses with Confusion's added effect", () => {
|
|
const { battle } = findSeed(
|
|
(seed) => duel([mon(ABRA, 40, [CONFUSION])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("became confused")),
|
|
);
|
|
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
|
|
});
|
|
it("eats the dream of a sleeping target only", () => {
|
|
const asleep = mon(SNORLAX, 60, [SPLASH], { status: 3 });
|
|
const user = mon(ABRA, 40, [DREAM_EATER]);
|
|
user.hp = 10;
|
|
const battle = duel([user], [asleep]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("dream was eaten");
|
|
expect(battle.player.mon.hp).toBeGreaterThan(10);
|
|
const awake = firstTurn(duel([mon(ABRA, 40, [DREAM_EATER])], [mon(SNORLAX, 60, [SPLASH])]));
|
|
expect(awake.texts.join(" ")).not.toContain("dream was eaten");
|
|
});
|
|
it("raises the target's Attack and confuses it with Swagger", () => {
|
|
const { battle, texts } = findSeed(
|
|
(seed) => duel([mon(ABRA, 40, [SWAGGER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("became confused")),
|
|
);
|
|
expect(battle.enemy.levels[C.ATTACK]).toBe(9);
|
|
expect(texts.join(" ")).toContain("ATTACK went way up");
|
|
});
|
|
it("sleeps with Hypnosis and the target stays asleep", () => {
|
|
const { battle } = findSeed(
|
|
(seed) => duel([mon(ABRA, 40, [HYPNOSIS])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
|
|
(texts) => texts.some((text) => text.includes("fell asleep")),
|
|
);
|
|
expect(battle.enemy.mon.status & C.SLP_MASK).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("items and money", () => {
|
|
it("steals the target's item with Thief, but not MAIL", () => {
|
|
const player = mon(MEOWTH, 30, [THIEF]);
|
|
const enemy = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
|
|
const { battle, texts } = findSeed(
|
|
(seed) => {
|
|
player.item = 0;
|
|
enemy.item = LEFTOVERS;
|
|
return duel([player], [enemy], { seed });
|
|
},
|
|
(texts) => texts.some((text) => text.includes("stole")),
|
|
);
|
|
expect(texts.join(" ")).toContain("stole LEFTOVERS");
|
|
expect(player.item).toBe(LEFTOVERS);
|
|
expect(battle.player.mon.item).toBe(LEFTOVERS);
|
|
expect(battle.enemy.mon.item).toBe(0);
|
|
expect(enemy.item).toBe(0);
|
|
const mail = mon(SNORLAX, 60, [SPLASH], { item: FLOWER_MAIL });
|
|
const thief = mon(MEOWTH, 30, [THIEF]);
|
|
firstTurn(duel([thief], [mail]));
|
|
expect(thief.item).toBe(0);
|
|
});
|
|
it("loses the player's item to a trainer's Thief for good", () => {
|
|
const player = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
|
|
const { battle } = findSeed(
|
|
(seed) => {
|
|
player.item = LEFTOVERS;
|
|
return duel([player], [mon(MEOWTH, 30, [THIEF])], { seed });
|
|
},
|
|
(texts) => texts.some((text) => text.includes("stole")),
|
|
);
|
|
expect(player.item).toBe(0);
|
|
expect(battle.enemy.mon.item).toBe(LEFTOVERS);
|
|
});
|
|
it("scatters coins with Pay Day and pays them out after a win, doubled by an Amulet Coin", () => {
|
|
for (const item of [0, AMULET_COIN]) {
|
|
const enemy = mon(SNORLAX, 30, [SPLASH]);
|
|
enemy.hp = 1;
|
|
const battle = duel([mon(MEOWTH, 20, [PAY_DAY], { item })], [enemy]);
|
|
const { texts } = playBattle(battle);
|
|
expect(texts).toContain("Coins scattered everywhere!");
|
|
const picked = texts.find((text) => text.includes("picked up"));
|
|
expect(picked).toBe(`CHRIS picked up ¥${item ? 80 : 40}!`);
|
|
}
|
|
});
|
|
it("checks the Amulet Coin on every send-out", () => {
|
|
const enemy = mon(SNORLAX, 30, [SPLASH]);
|
|
enemy.hp = 1;
|
|
const fainted = mon(PIDGEY, 5, [TACKLE], { hp: 0 });
|
|
const battle = duel([mon(PIDGEY, 20, [SPLASH]), mon(MEOWTH, 20, [PAY_DAY], { item: AMULET_COIN }), fainted], [enemy]);
|
|
const { texts } = playBattle(battle, {
|
|
action: [{ action: "pkmn" }, { action: "fight", move: 0 }],
|
|
party: [1],
|
|
});
|
|
expect(texts.find((text) => text.includes("picked up"))).toBe("CHRIS picked up ¥80!");
|
|
});
|
|
});
|
|
|
|
describe("Selfdestruct", () => {
|
|
it("faints the user even if it misses", () => {
|
|
const player = mon(GEODUDE, 30, [SELFDESTRUCT]);
|
|
const battle = duel([player, mon(PIDGEY, 30, [TACKLE])], [mon(GASTLY, 60, [SPLASH])]);
|
|
const { texts } = firstTurn(battle);
|
|
expect(texts.join(" ")).toContain("doesn't affect");
|
|
expect(player.hp).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("holds on at 1 HP with a Focus Band", () => {
|
|
const { texts, battle } = findSeed(
|
|
(seed) => {
|
|
const enemy = mon(SNORLAX, 60, [SPLASH], { item: FOCUS_BAND });
|
|
enemy.hp = 1;
|
|
return duel([mon(JOLTEON, 40, [TACKLE])], [enemy], { seed });
|
|
},
|
|
(texts) => texts.some((text) => text.includes("hung on")),
|
|
);
|
|
expect(battle.enemy.mon.hp).toBe(1);
|
|
expect(texts.join(" ")).toContain("FOCUS BAND");
|
|
});
|
|
});
|