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 { emptyScriptState } from "../src/game/scripts/interpreter"; import { generatePartyMon } from "../src/game/party/pokemon"; import type { Direction } from "../src/game/world/types"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const NEW_BARK_TOWN = "24:4", PLAYERS_HOUSE_1F = "24:6", ROUTE_29 = "24:3", CHERRYGROVE_CITY = "26:3", ROUTE_31 = "26:2", DARK_CAVE_VIOLET_ENTRANCE = "3:78"; const SPAWN_NEW_BARK = 14, SPAWN_CHERRYGROVE = 15, SPAWN_MT_SILVER = 26; const ENGINE_STORMBADGE = 32, EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64; const FLY = 0x13, DIG = 0x5b, TELEPORT = 0x64; const PIDGEOT = 18, ESCAPE_ROPE = 0x13; describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope", () => { /** A world with one Pokémon that knows `moves`, on `map`. */ function fixture( moves: number[], map: string, x: number, y: number, direction: Direction = "down", ported: string[] = [], ) { const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const isPorted = assets.isPorted.bind(assets); assets.isPorted = (id) => isPorted(id) || ported.includes(id); const world = new GameWorld( assets, () => new Date("2026-09-22T12:00:00"), () => 0, ); world.state = emptyScriptState(); const mon = generatePartyMon(assets.pokemon, { species: PIDGEOT, level: 40, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0, }); mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves; mon.pp = [10, 10, 10, 10]; mon.nickname = "BIRDY"; world.state.party = [mon]; world.state.scenes[NEW_BARK_TOWN] = 1; // Route 31's NEWMAP callback would otherwise queue Mom's worried call. world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST); world.restore({ ...world.save(), map, x, y, direction, objects: [] }); return { assets, world }; } const visit = (world: GameWorld, ...spawns: number[]) => { for (const spawn of spawns) world.state.flags.add(world.assets.spawnEngineFlag(spawn)!); }; /** Acknowledge pages up to the next choice; returns the pages seen. */ function until(world: GameWorld) { const pages: string[] = []; for (let tick = 0; tick < 500; tick++) { const dialogue = world.dialogue; if (!dialogue) { if (!world.busy) return pages; world.tick(); continue; } const page = dialogue.pages[dialogue.index]; if (page && pages.at(-1) !== page) pages.push(page); const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice) return pages; if (last && dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } throw new Error("No choice appeared."); } function pick(world: GameWorld, label: string) { const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`); const index = choice.items.findIndex((item) => item.startsWith(label)); expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0); world.choose(index); return until(world); } /** Start → POKéMON → BIRDY → `move`. */ function useMove(world: GameWorld, move: string) { expect(world.openStartMenu()).toBe(true); until(world); pick(world, "POKéMON"); pick(world, "BIRDY"); return pick(world, move); } /** B out of every menu, then let any queued script finish. */ function close(world: GameWorld) { for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue(); return settle(world); } const flyMap = (world: GameWorld) => { const choice = world.dialogue?.choice; return choice?.kind === "flymap" ? choice.view : undefined; }; it("needs the Storm Badge and an outdoor map to open the Fly map", () => { const { world } = fixture([FLY], PLAYERS_HOUSE_1F, 7, 4); // A failure prints its message and returns to the party menu. expect(useMove(world, "FLY")).toEqual([ "Sorry! A new BADGE\nis required.", "Choose a POKéMON.", ]); close(world); world.state.flags.add(ENGINE_STORMBADGE); expect(useMove(world, "FLY")[0]).toBe("Can't use that\nhere."); close(world); expect(world.mapId).toBe(PLAYERS_HOUSE_1F); expect(world.notice, world.diagnostic).toBe(""); }); it("cycles through visited flypoints on the Fly map, and B returns to the party", () => { const { world } = fixture([FLY], ROUTE_29, 50, 10); world.state.flags.add(ENGINE_STORMBADGE); visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE); useMove(world, "FLY"); // New Bark Town is the default, with the player's icon on Route 29. expect(flyMap(world)).toMatchObject({ kanto: false, where: "Where?", name: "NEW BARK TOWN", cursor: 1, player: world.map.landmark, species: PIDGEOT, }); // Up goes to the next visited flypoint, down back; both wrap around. world.moveCursor("up"); expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY"); world.moveCursor("up"); expect(flyMap(world)?.name).toBe("NEW BARK TOWN"); world.moveCursor("down"); expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY"); // Other buttons do nothing. world.choose("start"); expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY"); world.advanceDialogue(); // B expect(world.dialogue?.choice?.kind).toBe("menu"); close(world); expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]); expect(world.notice, world.diagnostic).toBe(""); }); it("flies to a flypoint with the party icon's animations", () => { const { world } = fixture([FLY], ROUTE_29, 50, 10, "left"); world.state.flags.add(ENGINE_STORMBADGE); visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE); useMove(world, "FLY"); world.moveCursor("up"); world.choose("a"); // The menu closes and FlyScript starts: the icon replaces every object. expect(world.dialogue).toBeUndefined(); expect(world.flyingIcon).toMatchObject({ species: PIDGEOT, x: 0, y: 0 }); expect(world.poses()).toEqual([]); const heights: number[] = []; while (world.busy && world.mapId === ROUTE_29) { heights.push(world.flyingIcon?.y ?? 0); world.tick(); } // SpriteAnimFunc_FlyFrom: 64 frames in place, then two pixels a frame // up to the top of the screen. expect(heights.slice(0, 32).every((y) => y === 0)).toBe(true); expect(Math.min(...heights)).toBe(-84); // MAPSETUP_FLY: Cherrygrove's spawn point, facing down; the icon then // comes down from above the screen onto the player. expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]); expect(world.flyingIcon?.y).toBeLessThan(-80); const landing: number[] = []; while (world.busy) { landing.push(world.flyingIcon?.y ?? 0); world.tick(); } expect(landing.at(-1)).toBe(0); expect(world.flyingIcon).toBeUndefined(); expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ direction: "down", }); expect(world.notice, world.diagnostic).toBe(""); // The flight commits: a save is on Cherrygrove. expect(world.save()).toMatchObject({ map: CHERRYGROVE_CITY, x: 29, y: 4 }); }); it("refuses to fly to a flypoint that is not ported", () => { const { world } = fixture([FLY], ROUTE_29, 50, 10); world.state.flags.add(ENGINE_STORMBADGE); visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_MT_SILVER); useMove(world, "FLY"); for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++) world.moveCursor("down"); expect(flyMap(world)?.name).toBe("SILVER CAVE"); world.choose("a"); until(world); expect(world.notice).toBe("This destination is not ported yet."); close(world); expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]); }); it("teleports outdoors to the last Pokémon Center's town", () => { const { world } = fixture([TELEPORT], ROUTE_29, 50, 10); // No spawn recorded yet, or indoors: the move cannot be used. expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere."); close(world); world.state.lastSpawnMap = CHERRYGROVE_CITY; const shown = [...useMove(world, "TELEPORT"), ...close(world)]; expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toContain("Return to the last\nPOKéMON CENTER."); expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]); expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ direction: "down", }); expect(world.busy).toBe(false); }); it("refuses Teleport indoors", () => { const { world } = fixture([TELEPORT], PLAYERS_HOUSE_1F, 7, 4); world.state.lastSpawnMap = CHERRYGROVE_CITY; expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere."); close(world); expect(world.mapId).toBe(PLAYERS_HOUSE_1F); }); /** Walk from Route 31 into Dark Cave, which records the dig warp. */ function enterDarkCave(moves: number[]) { const fixtureWorld = fixture(moves, ROUTE_31, 34, 6, "up", [ ROUTE_31, DARK_CAVE_VIOLET_ENTRANCE, ]); const { world } = fixtureWorld; for (let tick = 0; tick < 40 && world.mapId === ROUTE_31; tick++) world.tick("up"); settle(world); expect(world.mapId).toBe(DARK_CAVE_VIOLET_ENTRANCE); // EnterMapWarp's SaveDigWarp: Route 31's third warp, the cave entrance. expect(world.state.digWarp).toEqual({ map: ROUTE_31, warp: 3 }); return fixtureWorld; } it("digs out of a cave to the entrance last used", () => { const { world } = enterDarkCave([DIG]); // The dig warp is saved with the game. const saved = world.save(); expect(saved.digWarp).toEqual({ map: ROUTE_31, warp: 3 }); world.restore(saved); const shown = [...useMove(world, "DIG"), ...close(world)]; expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toContain("BIRDY used\nDIG!"); // MAPSETUP_DOOR onto the cave entrance, which faces the player down; // standing on it, the player then walks out. expect(world.mapId).toBe(ROUTE_31); expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ direction: "down", }); for (let tick = 0; tick < 40 && world.y < 6; tick++) world.tick(); expect([world.x, world.y]).toEqual([34, 6]); }); it("explains that Dig only works in caves", () => { const { world } = fixture([DIG], ROUTE_29, 50, 10); world.state.digWarp = { map: ROUTE_31, warp: 3 }; expect(useMove(world, "DIG")[0]).toMatch(/^Can't use that/); close(world); expect(world.mapId).toBe(ROUTE_29); }); it("uses an Escape Rope in a cave, and only there", () => { const { world } = enterDarkCave([]); world.state.bag.items = [{ item: ESCAPE_ROPE, quantity: 2 }]; const useRope = () => { expect(world.openStartMenu()).toBe(true); until(world); pick(world, "PACK"); const choice = world.dialogue?.choice; if (choice?.kind !== "pack") throw new Error("Expected the pack."); const pocket = choice.pockets.findIndex(({ entries }) => entries.some((entry) => entry.item === ESCAPE_ROPE), ); world.choosePackEntry( pocket, choice.pockets[pocket].entries.findIndex( (entry) => entry.item === ESCAPE_ROPE, ), ); until(world); return pick(world, "USE"); }; const shown = [...useRope(), ...close(world)]; expect(world.notice, world.diagnostic).toBe(""); expect(shown.join("\n")).toMatch(/used an\nESCAPE ROPE/); expect(world.mapId).toBe(ROUTE_31); expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]); // Outdoors it fails silently, then Oak warns; the rope is kept. for (let tick = 0; tick < 40 && world.busy; tick++) world.tick(); expect(useRope()).toEqual([ "OAK: CHRIS!\nThis isn't the\ntime to use that!", ]); close(world); expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]); }); it("does not record a dig warp into Mount Moon Square or Tin Tower's roof", () => { const { assets } = fixture([], ROUTE_29, 50, 10); // SaveDigWarp's exclusions name these two outdoor maps. expect(assets.map("15:10").location).toBe("Mt.Moon"); expect(assets.map("15:12").location).toBe("Tin Tower"); expect(assets.map("15:10").environment).not.toBe(3); }); });