import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld, migrateSave } from "../src/game/world/world"; import { generatePartyMon } from "../src/game/party/pokemon"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { OW } from "../src/game/world/constants"; import { Pokegear, type PokegearView } from "../src/game/scripts/pokegear"; import { emptyScriptState, type ScriptHost, } from "../src/game/scripts/interpreter"; 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", CYNDAQUIL = 155; const EVENT_GOT_A_POKEMON_FROM_ELM = 26, EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30, EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64, EVENT_DUDE_TALKED_TO_YOU = 65; const LANDMARK_ROUTE_29 = 2; describe.skipIf(!existsSync(romPath))("the Pokégear", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** Outdoors in New Bark Town on a Friday at noon, with Mom's and Elm's * numbers, the Pokégear and its map and phone cards. */ function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) { const world = new GameWorld( assets(), () => new Date("2026-09-25T12:00:00"), () => 0x42, ); world.state.party = [ generatePartyMon(world.assets.pokemon, { species: CYNDAQUIL, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }), ]; world.state.flags.add(ENGINE_FLAGS.pokegear); world.state.flags.add(OW.ENGINE_PHONE_CARD); world.state.flags.add(OW.ENGINE_MAP_CARD); world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM]; world.state.events.add(EVENT_GOT_A_POKEMON_FROM_ELM); world.state.scenes[NEW_BARK_TOWN] = 1; // Arriving through a warp clears the map's temporary events, such as // the bedroom's EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 that Mom checks. for (let event = 0; event < 8; event++) world.state.events.delete(event); world.restore({ ...world.save(), map, x, y, direction: "down", objects: [], }); return world; } /** Acknowledge pages up to the next choice; returns the pages seen. */ function until(world: GameWorld) { const pages: string[] = []; for (let tick = 0; tick < 2000; tick++) { const dialogue = world.dialogue; if (!dialogue) { if (!world.busy) return pages; world.tick(); continue; } const page = dialogue.pages[dialogue.index].replace(/\n/g, " "); 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."); } /** Open the Pokégear from the start menu. */ function openGear(world: GameWorld) { expect(world.openStartMenu()).toBe(true); until(world); const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error("Expected the start menu."); world.choose(choice.items.indexOf("POKéGEAR")); return until(world); } function view(world: GameWorld): PokegearView { const choice = world.dialogue?.choice; if (choice?.kind !== "pokegear") throw new Error(`Expected the Pokégear, got ${choice?.kind}`); return choice.view; } function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") { if (button === "a") world.interact(); else if (button === "b") world.advanceDialogue(); else world.moveCursor(button); return until(world); } /** B out of the Pokégear and the start menu. */ function close(world: GameWorld) { for (let i = 0; i < 10 && world.busy; i++) { if (world.dialogue?.choice) world.advanceDialogue(); until(world); } settle(world); expect(world.diagnostic).toBe(""); expect(world.pokegear).toBeUndefined(); } it("opens on the clock card and switches cards with left and right", () => { const world = fixture(); expect(openGear(world)).toEqual(["Press any button to exit."]); expect(view(world)).toMatchObject({ card: "clock", cards: ["clock", "map", "phone"], clock: { day: "FRIDAY", time: "12:00 PM" }, }); expect(world.pokegear).toBe(view(world)); // Left does nothing on the clock card. press(world, "left"); expect(view(world).card).toBe("clock"); press(world, "right"); expect(view(world)).toMatchObject({ card: "map", map: { kanto: false, cursor: 1, player: 1, name: "NEW BARK TOWN" }, }); press(world, "up"); expect(view(world).map).toMatchObject({ cursor: LANDMARK_ROUTE_29, name: "ROUTE 29" }); press(world, "down"); press(world, "down"); // The cursor wraps to the last Johto landmark. expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST); press(world, "right"); expect(view(world).phone).toEqual({ rows: ["MOM:", "PROF.ELM:", "----------", "----------"], cursor: 0, signal: true, }); expect(world.dialogue?.pages).toEqual(["Whom do you want\nto call?"]); // There is no radio card; right stays on the phone. press(world, "right"); expect(view(world).card).toBe("phone"); press(world, "left"); // The map card keeps its cursor while the Pokégear is open. expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST); press(world, "left"); expect(view(world).card).toBe("clock"); // Any button leaves the clock card for the start menu. press(world, "a"); expect(world.dialogue?.choice?.kind).toBe("menu"); expect(world.pokegear).toBeUndefined(); close(world); }); it("follows the game clock while the clock card is open", () => { let now = new Date("2026-09-25T00:05:00"); const world = new GameWorld(assets(), () => now, () => 0x42); world.state.flags.add(ENGINE_FLAGS.pokegear); openGear(world); expect(view(world).clock).toEqual({ day: "FRIDAY", time: "12:05 AM" }); now = new Date("2026-09-25T09:41:00"); world.tick(); expect(world.pokegear?.clock).toEqual({ day: "FRIDAY", time: " 9:41 AM" }); // Without the map or phone card, right does nothing. press(world, "right"); expect(view(world)).toMatchObject({ card: "clock", cards: ["clock"] }); close(world); }); it("scrolls the phone list through all ten slots", () => { const world = fixture(); openGear(world); press(world, "right"); press(world, "right"); for (let i = 0; i < 3; i++) press(world, "down"); expect(view(world).phone).toMatchObject({ cursor: 3 }); for (let i = 0; i < 10; i++) press(world, "down"); // The list scrolls until its last four slots show. expect(view(world).phone).toMatchObject({ cursor: 3, rows: ["----------", "----------", "----------", "----------"], }); // A on an empty slot does nothing. press(world, "a"); expect(view(world).phone?.submenu).toBeUndefined(); for (let i = 0; i < 9; i++) press(world, "up"); expect(view(world).phone).toMatchObject({ cursor: 0, rows: ["MOM:", "PROF.ELM:", "----------", "----------"] }); close(world); }); it("calls Elm with his callee script", () => { const world = fixture(); openGear(world); press(world, "right"); press(world, "right"); press(world, "down"); press(world, "a"); // Mom and Elm cannot be deleted. expect(view(world).phone).toMatchObject({ chosen: 1, submenu: { items: ["CALL", "CANCEL"], selected: 0 }, }); // The submenu stops at its ends. press(world, "down"); press(world, "down"); expect(view(world).phone?.submenu?.selected).toBe(1); press(world, "a"); expect(view(world).phone?.submenu).toBeUndefined(); press(world, "a"); press(world, "up"); const pages = press(world, "a"); expect(pages[0]).toBe("……"); expect(pages.slice(1, -3)).toEqual([ "Hello, CHRIS?", "Try not to overdo it.", "Be sure to heal your POKéMON if they are hurt.", ]); expect(pages.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]); expect(view(world).phone?.chosen).toBeUndefined(); close(world); }); it("tells the player to talk to a contact on the same map", () => { const world = fixture(PLAYERS_HOUSE_1F, 7, 3); openGear(world); press(world, "right"); press(world, "right"); press(world, "a"); const pages = press(world, "a"); expect(pages).toContain("Just go talk to that person!"); close(world); }); it("calls Mom, whose lecture after the catching lesson starts her savings", () => { const world = fixture(); world.state.events.add(EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON); world.state.events.add(EVENT_GAVE_MYSTERY_EGG_TO_ELM); world.state.events.add(EVENT_DUDE_TALKED_TO_YOU); world.state.specialPhoneCall = 1; openGear(world); press(world, "right"); press(world, "right"); press(world, "a"); const lecture = press(world, "a"); expect(lecture).toContain("Hello?"); expect(world.dialogue?.choice?.kind).toBe("yesno"); // Saves made during the call keep the state from before the menu. expect(world.save().events).not.toContain(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST); world.choose(1); const answer = until(world); expect(answer.slice(0, 2)).toEqual([ "OK. I'll save your money.", "CHRIS, keep it up! I'm rooting for you, baby!", ]); expect(answer.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]); close(world); expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST)).toBe(true); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true); expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true); expect(world.state.specialPhoneCall).toBe(0); // The next call asks about her savings from where the player is. world.state.momsMoney = 1234; openGear(world); press(world, "right"); press(world, "right"); press(world, "a"); const greeting = press(world, "a"); expect(greeting).toEqual( expect.arrayContaining([ "Hello?", "Oh, hi, CHRIS! Working hard?", "What? You're in NEW BARK TOWN?", "Come see your MOM sometime!", ]), ); expect(greeting.slice(-2).join(" ")).toMatch( /¥1234.*Do you want to keep on saving\?$/, ); world.choose(0); expect(until(world)).toContain("OK. I won't save your money."); close(world); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false); }); it("reports no service where the map has none", () => { const world = fixture(); const state = emptyScriptState(); state.flags.add(OW.ENGINE_PHONE_CARD); state.phone = [OW.PHONECONTACT_ELM]; const scriptHost: ScriptHost = { ...(world as unknown as { host(id: number): ScriptHost }).host(0), phoneService: () => 1, }; const gear = new Pokegear(state, { reader: world.assets.reader, data: world.assets.pokemon, text: () => scriptHost.text(), scriptHost: () => scriptHost, landmarkOf: () => 1, }).run(); const effects: unknown[] = []; let effect = gear.next(); const answers = ["right", "a", "a", undefined, undefined, "b", "b"]; while (!effect.done) { effects.push(effect.value); effect = gear.next(answers.shift()); } const phone = effects.find( (e) => (e as { view?: PokegearView }).view?.card === "phone", ) as { view: PokegearView }; expect(phone.view.phone?.signal).toBe(false); expect(effects).toContainEqual({ kind: "text", pages: ["……"], holdTicks: 60 }); expect(effects).toContainEqual({ kind: "text", pages: ["You're out of the\nservice area."], }); }); it("keeps Mom's savings in saves", () => { const world = fixture(); world.state.momsMoney = 4321; const save = world.save(); expect(save.momsMoney).toBe(4321); const { momsMoney: _, ...old } = save; const migrated = migrateSave(old, () => 0)!; expect(migrated.momsMoney).toBe(0); world.restore(migrated); expect(world.state.momsMoney).toBe(0); expect(() => world.restore({ ...save, momsMoney: -1 })).toThrow(); }); });