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; // 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; const AMULET_COIN = 0x5b, LEFTOVERS = 0x92, FOCUS_BAND = 0x77, FLOWER_MAIL = 0x9e; const FRZ = 1 << C.FRZ, PSN = 1 << C.PSN; describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => { let assets: CrystalAssets, trainers: TrainerData; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); trainers = new TrainerData(assets.reader); }); const mon = ( species: number, level: number, moves: number[], extra: Partial = {}, ): PartyMon => { const result = generatePartyMon(assets.pokemon, { species, level, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); result.moves = [0, 1, 2, 3].map((i) => moves[i] ?? 0) as PartyMon["moves"]; result.pp = result.moves.map((move) => move ? assets.pokemon.move(move).pp : 0, ) as PartyMon["pp"]; return Object.assign(result, extra); }; /** A battle against a trainer with this party and no AI layers, so a * Pokémon with one move always uses it. */ const duel = ( party: PartyMon[], enemy: PartyMon[] | { wild: { species: number; level: number } }, { seed = 1, ...options }: { seed?: number } & Partial = {}, ) => { const base = battleOptions( assets, trainers, party, { wild: { species: PIDGEY, level: 2 } }, { seed }, ); return new Battle({ ...base, ...(Array.isArray(enemy) ? { wild: undefined, trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, reward: 40, }, } : enemy), ...options, }); }; /** Play the first turn: the texts from FIGHT with the first move until * the next battle menu or the end. */ const firstTurn = (battle: Battle) => { const steps = battleSteps(battle); const before = steps.texts.length; steps.answer({ action: "fight", move: 0 }); return { steps, texts: steps.texts.slice(before) }; }; /** 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 } = firstTurn(battle); if (test(texts, battle)) return { seed, battle, texts }; } throw new Error("No seed found."); }; describe("multi-hit moves", () => { it("hits two to five times and says how often", () => { const counts = new Set(); for (let seed = 1; seed < 60; seed++) { const battle = duel([mon(CYNDAQUIL, 30, [FURY_ATTACK])], [mon(SNORLAX, 50, [SPLASH])], { seed }); const { texts } = firstTurn(battle); const missed = texts.some((text) => text.includes("attack missed")); const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean); if (missed) { expect(hit).toBeUndefined(); continue; } const n = Number(hit![1]); counts.add(n); expect(n).toBeGreaterThanOrEqual(2); expect(n).toBeLessThanOrEqual(5); // The loop counter and the hit count are spent; the high byte of // the user's damage taken is cleared again. expect(battle.player.rolloutCount).toBe(0); expect(battle.player.damageTaken >> 8).toBe(0); expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false); } expect([...counts].sort()).toEqual([2, 3, 4, 5]); }); it("hits twice with Double Kick and Twineedle, and only says effectiveness once", () => { for (const move of [DOUBLE_KICK, TWINEEDLE]) { const { texts } = findSeed( (seed) => duel([mon(HITMONLEE, 30, [move])], [mon(GEODUDE, 50, [SPLASH])], { seed }), (texts) => texts.includes("Hit 2 times!"), ); const effectiveness = texts.filter((text) => /effective/.test(text)); // Double Kick is super effective against Geodude; Twineedle is // neutral. Either way the loop's later hits say nothing. expect(effectiveness).toHaveLength(move === DOUBLE_KICK ? 1 : 0); } }); it("stops without the hit count when the target faints mid-loop", () => { const enemy = mon(RATTATA, 2, [SPLASH]); enemy.hp = 1; const battle = duel([mon(CYNDAQUIL, 30, [DOUBLESLAP])], [enemy]); const { texts } = playBattle(battle); expect(texts.join(" ")).toContain("fainted"); expect(texts.some((text) => text.startsWith("Hit "))).toBe(false); expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false); }); it("kicks up to three times with growing damage", () => { const counts = new Set(); for (let seed = 1; seed < 40; seed++) { const battle = duel([mon(HITMONLEE, 30, [TRIPLE_KICK])], [mon(SNORLAX, 60, [SPLASH])], { seed }); const { texts } = firstTurn(battle); const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean); if (hit) counts.add(Number(hit[1])); } expect([...counts].sort()).toEqual([1, 2, 3]); }); it("beats up with each healthy member, by nickname, skipping fainted and statused ones", () => { const party = [ mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }), mon(TOTODILE, 20, [TACKLE], { nickname: "BRAVO", hp: 0 }), mon(PIDGEY, 20, [TACKLE], { nickname: "CHARLIE", status: PSN }), mon(RATTATA, 20, [TACKLE], { nickname: "DELTA" }), ]; const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])])); const attacks = texts.filter((text) => text.endsWith("'s attack!")); expect(attacks).toEqual(["ALPHA's attack!", "DELTA's attack!"]); expect(texts.some((text) => text.startsWith("Hit "))).toBe(false); }); it("lets a lone Pokémon beat up once, then ends the move", () => { const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])])); expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]); expect(texts).not.toContain("But it failed!"); }); it("names a trainer's members by species", () => { const enemy = [mon(SNORLAX, 60, [BEAT_UP]), mon(GEODUDE, 20, [SPLASH])]; const { texts } = firstTurn(duel([mon(PIDGEY, 60, [SPLASH])], enemy)); expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["SNORLAX's attack!", "GEODUDE's attack!"]); }); }); describe("fixed and special damage", () => { const damageTo = (move: number, enemy: PartyMon, level = 30) => { const battle = duel([mon(ABRA, level, [move])], [enemy]); const before = enemy.hp; const { texts } = firstTurn(battle); return { damage: before - battle.enemy.mon.hp, texts, battle }; }; it("deals the level, 20 or 40 HP, or half the target's HP", () => { expect(damageTo(SEISMIC_TOSS, mon(SNORLAX, 50, [SPLASH]), 37).damage).toBe(37); expect(damageTo(NIGHT_SHADE, mon(GEODUDE, 50, [SPLASH]), 41).damage).toBe(41); expect(damageTo(SONICBOOM, mon(SNORLAX, 50, [SPLASH])).damage).toBe(20); expect(damageTo(DRAGON_RAGE, mon(SNORLAX, 50, [SPLASH])).damage).toBe(40); const snorlax = mon(SNORLAX, 50, [SPLASH]); const half = snorlax.hp >> 1; expect(damageTo(SUPER_FANG, snorlax).damage).toBe(half); }); it("misses an immune type", () => { const normal = damageTo(NIGHT_SHADE, mon(SNORLAX, 50, [SPLASH])); expect(normal.damage).toBe(0); expect(normal.texts.join(" ")).toContain("doesn't affect"); const ghost = damageTo(SEISMIC_TOSS, mon(GASTLY, 50, [SPLASH])); expect(ghost.damage).toBe(0); expect(ghost.texts.join(" ")).toContain("doesn't affect"); }); it("rolls Psywave below one and a half times the level", () => { for (let seed = 1; seed < 20; seed++) { const enemy = mon(SNORLAX, 50, [SPLASH]); const battle = duel([mon(ABRA, 40, [PSYWAVE])], [enemy], { seed }); firstTurn(battle); const damage = enemy.hp - battle.enemy.mon.hp; if (!damage) continue; expect(damage).toBeGreaterThan(0); expect(damage).toBeLessThan(60); } }); it("hits harder with Flail at low HP and resets its power", () => { const full = damageTo(FLAIL, mon(SNORLAX, 50, [SPLASH])); const low = (() => { const user = mon(ABRA, 30, [FLAIL]); user.hp = 1; const enemy = mon(SNORLAX, 50, [SPLASH]); const battle = duel([user], [enemy]); firstTurn(battle); return { damage: enemy.hp - battle.enemy.mon.hp, battle }; })(); expect(low.damage).toBeGreaterThan(full.damage * 4); expect(low.battle.player.move.power).toBe(1); }); it("knocks out in one hit or is unaffected against a higher level", () => { const { texts, battle } = findSeed( (seed) => duel([mon(SNORLAX, 60, [HORN_DRILL])], [mon(LAPRAS, 20, [SPLASH])], { seed }), (texts) => texts.includes("It's a one-hit KO!"), ); expect(battle.enemy.mon.hp).toBe(0); expect(texts).toContain("It's a one-hit KO!"); const higher = firstTurn(duel([mon(SNORLAX, 20, [HORN_DRILL])], [mon(LAPRAS, 60, [SPLASH])])); expect(higher.texts.join(" ")).toContain("unaffected"); }); it("leaves 1 HP with False Swipe", () => { const enemy = mon(RATTATA, 3, [SPLASH]); const battle = duel([mon(SNORLAX, 60, [FALSE_SWIPE])], [enemy]); firstTurn(battle); expect(battle.enemy.mon.hp).toBe(1); }); it("crashes on a missed Jump Kick for an eighth of the damage", () => { const { texts, battle } = findSeed( (seed) => duel([mon(HITMONLEE, 30, [JUMP_KICK])], [mon(SNORLAX, 50, [SPLASH])], { seed }), (texts) => texts.some((text) => text.includes("crashed")), ); expect(battle.player.mon.hp).toBeLessThan(battle.player.mon.maxHp); expect(texts.join(" ")).toContain("missed"); }); it("doubles Stomp's damage against a minimized target", () => { const battle = duel([mon(SNORLAX, 40, [STOMP])], [mon(GASTLY, 50, [MINIMIZE])]); firstTurn(battle); expect(battle.enemy.minimized).toBe(1); }); it("sets Hidden Power's type and power from the DVs", () => { // DVs $823b: Poison, 55 power. const battle = duel([mon(ABRA, 30, [HIDDEN_POWER])], [mon(GEODUDE, 50, [SPLASH])]); const { texts } = firstTurn(battle); expect(battle.player.move.type).toBe(C.POISON); expect(texts.join(" ")).toContain("not very effective"); }); it("deals no damage with Return at very low happiness or Frustration at very high", () => { for (const [move, happiness] of [[RETURN, 2], [FRUSTRATION, 255]]) { const enemy = mon(SNORLAX, 50, [SPLASH]); const battle = duel([mon(ABRA, 30, [move], { happiness })], [enemy]); firstTurn(battle); expect(battle.enemy.mon.hp).toBe(enemy.hp); } const enemy = mon(SNORLAX, 50, [SPLASH]); const battle = duel([mon(ABRA, 30, [RETURN], { happiness: 255 })], [enemy]); firstTurn(battle); expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp); }); it("announces Magnitude 4 to 10", () => { const seen = new Set(); for (let seed = 1; seed < 60; seed++) { const { texts } = firstTurn(duel([mon(GEODUDE, 30, [MAGNITUDE])], [mon(SNORLAX, 50, [SPLASH])], { seed })); const text = texts.find((t) => t.startsWith("Magnitude ")); if (text) seen.add(text); } expect([...seen].sort()).toEqual( ["Magnitude 10!", "Magnitude 4!", "Magnitude 5!", "Magnitude 6!", "Magnitude 7!", "Magnitude 8!", "Magnitude 9!"], ); }); it("heals the target with a Present, which a full target refuses only with animations off", () => { const healed = findSeed( (seed) => { const enemy = mon(SNORLAX, 50, [SPLASH]); enemy.hp = 20; return duel([mon(ABRA, 30, [PRESENT])], [enemy], { seed }); }, (texts) => texts.some((text) => text.includes("regained health")), ); const quarter = healed.battle.enemy.mon.maxHp >> 2; expect(healed.battle.enemy.mon.hp).toBe(20 + quarter); expect(healed.texts.join(" ")).toContain("SNORLAX regained health"); const refused = (battleScene: boolean) => firstTurn(duel([mon(ABRA, 30, [PRESENT])], [mon(SNORLAX, 50, [SPLASH])], { seed: healed.seed, battleScene })).texts; expect(refused(false).join(" ")).toContain("refused the gift"); expect(refused(true).join(" ")).not.toContain("refused the gift"); }); }); describe("Counter and Mirror Coat", () => { it("returns twice a physical hit taken first", () => { const player = mon(SNORLAX, 40, [COUNTER]); const enemy = mon(JOLTEON, 40, [TACKLE]); const battle = duel([player], [enemy]); firstTurn(battle); const taken = player.stats[0] - battle.player.mon.hp; expect(taken).toBeGreaterThan(0); expect(enemy.hp - battle.enemy.mon.hp).toBe(taken * 2); }); it("fails against special moves, and Mirror Coat the other way round", () => { const counter = duel([mon(SNORLAX, 40, [COUNTER])], [mon(JOLTEON, 40, [EMBER])]); expect(firstTurn(counter).texts.join(" ")).toContain("missed"); const enemy = mon(JOLTEON, 40, [EMBER]); const mirror = duel([mon(SNORLAX, 40, [MIRROR_COAT])], [enemy]); firstTurn(mirror); const taken = mirror.player.mon.maxHp - mirror.player.mon.hp; expect(enemy.hp - mirror.enemy.mon.hp).toBe(taken * 2); }); }); describe("statuses", () => { it("freezes with an ice move, and the frozen Pokémon cannot move", () => { const { battle, texts } = findSeed( (seed) => duel([mon(LAPRAS, 40, [ICE_BEAM])], [mon(SNORLAX, 60, [TACKLE])], { seed }), (texts) => texts.some((text) => text.includes("frozen solid")), ); expect(battle.enemy.mon.status & FRZ).toBeTruthy(); expect(texts.join(" ")).not.toContain("SNORLAX used"); }); it("thaws a frozen target with a fire move, whatever the burn chance", () => { const enemy = mon(SNORLAX, 60, [SPLASH], { status: FRZ }); const battle = duel([mon(CYNDAQUIL, 30, [EMBER])], [enemy]); const { texts } = firstTurn(battle); expect(texts.join(" ")).toContain("was defrosted"); expect(battle.enemy.mon.status).toBe(0); expect(enemy.status).toBe(0); }); it("thaws a frozen user of Flame Wheel", () => { const player = mon(CYNDAQUIL, 30, [FLAME_WHEEL], { status: FRZ }); const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]); const { texts } = firstTurn(battle); expect(texts.join(" ")).toContain("was defrosted"); expect(player.status).toBe(0); }); it("may paralyze, freeze or burn with Tri Attack", () => { const seen = new Set(); for (let seed = 1; seed < 400 && seen.size < 3; seed++) { const { texts } = firstTurn(duel([mon(ABRA, 40, [TRI_ATTACK])], [mon(SNORLAX, 60, [SPLASH])], { seed })); for (const [pattern, name] of [[/paralyzed/, "par"], [/frozen solid/, "frz"], [/burned/, "brn"]] as const) if (texts.some((text) => pattern.test(text))) seen.add(name); } expect([...seen].sort()).toEqual(["brn", "frz", "par"]); }); it("confuses with Confusion's added effect", () => { const { battle } = findSeed( (seed) => duel([mon(ABRA, 40, [CONFUSION])], [mon(SNORLAX, 60, [SPLASH])], { seed }), (texts) => texts.some((text) => text.includes("became confused")), ); expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(true); }); it("eats the dream of a sleeping target only", () => { const asleep = mon(SNORLAX, 60, [SPLASH], { status: 3 }); const user = mon(ABRA, 40, [DREAM_EATER]); user.hp = 10; const battle = duel([user], [asleep]); const { texts } = firstTurn(battle); expect(texts.join(" ")).toContain("dream was eaten"); expect(battle.player.mon.hp).toBeGreaterThan(10); const awake = firstTurn(duel([mon(ABRA, 40, [DREAM_EATER])], [mon(SNORLAX, 60, [SPLASH])])); expect(awake.texts.join(" ")).not.toContain("dream was eaten"); }); it("raises the target's Attack and confuses it with Swagger", () => { const { battle, texts } = findSeed( (seed) => duel([mon(ABRA, 40, [SWAGGER])], [mon(SNORLAX, 60, [SPLASH])], { seed }), (texts) => texts.some((text) => text.includes("became confused")), ); expect(battle.enemy.levels[C.ATTACK]).toBe(9); expect(texts.join(" ")).toContain("ATTACK went way up"); }); it("sleeps with Hypnosis and the target stays asleep", () => { const { battle } = findSeed( (seed) => duel([mon(ABRA, 40, [HYPNOSIS])], [mon(SNORLAX, 60, [TACKLE])], { seed }), (texts) => texts.some((text) => text.includes("fell asleep")), ); expect(battle.enemy.mon.status & C.SLP_MASK).toBeTruthy(); }); }); describe("items and money", () => { it("steals the target's item with Thief, but not MAIL", () => { const player = mon(MEOWTH, 30, [THIEF]); const enemy = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS }); const { battle, texts } = findSeed( (seed) => { player.item = 0; enemy.item = LEFTOVERS; return duel([player], [enemy], { seed }); }, (texts) => texts.some((text) => text.includes("stole")), ); expect(texts.join(" ")).toContain("stole LEFTOVERS"); expect(player.item).toBe(LEFTOVERS); expect(battle.player.mon.item).toBe(LEFTOVERS); expect(battle.enemy.mon.item).toBe(0); expect(enemy.item).toBe(0); const mail = mon(SNORLAX, 60, [SPLASH], { item: FLOWER_MAIL }); const thief = mon(MEOWTH, 30, [THIEF]); firstTurn(duel([thief], [mail])); expect(thief.item).toBe(0); }); it("loses the player's item to a trainer's Thief for good", () => { const player = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS }); const { battle } = findSeed( (seed) => { player.item = LEFTOVERS; return duel([player], [mon(MEOWTH, 30, [THIEF])], { seed }); }, (texts) => texts.some((text) => text.includes("stole")), ); expect(player.item).toBe(0); expect(battle.enemy.mon.item).toBe(LEFTOVERS); }); it("scatters coins with Pay Day and pays them out after a win, doubled by an Amulet Coin", () => { for (const item of [0, AMULET_COIN]) { const enemy = mon(SNORLAX, 30, [SPLASH]); enemy.hp = 1; const battle = duel([mon(MEOWTH, 20, [PAY_DAY], { item })], [enemy]); const { texts } = playBattle(battle); expect(texts).toContain("Coins scattered everywhere!"); const picked = texts.find((text) => text.includes("picked up")); expect(picked).toBe(`CHRIS picked up ¥${item ? 80 : 40}!`); } }); it("checks the Amulet Coin on every send-out", () => { const enemy = mon(SNORLAX, 30, [SPLASH]); enemy.hp = 1; const fainted = mon(PIDGEY, 5, [TACKLE], { hp: 0 }); const battle = duel([mon(PIDGEY, 20, [SPLASH]), mon(MEOWTH, 20, [PAY_DAY], { item: AMULET_COIN }), fainted], [enemy]); const { texts } = playBattle(battle, { action: [{ action: "pkmn" }, { action: "fight", move: 0 }], party: [1], }); expect(texts.find((text) => text.includes("picked up"))).toBe("CHRIS picked up ¥80!"); }); }); describe("Selfdestruct", () => { it("faints the user even if it misses", () => { const player = mon(GEODUDE, 30, [SELFDESTRUCT]); const battle = duel([player, mon(PIDGEY, 30, [TACKLE])], [mon(GASTLY, 60, [SPLASH])]); const { texts } = firstTurn(battle); expect(texts.join(" ")).toContain("doesn't affect"); expect(player.hp).toBe(0); }); }); it("holds on at 1 HP with a Focus Band", () => { const { texts, battle } = findSeed( (seed) => { const enemy = mon(SNORLAX, 60, [SPLASH], { item: FOCUS_BAND }); enemy.hp = 1; return duel([mon(JOLTEON, 40, [TACKLE])], [enemy], { seed }); }, (texts) => texts.some((text) => text.includes("hung on")), ); expect(battle.enemy.mon.hp).toBe(1); expect(texts.join(" ")).toContain("FOCUS BAND"); }); });