import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { Battle, UnsupportedBattle, type BattleEffect, type BattleInput, } from "../src/game/battle/battle"; import { C } from "../src/game/battle/constants"; import { calcStats, generatePartyMon, type PartyMon, } from "../src/game/party/pokemon"; import { defaultTextContext } from "../src/game/scripts/text"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))( "native battle engine with the local ROM", () => { let data: CrystalAssets["pokemon"]; beforeAll(() => { data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon; }); const PIDGEY = 16, CYNDAQUIL = 155, TACKLE = 33; const starter = (level = 5, exp?: number): PartyMon => { const mon = generatePartyMon(data, { species: CYNDAQUIL, level, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); if (exp !== undefined) mon.exp = exp; return mon; }; /** A reproducible stand-in for the hardware Random routine. */ const seeded = (seed: number) => () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }; const battle = ( party: PartyMon[], wild: { species: number; level: number }, seed = 1, ) => { const pokedex = { seen: new Set(), caught: new Set() }; return { pokedex, battle: new Battle({ data, party, pokedex, playerId: 1234, playerName: "CHRIS", timeOfDay: 1, random: seeded(seed), wild, text: () => defaultTextContext("CHRIS"), }), }; }; /** Play a battle, answering the menu and prompts; returns texts and result. */ const play = ( battle: Battle, answer: (effect: BattleEffect) => BattleInput = (effect) => effect.kind === "action" ? { action: "fight", move: 0 } : effect.kind === "yesno" ? 1 : undefined, ) => { const texts: string[] = []; const effects: BattleEffect[] = []; const run = battle.run(); let input: BattleInput; for (let step = 0; step < 2000; step++) { const next = run.next(input); if (next.done) return { texts, effects, result: next.value.result }; effects.push(next.value); if (next.value.kind === "text") texts.push(next.value.text.replace(/\n/g, " ")); input = answer(next.value); } throw new Error("Battle did not end."); }; it("wins a wild battle with the original messages, experience and stat experience", () => { const mon = starter(); const { battle: game, pokedex } = battle([mon], { species: PIDGEY, level: 3, }); const { texts, result } = play(game); expect(texts.slice(0, 3)).toEqual([ "Wild PIDGEY appeared!", "Go! CYNDAQUIL!", "CYNDAQUIL used TACKLE!", ]); expect(texts).toContain("Enemy PIDGEY fainted!"); // Base experience 55 × level 3 ÷ 7. expect(texts.at(-1)).toBe("CYNDAQUIL gained 23 EXP. Points!"); expect(result).toBe(C.WIN); expect(mon.exp).toBe(135 + 23); expect(mon.statExp).toEqual([40, 45, 40, 56, 35]); expect(mon.pp[0]).toBeLessThan(35); expect(pokedex.seen.has(PIDGEY)).toBe(true); expect(mon.hp).toBeLessThan(mon.stats[0]); }); it("escapes when the player's Pokémon is faster", () => { const mon = starter(20); const { battle: game } = battle([mon], { species: PIDGEY, level: 2 }); const { texts, result } = play(game, (effect) => effect.kind === "action" ? { action: "run" } : undefined, ); expect(texts.slice(-1)).toEqual(["Got away safely!"]); expect(result).toBe(C.DRAW); expect(mon.exp).toBe(data.expAtLevel(3, 20)); }); it("loses when the only Pokémon faints", () => { const mon = starter(); mon.hp = 1; const { battle: game } = battle([mon], { species: 19, level: 10 }, 5); const { texts, result } = play(game, (effect) => effect.kind === "action" ? { action: "fight", move: 1 } : undefined, ); expect(texts).toContain("CYNDAQUIL fainted!"); expect(result).toBe(C.LOSE); expect(mon.hp).toBe(0); expect(mon.status).toBe(0); }); it("levels up, shows the stat box and learns the level's move", () => { const mon = starter(5, 178); const { battle: game } = battle([mon], { species: PIDGEY, level: 3 }); const { texts, effects } = play(game); expect(texts).toContain("CYNDAQUIL grew to level 6!"); const box = effects.find((effect) => effect.kind === "stats"); expect(box).toMatchObject({ labels: ["ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED"], }); expect(texts).toContain("CYNDAQUIL learned SMOKESCREEN!"); expect(mon.level).toBe(6); expect(mon.moves).toEqual([TACKLE, 43, 108, 0]); // Level-up recalculates stats with the new stat experience. expect(mon.stats).toEqual( calcStats(data, CYNDAQUIL, mon.dvs, 6, mon.statExp), ); }); it("asks which move to forget when four moves are known", () => { const mon = starter(5, 178); mon.moves = [TACKLE, 43, 52, 98]; mon.pp = [35, 30, 25, 30]; const { battle: game } = battle([mon], { species: PIDGEY, level: 3 }); const { texts } = play(game, (effect) => effect.kind === "action" ? { action: "fight", move: 0 } : effect.kind === "yesno" ? 1 : effect.kind === "forget" ? 1 : undefined, ); expect(texts).toContain("Which move should be forgotten?"); expect(texts.some((text) => text.startsWith("1, 2 and…"))).toBe(true); expect(mon.moves).toEqual([TACKLE, 108, 52, 98]); expect(mon.pp[1]).toBe(data.move(108).pp); }); it("refuses battles that need untranslated rules", () => { const { battle: game } = battle([starter()], { species: 201, level: 5 }); expect(() => play(game)).toThrow(UnsupportedBattle); }); }, );