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 { snorlaxAwake } from "../src/game/scripts/kanto"; import { TEARDOWN_FRAMES } from "../src/game/scripts/magnet-train"; import { SOUND } from "../src/game/audio/constants"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Kanto with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const VERMILION = "12:3", BILLS_HOUSE = "7:11", TRAINER_HOUSE_B1F = "23:7", SAFFRON_STATION = "25:9", GOLDENROD_STATION = "11:7", CELADON_MANSION_3F = "21:14", OAKS_LAB = "13:6", GOLDENROD = "11:2", MOUNT_MOON = "3:85", INDIGO_PLATEAU_CENTER = "16:2"; const LICKITUNG = 108, EVERSTONE = 0x70, PASS = 0x86, EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 0x320, EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 0x31b, EVENT_ENABLE_DIPLOMA_PRINTING = 0xd6, EVENT_RESTORED_POWER_TO_KANTO = 0xcd, EVENT_OPENED_MT_SILVER = 0x74f, EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1, EVENT_BEAT_ELITE_FOUR = 0x44, COIN_CASE = 0x36, PIKACHU = 25, THUNDERBOLT = 85, ENGINE_DAILY_MOVE_TUTOR = 94, EVENT_BEAT_RIVAL_IN_MT_MOON = 0x319; const world = (date = "2026-09-22T12:00:00") => { let seed = 33; const game = new GameWorld( new CrystalAssets(bytes), () => new Date(date), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); game.state.party = [mon(game, 157, 60)]; return game; }; const mon = (game: GameWorld, species: number, level = 30) => generatePartyMon(game.assets.pokemon, { species, level, item: 0, otId: game.playerId, ot: "KAWA", dvs: 0x8888, caughtData: 0, }); /** Stand at (x, y) on `map` facing `face`, press A and answer. */ const talk = ( game: GameWorld, map: string, x: number, y: number, face: "up" | "down" | "left" | "right", answers: (number | string)[] = [], ) => { game.warpTo(map, x, y); settle(game); game.restore({ ...game.save(), x, y, direction: face }); game.interact(); const shown = settle(game, answers).join(" "); expect(game.notice, game.diagnostic).toBe(""); return shown; }; /** Run scripts until a battle starts, answering questions with YES. */ const untilBattle = (game: GameWorld) => { const shown: string[] = []; for (let tick = 0; tick < 3000 && !game.battle; tick++) { expect(game.notice, game.diagnostic).toBe(""); const dialogue = game.dialogue; if (dialogue) { const last = dialogue.index === dialogue.pages.length - 1; if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]); if (last && dialogue.choice) game.choose(1); else if (last && dialogue.holdTicks !== undefined) game.tick(); else game.advanceDialogue(); } else game.tick(); } return shown.join(" "); }; /** Run a screen that plays on its own to its end, counting its frames. */ const watch = (game: GameWorld) => { let frames = 0; for (let tick = 0; tick < 2000 && game.dialogue?.choice?.kind === "screen"; tick++) { frames += 2; game.tick(); } return frames; }; describe("Vermilion's Snorlax", () => { it("wakes only to the Poké Flute channel, from beside it", () => { const r = new CrystalAssets(bytes).reader; const flute = SOUND.MUSIC_POKE_FLUTE_CHANNEL; for (const [x, y] of [[33, 8], [34, 10], [35, 10], [36, 8], [36, 9]]) expect(snorlaxAwake(r, flute, { x, y })).toBe(1); expect(snorlaxAwake(r, flute, { x: 34, y: 11 })).toBe(0); expect(snorlaxAwake(r, SOUND.MUSIC_NONE, { x: 34, y: 10 })).toBe(0); }); it("covers two tiles by two and snores until the flute plays", () => { const game = world(); // The big Snorlax at (34, 8) also fills (35, 9): no step there. game.warpTo(VERMILION, 36, 9); settle(game); game.restore({ ...game.save(), x: 36, y: 9, direction: "left" }); game.move("left"); settle(game); expect({ x: game.x, y: game.y }).toEqual({ x: 36, y: 9 }); expect(talk(game, VERMILION, 34, 10, "up")).toContain("SNORLAX is snoring\npeacefully…"); // The Pokégear's radio left on the Poké Flute channel (wMapMusic). game.restore({ ...game.save(), x: 34, y: 10, direction: "up" }); game.sound.engine.mapMusic = SOUND.MUSIC_POKE_FLUTE_CHANNEL; game.interact(); expect(untilBattle(game)).toContain("SNORLAX"); expect(game.battle?.engine.enemy.mon.species).toBe(143); expect(game.battle?.engine.enemy.mon.level).toBe(50); }); }); it("has Bill's grandpa take a look at a Lickitung for the Everstone", () => { const game = world(); game.state.party.push(mon(game, LICKITUNG)); // YES to show him, then the party's second Pokémon. const shown = talk(game, BILLS_HOUSE, 2, 4, "up", [1, 1]); expect(shown).toContain("Hm? You know BILL?"); expect(shown).toContain("LICKITUNG"); expect(game.state.events.has(EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA)).toBe(true); expect(game.state.events.has(EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA)).toBe(true); expect(game.state.bag.items.some((slot) => slot.item === EVERSTONE)).toBe(true); }); it("matches the Trainer House's default trainer, CAL", () => { const game = world(); // The receptionist's coordinate event at (7, 3), from the stairs' side. game.warpTo(TRAINER_HOUSE_B1F, 8, 3); settle(game); game.restore({ ...game.save(), x: 8, y: 3, direction: "left" }); expect(game.move("left")).toBe(true); // TrainerHouse returns 0 without a Mystery Gift partner: CAL3. expect(untilBattle(game)).toContain("CAL"); expect(game.battle?.engine.enemy.mon).toBeDefined(); }); it("rides the Magnet Train both ways with the PASS once the power is back", () => { const game = world(); game.state.bag.keyItems.push(PASS); game.state.events.add(EVENT_RESTORED_POWER_TO_KANTO); // Saffron's officer at (9, 9): YES, then the ride to Goldenrod. game.warpTo(SAFFRON_STATION, 9, 10); settle(game); game.restore({ ...game.save(), x: 9, y: 10, direction: "up" }); game.interact(); for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) { if (game.dialogue?.choice) game.choose(1); else if (game.dialogue) game.advanceDialogue(); else game.tick(); } expect(game.dialogue?.choice?.kind).toBe("screen"); const frames = watch(game); // The first run through, its palette frame, the ride and the teardown. expect(frames).toBeGreaterThan(300 + TEARDOWN_FRAMES); settle(game); expect(game.notice, game.diagnostic).toBe(""); expect(game.mapId).toBe(GOLDENROD_STATION); // And back from Goldenrod's officer. game.restore({ ...game.save(), x: 9, y: 10, direction: "up" }); game.interact(); for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) { if (game.dialogue?.choice) game.choose(1); else if (game.dialogue) game.advanceDialogue(); else game.tick(); } watch(game); settle(game); expect(game.mapId).toBe(SAFFRON_STATION); }); it("shows the Diploma for a complete Pokédex, then cannot print it", () => { const game = world(); for (let species = 1; species <= 251; species++) { game.state.pokedex.seen.add(species); game.state.pokedex.caught.add(species); } // The game designer at (3, 6), from below. game.warpTo(CELADON_MANSION_3F, 3, 7); settle(game); game.restore({ ...game.save(), x: 3, y: 7, direction: "up" }); game.interact(); let diploma = ""; for (let tick = 0; tick < 400; tick++) { const choice = game.dialogue?.choice; if (choice?.kind === "screen") { diploma = choice.screen.label; game.choose("a"); break; } if (game.dialogue) game.advanceDialogue(); else game.tick(); } expect(diploma).toBe(`Diploma: ${game.name}`); settle(game); expect(game.state.events.has(EVENT_ENABLE_DIPLOMA_PRINTING)).toBe(true); // The graphic artist at (3, 4) prints it: no printer, B cancels. game.restore({ ...game.save(), x: 3, y: 5, direction: "up" }); game.interact(); let printer = ""; for (let tick = 0; tick < 400; tick++) { const choice = game.dialogue?.choice; if (choice?.kind === "yesno") game.choose(1); else if (choice?.kind === "screen") { printer = choice.screen.label; game.choose("b"); break; } else if (game.dialogue) game.advanceDialogue(); else game.tick(); } expect(printer).toMatch(/B/); settle(game); expect(game.notice, game.diagnostic).toBe(""); }); it("has Professor Oak open Mt. Silver for sixteen badges and rate the Pokédex", () => { const game = world(); for (let badge = 0x1b; badge < 0x1b + 16; badge++) game.state.flags.add(badge); for (const species of [1, 4, 7, 25]) game.state.pokedex.seen.add(species); game.state.pokedex.caught.add(25); // Oak at (4, 2), from below. const shown = talk(game, OAKS_LAB, 4, 3, "up"); expect(game.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true); expect(game.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true); expect(shown).toContain("4 POKéMON seen\n1 POKéMON owned"); expect(shown).toContain("Look for POKéMON\nin grassy areas!"); }); it("has Goldenrod's move tutor teach Thunderbolt for 4000 coins on a Wednesday after the League", () => { // 2026-09-23 is a Wednesday. const game = world("2026-09-23T12:00:00"); game.state.party.push(mon(game, PIKACHU, 5)); game.state.events.add(EVENT_BEAT_ELITE_FOUR); game.state.bag.keyItems.push(COIN_CASE); game.state.coins = 5000; // The tutor at (12, 22), from below. game.warpTo(GOLDENROD, 12, 23); settle(game); expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(true); game.restore({ ...game.save(), x: 12, y: 23, direction: "up" }); game.interact(); // YES, YES to 4000 coins, THUNDERBOLT, then Typhlosion (NOT ABLE: // back to the menu) and Pikachu. const party = [0, 1], details: string[][] = []; for (let tick = 0; tick < 3000 && game.busy; tick++) { expect(game.notice, game.diagnostic).toBe(""); const dialogue = game.dialogue; const last = dialogue && dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice?.kind === "yesno") game.choose(1); else if (last && dialogue.choice?.kind === "menu" && dialogue.choice.items.includes("THUNDERBOLT")) game.choose(dialogue.choice.items.indexOf("THUNDERBOLT")); else if (last && dialogue.choice?.kind === "menu") { details.push(dialogue.choice.details ?? []); game.choose(party.shift()!); } else if (dialogue) game.advanceDialogue(); else game.tick(); } expect(details).toEqual([ ["NOT ABLE", "ABLE"], ["NOT ABLE", "ABLE"], ]); const pikachu = game.state.party[1]; expect(pikachu.moves).toContain(THUNDERBOLT); expect(game.state.coins).toBe(1000); expect(game.state.flags.has(ENGINE_DAILY_MOVE_TUTOR)).toBe(true); // Into the Game Corner: gone from the street. expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(false); }); describe("the rival in Kanto", () => { it("waits in Mt. Moon", () => { const game = world(); // MountMoonRivalEncounterScene, from the Route 3 entrance. game.warpTo(MOUNT_MOON, 3, 3); expect(untilBattle(game)).toContain(`It's been a while,\n${game.name}.`); expect(game.battle).toBeDefined(); }); // PlateauRivalBattle1 and 2: Mondays and Wednesdays, after Mt. Moon. for (const [date, day, fights] of [ ["2026-09-21T12:00:00", "a Monday", true], ["2026-09-22T12:00:00", "a Tuesday", false], ] as const) it(`${fights ? "battles" : "stays away"} at Indigo Plateau on ${day}`, () => { const game = world(date); game.state.events.add(EVENT_BEAT_RIVAL_IN_MT_MOON); game.warpTo(INDIGO_PLATEAU_CENTER, 16, 5); settle(game); game.restore({ ...game.save(), x: 16, y: 5, direction: "up" }); expect(game.move("up")).toBe(true); if (fights) { expect(untilBattle(game)).toContain("Hold it."); expect(game.battle).toBeDefined(); } else { settle(game); expect(game.battle).toBeUndefined(); expect({ x: game.x, y: game.y }).toEqual({ x: 16, y: 4 }); } }); }); });