Files
Crystal/tests/playthrough.test.ts
T

803 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();
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);
},
);