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 } from "../src/game/party/pokemon"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const ROUTE_30 = "26:1"; const MIKEY = 4, DON = 5; const EVENT_BEAT_YOUNGSTER_MIKEY = 1450, EVENT_BEAT_BUG_CATCHER_DON = 1336; const POTION = 0x12; describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", () => { const fixture = (position: { x: number; y: number; direction: "up" | "down" | "left" | "right" }) => { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); let seed = 7; const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }); const mon = generatePartyMon(assets.pokemon, { species: 158, level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }); mon.moves = [10, 0, 0, 0]; // Scratch mon.pp = [35, 0, 0, 0]; world.state.party = [mon]; world.restore({ ...world.save(), map: ROUTE_30, ...position }); return world; }; /** Play through scripts and battles, fighting with the first move. */ const drive = (world: GameWorld) => { const shown: string[] = []; let emote = false; for (let tick = 0; tick < 50_000; tick++) { if (world.emote) emote = true; const view = world.battle; if (view) { if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text); if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); else if (view.prompt?.kind === "yesno") world.battleChoose(0); else if (view.prompt?.kind === "forget") world.battleChoose(4); else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact(); else world.tick(); continue; } const dialogue = world.dialogue; if (dialogue) { shown.push(dialogue.pages[dialogue.index]); if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); continue; } if (!world.busy) return { text: shown.join("\n"), emote }; world.tick(); } throw new Error(`The world did not settle: ${world.diagnostic}`); }; const object = (world: GameWorld, id: number) => world.objects().find((candidate) => candidate.id === id)!; it("Bug Catcher Don sees the player, walks over, sends out both Caterpie and stays beaten", () => { const world = fixture({ x: 1, y: 12, direction: "up" }); const money = world.state.money; expect(world.move("up")).toBe(true); // (1, 11): four tiles away expect(world.busy).toBe(false); expect(world.move("up")).toBe(true); // (1, 10): within his range of three expect(world.activity).toBe("scene"); const { text, emote } = drive(world); expect(world.notice, world.diagnostic).toBe(""); expect(emote).toBe(true); // TrainerWalkToPlayer stops beside the player; writeobjectxy keeps it there. expect(object(world, DON)).toMatchObject({ x: 1, y: 9, direction: "down" }); expect(world.direction).toBe("up"); expect(text.match(/sent out/g)?.length).toBe(2); expect(text).toContain("CATERPIE"); expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true); expect(world.state.money).toBe(money + 48); expect(world.state.pokedex.seen.has(10)).toBe(true); // endifjustbattled: no after-battle text straight after the battle. const after = text.slice(text.lastIndexOf("48")); expect(after.split("\n").length).toBeLessThanOrEqual(2); // The position survives a save, and a beaten trainer no longer sees. const saved = world.save(); expect(saved.objects).toContainEqual({ id: DON, x: 1, y: 9, direction: "down" }); world.restore(saved); expect(world.save()).toEqual(saved); world.move("down"); world.move("up"); world.tick(); expect(world.busy).toBe(false); // TalkToTrainer: already beaten, so only the after-battle text. world.interact(); const talk = drive(world); expect(talk.text.length).toBeGreaterThan(0); expect(talk.text).not.toMatch(/sent out/); expect(world.state.money).toBe(money + 48); }); it("replays the whole sighting from committed state after a mid-battle reload", () => { const world = fixture({ x: 1, y: 11, direction: "up" }); const before = world.save(); world.move("up"); for (let tick = 0; tick < 5000 && !world.battle?.prompt; tick++) { if (world.dialogue) world.advanceDialogue(); else if (world.battle?.message?.prompt) world.interact(); else world.tick(); } expect(world.battle?.prompt?.kind).toBe("action"); const midBattle = world.save(); expect(midBattle.events).toEqual(before.events); expect(midBattle.objects).toEqual(before.objects); expect(midBattle).toMatchObject({ x: 1, y: 10 }); world.restore(midBattle); expect(world.battle).toBeUndefined(); expect(object(world, DON)).toMatchObject({ x: 1, y: 7 }); // The first idle check sees the player again. world.tick(); expect(world.activity).toBe("scene"); drive(world); expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true); }); it("does not re-trigger a refused sighting until the player moves", () => { const world = fixture({ x: 1, y: 11, direction: "up" }); world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused world.state.party[0].pp = [10, 0, 0, 0]; const before = world.save(); world.move("up"); drive(world); expect(world.notice).toContain("not been ported"); expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(false); expect(world.save().objects).toEqual(before.objects); for (let tick = 0; tick < 10; tick++) world.tick(); expect(world.activity).toBe("idle"); expect(world.move("down")).toBe(true); expect(world.move("up")).toBe(true); expect(world.activity).toBe("scene"); }); it("battles Youngster Mikey when spoken to, then gives his after-battle text", () => { const world = fixture({ x: 5, y: 22, direction: "down" }); expect(world.busy).toBe(false); // Mikey faces his Rattata, not the player. world.interact(); const { text } = drive(world); expect(world.notice, world.diagnostic).toBe(""); expect(object(world, MIKEY).direction).toBe("up"); // faceplayer expect(text).toContain("PIDGEY"); expect(text).toContain("RATTATA"); expect(world.state.events.has(EVENT_BEAT_YOUNGSTER_MIKEY)).toBe(true); world.interact(); expect(drive(world).text).not.toMatch(/sent out/); }); it("finds the hidden Potion once", () => { const world = fixture({ x: 14, y: 10, direction: "up" }); world.interact(); expect(settle(world).join("\n")).toContain("POTION"); expect(world.notice, world.diagnostic).toBe(""); const potions = () => world.state.bag.items.find((slot) => slot.item === POTION)?.quantity ?? 0; expect(potions()).toBe(1); world.interact(); expect(settle(world)).toEqual([]); expect(potions()).toBe(1); }); it("runs the Route 30 Pokémon battle scene and the Berry house, and enters Mr. Pokémon's house", () => { const world = fixture({ x: 5, y: 27, direction: "up" }); world.interact(); expect(settle(world).length).toBeGreaterThan(1); expect(world.notice, world.diagnostic).toBe(""); world.restore({ ...world.save(), x: 7, y: 40, direction: "up" }); expect(world.move("up")).toBe(true); settle(world); expect(world.mapId).toBe("26:9"); const berry = world.state.bag.items.length; // Facing the Pokéfan M, who gives a Berry once. const pokefan = world.objects()[0]; world.restore({ ...world.save(), x: pokefan.x, y: pokefan.y + 1, direction: "up" }); world.interact(); expect(settle(world).join("\n")).toMatch(/BERRY/); expect(world.state.bag.items.length).toBe(berry + 1); world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" }); expect(world.move("up")).toBe(true); settle(world); expect(world.mapId).toBe("26:10"); expect(world.notice, world.diagnostic).toBe(""); }); });