Files
Crystal/tests/playthrough.test.ts
kawaandClaude Opus 5.5 d3be732118
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m40s
Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The
Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer
House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's
postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet
Train and the Diploma are compared with the cartridge frame by frame.

The playthrough continues from the Hall of Fame through the S.S. Aqua,
the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and
credits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:53:57 +02:00

2130 lines
79 KiB
TypeScript

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 { tmhmItem } from "../src/game/rom/pokemon";
import { Playthrough } from "./playthrough-driver";
import { findPath } from "./world-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",
FLOWER_SHOP = "11:8",
RADIO_TOWER_1F = "3:17";
const ROUTE_35 = "10:2",
ROUTE_35_NATIONAL_PARK_GATE = "10:15",
NATIONAL_PARK = "3:15",
ROUTE_36_NATIONAL_PARK_GATE = "10:17",
ROUTE_35_GATE = "10:14",
ROUTE_36 = "10:3",
ROUTE_37 = "10:4",
ECRUTEAK = "4:9",
ECRUTEAK_CENTER = "4:3",
ECRUTEAK_MART = "4:6",
ECRUTEAK_GYM = "4:7",
DANCE_THEATER = "4:5",
BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
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;
const SQUIRTBOTTLE = 0xaf;
const EVENT_MET_FLORIA = 0xb9,
EVENT_GOT_SQUIRTBOTTLE = 0x5c,
EVENT_FOUGHT_SUDOWOODO = 0x2a,
EVENT_GOT_HM03_SURF = 0x12,
EVENT_RIVAL_BURNED_TOWER = 0x6c5,
EVENT_HOLE_IN_BURNED_TOWER = 0x332;
const EVENT_RELEASED_THE_BEASTS = 0x7b,
EVENT_BURNED_TOWER_MORTY = 0x764,
EVENT_ECRUTEAK_GYM_GRAMPS = 0x7a8,
EVENT_ECRUTEAK_CITY_GRAMPS = 0x7a9,
EVENT_BEAT_SAGE_JEFFREY = 0x415,
EVENT_BEAT_SAGE_PING = 0x416,
EVENT_BEAT_MEDIUM_MARTHA = 0x58b,
EVENT_BEAT_MEDIUM_GRACE = 0x58c,
EVENT_BEAT_MORTY = 0x4c0,
EVENT_GOT_TM30_SHADOW_BALL = 0xc,
ENGINE_FOGBADGE = 30,
RAIKOU = 0xf3,
ENTEI = 0xf4,
ROUTE_42 = "2:5";
const ROUTE_38_GATE = "1:9",
ROUTE_38 = "1:12",
ROUTE_39 = "1:13",
OLIVINE = "1:14",
OLIVINE_CENTER = "1:1",
OLIVINE_GYM = "1:2",
OLIVINE_CAFE = "1:7",
GOOD_ROD_HOUSE = "1:6",
OLIVINE_MART = "1:8",
LIGHTHOUSE = ["3:42", "3:43", "3:44", "3:45", "3:46", "3:47"],
ROUTE_40 = "22:1",
ROUTE_41 = "22:2",
CIANWOOD = "22:3",
CIANWOOD_GYM = "22:5",
CIANWOOD_CENTER = "22:6",
CIANWOOD_PHARMACY = "22:7";
const GOOD_ROD = 0x3b,
SECRETPOTION = 0x43,
HM02 = 0xf4,
HM03 = 0xf5,
HM04 = 0xf6,
KRABBY = 98,
SURF = 0x39,
STRENGTH = 0x46,
EVENT_GOT_HM04_STRENGTH = 0x13,
EVENT_GOT_GOOD_ROD = 0x18,
EVENT_RIVAL_OLIVINE_CITY = 0x6c3,
EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 0x37,
EVENT_FOUGHT_EUSINE = 0x333,
EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 0x23,
EVENT_BEAT_CHUCK = 0x4c2,
EVENT_GOT_TM01_DYNAMICPUNCH = 0xa,
EVENT_GOT_HM02_FLY = 0x11,
EVENT_JASMINE_RETURNED_TO_GYM = 0x20,
EVENT_BEAT_JASMINE = 0x4c1,
EVENT_GOT_TM23_IRON_TAIL = 0xd,
ENGINE_MINERALBADGE = 31,
ENGINE_STORMBADGE = 32;
const ROUTE_42_GATE = "2:4",
MAHOGANY = "2:7",
MAHOGANY_CENTER = "2:3",
MAHOGANY_GYM = "2:2",
MAHOGANY_SHOP = "3:48",
ROCKET_HIDEOUT = ["3:49", "3:50", "3:51"],
ROUTE_43_MAHOGANY_GATE = "9:3",
ROUTE_43_GATE = "9:4",
ROUTE_43 = "9:5",
LAKE_OF_RAGE = "9:6";
const RED_SCALE = 0x42,
EVENT_BEAT_POKEMANIAC_SHANE = 0x4e7,
EVENT_LAKE_OF_RAGE_RED_GYARADOS = 0x751,
EVENT_DECIDED_TO_HELP_LANCE = 0x60,
EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 0x2e2,
EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 0x4c,
EVENT_LEARNED_SLOWPOKETAIL = 0x301,
EVENT_LEARNED_RATICATE_TAIL = 0x302,
EVENT_LEARNED_HAIL_GIOVANNI = 0x2ff,
EVENT_RIVAL_TEAM_ROCKET_BASE = 0x6c0,
EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 0x574,
EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 0x570,
EVENT_CLEARED_ROCKET_HIDEOUT = 0x22,
EVENT_GOT_HM06_WHIRLPOOL = 0x15,
EVENT_BEAT_PRYCE = 0x4c3,
EVENT_GOT_TM16_ICY_WIND = 0xe,
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
ENGINE_GLACIERBADGE = 0x21,
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
const RADIO_TOWER = ["3:17", "3:18", "3:19", "3:20", "3:21"],
UNDERGROUND = "3:53",
SWITCH_ROOM = "3:54",
DEPT_STORE_B1F = "3:55",
WAREHOUSE = "3:56",
DEPT_STORE_1F = "11:11",
ELEVATOR = "11:17",
ROUTE_44 = "2:6",
ICE_PATH = ["3:61", "3:62", "3:63", "3:64", "3:65"],
BLACKTHORN = "5:10",
BLACKTHORN_CENTER = "5:6",
BLACKTHORN_MART = "5:5",
BLACKTHORN_GYM = ["5:1", "5:2"],
ROUTE_45 = "5:8",
DRAGONS_DEN = ["3:80", "3:81"],
DRAGON_SHRINE = "3:82";
const BASEMENT_KEY = 0x85,
CARD_KEY = 0x7f,
CLEAR_BELL = 0x46,
HM06 = 0xf8,
DRATINI = 147,
EXTREMESPEED = 245,
EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 0x573,
EVENT_USED_BASEMENT_KEY = 0x49,
EVENT_RIVAL_GOLDENROD_UNDERGROUND = 0x6c1,
EVENT_RECEIVED_CARD_KEY = 0x4a,
EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 0x25,
EVENT_CLEARED_RADIO_TOWER = 0x21,
EVENT_GOT_CLEAR_BELL = 0x78,
EVENT_BOULDER_IN_ICE_PATH_2A = 0x70e,
EVENT_BEAT_CLAIR = 0x4c4,
EVENT_GOT_TM24_DRAGONBREATH = 0xf,
EVENT_GOT_DRATINI = 0xbd,
EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1,
ENGINE_RISINGBADGE = 0x22;
const ROUTE_46 = "5:9",
ROUTE_29_ROUTE_46_GATE = "24:13",
TIN_TOWER_ENTRANCE = "4:1",
WISE_TRIOS_ROOM = "4:2",
TIN_TOWER_1F = "3:4",
EVENT_RANG_CLEAR_BELL_2 = 0x767,
EVENT_FOUGHT_SUICUNE = 0x335;
const WISE_TRIOS_ROOM_EXIT = "4:2",
MAHOGANY_ROUTE_44 = "2:6",
ROUTE_27 = "24:2",
TOHJO_FALLS = "3:83",
ROUTE_26 = "24:1",
VICTORY_ROAD_GATE = "23:13",
VICTORY_ROAD = "3:91",
ROUTE_23 = "16:1",
INDIGO_PLATEAU = "16:2",
ELITE_FOUR = ["16:3", "16:4", "16:5", "16:6"],
LANCES_ROOM = "16:7";
const HM07 = 0xf9,
WATERFALL = 127,
IRON_TAIL = 231,
TYPHLOSION = 157,
EVENT_GOT_HM07_WATERFALL = 0x688,
EVENT_RIVAL_VICTORY_ROAD = 0x6c2,
EVENT_BEAT_ELITE_4_WILL = 0x5b8,
EVENT_BEAT_ELITE_4_KOGA = 0x5b9,
EVENT_BEAT_ELITE_4_BRUNO = 0x5ba,
EVENT_BEAT_ELITE_4_KAREN = 0x5bb,
EVENT_BEAT_CHAMPION_LANCE = 0x5bc,
EVENT_BEAT_ELITE_FOUR = 0x44;
const FAST_SHIP_1F = "15:3",
FAST_SHIP_CABINS_NNW = "15:4",
FAST_SHIP_CABINS_SE = "15:6",
FAST_SHIP_B1F = "15:7",
OLIVINE_PORT = "15:1",
OLIVINE_PORT_PASSAGE = "15:8",
VERMILION_PORT_PASSAGE = "15:9",
VERMILION = "12:3",
VERMILION_CENTER = "12:5",
VERMILION_GYM = "12:11",
ROUTE_6 = "12:1",
ROUTE_6_SAFFRON_GATE = "12:12",
SAFFRON = "25:2",
ROUTE_5 = "25:1",
ROUTE_5_SAFFRON_GATE = "25:14",
SAFFRON_GYM = "25:4",
SAFFRON_MART = "25:5",
SAFFRON_CENTER = "25:6",
CERULEAN = "7:17",
CERULEAN_CENTER = "7:4",
CERULEAN_GYM = "7:6",
POWER_PLANT = "7:10",
ROUTE_9 = "7:13",
ROUTE_10_NORTH = "7:14",
ROUTE_24 = "7:15",
ROUTE_25 = "7:16",
ROCK_TUNNEL = ["3:87", "3:88"],
ROUTE_10_SOUTH = "18:3",
LAVENDER = "18:4",
LAVENDER_CENTER = "18:5",
LAVENDER_RADIO_TOWER = "18:12",
ROUTE_8 = "18:1",
ROUTE_8_SAFFRON_GATE = "18:13",
DIGLETTS_CAVE = "3:84",
ROUTE_2 = "23:1",
VIRIDIAN = "23:3",
ROUTE_22 = "23:2",
VIRIDIAN_GYM = "23:4",
VIRIDIAN_CENTER = "23:9",
PEWTER = "14:2",
PEWTER_GYM = "14:4",
PEWTER_CENTER = "14:6",
ROUTE_1 = "13:1",
PALLET = "13:2",
OAKS_LAB = "13:6",
CINNABAR_CENTER = "6:1",
ROUTE_19_FUCHSIA_GATE = "6:3",
SEAFOAM_GYM = "6:4",
ROUTE_19 = "6:5",
ROUTE_20 = "6:6",
ROUTE_21 = "6:7",
CINNABAR = "6:8",
FUCHSIA_GYM = "17:8",
ROUTE_18 = "17:4",
FUCHSIA = "17:5",
FUCHSIA_CENTER = "17:10",
ROUTE_7 = "21:1",
ROUTE_17 = "21:3",
CELADON = "21:4",
CELADON_CENTER = "21:17",
CELADON_GYM = "21:21",
ROUTE_7_SAFFRON_GATE = "21:25",
ROUTE_17_ROUTE_18_GATE = "21:26",
ROUTE_28 = "19:1",
SILVER_CAVE_OUTSIDE = "19:2",
SILVER_CAVE_CENTER = "19:3",
SILVER_CAVE = ["3:74", "3:75"],
SILVER_CAVE_ROOM_3 = "3:76";
const S_S_TICKET = 0x44,
MACHINE_PART = 0x80,
ENGINE_EXPN_CARD = 3,
ENGINE_BOULDERBADGE = 0x23,
ENGINE_CASCADEBADGE = 0x24,
ENGINE_THUNDERBADGE = 0x25,
ENGINE_RAINBOWBADGE = 0x26,
ENGINE_SOULBADGE = 0x27,
ENGINE_MARSHBADGE = 0x28,
ENGINE_VOLCANOBADGE = 0x29,
ENGINE_EARTHBADGE = 0x2a,
EARTHQUAKE = 89,
EVENT_GOT_SS_TICKET_FROM_ELM = 0x24,
EVENT_FAST_SHIP_FIRST_TIME = 0x30,
EVENT_RETURNED_MACHINE_PART = 0xc9,
EVENT_MET_MANAGER_AT_POWER_PLANT = 0xca,
EVENT_RESTORED_POWER_TO_KANTO = 0xcd,
EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 0xfb,
EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1,
EVENT_BLUE_IN_CINNABAR = 0x775,
EVENT_BEAT_BLUE = 0x4cc,
EVENT_OPENED_MT_SILVER = 0x74f,
EVENT_FOUGHT_SNORLAX = 0x750,
EVENT_RED_IN_MT_SILVER = 0x762;
/** 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,
travel,
sail,
toChoice,
choose,
choosePack,
talkTo,
buy,
} = 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");
};
/** Route 35 and Route 36 meet only through the National Park and
* its two gates (Route 36's Sudowoodo still blocks the way from
* Violet City). */
const toRoute36 = () => {
stairs(ROUTE_35_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(ROUTE_36);
};
const toGoldenrod = () => {
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_GATE);
stairs(GOLDENROD);
};
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");
// PlaceEnemysName: a rival is wRivalName alone, "???" until named.
expect(rival).toContain("???\nwants to battle!");
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();
writeTestSave("route-34");
}, 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();
writeTestSave("goldenrod-city");
}, 120_000);
it("meets Floria on Route 36 and gets the SquirtBottle from the flower shop", () => {
traceFailures();
toRoute36();
// ROUTE36_FLORIA is the eighth object in Route36_MapEvents.
talkTo(p.world.map.objects[7].id);
play();
expect(p.world.state.events.has(EVENT_MET_FLORIA)).toBe(true);
toGoldenrod();
enter(FLOWER_SHOP, "up");
// Floria walked here from Route 36; talk to her first
// (EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP), then the Teacher gives
// the SquirtBottle since the PLAINBADGE is already held.
talkTo(p.world.map.objects[1].id);
play();
talkTo(p.world.map.objects[0].id);
play([1]);
// The Squirtbottle is a key item (ItemAttributes' pocket byte).
expect(p.world.state.bag.keyItems).toContain(SQUIRTBOTTLE);
expect(p.world.state.events.has(EVENT_GOT_SQUIRTBOTTLE)).toBe(true);
exit(GOLDENROD);
reload();
}, 240_000);
it("waters the weird tree on Route 36 and battles Sudowoodo, then crosses Route 37 to Ecruteak City", () => {
traceFailures();
toRoute36();
const tree = p.world.map.objects[2].id; // ROUTE36_WEIRD_TREE
talkTo(tree);
play([1]);
expect(p.world.state.events.has(EVENT_FOUGHT_SUDOWOODO)).toBe(true);
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
expect(p.world.mapId).toBe(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
reload();
}, 240_000);
it("beats all five Kimono Girls for HM03 Surf from the Dance Theater's gentleman", () => {
traceFailures();
enter(DANCE_THEATER, "up");
// The five Kimono Girls are objects 2 through 6 (file order); the
// gentleman who gives Surf is object 7. An already-beaten Kimono
// Girl's trainer header just shows her after-text, so healing
// between fights (five single-Pokémon trainers in a row otherwise
// wear the lead down) is safe to repeat.
for (let id = 2; id <= 6; id++) {
if (lead().hp < lead().stats[0]) {
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(DANCE_THEATER, "up");
}
talkTo(id);
play();
}
talkTo(7);
play([1]);
expect(p.world.state.bag.tmsHms[52]).toBe(1); // HM03 (50 TMs + HM03 - 1)
expect(p.world.state.events.has(EVENT_GOT_HM03_SURF)).toBe(true);
exit(ECRUTEAK);
reload();
}, 240_000);
it("meets Eusine and the rival at the Burned Tower, falling to the basement", () => {
traceFailures();
// Stock up on Super Potions first: the rival's Croconaw hits the
// Fire-type lead for super-effective damage on top of Haunter's
// Curse, so a careful player restocks before a hard fight rather
// than going in on whatever is left from Route 36.
enter(ECRUTEAK_MART, "up");
const money = p.world.state.money;
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
expect(p.world.state.money).toBe(money - 700 * 10);
expect(p.world.state.bag.items).toContainEqual({ item: C.SUPER_POTION, quantity: 10 });
exit(ECRUTEAK);
enter(BURNED_TOWER_1F, "up");
// BurnedTower1FMeetEusineScene runs automatically on the first idle
// tick (sdefer), as Ecruteak's Pokémon Center's Bill scene does.
expect(p.log.join(" ")).toContain("EUSINE: My name's");
// The only way to any tile by the rival crosses the coordinate event
// at (11, 9); walking there triggers BurnedTowerRivalBattleScript,
// which ends with the player falling to the basement, so the usual
// walk() helper (which expects to stay on this map unless stepping
// onto a listed warp) does not fit here.
// A loss whites out (healed) to Ecruteak's spawn point outside the
// Pokémon Center with the scene still set, so the player tries again.
for (let attempt = 0; attempt < 3; attempt++) {
for (const direction of findPath(p.world, 11, 9)) {
if (!p.world.move(direction)) break;
play();
if (p.world.mapId !== BURNED_TOWER_1F) break;
}
if (p.world.mapId === BURNED_TOWER_B1F) break;
expect(p.world.mapId, "whited out to Ecruteak").toBe(ECRUTEAK);
enter(BURNED_TOWER_1F, "up");
}
expect(p.world.state.events.has(EVENT_RIVAL_BURNED_TOWER)).toBe(true);
expect(p.world.state.events.has(EVENT_HOLE_IN_BURNED_TOWER)).toBe(true);
expect(p.world.mapId).toBe(BURNED_TOWER_B1F);
reload();
}, 240_000);
it("releases the beasts, says goodbye to Eusine and climbs back to Ecruteak", () => {
traceFailures();
// The coordinate event at (10, 6) runs ReleaseTheBeasts: the three
// beasts flee one by one through InitRoamMons, which starts Raikou
// and Entei roaming (Suicune stays put until Cianwood).
walk(10, 6);
play();
expect(p.world.state.events.has(EVENT_RELEASED_THE_BEASTS)).toBe(true);
expect(p.world.state.events.has(EVENT_BURNED_TOWER_MORTY)).toBe(true);
expect(p.world.state.events.has(EVENT_ECRUTEAK_GYM_GRAMPS)).toBe(true);
expect(p.world.state.events.has(EVENT_ECRUTEAK_CITY_GRAMPS)).toBe(false);
expect(p.world.state.roamMons[0]).toMatchObject({ species: RAIKOU, mapId: ROUTE_42 });
expect(p.world.state.roamMons[1]).toMatchObject({ species: ENTEI, mapId: ROUTE_37 });
// Eusine appears once the beasts are gone (BurnedTowerB1F_EUSINE's
// own flag, set by `appear` rather than a scene variable) and says
// goodbye before leaving through the tower's own exit sound.
talkTo(p.world.map.objects[8].id);
play();
expect(p.log.join(" ")).toContain("I'm all choked up!");
// Multiple small stairs lead back to 1F now that the ladder at (6,
// 14) is in place (BurnedTowerB1FLadderCallback); any of them works.
stairs(BURNED_TOWER_1F);
exit(ECRUTEAK);
reload();
}, 120_000);
it("beats Ecruteak Gym's trainers and Morty for the FOGBADGE and TM30", () => {
traceFailures();
// Morty's own Gengar leans on Hypnosis and Dream Eater; a careful
// player also stocks Awakenings, not just Super Potions, before a
// Ghost-type gym.
enter(ECRUTEAK_MART, "up");
buy(p.world.map.objects[0].id, C.AWAKENING, 5);
expect(p.world.state.bag.items).toContainEqual({ item: C.AWAKENING, quantity: 5 });
exit(ECRUTEAK);
// EcruteakGymForcedToLeaveScene (the default scene) no longer plays:
// ReleaseTheBeasts already set SCENE_ECRUTEAKGYM_NOOP for this map.
enter(ECRUTEAK_GYM, "up");
// The trainers stand past Morty's own invisible-floor maze; findPath
// already treats every unlisted pit tile as impassable (the same
// warps the player would fall through), so it finds the real floor
// on its own, the way a player who knows the safe path would.
const [morty, jeffrey, ping, martha, grace] = p.world.map.objects.map(
(object) => object.id,
);
// Ghost types shrug off Normal moves entirely ("No normal-type
// attack can harm them!", straight from Sage Ping's own text), so
// EMBER is the lead's only way to damage any of them; a cautious
// player heals between fights (restoring its PP, not just HP) so
// a long multi-Pokémon trainer never finds EMBER run dry.
for (const trainer of [jeffrey, ping, martha, grace]) {
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(ECRUTEAK_GYM, "up");
talkTo(trainer);
play();
}
expect(p.world.state.events.has(EVENT_BEAT_SAGE_JEFFREY)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_SAGE_PING)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_MARTHA)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_GRACE)).toBe(true);
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(ECRUTEAK_GYM, "up");
talkTo(morty);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_MORTY), p.log.join("|")).toBe(true);
expect(p.world.state.flags.has(ENGINE_FOGBADGE)).toBe(true);
expect(p.log.join(" ")).toContain("FOGBADGE");
// MortyText_FogBadgeSpeech and the TM30 Shadow Ball gift follow in
// the same conversation (EVENT_GOT_TM30_SHADOW_BALL was not yet set).
expect(p.world.state.events.has(EVENT_GOT_TM30_SHADOW_BALL)).toBe(true);
expect(p.world.state.bag.tmsHms[29]).toBe(1); // TM30
expect(p.log.join(" ")).toContain("SHADOW BALL");
exit(ECRUTEAK);
reload();
writeTestSave("ecruteak-city");
}, 240_000);
it("crosses Routes 38 and 39 to Olivine City, where the rival is waiting", () => {
traceFailures();
// Ecruteak's west gate and the gate's own west side are edge warps.
exit(ROUTE_38_GATE, "left");
exit(ROUTE_38, "left");
cross("left", ROUTE_39);
cross("down", OLIVINE);
// OlivineCityRivalSceneTop/Bottom: the way from Route 39 to the
// Pokémon Center crosses one of the two coordinate events.
heal(OLIVINE_CENTER, OLIVINE);
expect(p.log.join(" ")).toContain("GYM LEADER isn't\nhere.");
expect(p.world.state.events.has(EVENT_RIVAL_OLIVINE_CITY)).toBe(true);
reload();
}, 240_000);
it("gets HM04 and the Good Rod, fishes up a Krabby and teaches it Surf", () => {
traceFailures();
enter(OLIVINE_CAFE, "up");
talkTo(p.world.map.objects[0].id); // OLIVINECAFE_SAILOR1
play();
expect(p.world.state.events.has(EVENT_GOT_HM04_STRENGTH)).toBe(true);
exit(OLIVINE);
enter(GOOD_ROD_HOUSE, "up");
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_GOT_GOOD_ROD)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(GOOD_ROD);
exit(OLIVINE);
// Olivine's Mart has Great Balls, not Poké Balls.
enter(OLIVINE_MART, "up");
buy(p.world.map.objects[0].id, C.GREAT_BALL, 5);
exit(OLIVINE);
// Typhlosion cannot learn Surf: fish a Krabby (FISHGROUP_SHORE's
// Good Rod table) from the nearest shore tile facing open water.
const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const;
const map = p.world.map;
const shore = (Object.keys(STEP) as (keyof typeof STEP)[])
.flatMap((face) =>
Array.from({ length: map.width * map.height }, (_, i) => {
const x = i % map.width,
y = Math.floor(i / map.width);
const [dx, dy] = STEP[face];
if (p.world.assets.collision(map, x + dx, y + dy) !== 0x29) return [];
try {
return [{ x, y, face, length: findPath(p.world, x, y).length }];
} catch {
return [];
}
}).flat(),
)
.sort((a, b) => a.length - b.length)[0];
walk(shore.x, shore.y);
p.wanted = KRABBY;
for (let cast = 0; cast < 40; cast++) {
if (p.world.state.party.some((mon) => mon.species === KRABBY)) break;
p.world.move(shore.face);
expect(p.world.openStartMenu()).toBe(true);
choose("PACK");
choosePack(GOOD_ROD);
choose("USE");
play();
}
p.wanted = 0;
const krabby = p.world.state.party.findIndex((mon) => mon.species === KRABBY);
expect(krabby).toBeGreaterThan(0);
p.teach(HM03, krabby);
// Strength replaces Tackle (the first move) on the lead.
p.teach(HM04, 0);
expect(p.world.state.party[krabby].moves).toContain(SURF);
expect(lead().moves).toContain(STRENGTH);
reload();
}, 240_000);
it("climbs the Lighthouse to Jasmine and her sick Ampharos", () => {
traceFailures();
enter(LIGHTHOUSE[0], "up");
// Two stairways and the outside ledges lead up; Jasmine stands by
// Amphy in 6F's far corner.
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
talkTo(p.world.map.objects[0].id); // OLIVINELIGHTHOUSE6F_JASMINE
play();
expect(p.world.state.events.has(EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS)).toBe(true);
expect(p.log.join(" ")).toContain("wonderful PHARMACY\nin CIANWOOD");
reload();
}, 240_000);
it("drops back down the Lighthouse and surfs Routes 40 and 41 to Cianwood City", () => {
traceFailures();
// The holes by the windows drop to the floor below; some land in
// corners cut off from the next hole, so search the floors.
travel(OLIVINE, LIGHTHOUSE);
heal(OLIVINE_CENTER, OLIVINE);
cross("left", ROUTE_40);
cross("down", ROUTE_41, { water: true });
cross("left", CIANWOOD, { water: true });
expect(p.world.mapId).toBe(CIANWOOD);
reload();
}, 240_000);
it("meets Suicune and Eusine, gets the SecretPotion and beats Chuck for the STORMBADGE and HM02", () => {
traceFailures();
sail(23, 44); // ashore by the Pokémon Center
heal(CIANWOOD_CENTER, CIANWOOD);
// ReleaseTheBeasts armed SCENE_CIANWOODCITY_SUICUNE_AND_EUSINE; its
// coordinate event is on the way north.
walk(11, 16);
expect(p.world.state.events.has(EVENT_FOUGHT_EUSINE)).toBe(true);
expect(p.log.join(" ")).toContain("SUICUNE running on\nthe waves.");
heal(CIANWOOD_CENTER, CIANWOOD);
enter(CIANWOOD_PHARMACY, "up");
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.events.has(EVENT_GOT_SECRETPOTION_FROM_PHARMACY)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(SECRETPOTION);
// Afterwards the pharmacist runs MARTTYPE_PHARMACY.
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
exit(CIANWOOD);
// The four black belts stand on the way to the boulders.
enter(CIANWOOD_GYM, "up");
walk(3, 8);
exit(CIANWOOD);
// Chuck's Poliwrath can win with critical hits; a loss whites out to
// Cianwood's Pokémon Center and the boulders go back in place.
for (let attempt = 0; attempt < 3; attempt++) {
if (p.world.state.events.has(EVENT_BEAT_CHUCK)) break;
heal(CIANWOOD_CENTER, CIANWOOD);
enter(CIANWOOD_GYM, "up");
walk(3, 8);
// AskStrengthScript from the left boulder, then push it up, the
// right one up and the middle one left into the gap.
p.world.move("up");
p.world.interact();
play([1]);
p.world.move("up");
play();
walk(5, 8);
p.world.move("up");
play();
walk(5, 7);
p.world.move("left");
play();
walk(4, 2);
p.world.move("up");
p.world.interact();
play([1]);
}
expect(p.world.state.events.has(EVENT_BEAT_CHUCK)).toBe(true);
expect(p.world.state.flags.has(ENGINE_STORMBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_TM01_DYNAMICPUNCH)).toBe(true);
expect(p.world.state.bag.tmsHms[0]).toBe(1); // TM01
exit(CIANWOOD);
talkTo(p.world.map.objects[9].id); // CIANWOODCITY_POKEFAN_F
play();
expect(p.world.state.events.has(EVENT_GOT_HM02_FLY)).toBe(true);
expect(p.world.state.bag.tmsHms[51]).toBe(1); // HM02
reload();
writeTestSave("cianwood-city");
}, 240_000);
it("brings the SecretPotion to Amphy and beats Jasmine for the MINERALBADGE and TM23", () => {
traceFailures();
// Nobody in the party learns Fly; surf back.
cross("right", ROUTE_41, { water: true });
cross("up", ROUTE_40, { water: true });
cross("right", OLIVINE, { water: true });
heal(OLIVINE_CENTER, OLIVINE);
enter(LIGHTHOUSE[0], "up");
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_JASMINE_RETURNED_TO_GYM)).toBe(true);
expect(p.world.state.bag.keyItems).not.toContain(SECRETPOTION);
travel(OLIVINE, LIGHTHOUSE);
heal(OLIVINE_CENTER, OLIVINE);
enter(OLIVINE_GYM, "up");
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_JASMINE)).toBe(true);
expect(p.world.state.flags.has(ENGINE_MINERALBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_TM23_IRON_TAIL)).toBe(true);
expect(p.world.state.bag.tmsHms[22]).toBe(1); // TM23
exit(OLIVINE);
reload();
writeTestSave("olivine-city");
}, 240_000);
it("crosses Route 42 past Mt. Mortar to Mahogany Town", () => {
traceFailures();
cross("up", ROUTE_39);
cross("right", ROUTE_38);
stairs(ROUTE_38_GATE);
stairs(ECRUTEAK);
stairs(ROUTE_42_GATE);
stairs(ROUTE_42);
// Route 42's pond splits it around Mt. Mortar; Krabby surfs across.
cross("right", MAHOGANY, { water: true });
expect(p.world.state.events.has(EVENT_BEAT_POKEMANIAC_SHANE)).toBe(true);
reload();
}, 240_000);
it("pays the Rockets' toll on Route 43 and stops the Red Gyarados for Lance", () => {
traceFailures();
stairs(ROUTE_43_MAHOGANY_GATE);
stairs(ROUTE_43);
// Route43GateRocketShakedownScene: ¥1000 to go through.
const money = p.world.state.money;
stairs(ROUTE_43_GATE);
expect(p.log.join(" ")).toContain("The toll is ¥1000");
expect(p.world.state.money).toBe(money - 1000);
stairs(ROUTE_43);
cross("up", LAKE_OF_RAGE);
// The Gyarados waits in the middle of the lake (18, 22).
sail(18, 23);
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_LAKE_OF_RAGE_RED_GYARADOS)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(RED_SCALE);
// Lance appears on the shore (21, 28).
sail(21, 29);
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_DECIDED_TO_HELP_LANCE)).toBe(true);
expect(p.log.join(" ")).toContain("from MAHOGANY is\nthe cause.");
reload();
}, 240_000);
it("follows Lance into the Rocket Hideout and learns both passwords", () => {
traceFailures();
cross("down", ROUTE_43);
// Route 43 sets the gate's shakedown scene again on every visit.
stairs(ROUTE_43_GATE);
stairs(ROUTE_43);
stairs(ROUTE_43_MAHOGANY_GATE);
stairs(MAHOGANY);
heal(MAHOGANY_CENTER, MAHOGANY);
// MahoganyMart1FLanceUncoversStaircaseScript: Dragonite's Hyper Beam.
enter(MAHOGANY_SHOP, "up");
expect(p.world.state.events.has(EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART)).toBe(true);
// B1F's cameras and exploding traps lie on the way down.
stairs(ROCKET_HIDEOUT[0]);
stairs(ROCKET_HIDEOUT[1]);
walk(5, 14); // LanceHealsScript1
expect(p.world.state.events.has(EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE)).toBe(true);
// B3F: the grunts tell their passwords when talked to again.
stairs(ROCKET_HIDEOUT[2]);
expect(p.log.join(" ")).toContain("two passwords to");
const [, , , slowpokeTail, raticateTail] = p.world.map.objects.map((object) => object.id);
for (const grunt of [slowpokeTail, slowpokeTail, raticateTail, raticateTail]) {
talkTo(grunt);
play();
}
expect(p.world.state.events.has(EVENT_LEARNED_SLOWPOKETAIL)).toBe(true);
expect(p.world.state.events.has(EVENT_LEARNED_RATICATE_TAIL)).toBe(true);
// The boss's door is reached from B2F's other stairs; the rival
// waits on the way (RocketBaseRival).
expect(route(ROCKET_HIDEOUT[2], ROCKET_HIDEOUT, 6, { x: 10, y: 10 })).toBe(true);
walk(10, 10);
expect(p.world.state.events.has(EVENT_RIVAL_TEAM_ROCKET_BASE)).toBe(true);
p.world.move("up");
p.world.interact();
play();
walk(10, 8); // RocketBaseBossLeft
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_4)).toBe(true);
talkTo(p.world.map.objects[2].id); // the Murkrow
play();
expect(p.world.state.events.has(EVENT_LEARNED_HAIL_GIOVANNI)).toBe(true);
reload();
}, 240_000);
it("beats the executive with Lance and the Electrodes for HM06", () => {
traceFailures();
expect(route(ROCKET_HIDEOUT[1], ROCKET_HIDEOUT, 6, { x: 14, y: 13 })).toBe(true);
walk(14, 13);
p.world.move("up");
p.world.interact();
play();
// RocketBaseBossFRight's coordinate event is two steps in.
p.world.move("up");
play();
p.world.move("up");
play();
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEF_2)).toBe(true);
// Lance takes the right-hand Electrodes; the player the left three.
for (let round = 0; round < 6; round++) {
const left = p.world.map.objects
.slice(4, 7)
.map((object) => object.id)
.filter((id) => p.world.objects().some((object) => object.id === id));
if (!left.length) break;
talkTo(left[0]);
play();
}
expect(p.world.state.events.has(EVENT_CLEARED_ROCKET_HIDEOUT)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_HM06_WHIRLPOOL)).toBe(true);
expect(p.world.state.bag.tmsHms[55]).toBe(1); // HM06
travel(MAHOGANY_SHOP, ROCKET_HIDEOUT);
exit(MAHOGANY);
reload();
}, 240_000);
it("slides across Pryce's ice for the GLACIERBADGE, and the Rockets take the Radio Tower", () => {
traceFailures();
heal(MAHOGANY_CENTER, MAHOGANY);
enter(MAHOGANY_GYM, "up");
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_PRYCE)).toBe(true);
expect(p.world.state.flags.has(ENGINE_GLACIERBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_GOT_TM16_ICY_WIND)).toBe(true);
expect(p.world.state.bag.tmsHms[15]).toBe(1); // TM16
// The seventh badge runs RadioTowerRocketsScript.
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(true);
expect(p.world.state.events.has(EVENT_RADIO_TOWER_ROCKET_TAKEOVER)).toBe(false);
exit(MAHOGANY);
reload();
writeTestSave("mahogany-town");
}, 240_000);
/** Mahogany to Goldenrod the long way round: Route 42, Ecruteak,
* Routes 37 and 36, the National Park and Route 35. */
const mahoganyToGoldenrod = () => {
cross("left", ROUTE_42, { water: true });
sail(2, 8); // Route 42's pond, then ashore by the gate
stairs(ROUTE_42_GATE);
stairs(ECRUTEAK);
cross("down", ROUTE_37);
cross("down", ROUTE_36);
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_GATE);
stairs(GOLDENROD);
};
it("walks back to Goldenrod and climbs the Rocket-held Radio Tower to the fake director", () => {
traceFailures();
mahoganyToGoldenrod();
heal(GOLDENROD_CENTER, GOLDENROD);
enter(RADIO_TOWER[0], "up");
for (const floor of RADIO_TOWER.slice(1)) stairs(floor);
walk(0, 3); // FakeDirectorScript
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_3)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(BASEMENT_KEY);
expect(p.log.join(" ")).toContain("UNDERGROUND\nWAREHOUSE.");
travel(GOLDENROD, RADIO_TOWER);
reload();
}, 240_000);
it("opens the basement door, works the Underground switches and frees the director", () => {
traceFailures();
// Fresh PP before the rival waiting in the switch room.
heal(GOLDENROD_CENTER, GOLDENROD);
stairs(SWITCH_ROOM);
stairs(UNDERGROUND);
walk(18, 7);
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_USED_BASEMENT_KEY)).toBe(true);
// The door leads to the Underground's other corner.
p.world.move("up");
play();
stairs(SWITCH_ROOM);
// From all off, switches 3, 2 and 1 leave doors 3, 5, 6, 8, 9 and 11
// open: a way from the switches down to the warehouse.
for (const [x, y] of [[2, 1], [10, 1], [16, 1]]) {
walk(x, y + 1);
p.world.move("up");
p.world.interact();
play([1]);
}
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 6 });
expect(p.world.state.events.has(EVENT_RIVAL_GOLDENROD_UNDERGROUND)).toBe(true);
stairs(WAREHOUSE);
// The warehouse resets the switches when entered.
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 0 });
talkTo(p.world.map.objects[3].id);
play();
expect(p.world.state.events.has(EVENT_RECEIVED_CARD_KEY)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(CARD_KEY);
// Out through the department store's basement and elevator.
stairs(DEPT_STORE_B1F);
stairs(ELEVATOR);
walk(3, 1);
p.world.move("up");
p.world.interact();
play([1]); // 1F
walk(2, 3);
p.world.move("down");
play();
expect(p.world.mapId).toBe(DEPT_STORE_1F);
exit(GOLDENROD);
reload();
}, 240_000);
it("opens the 3F shutters with the Card Key and beats the executive for the Clear Bell", () => {
traceFailures();
heal(GOLDENROD_CENTER, GOLDENROD);
enter(RADIO_TOWER[0], "up");
stairs(RADIO_TOWER[1]);
stairs(RADIO_TOWER[2]);
walk(14, 3); // CardKeySlotScript faces up
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER)).toBe(true);
walk(17, 0); // the stairs behind the shutters
expect(p.world.mapId).toBe(RADIO_TOWER[3]);
stairs(RADIO_TOWER[4]);
walk(16, 5); // RadioTower5FRocketBossScript
expect(p.world.state.events.has(EVENT_CLEARED_RADIO_TOWER)).toBe(true);
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(false);
expect(p.world.state.events.has(EVENT_GOT_CLEAR_BELL)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(CLEAR_BELL);
// The director walks out and disappears; a save keeps him gone.
reload();
travel(RADIO_TOWER[0], RADIO_TOWER.slice(1));
exit(GOLDENROD);
reload();
}, 240_000);
it("crosses Route 44 and Ice Path's boulders to Blackthorn City", () => {
traceFailures();
toRoute36();
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
stairs(ROUTE_42_GATE);
stairs(ROUTE_42);
cross("right", MAHOGANY, { water: true });
heal(MAHOGANY_CENTER, MAHOGANY);
cross("right", ROUTE_44);
stairs(ICE_PATH[0]);
stairs(ICE_PATH[1]);
// B1F: drop boulder 2 down its hole, then fall through another; on
// B2F's ice it stops a slide on the way to B3F.
p.boulders([{ id: 3, x: 4, y: 7 }], [2]);
expect(p.world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
walk(5, 12);
expect(p.world.mapId).toBe(ICE_PATH[2]);
walk(9, 11);
expect(p.world.mapId).toBe(ICE_PATH[4]);
travel(BLACKTHORN, [ICE_PATH[0], ICE_PATH[1], ICE_PATH[3], ICE_PATH[4]]);
reload();
}, 240_000);
it("trains on Route 45, drops the gym's boulders and beats Clair", () => {
traceFailures();
// Clair's Kingdra outclasses a level 53 lead; a player trains first.
cross("down", ROUTE_45);
train(62, () => {
cross("up", BLACKTHORN);
heal(BLACKTHORN_CENTER, BLACKTHORN);
cross("down", ROUTE_45);
});
cross("up", BLACKTHORN);
enter(BLACKTHORN_MART, "up");
buy(p.world.map.objects[0].id, C.MAX_POTION, 4);
exit(BLACKTHORN);
heal(BLACKTHORN_CENTER, BLACKTHORN);
enter(BLACKTHORN_GYM[0], "up");
stairs(BLACKTHORN_GYM[1]);
// Boulders 1 and 3 fall to 1F and bridge the way to Clair.
p.boulders(
[
{ id: 4, x: 8, y: 3 },
{ id: 6, x: 8, y: 7 },
],
[5, 7, 8, 9],
);
expect(route(BLACKTHORN_GYM[0], BLACKTHORN_GYM, 4, { x: 5, y: 4 })).toBe(true);
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_CLAIR)).toBe(true);
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(false);
expect(p.log.join(" ")).toContain("DRAGON'S DEN");
travel(BLACKTHORN, BLACKTHORN_GYM);
reload();
}, 360_000);
it("passes the Dragon Shrine's test for the RISINGBADGE, TM24 and Dratini", () => {
traceFailures();
heal(BLACKTHORN_CENTER, BLACKTHORN);
// Krabby learns Whirlpool in place of Leer for Dragon's Den.
p.teach(HM06, 4, 1);
expect(p.world.state.party[4].moves).toContain(250);
const den = p.world.map.warps.find((warp) => warp.targetMap === DRAGONS_DEN[0])!;
sail(den.x, den.y + 1);
p.world.move("up");
play();
walk(3, 4); // the ladder down to 1F's lower part
p.world.move("up");
play();
stairs(DRAGONS_DEN[1]);
sail(19, 31, { whirlpools: true });
walk(19, 30);
// DragonShrineTakeTestScript: five questions, all answered right.
p.world.move("up");
play([0, 0, 1, 0, 1]);
expect(p.world.mapId).toBe(DRAGON_SHRINE);
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG)).toBe(false);
// Clair waits by the door with TM24 (DragonsDenB1F_ClairScene).
stairs(DRAGONS_DEN[1]);
expect(p.world.state.events.has(EVENT_GOT_TM24_DRAGONBREATH)).toBe(true);
expect(p.world.state.bag.tmsHms[23]).toBe(1); // TM24
// Back inside, the elder gives Dratini with Extremespeed.
p.world.move("up");
play();
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.events.has(EVENT_GOT_DRATINI)).toBe(true);
const dratini = p.world.state.party.at(-1)!;
expect(dratini).toMatchObject({ species: DRATINI, level: 15 });
expect(dratini.moves).toEqual([35, 86, 239, EXTREMESPEED]);
expect(dratini.pp).toEqual([20, 20, 20, 5]);
// Out: the whirlpool is back after the map reload.
stairs(DRAGONS_DEN[1]);
sail(20, 4, { whirlpools: true });
p.world.move("up");
play();
walk(5, 14);
p.world.move("up");
play();
stairs(BLACKTHORN);
sail(17, 14);
reload();
writeTestSave("blackthorn-city");
}, 240_000);
it("takes Routes 45 and 46 down to Route 29 and walks back to Ecruteak", () => {
traceFailures();
// From here on the driver plans ledge jumps (Route 45, Tin Tower).
p.ledges = true;
heal(BLACKTHORN_CENTER, BLACKTHORN);
cross("down", ROUTE_45);
// Route 45's ledges only lead down; Route 46 joins it on the west.
cross("left", ROUTE_46);
stairs(ROUTE_29_ROUTE_46_GATE);
stairs(ROUTE_29);
cross("left", CHERRYGROVE);
cross("up", ROUTE_30);
cross("up", ROUTE_31);
stairs(ROUTE_31_GATE);
stairs(VIOLET);
cross("left", ROUTE_36);
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
reload();
}, 240_000);
it("rings the Clear Bell for the sages and faces Suicune in Tin Tower", () => {
traceFailures();
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(TIN_TOWER_ENTRANCE, "up");
// EcruteakTinTowerEntranceSageBlocks: a sage steps in the way; told
// about the Clear Bell, he lets the player through.
walk(5, 8);
p.world.move("up");
play();
if (p.world.save().direction !== "up") p.world.move("up");
p.world.interact();
play();
expect(p.log.join(" ")).toContain("The sound of that\nCLEAR BELL!");
expect(p.world.state.events.has(EVENT_RANG_CLEAR_BELL_2)).toBe(true);
walk(5, 4); // the stairs at (5, 3) lead to the building's far side
p.world.move("up");
play();
stairs(WISE_TRIOS_ROOM);
stairs(ECRUTEAK);
// TinTower1FSuicuneBattleScript: Raikou and Entei run; Suicune stays.
stairs(TIN_TOWER_1F);
expect(p.world.state.events.has(EVENT_FOUGHT_SUICUNE)).toBe(true);
expect(p.log.join(" ")).toContain("SUICUNE");
expect(p.log.join(" ")).toContain("rainbow-");
reload();
writeTestSave("tin-tower");
}, 240_000);
it("goes back to Ice Path for HM07 and walks home to New Bark Town", () => {
traceFailures();
// From here on the run fights as a player facing the League would.
p.careful = true;
// Tin Tower's grounds lead back through the Wise Trio's room and the
// entrance building, whose far side comes down by its stairs.
stairs(ECRUTEAK);
stairs(WISE_TRIOS_ROOM_EXIT);
stairs(TIN_TOWER_ENTRANCE);
walk(17, 14);
p.world.move("down");
play();
stairs(ECRUTEAK);
stairs(ROUTE_42_GATE);
stairs(ROUTE_42);
cross("right", MAHOGANY, { water: true });
heal(MAHOGANY_CENTER, MAHOGANY);
cross("right", MAHOGANY_ROUTE_44);
stairs(ICE_PATH[0]);
// The HM07 ball at (31, 7), reached from its left over the ice.
walk(30, 7);
if (p.world.save().direction !== "right") p.world.move("right");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_GOT_HM07_WATERFALL)).toBe(true);
expect(p.world.state.bag.tmsHms[56]).toBe(1);
stairs(MAHOGANY_ROUTE_44);
cross("left", MAHOGANY);
cross("left", ROUTE_42, { water: true });
sail(2, 8);
stairs(ROUTE_42_GATE);
stairs(ECRUTEAK);
cross("down", ROUTE_37);
cross("down", ROUTE_36);
cross("right", VIOLET);
stairs(ROUTE_31_GATE);
stairs(ROUTE_31);
cross("down", ROUTE_30);
cross("down", CHERRYGROVE);
cross("right", ROUTE_29);
cross("right", NEW_BARK);
reload();
}, 240_000);
it("surfs Route 27, climbs Tohjo Falls and takes Victory Road to the Plateau", () => {
traceFailures();
// Dratini learns Waterfall; Typhlosion takes Iron Tail for Leer.
p.teach(HM07, 5, 0);
p.teach(tmhmItem(23), 0, 1);
expect(p.world.state.party[5].moves[0]).toBe(WATERFALL);
expect(p.world.state.party[0].moves[1]).toBe(IRON_TAIL);
cross("right", ROUTE_27, { water: true });
travel(TOHJO_FALLS, [], { water: true });
// Up the western waterfall, along the top pool and down the eastern
// one to the far shore and Route 27's other half.
sail(9, 12);
if (p.world.save().direction !== "up") p.world.move("up");
p.world.interact();
play([1]);
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 9, y: 7 });
sail(22, 14);
walk(25, 15);
if (p.world.mapId === TOHJO_FALLS) {
p.world.move("down");
play();
}
expect(p.world.mapId).toBe(ROUTE_27);
cross("right", ROUTE_26, { water: true });
travel(VICTORY_ROAD_GATE, [], { water: true });
// VictoryRoadGateBadgeCheckScript on the way north.
stairs(VICTORY_ROAD);
expect(p.log.join(" ")).toContain("BADGES of JOHTO!");
// Victory Road's floors are joined by its own ladders; the rival
// waits by the exit.
travel(ROUTE_23, [VICTORY_ROAD]);
// The rival leaves (EVENT_RIVAL_VICTORY_ROAD hides him) after the battle.
expect(p.world.state.events.has(EVENT_RIVAL_VICTORY_ROAD)).toBe(true);
expect(p.log.join(" ")).toContain("SILVER\nwants to battle!");
reload();
}, 240_000);
it("stocks up at Indigo Plateau and beats Will", () => {
traceFailures();
stairs(INDIGO_PLATEAU);
talkTo(2); // the nurse, across the counter
play();
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
writeTestSave("indigo-plateau");
buy(3, C.FULL_HEAL, 15);
buy(3, C.MAX_POTION, Math.floor((p.world.state.money - 2000) / 2500));
stairs(ELITE_FOUR[0]);
play(); // the door locks behind the player
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.events.has(EVENT_BEAT_ELITE_4_WILL)).toBe(true);
reload();
}, 240_000);
it("beats Koga, Bruno and Karen", () => {
traceFailures();
const events = [EVENT_BEAT_ELITE_4_KOGA, EVENT_BEAT_ELITE_4_BRUNO, EVENT_BEAT_ELITE_4_KAREN];
ELITE_FOUR.slice(1).forEach((room, index) => {
stairs(room);
play();
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.mapId).toBe(room);
expect(p.world.state.events.has(events[index])).toBe(true);
});
reload();
}, 240_000);
it("beats Lance and enters the Hall of Fame, then wakes in New Bark Town", () => {
traceFailures();
stairs(LANCES_ROOM);
play();
// Script_ApproachLanceFromRight: the step towards Lance starts it;
// after the battle Mary and Oak rush in and Lance leads the way to
// the Hall of Fame, where the ceremony and the credits play.
walk(5, 6);
p.world.move("up");
play();
const log = p.log.join(" ");
expect(log).toContain("CHAMPION LANCE\nwas defeated!");
expect(log).toContain("MARY: Let's inter-");
expect(log).toContain("and your partners\nas CHAMPIONS!");
expect(p.world.state.events.has(EVENT_BEAT_CHAMPION_LANCE)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_ELITE_FOUR)).toBe(true);
expect(p.world.state.hallOfFame).toBe(true);
expect(p.world.state.hallOfFameCount).toBe(1);
expect(p.world.state.hallOfFameTeams[0].mons.map((mon) => mon.species)).toEqual(
p.world.state.party.map((mon) => mon.species),
);
expect(p.world.state.hallOfFameTeams[0].mons[0].species).toBe(TYPHLOSION);
// Continue after the credits: SPAWN_NEW_BARK, outside the lab.
expect(p.world.mapId).toBe(NEW_BARK);
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 13, y: 6 });
expect(p.world.busy).toBe(false);
reload();
writeTestSave("hall-of-fame-new-bark");
}, 300_000);
/** A Kanto gym leader in front of the player's way in: the battle
* and the badge. */
const beatLeader = (id: number, badge: number) => {
talkTo(id);
play();
expect(p.world.state.flags.has(badge), p.log.slice(-3).join(" ")).toBe(true);
};
it("takes the S.S. Ticket from Elm and sails the S.S. Aqua to Vermilion", () => {
traceFailures();
p.world.move("down");
play();
enter(ELMS_LAB, "up");
play();
talkTo(2); // Elm
play();
expect(p.world.state.events.has(EVENT_GOT_SS_TICKET_FROM_ELM)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(S_S_TICKET);
stairs(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
cross("up", ROUTE_30);
cross("up", ROUTE_31);
stairs(ROUTE_31_GATE);
stairs(VIOLET);
cross("left", ROUTE_36);
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
stairs(ROUTE_38_GATE);
stairs(ROUTE_38);
cross("left", ROUTE_39);
cross("down", OLIVINE);
travel(OLIVINE_PORT, [OLIVINE_PORT_PASSAGE]);
// OlivinePortWalkUpToShipScript stops the player on the way to the
// gangway: YES, the ticket, and on board.
try {
walk(7, 16);
} catch {
// The script takes over the walk.
}
play();
expect(p.world.mapId).toBe(FAST_SHIP_1F);
// FastShipB1FSailorBlocksLeft: a sailor steps in front of the
// corridor west until the lazy sailor in the north cabins goes back
// to work.
stairs(FAST_SHIP_B1F);
walk(30, 8);
p.world.move("up");
play();
if (p.world.save().direction !== "up") p.world.move("up");
p.world.interact();
play();
walk(31, 13); // back up the east ladder
play();
expect(route(FAST_SHIP_CABINS_NNW, [FAST_SHIP_1F], 3, { x: 4, y: 27 })).toBe(true);
talkTo(6); // the lazy sailor
play();
// Down to B1F and along it to the captain's cabin, where the lost
// girl is found and the ship comes in.
expect(route(FAST_SHIP_CABINS_SE, [FAST_SHIP_B1F, FAST_SHIP_1F], 6, { x: 1, y: 26 })).toBe(true);
talkTo(5);
play();
// Off the ship: the sailor at the gangway lets the player out.
expect(route(FAST_SHIP_1F, [FAST_SHIP_B1F], 4, { x: 25, y: 3 })).toBe(true);
talkTo(2);
play();
travel(VERMILION, [VERMILION_PORT_PASSAGE]);
expect(p.world.state.events.has(EVENT_FAST_SHIP_FIRST_TIME)).toBe(true);
reload();
writeTestSave("vermilion-city");
}, 300_000);
it("wins the THUNDERBADGE from Lt. Surge", () => {
traceFailures();
heal(VERMILION_CENTER, VERMILION);
// A tree hides the gym's yard; Bellsprout cuts it.
p.cut(13, 18);
enter(VERMILION_GYM, "up");
beatLeader(p.world.map.objects[0].id, ENGINE_THUNDERBADGE);
reload();
}, 240_000);
it("wins the MARSHBADGE from Sabrina", () => {
traceFailures();
exit(VERMILION);
p.cut(13, 18); // the tree has grown back
cross("up", ROUTE_6);
stairs(ROUTE_6_SAFFRON_GATE);
stairs(SAFFRON);
heal(SAFFRON_CENTER, SAFFRON);
writeTestSave("saffron-city");
enter(SAFFRON_GYM, "up");
// Sabrina at (9, 8), past the gym's warp panels.
expect(route(SAFFRON_GYM, [SAFFRON_GYM], 12, { x: 9, y: 9 })).toBe(true);
beatLeader(p.world.map.objects[0].id, ENGINE_MARSHBADGE);
reload();
}, 240_000);
it("meets the Power Plant's manager, finds the Machine Part and wins the CASCADEBADGE", () => {
traceFailures();
expect(route(SAFFRON, [SAFFRON_GYM], 12)).toBe(true);
stairs(ROUTE_5_SAFFRON_GATE);
stairs(ROUTE_5);
cross("up", CERULEAN);
heal(CERULEAN_CENTER, CERULEAN);
cross("right", ROUTE_9);
p.cut(5, 8);
// Route 10's fence lets no one down from its Pokémon Center: the
// river from Route 9's east end leads down to the Power Plant.
sail(56, 17);
p.world.move("down");
play();
sail(3, 11);
stairs(POWER_PLANT);
talkTo(7); // the manager
play();
expect(p.world.state.events.has(EVENT_MET_MANAGER_AT_POWER_PLANT)).toBe(true);
stairs(ROUTE_10_NORTH);
// Back up the river, ashore at its top and west to Cerulean; the
// tree has grown back.
sail(16, 0);
p.world.move("up");
play();
sail(50, 4);
p.cut(5, 8);
cross("left", CERULEAN);
// The thief Rocket's scene on the way in, and the grunt on Route 24
// tells where he hid the part.
enter(CERULEAN_GYM, "up");
play();
exit(CERULEAN);
cross("up", ROUTE_24);
talkTo(2);
play();
cross("down", CERULEAN);
enter(CERULEAN_GYM, "up");
p.inspect(3, 8);
expect(p.world.state.events.has(EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(MACHINE_PART);
exit(CERULEAN);
cross("up", ROUTE_24);
cross("up", ROUTE_25);
// Route25MistyDate: the date breaks up as the player walks by.
walk(41, 6);
p.world.move("right");
play();
expect(p.log.join(" ")).toContain("MISTY");
cross("down", ROUTE_24);
cross("down", CERULEAN);
heal(CERULEAN_CENTER, CERULEAN);
enter(CERULEAN_GYM, "up");
beatLeader(3, ENGINE_CASCADEBADGE); // Misty, on the pool's island
reload();
}, 300_000);
it("restores the power, crosses Rock Tunnel and takes the EXPN card in Lavender", () => {
traceFailures();
exit(CERULEAN);
cross("right", ROUTE_9);
p.cut(5, 8);
sail(56, 17);
p.world.move("down");
play();
sail(3, 11);
stairs(POWER_PLANT);
talkTo(7);
play();
expect(p.world.state.events.has(EVENT_RETURNED_MACHINE_PART)).toBe(true);
expect(p.world.state.events.has(EVENT_RESTORED_POWER_TO_KANTO)).toBe(true);
stairs(ROUTE_10_NORTH);
sail(16, 0);
p.world.move("up");
play();
sail(50, 4);
// Rock Tunnel's door on Route 9 is reached from below: Route 10's
// Pokémon Center grounds lead north into Route 9's corner by it.
cross("down", ROUTE_10_NORTH);
walk(8, 0);
p.world.move("up");
play();
walk(48, 16);
p.world.move("up");
play();
travel(ROUTE_10_SOUTH, ROCK_TUNNEL);
cross("down", LAVENDER);
heal(LAVENDER_CENTER, LAVENDER);
enter(LAVENDER_RADIO_TOWER, "up");
talkTo(5); // the director, by the stairs
play();
expect(p.world.state.flags.has(ENGINE_EXPN_CARD)).toBe(true);
reload();
}, 300_000);
it("wakes Vermilion's Snorlax with the Poké Flute channel", () => {
traceFailures();
exit(LAVENDER);
cross("left", ROUTE_8);
stairs(ROUTE_8_SAFFRON_GATE);
stairs(SAFFRON);
stairs(ROUTE_6_SAFFRON_GATE);
stairs(ROUTE_6);
cross("down", VERMILION);
// SnorlaxAwake: the radio left on the Poké Flute channel, beside it.
walk(34, 10);
p.tuneRadio("20.0");
if (p.world.save().direction !== "up") p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_FOUGHT_SNORLAX)).toBe(true);
reload();
}, 240_000);
it("goes through Diglett's Cave and wins the BOULDERBADGE from Brock", () => {
traceFailures();
heal(VERMILION_CENTER, VERMILION);
travel(ROUTE_2, [DIGLETTS_CAVE]);
p.cut(5, 8);
cross("up", PEWTER);
heal(PEWTER_CENTER, PEWTER);
enter(PEWTER_GYM, "up");
beatLeader(p.world.map.objects[0].id, ENGINE_BOULDERBADGE);
reload();
}, 240_000);
it("surfs from Pallet Town to Cinnabar and wins the VOLCANOBADGE at Seafoam", () => {
traceFailures();
exit(PEWTER);
cross("down", ROUTE_2);
p.cut(5, 8);
cross("down", VIRIDIAN);
heal(VIRIDIAN_CENTER, VIRIDIAN);
cross("down", ROUTE_1);
cross("down", PALLET);
cross("down", ROUTE_21, { water: true });
cross("down", CINNABAR, { water: true });
heal(CINNABAR_CENTER, CINNABAR);
cross("right", ROUTE_20, { water: true });
travel(SEAFOAM_GYM, [], { water: true });
beatLeader(2, ENGINE_VOLCANOBADGE); // Blaine
reload();
}, 300_000);
it("hears Blue out on Cinnabar and wins the SOULBADGE from Janine", () => {
traceFailures();
stairs(ROUTE_20);
cross("left", CINNABAR, { water: true });
talkTo(2); // Blue
play();
expect(p.log.join(" ")).toContain("Who are you?");
expect(p.world.state.events.has(EVENT_BLUE_IN_CINNABAR)).toBe(true);
cross("right", ROUTE_20, { water: true });
cross("right", ROUTE_19, { water: true });
travel(FUCHSIA, [ROUTE_19_FUCHSIA_GATE], { water: true });
heal(FUCHSIA_CENTER, FUCHSIA);
enter(FUCHSIA_GYM, "up");
beatLeader(2, ENGINE_SOULBADGE);
reload();
}, 300_000);
it("rides up Cycling Road and wins the RAINBOWBADGE from Erika", () => {
traceFailures();
exit(FUCHSIA);
cross("left", ROUTE_18);
// Cycling Road: on the Bicycle through the gate and up Route 17,
// which continues north into Route 16 between x 4 and 9.
p.world.useBicycle();
play();
stairs(ROUTE_17_ROUTE_18_GATE);
stairs(ROUTE_17);
p.rideUp(6, 0);
// Through the Route 16 gate to its east side, then Celadon.
walk(9, 7);
p.world.move("right");
play();
walk(9, 5);
p.world.move("right");
play();
cross("right", CELADON);
if (p.world.state.playerState === 1) {
p.world.useBicycle();
play();
}
heal(CELADON_CENTER, CELADON);
writeTestSave("celadon-city");
p.cut(28, 35); // the tree by the gym's yard
enter(CELADON_GYM, "up");
beatLeader(2, ENGINE_RAINBOWBADGE);
reload();
}, 300_000);
it("restocks in Saffron and wins the EARTHBADGE from Blue", () => {
traceFailures();
exit(CELADON);
p.cut(28, 35); // grown back
heal(CELADON_CENTER, CELADON);
cross("right", ROUTE_7);
stairs(ROUTE_7_SAFFRON_GATE);
stairs(SAFFRON);
// Saffron's mart sells Max Potions.
enter(SAFFRON_MART, "up");
buy(2, C.MAX_POTION, Math.min(99, Math.floor((p.world.state.money - 3000) / 2500)));
exit(SAFFRON);
stairs(ROUTE_6_SAFFRON_GATE);
stairs(ROUTE_6);
cross("down", VERMILION);
travel(ROUTE_2, [DIGLETTS_CAVE]);
p.cut(5, 8);
cross("down", VIRIDIAN);
heal(VIRIDIAN_CENTER, VIRIDIAN);
enter(VIRIDIAN_GYM, "up");
beatLeader(2, ENGINE_EARTHBADGE);
expect(p.world.state.events.has(EVENT_BEAT_BLUE)).toBe(true);
reload();
}, 300_000);
it("has Oak open Mt. Silver for sixteen badges", () => {
traceFailures();
exit(VIRIDIAN);
heal(VIRIDIAN_CENTER, VIRIDIAN);
cross("down", ROUTE_1);
cross("down", PALLET);
enter(OAKS_LAB, "up");
talkTo(2); // Oak
play();
expect(p.world.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true);
expect(p.world.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true);
expect(p.log.join(" ")).toContain("POKéMON seen");
reload();
}, 240_000);
it("reaches Mt. Silver by Route 28 and trains outside Silver Cave", () => {
traceFailures();
exit(PALLET);
cross("up", ROUTE_1);
cross("up", VIRIDIAN);
cross("left", ROUTE_22);
stairs(VICTORY_ROAD_GATE);
stairs(ROUTE_28);
cross("left", SILVER_CAVE_OUTSIDE);
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
writeTestSave("mt-silver");
// Red's team is in the 70s and 80s: the lead trains in steps.
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
travel(SILVER_CAVE_OUTSIDE, SILVER_CAVE);
const rest = () => heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
for (const level of [84, 88, 91, 93, 95]) train(level, rest);
expect(lead().level).toBeGreaterThanOrEqual(95);
reload();
}, 900_000);
it("takes TM26 from Victory Road, beats Red and sees the credits", () => {
traceFailures();
cross("right", ROUTE_28);
stairs(VICTORY_ROAD_GATE);
stairs(VICTORY_ROAD);
expect(route(VICTORY_ROAD, [VICTORY_ROAD], 8, { x: 4, y: 28 })).toBe(true);
talkTo(3); // TM26's ball
play();
p.teach(tmhmItem(26), 0, 2); // Earthquake for Smokescreen
expect(lead().moves[2]).toBe(EARTHQUAKE);
travel(VICTORY_ROAD_GATE, [VICTORY_ROAD]);
stairs(ROUTE_28);
cross("left", SILVER_CAVE_OUTSIDE);
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
talkTo(2); // Red
play();
const log = p.log.join(" ");
expect(log).toContain("RED\nwas defeated!");
// RedCredits, then SPAWN_MT_SILVER outside the cave, with Red gone
// for good and the party healed.
expect(p.world.state.events.has(EVENT_RED_IN_MT_SILVER)).toBe(true);
expect(p.world.mapId).toBe(SILVER_CAVE_OUTSIDE);
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 23, y: 20 });
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
expect(p.world.busy).toBe(false);
reload();
writeTestSave("red-beaten");
}, 300_000);
},
);