import { beforeEach, describe, expect, it, onTestFailed } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { C } from "../src/game/battle/constants"; import { Playthrough } from "./playthrough-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const NEW_BARK = "24:4", ELMS_LAB = "24:5", ROUTE_29 = "24:3", CHERRYGROVE = "26:3", ROUTE_30 = "26:1", MR_POKEMONS_HOUSE = "26:10", CHERRYGROVE_CENTER = "26:5", ROUTE_31 = "26:2", ROUTE_31_GATE = "26:11", VIOLET = "10:5", VIOLET_CENTER = "10:10", VIOLET_GYM = "10:7", SPROUT_1F = "3:1", SPROUT_2F = "3:2", SPROUT_3F = "3:3", KYLES_HOUSE = "10:11"; const DARK_CAVE = "3:78", EARLS_ACADEMY = "10:8"; const ROUTE_32 = "10:1", ROUTE_32_CENTER = "10:13", UNION_CAVE_1F = "3:37", ROUTE_33 = "8:6", AZALEA = "8:7", AZALEA_CENTER = "8:1", KURTS_HOUSE = "8:4", AZALEA_GYM = "8:5", SLOWPOKE_WELL_B1F = "3:40", ILEX_GATE = "11:22", ILEX_FOREST = "3:52", ROUTE_34_GATE = "11:23", ROUTE_34 = "11:1", GOLDENROD = "11:2", GOLDENROD_CENTER = "11:20", GOLDENROD_GYM = "11:3", BIKE_SHOP = "11:4", RADIO_TOWER_1F = "3:17"; const MIRACLE_SEED = 0x75, LURE_BALL = 0xa0, HM01 = 0xf3, CUT = 15, BICYCLE = 7; const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b, EVENT_BEAT_BUGSY = 0x4be, EVENT_RIVAL_AZALEA_TOWN = 0x6bf, EVENT_HERDED_FARFETCHD = 0x29, ENGINE_HIVEBADGE = 0x1c, ENGINE_PLAINBADGE = 0x1d, ENGINE_RADIO_CARD = 0, EVENT_BEAT_WHITNEY = 0x4bf; const PHONE_BUG_CATCHER_WADE = 16; const FLASH = 148, HM05 = 0xf7; const TOGEPI = 175, BELLSPROUT = 69, ONIX = 95; const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40, EVENT_GOT_HM05_FLASH = 0x14, EVENT_BEAT_FALKNER = 0x4bd, EVENT_GOT_TM31_MUD_SLAP = 0x8, EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d, ENGINE_ZEPHYRBADGE = 0x1b; /** The acceptance run for PLAN section 2: a new game played from the * introduction to the catching tutorial through the world's own inputs, * without fixtures or story flags set by hand. Every stage ends with a * save serialized to JSON and loaded into a fresh world. The stages share * one world and run in order, so run the file rather than a single stage. */ describe.skipIf(!existsSync(romPath))( "a continuous opening playthrough", () => { const assets = existsSync(romPath) ? new CrystalAssets(new Uint8Array(readFileSync(romPath))) : undefined; const p = new Playthrough(assets!); const { play, walk, cross, enter, exit, reload, lead, writeTestSave, heal, train, stairs, route, toChoice, choose, choosePack, talkTo, } = p; /** From Route 31: through the gate to Violet's Pokémon Center and back. */ const restInViolet = () => { exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); heal(VIOLET_CENTER, VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); }; beforeEach(() => { p.log.length = 0; }); const traceFailures = () => onTestFailed(() => console.log(p.trace())); it("plays the introduction and Mom's scene", () => { p.start(); traceFailures(); p.world.beginIntro(); play([0, "", 1, "", 1, 0, "KAWA"]); expect(p.world.intro).toBe(false); expect(p.world.name).toBe("KAWA"); reload(); walk(7, 0); expect(p.world.mapId).toBe("24:6"); walk(9, 3); expect(p.world.move("down")).toBe(true); play([1, 1, 1, 1, 1]); expect(p.log).toContain("KAWA received\nPOKéGEAR."); reload(); }); it("meets Elm, takes Cyndaquil and the aide's Potion", () => { traceFailures(); walk(6, 7); expect(p.world.move("down")).toBe(true); play(); expect(p.world.mapId).toBe(NEW_BARK); enter(ELMS_LAB, "up"); play([0, 1]); walk(6, 4); p.world.move("up"); p.world.interact(); play([1, 1, ""]); expect(p.world.state.party.map((mon) => mon.species)).toEqual([155]); walk(4, 7); expect(p.world.move("down")).toBe(true); play(); expect(p.world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]); reload(); }); it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => { traceFailures(); exit(NEW_BARK); cross("left", ROUTE_29); cross("left", CHERRYGROVE); reload(); cross("up", ROUTE_30); enter(MR_POKEMONS_HOUSE, "up"); play(); expect(p.world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG expect(p.world.state.flags.has(11)).toBe(true); // the Pokédex reload(); }); it("takes Elm's call outside and meets the rival in Cherrygrove", () => { traceFailures(); exit(ROUTE_30); expect(p.log.join("\n")).toContain("It's a disaster!"); expect(p.world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON reload(); cross("down", CHERRYGROVE); // The rival's scene blocks the way east to Route 29. cross("right", ROUTE_29); const rival = p.log.join("\n"); expect(rival).toContain("RIVAL ?"); expect(rival).toContain("My name's ???."); expect(p.world.state.scenes[CHERRYGROVE]).toBe(0); reload(); }); it("names the rival for the officer and returns the Egg to Elm", () => { traceFailures(); cross("right", NEW_BARK); enter(ELMS_LAB, "up"); walk(4, 6); expect(p.world.move("up")).toBe(true); play(["SILVER"]); expect(p.world.state.rivalName).toBe("SILVER"); reload(); walk(5, 3); p.world.move("up"); // Elm stands at 5,2: this only turns the player. p.world.interact(); play(); expect(p.world.state.bag.keyItems).toEqual([]); walk(4, 8); expect(p.world.state.bag.balls).toEqual([ { item: C.POKE_BALL, quantity: 5 }, ]); reload(); }); it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => { traceFailures(); exit(NEW_BARK); cross("left", ROUTE_29); cross("left", CHERRYGROVE); const pages = p.log.join("\n").replace(/\n/g, " "); expect(pages).toContain("show you how to catch POKéMON?"); expect(pages).toContain("That's how you do it"); expect(p.world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON expect(p.world.state.scenes[ROUTE_29]).toBe(0); reload(); const save = p.world.save(); expect(save.party[0].species).toBe(155); expect(save.pokedex.caught).toContain(155); expect(save.rivalName).toBe("SILVER"); expect(save.intro).toBe(false); }); it("trains and walks Route 30 and Route 31 to Violet City", () => { traceFailures(); const restInCherrygrove = () => { cross("left", CHERRYGROVE); heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("right", ROUTE_29); }; heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("right", ROUTE_29); train(10, restInCherrygrove); cross("left", CHERRYGROVE); heal(CHERRYGROVE_CENTER, CHERRYGROVE); cross("up", ROUTE_30); cross("up", ROUTE_31); exit(ROUTE_31_GATE, "left"); // Mom worries when the player has not come home since the Egg; the // call rings on a step after Route 31's callback queued it. expect(p.world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), p.log.join("|")).toBe(true); exit(VIOLET, "left"); expect(p.world.state.flags.size).toBeGreaterThan(0); reload(); writeTestSave("violet-city"); }, 120_000); it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => { traceFailures(); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); train(14, restInViolet); restInViolet(); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(SPROUT_1F, "up"); expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true); walk(10, 3); p.world.move("up"); // Elder Li: this only turns the player p.world.interact(); play(); const pages = p.log.join("\n").replace(/\n/g, " "); expect(pages).toContain("FLASH"); expect(p.world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true); reload(); }, 120_000); it("beats Falkner for the ZEPHYRBADGE and TM31, then hears from Elm's aide", () => { traceFailures(); expect(route(SPROUT_1F, [SPROUT_1F, SPROUT_2F, SPROUT_3F], 6, { x: 9, y: 15 })).toBe(true); exit(VIOLET); heal(VIOLET_CENTER, VIOLET); enter(VIOLET_GYM, "up"); walk(5, 2); p.world.move("up"); // Falkner: this only turns the player p.world.interact(); play(); expect(p.world.state.events.has(EVENT_BEAT_FALKNER), p.log.join("|")).toBe(true); expect(p.world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true); expect(p.world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true); expect(p.world.state.bag.tmsHms[30]).toBe(1); // TM31 reload(); exit(VIOLET); // SPECIALCALL_ASSISTANT rings on a step: the aide waits in the // Pokémon Center with the Egg. enter(VIOLET_CENTER, "up"); expect(p.log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER"); // The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER). const aide = p.world.assets.map(VIOLET_CENTER).objects[4]; expect(p.world.state.events.has(aide.eventFlag)).toBe(false); reload(); }, 120_000); it("takes the Egg from Elm's aide", () => { traceFailures(); walk(4, 4); p.world.move("up"); // the aide: this only turns the player p.world.interact(); play([1]); expect(p.world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true); const egg = p.world.state.party.at(-1)!; expect(egg).toMatchObject({ species: TOGEPI, egg: true, nickname: "EGG", hp: 0, happiness: p.world.assets.pokemon.baseData(TOGEPI).eggCycles, }); // An Egg is neither seen nor caught. expect(p.world.state.pokedex.caught.has(TOGEPI)).toBe(false); reload(); }); it("catches a Bellsprout on Route 31 and trades it to Kyle for Onix", () => { traceFailures(); exit(VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); p.wanted = BELLSPROUT; try { for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++) { if (!p.world.state.bag.balls.length) break; train(lead().level + 1, restInViolet); } } finally { p.wanted = 0; } const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT); expect(bellsprout, p.log.slice(-20).join("|")).toBeGreaterThan(0); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(KYLES_HOUSE, "up"); const kyle = p.world.objects().find((object) => object.sprite === p.world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === p.world.assets.map(KYLES_HOUSE).objects[1].id)!; walk(kyle.x - 1, kyle.y); p.world.move("right"); p.world.interact(); play([1, bellsprout]); const onix = p.world.state.party.at(-1)!; expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 }); expect(p.world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false); expect(p.world.state.tradeFlags).toBe(1 << 1); expect(p.world.state.pokedex.caught.has(ONIX)).toBe(true); reload(); }, 120_000); it("teaches HM05 to a new Bellsprout and lights up Dark Cave with FLASH", () => { traceFailures(); exit(VIOLET); exit(ROUTE_31_GATE, "right"); exit(ROUTE_31, "right"); p.wanted = BELLSPROUT; try { for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++) train(lead().level + 1, restInViolet); } finally { p.wanted = 0; } const slot = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT); expect(slot).toBeGreaterThan(0); const nickname = p.world.state.party[slot].nickname; // PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout. expect(p.world.openStartMenu()).toBe(true); choose("PACK"); choosePack(HM05); choose("USE"); expect(toChoice().kind).toBe("yesno"); p.world.choose(1); const party = toChoice(); expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE"); p.world.choose(slot); if (p.world.state.party[slot].moves.every(Boolean)) { // A full move list asks which move to forget: the first one. expect(toChoice().kind).toBe("yesno"); p.world.choose(1); toChoice(); p.world.choose(0); } for (let i = 0; i < 10 && p.world.dialogue?.choice?.kind !== "pack"; i++) toChoice(); expect(p.log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!"); // The menus work on a draft that is kept once they close. for (let i = 0; i < 10 && p.world.busy; i++) { if (p.world.dialogue?.choice) p.world.advanceDialogue(); else p.world.tick(); } play(); expect(p.world.state.party[slot].moves).toContain(FLASH); expect(p.world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept // Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows. enter(DARK_CAVE, "up"); expect(p.world.state.flash).toBe(false); expect(p.world.openStartMenu()).toBe(true); choose("POKéMON"); choose(nickname); choose("FLASH"); play(); expect(p.world.state.flash, p.log.slice(-6).join("|")).toBe(true); reload(); }, 120_000); it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => { traceFailures(); exit(ROUTE_31); talkTo(p.world.map.objects[2].id); play([1]); const pages = p.log.join("\n").replace(/\n/g, " "); expect(pages).toContain("BUG CATCHER WADE"); expect(p.world.state.phone).toContain(PHONE_BUG_CATCHER_WADE); reload(); restInViolet(); exit(ROUTE_31_GATE, "left"); exit(VIOLET, "left"); enter(EARLS_ACADEMY, "up"); walk(3, 1); p.world.move("up"); p.world.interact(); choose("PSN"); toChoice(); expect(p.log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP"); // B leaves the blackboard. p.world.choose(-1); play(); expect(p.world.busy).toBe(false); reload(); }, 120_000); it("takes Route 32 and Union Cave to Azalea Town", () => { traceFailures(); exit(VIOLET); cross("down", ROUTE_32); // The man at Route 32's north end gives the Miracle Seed after the // ZEPHYRBADGE and the Egg. expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(false); p.talkTo(p.world.map.objects[7].id); play(); expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(true); stairs(UNION_CAVE_1F); stairs(ROUTE_33); cross("left", AZALEA); reload(); }, 240_000); it("follows Kurt into the Slowpoke Well and drives out Team Rocket", () => { traceFailures(); enter(KURTS_HOUSE, "up"); talkTo(p.world.map.objects[0].id); play(); expect(p.log.join(" ")).toContain("Old KURT is on his\nway!"); exit(AZALEA); stairs(SLOWPOKE_WELL_B1F); // The three grunts, then the one by the boulder, whose defeat ends // the scene in Kurt's house. for (const index of [0, 2, 3, 1]) { const id = p.world.map.objects[index].id; if (!p.world.objects().some((object) => object.id === id)) continue; talkTo(id); play(); } expect(p.world.mapId).toBe(KURTS_HOUSE); expect(p.world.state.events.has(EVENT_CLEARED_SLOWPOKE_WELL)).toBe(true); talkTo(p.world.map.objects[0].id); play(); expect(p.world.state.bag.balls.some((slot) => slot.item === LURE_BALL)).toBe(true); reload(); }, 120_000); it("beats Bugsy for the HIVEBADGE and the rival on the way to Ilex Forest", () => { traceFailures(); exit(AZALEA); heal(AZALEA_CENTER, AZALEA); enter(AZALEA_GYM, "up"); talkTo(p.world.map.objects[0].id); play(); expect(p.world.state.events.has(EVENT_BEAT_BUGSY)).toBe(true); expect(p.world.state.flags.has(ENGINE_HIVEBADGE)).toBe(true); expect(p.world.state.bag.tmsHms[48]).toBe(1); // TM49 FURY CUTTER exit(AZALEA); heal(AZALEA_CENTER, AZALEA); // The rival waits at the town's west end (SCENE_AZALEATOWN_RIVAL_BATTLE). stairs(ILEX_GATE); expect(p.world.state.events.has(EVENT_RIVAL_AZALEA_TOWN)).toBe(true); reload(); }, 120_000); it("herds the Farfetch'd back in Ilex Forest and cuts through to Route 34", () => { traceFailures(); stairs(ILEX_FOREST); const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const; const bird = p.world.map.objects[0].id; // wFarfetchdPosition 1 → 2 from any side, 2 → 8 and 8 → 9 facing // down, then 9 → 10 facing left. for (const face of ["up", "down", "down", "left"] as const) { const at = p.world.objects().find((object) => object.id === bird)!; walk(at.x - STEP[face][0], at.y - STEP[face][1]); p.world.move(face); p.world.interact(); play(); } expect(p.world.state.memory.wFarfetchdPosition).toBe(10); expect(p.world.state.events.has(EVENT_HERDED_FARFETCHD)).toBe(true); talkTo(p.world.map.objects[2].id); // the charcoal master play(); expect(p.world.state.bag.tmsHms[50]).toBe(1); // HM01 reload(); const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT); p.teach(HM01, bellsprout); expect(p.world.state.party[bellsprout].moves).toContain(CUT); p.cut(8, 25); expect(p.log).toContain("BELLSPROUT used\nCUT!"); stairs(ROUTE_34_GATE); reload(); }, 120_000); it("reaches Goldenrod City and gets a Bicycle", () => { traceFailures(); stairs(ROUTE_34); cross("up", GOLDENROD); heal(GOLDENROD_CENTER, GOLDENROD); enter(BIKE_SHOP, "up"); talkTo(p.world.map.objects[0].id); // the clerk, across the counter play([1]); expect(p.world.state.bag.keyItems).toContain(BICYCLE); exit(GOLDENROD); reload(); }, 120_000); it("trains on Route 34 and beats Whitney for the PLAINBADGE", () => { traceFailures(); const restInGoldenrod = () => { cross("up", GOLDENROD); heal(GOLDENROD_CENTER, GOLDENROD); cross("down", ROUTE_34); }; cross("down", ROUTE_34); train(30, restInGoldenrod); restInGoldenrod(); cross("up", GOLDENROD); enter(GOLDENROD_GYM, "up"); const whitney = p.world.map.objects[0].id; talkTo(whitney); play(); expect(p.world.state.events.has(EVENT_BEAT_WHITNEY)).toBe(true); // She cries; Bridget explains on the way out, then the badge. exit(GOLDENROD); expect(p.log.join(" ")).toContain("WHITNEY cry."); enter(GOLDENROD_GYM, "up"); talkTo(whitney); play(); expect(p.world.state.flags.has(ENGINE_PLAINBADGE)).toBe(true); expect(p.world.state.bag.tmsHms[44]).toBe(1); // TM45 ATTRACT exit(GOLDENROD); reload(); }, 240_000); it("answers the Radio Tower quiz for the radio card", () => { traceFailures(); enter(RADIO_TOWER_1F, "up"); talkTo(p.world.map.objects[5].id); play([1, 1, 1, 0, 1, 0]); expect(p.world.state.flags.has(ENGINE_RADIO_CARD)).toBe(true); exit(GOLDENROD); reload(); }, 120_000); }, );