import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture"; import type { GameWorld } from "../src/game/world/world"; import { generatePartyMon, calcStats } from "../src/game/party/pokemon"; import { withoutPlayTime } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const fixture = (scenario: BattleScenario = "win") => battleFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), scenario); function encounter(world: GameWorld) { for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); expect(world.battle).toBeDefined(); } function finish(world: GameWorld, action: "fight" | "run" = "fight") { const messages = new Set(); for (let tick = 0; tick < 10000; tick++) { if (world.battle) { const view = world.battle; if (view.message) messages.add(view.message.text); if (view.prompt?.kind === "action") world.battleAction({ action, move: 0 }); else if (view.prompt?.kind === "yesno") world.battleChoose(1); else if (view.prompt?.kind === "forget") world.battleChoose(3); else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact(); else world.tick(); } else if (world.dialogue) { messages.add(world.dialogue.pages[world.dialogue.index]); world.advanceDialogue(); world.tick(); } else if (world.busy) world.tick(); else return [...messages].join("\n"); } throw new Error(`Battle did not settle: ${world.diagnostic}`); } describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => { it("commits a victory, level-up, move learning and Pokédex sighting", () => { const world = fixture(); encounter(world); const before = world.save(); expect(before.party[0].level).toBe(5); expect(before.pokedex.seen).toEqual([155]); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("SMOKESCREEN"); expect(world.state.party[0].level).toBe(6); expect(world.state.party[0].moves).toContain(108); expect(world.state.pokedex.seen.has(16)).toBe(true); expect(world.state.pokedex.caught.has(16)).toBe(false); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("evolves a Pokémon that levelled up once the battle is won", () => { const world = fixture(); const data = world.assets.pokemon; const mon = generatePartyMon(data, { species: 155, level: 13, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.exp = data.expAtLevel(data.baseData(155).growthRate, 14) - 1; world.state.party[0] = mon; encounter(world); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("What? CYNDAQUIL\nis evolving!"); expect(messages).toContain("evolved into\nQUILAVA!"); const evolved = world.state.party[0]; expect([evolved.species, evolved.nickname, evolved.level]).toEqual([156, "QUILAVA", 14]); expect(evolved.stats).toEqual(calcStats(data, 156, evolved.dvs, 14, evolved.statExp)); expect(world.state.pokedex.caught.has(156)).toBe(true); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => { const world = fixture(); const data = world.assets.pokemon; // A level-14 Cyndaquil that does not take part could evolve, but // EvolveAfterBattle only checks the one that grew to level 6. world.state.party.push(generatePartyMon(data, { species: 155, level: 14, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, })); encounter(world); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.party[0].level).toBe(6); expect(messages).not.toContain("evolving"); expect(world.state.party.map((mon) => mon.species)).toEqual([155, 155]); }); it("struggles with recoil once every move is out of PP", () => { const world = fixture(); world.state.party[0].pp = [0, 0, 0, 0]; encounter(world); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toMatch(/STRUGGLE/); expect(messages).toMatch(/recoil/i); }); it("escapes without awarding experience and returns to walking", () => { const world = fixture(); encounter(world); expect(finish(world, "run")).toMatch(/Got away safely/); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.party[0].exp).toBe(178); expect(world.state.pokedex.seen.has(16)).toBe(true); expect(world.move("down")).toBe(true); }); it("restores earned stat experience before the next stat recalculation", () => { const world = fixture(); const mon = generatePartyMon(world.assets.pokemon, { species: 155, level: 100, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x823b, caughtData: 0, }); mon.moves = [33, 0, 0, 0]; mon.pp = [35, 0, 0, 0]; world.state.party = [mon]; encounter(world); finish(world); expect(world.notice, world.diagnostic).toBe(""); const saved = world.save(); expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true); expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp)); expect(() => world.restore(saved)).not.toThrow(); expect(world.save()).toEqual(saved); const corrupt = structuredClone(saved); corrupt.party[0].stats[1] = 999; expect(() => world.restore(corrupt)).toThrow("Invalid native save"); }); it("whiteout heals the party, halves money and returns home", () => { const world = fixture("loss"); encounter(world); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toMatch(/whited\s+out/i); expect(world.save()).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 }); const mon = world.state.party[0]; expect(mon.hp).toBe(mon.stats[0]); expect(mon.pp[0]).toBe(35); expect(mon.status).toBe(0); }); it("a mid-battle save restores the committed party and position", () => { const world = fixture(); encounter(world); const committed = world.save(); for (let tick = 0; tick < 5000; tick++) { if (world.battle?.prompt?.kind === "action") { world.battleAction({ action: "fight", move: 0 }); break; } if (world.battle?.message?.prompt) world.interact(); else world.tick(); } expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed)); world.restore(committed); expect(world.battle).toBeUndefined(); expect(world.save()).toEqual(committed); expect(world.move("right")).toBe(true); }); it("a failed whiteout map callback cannot commit healing or money loss", () => { const world = fixture("loss"); encounter(world); const before = world.save(); // A malformed callback models a destination whose logic is not supported. world.assets.map("24:7").callbacks.push({ kind: 5, script: world.assets.reader.bytes.length }); finish(world); expect(world.notice).toContain("not been ported"); expect(world.save().money).toBe(before.money); expect(world.save().party).toEqual(before.party); expect(world.save().pokedex).toEqual(before.pokedex); }); it("unsupported effects discard all battle party and Pokédex changes", () => { const world = fixture("unsupported"); encounter(world); const before = world.save(); finish(world); expect(world.notice).toContain("not been ported"); expect(world.save().party).toEqual(before.party); expect(world.save().pokedex).toEqual(before.pokedex); expect(world.save().money).toBe(before.money); }); });