import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { generatePartyMon, validPartyMon } from "../src/game/party/pokemon"; import { C } from "../src/game/battle/constants"; import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const ROUTE_29 = "24:3", DUDE = 2, RATTATA = 19, PIDGEY = 16; const EVENT_GOT_A_POKEMON_FROM_ELM = 26, EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, EVENT_DUDE_TALKED_TO_YOU = 65, EVENT_LEARNED_TO_CATCH_POKEMON = 66; describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the world", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** After returning the Egg: Route 29 is in its catching-tutorial scene. */ const tutorialWorld = (position = { x: 54, y: 8 }) => { let seed = 3; const world = new GameWorld(assets(), () => new Date("2026-09-24T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }); const mon = generatePartyMon(world.assets.pokemon, { species: 158, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }); world.state.party = [mon]; world.state.pokedex.seen.add(158); world.state.pokedex.caught.add(158); for (const event of [EVENT_GOT_A_POKEMON_FROM_ELM, EVENT_GAVE_MYSTERY_EGG_TO_ELM]) world.state.events.add(event); world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 5 }]; world.restore({ ...world.save(), map: ROUTE_29, ...position, direction: "left", scenes: { [ROUTE_29]: 1 } }); return world; }; const dude = (world: GameWorld) => world.objects().find((object) => object.id === DUDE)!; it.each([ [8, "Tutorial1"], [9, "Tutorial2"], ])("runs the Dude's demonstration from the trigger at y=%i (%s)", (y) => { const world = tutorialWorld({ x: 54, y }); const before = world.save(); expect(world.move("left")).toBe(true); const pages = settle(world).join("\n").replace(/\n/g, " "); expect(world.notice, world.diagnostic).toBe(""); expect(pages).toContain("show you how to catch POKéMON?"); expect(pages).toContain("That's how you do it"); expect(world.state.events.has(EVENT_DUDE_TALKED_TO_YOU)).toBe(true); expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true); expect(world.state.scenes[ROUTE_29]).toBe(0); // Only the Pokédex sighting remains; the Dude's catch is not kept. expect(world.state.pokedex.seen.has(RATTATA)).toBe(true); expect(world.state.pokedex.caught.has(RATTATA)).toBe(false); expect(world.state.party).toEqual(before.party); expect(world.state.bag).toEqual(before.bag); expect(dude(world)).toMatchObject({ x: 50, y: 12 }); // Nothing replays after reload. world.restore(world.save()); expect(settle(world)).toEqual([]); }); it("shows the battle as the Dude with automatic messages", () => { const world = tutorialWorld(); expect(world.move("left")).toBe(true); const messages: string[] = []; for (let tick = 0; tick < 5000 && !world.battle; tick++) { if (world.dialogue?.choice) world.choose(1); else if (world.dialogue) world.advanceDialogue(); else world.tick(); } expect(world.battle?.engine.player.mon).toBeUndefined(); // A save during the battle holds the state before the scene. const saved = world.save(); expect(saved.events).not.toContain(EVENT_DUDE_TALKED_TO_YOU); expect(saved.pokedex.seen).not.toContain(RATTATA); while (world.battle) { const text = world.battle.message?.text; if (text && messages.at(-1) !== text) messages.push(text); expect(world.battle.prompt).toBeUndefined(); expect(world.battle.message?.prompt).not.toBe(true); world.tick(); } expect(messages.map((text) => text.replace(/\n/g, " "))).toEqual([ "Wild RATTATA appeared!", "DUDE used the POKé BALL.", "Gotcha! RATTATA was caught!", ]); settle(world); expect(world.notice, world.diagnostic).toBe(""); // Reloading the mid-battle save replays the whole scene. world.restore(saved); expect(world.x).toBe(54); expect(world.move("left")).toBe(true); settle(world); expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true); }); it("lets the player decline, then offers the demonstration again when talked to", () => { const world = tutorialWorld(); expect(world.move("left")).toBe(true); const declined = settle(world, [0]).join("\n").replace(/\n/g, " "); expect(world.notice, world.diagnostic).toBe(""); expect(declined).toContain("Oh. Fine, then"); expect(world.state.scenes[ROUTE_29]).toBe(0); expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(false); expect(dude(world)).toMatchObject({ x: 50, y: 12 }); // CatchingTutorialDudeScript: stand below him and talk. world.restore({ ...world.save(), x: 50, y: 13, direction: "up" }); world.interact(); const pages = settle(world).join("\n").replace(/\n/g, " "); expect(world.notice, world.diagnostic).toBe(""); expect(pages).toContain("That's how you do it"); expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true); expect(world.state.pokedex.seen.has(RATTATA)).toBe(true); }); it("catches a wild Pokémon through the world and keeps it after reload", () => { const world = battleFixtureWorld(assets()); world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }]; for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); expect(world.battle).toBeDefined(); let named = false; for (let tick = 0; tick < 20_000 && world.busy; tick++) { const view = world.battle; if (!view) { if (world.dialogue) world.advanceDialogue(); else world.tick(); continue; } const prompt = view.prompt; if (prompt?.kind === "action") world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 }); else if (prompt?.kind === "yesno") world.battleChoose(1); else if (prompt?.kind === "name") { expect(prompt.title).toBe("PIDGEY'S\nNICKNAME?"); world.battleName("PIDGE"); named = true; } else if (view.message?.prompt) world.interact(); else world.tick(); } expect(world.notice, world.diagnostic).toBe(""); expect(named).toBe(true); expect(world.state.party).toHaveLength(2); const caught = world.state.party[1]; expect(caught).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name }); // SetCaughtData: day (2 << 6 | level), Chris, New Bark Town's landmark. expect(caught.caughtData >> 8).toBe((2 << 6) | 3); expect(caught.caughtData & 0x80).toBe(0); expect(caught.caughtData & 0x7f).toBe(world.assets.map("24:7").landmark); expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true); expect(world.state.bag.balls[0].quantity).toBeLessThan(10); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true); }); /** Throw balls until the battle ends, answering prompts; returns the * battle messages and the pages shown afterwards. */ const catchAll = (world: GameWorld, nickname?: string) => { const messages: string[] = [], pages: string[] = []; for (let tick = 0; tick < 20_000 && world.busy; tick++) { const view = world.battle; if (!view) { if (world.dialogue) { const page = world.dialogue.pages[world.dialogue.index]; if (pages.at(-1) !== page) pages.push(page); world.advanceDialogue(); } else world.tick(); continue; } const text = view.message?.text; if (text && messages.at(-1) !== text) messages.push(text); const prompt = view.prompt; if (prompt?.kind === "action") world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 }); else if (prompt?.kind === "yesno") world.battleChoose(nickname ? 1 : 0); else if (prompt?.kind === "name") world.battleName(nickname!); else if (view.message?.prompt) world.interact(); else world.tick(); } return { messages: messages.map((text) => text.replace(/\n/g, " ")), pages: pages.map((text) => text.replace(/\n/g, " ")), }; }; const fullParty = (boxed = 0) => { const world = battleFixtureWorld(assets()); const mon = world.state.party[0]; world.state.party = Array.from({ length: 6 }, () => structuredClone(mon)); world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }]; const { status: _s, hp: _h, stats: _t, ...box } = mon; world.state.storage.boxes[0] = Array.from({ length: boxed }, () => structuredClone(box)); return world; }; it("sends a catch with a full party to the current box", () => { const world = fullParty(); for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); const { messages } = catchAll(world, "PIDGE"); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("PIDGE was sent to BILL's PC."); expect(world.state.party).toHaveLength(6); // SendMonIntoBox: first in the box, with full PP and base happiness. const [boxed] = world.state.storage.boxes[0]; expect(boxed).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name, happiness: 70, statExp: [0, 0, 0, 0, 0] }); expect(boxed).not.toHaveProperty("hp"); const data = world.assets.pokemon; expect(boxed.pp).toEqual(boxed.moves.map((move) => (move ? data.move(move).pp : 0))); expect(boxed.caughtData >> 8).toBe((2 << 6) | 3); expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("keeps the ball when the party and the box are full", () => { const world = fullParty(20); const before = world.save(); for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) if (world.battle?.message?.prompt) world.interact(); else world.tick(); world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 }); const messages: string[] = []; for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) { const text = world.battle?.message?.text; if (text && messages.at(-1) !== text) messages.push(text.replace(/\n/g, " ")); if (world.battle?.message?.prompt) world.interact(); else world.tick(); } // Ball_BoxIsFullMessage: the item is not used and the turn is not taken. expect(messages).toEqual(["The POKéMON BOX is full. That can't be used now."]); expect(world.battle?.prompt?.kind).toBe("action"); expect(world.battle?.engine.enemy.mon.hp).toBe(world.battle?.engine.enemy.mon.maxHp); for (let tick = 0; tick < 2000 && world.battle; tick++) { if (world.battle.prompt?.kind === "action") world.battleAction({ action: "run" }); else if (world.battle.message?.prompt) world.interact(); else world.tick(); } settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.bag).toEqual(before.bag); expect(world.state.storage).toEqual(before.storage); }); it("has Bill call after a catch fills the box", () => { const world = fullParty(19); for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); const { messages } = catchAll(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("PIDGEY was sent to BILL's PC."); expect(world.state.storage.boxes[0]).toHaveLength(20); // Script_SpecialBillCall runs at the next PlayerEvents. expect(world.busy).toBe(false); world.tick(); const call = settle(world).join(" ").replace(/\n/g, " "); expect(call).toContain("It's me, BILL!"); expect(call).toContain("Click!"); expect(world.busy).toBe(false); }); });