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 { receiveItem } from "../src/game/party/bag"; import { ENGINE_FLAGS, PC, SYMBOLS } from "../src/game/rom/profile"; import { TrainerData } from "../src/game/rom/trainers"; import { PokedexData } from "../src/game/rom/pokedex"; import { RadioShow, RADIO_LINES as L, buenasPassword, radioStation, type RadioHost } from "../src/game/scripts/radio"; import { radioSpecialContext } from "../src/game/scripts/pokegear"; import { copyScriptState, type ScriptState } from "../src/game/scripts/interpreter"; import { SOUND } from "../src/game/audio/constants"; import { isRinging } from "./world-driver"; import { defaultTextContext } from "../src/game/scripts/text"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const PHONE_CONTACT_SIZE = 12; const PHONE_YOUNGSTER_JOEY = 15; const ROUTE_29 = "24:3"; const ROUTE_30 = "26:1"; describe.skipIf(!existsSync(romPath))("the phone and the radio with the local ROM", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); const seeded = (seed: number) => () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }; const strongParty = (world: GameWorld) => { for (const species of [157, 160, 154]) world.state.party.push( generatePartyMon(world.assets.pokemon, { species, level: 90, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }), ); }; /** Talk, then answer everything until the world is idle again; the * texts shown, and any refusal. */ const drive = (world: GameWorld) => { const texts: string[] = []; for (let tick = 0; tick < 30000; tick++) { if (world.notice) return { texts, refused: world.diagnostic || world.notice }; const battle = world.battle; if (battle) { const prompt = battle.prompt; if (battle.message) texts.push(battle.message.text); if (prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); // No switching when a trainer sends out the next Pokémon. else if (prompt?.kind === "yesno") world.battleChoose(0); else if (prompt?.kind === "party") world.battleChoose(prompt.cancel ? -1 : 0); else if (prompt?.kind === "forget") world.battleChoose(4); else if (prompt?.kind === "stats" || battle.message?.prompt) world.interact(); else world.tick(); continue; } const dialogue = world.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index]; if (texts.at(-1) !== page && !isRinging(page)) texts.push(page); if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) world.choose(dialogue.choice.kind === "yesno" ? 1 : 0); else if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); continue; } if (!world.busy) return { texts, refused: "" }; world.tick(); } throw new Error(`The world did not settle: ${JSON.stringify({ map: world.mapId, x: world.x, y: world.y, choice: world.dialogue?.choice?.kind, page: world.dialogue?.pages[world.dialogue.index], battle: world.battle?.prompt?.kind ?? world.battle?.message, texts: texts.slice(-4) })}`); }; it("lets every registered trainer rematch, give gifts and talk again on their own map", () => { const rom = assets(), r = rom.reader, trainers = new TrainerData(r); const rematchFlags = rom.dailyEngineFlags(); const refusals: string[] = []; let battles = 0; for (let contact = 5; contact <= 36; contact++) { const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE; const classId = r.byte(entry); if (!classId) continue; const mapId = `${r.byte(entry + 2)}:${r.byte(entry + 3)}`; if (!rom.isPorted(mapId)) continue; const map = rom.map(mapId); const objects = map.objects.filter((object) => { if (object.kind !== 2) return false; const header = trainers.header(object.script); return header.classId === classId && trainers.trainer(header.classId, header.id).name === trainers.trainer(classId, r.byte(entry + 1)).name; }); for (const object of objects) for (const fights of [0, 1, 2, 3, 4]) { const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(contact * 7 + fights)); strongParty(world); const header = trainers.header(object.script); world.state.events.add(header.eventFlag); // The trainer is out (Joey appears only once the Egg is found). if (object.eventFlag !== 0xffff) world.state.events.delete(object.eventFlag); world.state.phone.push(contact); for (const flag of rematchFlags) world.state.flags.add(flag); for (let badge = 0; badge < 16; badge++) world.state.flags.add(0x1b + badge); for (const label of Object.keys(world.state.memory)) delete world.state.memory[label as never]; const counts = Object.keys(SAVED_FIGHTS); for (const label of counts) (world.state.memory as Record)[label] = fights; const save = world.save(); // Stand next to the trainer, facing it. let placed = false; for (const [dx, dy, direction] of [[0, 1, "up"], [0, -1, "down"], [1, 0, "left"], [-1, 0, "right"]] as const) { const x = object.x + dx, y = object.y + dy; if (x < 0 || y < 0 || x >= map.width || y >= map.height) continue; if (rom.permission(rom.collision(map, x, y)) !== 0) continue; try { world.restore({ ...save, map: mapId, x, y, direction }); placed = true; break; } catch { continue; } } if (!placed) continue; world.interact(); const { texts, refused } = drive(world); if (refused) refusals.push(`${mapId} contact ${contact} fights ${fights}: ${refused}`); if (texts.some((text) => text.includes("defeated") || text.includes("Wants to battle") || text.includes("would like to battle"))) battles++; } } expect(refusals).toEqual([]); expect(battles).toBeGreaterThan(20); }); it("rings with a registered trainer's call once the call timer runs out outdoors", () => { const rom = assets(); const clock = { now: new Date("2026-09-22T13:00:00") }; const world = new GameWorld(rom, () => clock.now, seeded(3)); strongParty(world); world.state.phone.push(1, PHONE_YOUNGSTER_JOEY); world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 }); // InitCallReceiveDelay's first wait is 20 minutes. for (let tick = 0; tick < 50; tick++) world.tick(); expect(world.busy).toBe(false); let calls = 0, rang = false; const pages: string[] = []; for (let step = 0; step < 40 && !calls; step++) { clock.now = new Date(clock.now.getTime() + 21 * 60_000); for (let tick = 0; tick < 4 && !world.busy; tick++) world.tick(); if (!world.busy) continue; calls++; for (let tick = 0; tick < 4000 && world.busy; tick++) { const dialogue = world.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index]; if (page.startsWith("☎ JOEY:")) rang = true; if (!isRinging(page) && pages.at(-1) !== page) pages.push(page); if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) world.choose(1); else if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } else world.tick(); } } expect(calls).toBe(1); expect(rang).toBe(true); expect(world.notice, world.diagnostic).toBe(""); expect(pages.slice(-2)).toEqual(["Click!", "……"]); // Joey's caller script runs: a rematch request or news of a Pokémon. expect(pages.some((page) => page.includes("JOEY"))).toBe(true); }); it("never rings in a doorway, without a signal or with nobody to call", () => { const rom = assets(); const clock = { now: new Date("2026-09-22T13:00:00") }; const world = new GameWorld(rom, () => clock.now, seeded(5)); world.state.phone.push(1, 4); // Mom and Elm have no caller hours. world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 }); for (let step = 0; step < 20; step++) { clock.now = new Date(clock.now.getTime() + 21 * 60_000); world.tick(); expect(world.busy).toBe(false); } }); it("clears the rematch and phone flags at the daily reset and counts Kenji's break down", () => { const rom = assets(); const clock = { now: new Date("2026-09-22T13:00:00") }; const world = new GameWorld(rom, () => clock.now, seeded(9)); world.tick(); const flags = rom.dailyEngineFlags(); const rematch = flags.filter((flag) => rom.reader.word(SYMBOLS.engineFlags + flag * 3) >= 0xdc4c); expect(rematch.length).toBeGreaterThan(30); for (const flag of rematch) world.state.flags.add(flag); const kenji = world.state.memory.wKenjiBreakTimer!; expect(kenji).toBeGreaterThanOrEqual(3); expect(kenji).toBeLessThanOrEqual(6); clock.now = new Date("2026-09-23T13:00:00"); world.tick(); for (const flag of rematch) expect(world.state.flags.has(flag)).toBe(false); expect(world.state.memory.wKenjiBreakTimer).toBe(kenji - 1); }); it("swarms: the Dunsparce swarm's table on its map, and the fishing swarms", () => { const rom = assets(); const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(11)); const table = (state: ScriptState) => (world as unknown as { encounterTable(state: ScriptState): { grass?: { slots: { species: number }[][] } } }) .encounterTable(state); const darkCave = "3:78"; // DARK_CAVE_VIOLET_ENTRANCE world.restore({ ...world.save(), map: darkCave, x: 3, y: 15 }); const plain = table(world.state).grass!; const state = copyScriptState(world.state); state.flags.add(ENGINE_FLAGS.dunsparceSwarm); Object.assign(state.memory, { wDunsparceMapGroup: 3, wDunsparceMapNumber: 78 }); const swarming = table(state).grass!; const DUNSPARCE = 206; expect(plain.slots[1].filter((slot) => slot.species === DUNSPARCE).length).toBeLessThan( swarming.slots[1].filter((slot) => slot.species === DUNSPARCE).length, ); }); describe("the radio's stations (PlayRadioShow)", () => { const run = (line: number, frames: number, setup: Partial<{ hours: number; state: ScriptState }> = {}) => { const rom = assets(); const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(13)); const state = setup.state ?? copyScriptState(world.state); const random = seeded(17); const music: number[] = []; const playMusic = world.sound.playMusic.bind(world.sound); world.sound.playMusic = (id) => { if (id) music.push(id); playMusic(id); }; const scriptHost = (world as unknown as { host(id: number): never }).host(0) as Parameters[1]; const host: RadioHost = { reader: rom.reader, data: rom.pokemon, dex: new PokedexData(rom.reader), state, text: () => scriptHost.text(), random, timeOfDay: () => 1, hours: () => setup.hours ?? 13, weekday: () => 2, landmark: () => rom.map(ROUTE_30).landmark, landmarkOf: (id) => rom.map(id).landmark, special: () => radioSpecialContext(state, scriptHost), sound: world.sound, }; const show = new RadioShow(host, line, ""); const shown: string[] = []; for (let frame = 0; frame < frames; frame++) { show.frame(); for (const text of show.lines) if (text && shown.at(-1) !== text && !shown.includes(text)) shown.push(text); } return { shown, music, state }; }; it("Oak's Pokémon Talk names a route's Pokémon, then the Pokémon Channel jingle", () => { const { shown, music } = run(L.OAKS_POKEMON_TALK, 101 * 40); expect(shown[0]).toBe("MARY: PROF.OAK'S"); expect(shown.some((line) => line.startsWith("OAK: "))).toBe(true); expect(shown).toContain("POKéMON Channel"); expect(music).toEqual([SOUND.MUSIC_POKEMON_TALK, SOUND.MUSIC_POKEMON_CHANNEL, SOUND.MUSIC_POKEMON_TALK]); }); it("the Pokédex Show reads a caught Pokémon's entry line by line", () => { const rom = assets(); const state = copyScriptState(new GameWorld(rom).state); state.pokedex.caught.add(1); const { shown } = run(L.POKEDEX_SHOW, 101 * 9, { state }); const entry = new PokedexData(rom.reader).entry(1); expect(shown).toContain(entry.category); expect(shown).toContain(entry.pages[0].split("\n")[0]); expect(shown).toContain(entry.pages[1].split("\n")[2]); }); it("the Lucky Channel reads this week's number", () => { const { shown, state } = run(L.LUCKY_CHANNEL, 101 * 10); const number = String(state.luckyNumber.id).padStart(5, "0"); expect(shown.some((line) => line.includes(number))).toBe(true); }); it("Buena's Password is on the air from 6 PM with the day's password", () => { const evening = run(L.BUENAS_PASSWORD, 101 * 6, { hours: 19 }); const password = evening.state.memory.wBuenasPassword!; expect(evening.state.flags.has(ENGINE_FLAGS.buenasPassword)).toBe(true); const rom = assets(); const word = buenasPassword(rom.reader, rom.pokemon, password, defaultTextContext("CHRIS")); expect(word.length).toBeGreaterThan(0); expect(evening.music).toEqual([SOUND.MUSIC_BUENAS_PASSWORD]); const afternoon = run(L.BUENAS_PASSWORD, 101 * 3, { hours: 13 }); // Off the air: no music, and BuenaOffTheAirText is an empty line. expect(afternoon.music).toEqual([]); expect(afternoon.shown).toEqual([]); }); it("tunes in only the stations a place can hear", () => { const rom = assets(); const johto = { landmark: rom.map(ROUTE_30).landmark, timeOfDay: 1, expnCard: false, rocketSignal: false, rocketsInRadioTower: false }; const context = defaultTextContext("CHRIS"); expect(radioStation(rom.reader, 16, johto, context)?.line).toBe(L.OAKS_POKEMON_TALK); expect(radioStation(rom.reader, 16, { ...johto, timeOfDay: 0 }, context)?.line).toBe(L.POKEDEX_SHOW); expect(radioStation(rom.reader, 18, johto, context)).toBeUndefined(); expect(radioStation(rom.reader, 52, johto, context)).toBeUndefined(); expect(radioStation(rom.reader, 64, johto, context)).toBeUndefined(); expect(radioStation(rom.reader, 52, { ...johto, landmark: L.LANDMARK_RUINS_OF_ALPH }, context)?.line).toBe(L.UNOWN_RADIO); }); }); it("plays the Pokégear's radio card: tuning, the station and its music", () => { const rom = assets(); const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(19)); strongParty(world); for (const flag of [0, 4]) world.state.flags.add(flag); // ENGINE_RADIO_CARD, ENGINE_POKEGEAR world.restore({ ...world.save(), map: ROUTE_30, x: 7, y: 30 }); world.openStartMenu(); const menu = world.dialogue!.choice!; expect(menu.kind).toBe("menu"); const items = (menu as { items: string[] }).items; world.choose(items.indexOf("POKéGEAR")); // The clock card first, then right to the radio. world.choose("right"); expect(world.pokegear?.card).toBe("radio"); for (let press = 0; press < 8; press++) { world.bufferScreenButton("up"); world.tick(); } expect(world.pokegear?.radio?.frequency).toBe("4.5"); expect(world.pokegear?.radio?.name).toMatch(/Talk/); for (let tick = 0; tick < 10; tick++) world.tick(); expect(world.pokegear?.radio?.lines[0]).toBe("MARY: PROF.OAK'S"); // A hundred frames later the next line comes. for (let tick = 0; tick < 60; tick++) world.tick(); expect(world.pokegear?.radio?.lines[1]).toBe("POKéMON TALK!"); expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_POKEMON_TALK); world.bufferScreenButton("b"); world.tick(); expect(world.pokegear?.card).not.toBe("radio"); expect(world.notice, world.diagnostic).toBe(""); }); it("plays a house radio until a button stops it", () => { const rom = assets(); const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(23)); strongParty(world); // Kurt's house radio (Radio1Script) at 6, 1. world.restore({ ...world.save(), map: "8:4", x: 6, y: 2, direction: "up" }); world.interact(); expect(world.dialogue?.choice?.kind, world.diagnostic).toBe("mapradio"); expect(world.dialogue?.pages[0]).toMatch(/^“.+”$/); for (let tick = 0; tick < 200; tick++) world.tick(); expect(world.dialogue?.pages[0]).not.toMatch(/^“/); world.bufferScreenButton("a"); for (let tick = 0; tick < 100 && world.busy; tick++) { if (world.dialogue && world.dialogue.choice?.kind !== "mapradio") world.advanceDialogue(); world.tick(); } expect(world.busy).toBe(false); expect(world.notice, world.diagnostic).toBe(""); }); }); describe.skipIf(!existsSync(romPath))("Buena's Password show with the local ROM", () => { const RADIO_TOWER_2F = "3:18"; const EVENT_MET_BUENA = 829, BLUE_CARD = 0x74; const buena = (hour: number) => { const rom = new CrystalAssets(new Uint8Array(readFileSync(romPath))); const world = new GameWorld(rom, () => new Date(`2026-09-22T${hour}:00:00`)); world.state.party.push( generatePartyMon(rom.pokemon, { species: 155, level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0, caughtData: 0 }), ); world.state.events.add(EVENT_MET_BUENA); receiveItem(rom.pokemon, world.state.bag, BLUE_CARD, 1); return world; }; const answer = (world: GameWorld, pick: (items: string[]) => number) => { const pages: string[] = []; for (let tick = 0; tick < 5000 && world.busy; tick++) { const dialogue = world.dialogue; if (!dialogue) { world.tick(); continue; } pages.push(dialogue.pages[dialogue.index]); const choice = dialogue.index === dialogue.pages.length - 1 ? dialogue.choice : undefined; if (choice?.kind === "menu") world.choose(pick(choice.items)); else if (choice) world.choose(1); else if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } return pages; }; it("takes today's password for a Blue Card point once a day", () => { const world = buena(19); world.state.flags.add(ENGINE_FLAGS.buenasPassword); world.state.memory.wBuenasPassword = 0x01; // JOHTO STARTERS: TOTODILE world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" }); world.interact(); answer(world, (items) => items.indexOf("TOTODILE")); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.blueCardBalance).toBe(1); expect(world.state.flags.has(96)).toBe(true); // ENGINE_BUENAS_PASSWORD_2 }); it("refuses a wrong password, and sends the player to the radio before the show", () => { const world = buena(19); world.state.flags.add(ENGINE_FLAGS.buenasPassword); world.state.memory.wBuenasPassword = 0x01; world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" }); world.interact(); answer(world, (items) => items.indexOf("CYNDAQUIL")); expect(world.state.blueCardBalance).toBe(0); const early = buena(13); early.restore({ ...early.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" }); early.interact(); answer(early, () => 0); expect(early.notice, early.diagnostic).toBe(""); expect(early.state.blueCardBalance).toBe(0); }); it("exchanges Blue Card points for prizes at the receptionist", () => { const world = buena(13); world.state.blueCardBalance = 3; world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 13, y: 7, direction: "left" }); world.interact(); let bought = false; answer(world, (items) => { if (bought) return -1; bought = true; return items.indexOf("ULTRA BALL"); }); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.blueCardBalance).toBe(1); expect(world.state.bag.balls.find((slot) => slot.item === 2)?.quantity).toBe(1); }); }); /** The trainers' fight counts (SAVED_WRAM). */ const SAVED_FIGHTS = Object.fromEntries( ["Jack", "Beverly", "Huey", "Gaven", "Beth", "Jose", "Reena", "Joey", "Wade", "Ralph", "Liz", "Anthony", "Todd", "Gina", "Irwin", "Arnie", "Alan", "Dana", "Chad", "Derek", "Tully", "Brent", "Tiffany", "Vance", "Wilton", "Kenji", "Parry", "Erin"].map((name) => [`w${name}FightCount`, 0]), );