Files
Crystal/tests/playthrough.test.ts
T
kawaandClaude Opus 5.5 2640fa2725 Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

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

800 lines
30 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 { 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";
/** 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,
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");
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);
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();
}, 240_000);
},
);