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); }); it("rolls back the whole battle when a full party would need the PC", () => { 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 before = world.save(); for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true); world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 }); for (let tick = 0; tick < 200 && world.battle; tick++) { if (world.battle.prompt?.kind === "action") world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 }); else if (world.battle.message?.prompt) world.interact(); else world.tick(); } expect(world.battle).toBeUndefined(); expect(world.notice).toMatch(/not been ported/); expect(world.state.bag).toEqual({ ...before.bag }); expect(world.state.party).toHaveLength(6); }); });