Open Route 31, Violet City and Sprout Tower (#31)

Enable Route 31 and its gate, Dark Cave's Violet entrance, Violet City with its buildings and Sprout Tower. Port the script commands they use: gettrainername, askforphonenumber (with AddPhoneNumber's room for Mom and Elm; addcellnum now reports failure as the original does), checkpokemail, loadmenu/_2dmenu/closewindow, giveegg and trade (NPCTrade, with wTradeFlags saved).

Eggs keep their species with an egg flag, as wPartySpecies does. The pack's TM/HM counts receive, check and toss TMs and HMs, which are named TM01-HM07.

The playthrough continues through Route 31 (with Mom's call), Violet City and Sprout Tower to Elder Li's Flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 16:24:29 +02:00
1 parent ec3d1cd5c4
commit 7909302db1
16 files changed
+585 -63

No files matched your search

+5 -4
View File
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15,
SPAWN_VIOLET = 16;
SPAWN_AZALEA = 18;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
@@ -203,10 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
it("refuses to fly to a flypoint that is not ported", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_VIOLET);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_AZALEA);
useMove(world, "FLY");
world.moveCursor("down"); // wraps to Violet City
expect(flyMap(world)?.name).toBe("VIOLET CITY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
+3 -3
View File
@@ -15,8 +15,7 @@ const romPath =
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const POKEMON_CENTER = "26:5",
CYNDAQUIL = 155,
PIDGEY = 16,
EGG = 0xfd;
PIDGEY = 16;
const POTION = 0x12,
BERRY = 0xad,
FLOWER_MAIL = 0x9e,
@@ -282,7 +281,8 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
];
world.state.party.push({
...structuredClone(world.state.party[1]),
species: EGG,
egg: true,
nickname: "EGG",
});
mailbox(world);
expect(menuItems(world)).toEqual([
+8 -6
View File
@@ -517,14 +517,16 @@ describe.skipIf(!existsSync(romPath))(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const route = world.assets.map("26:1");
const edge = Array.from({ length: route.width }, (_, x) => x).find(
// Violet City's south edge leads to Route 32, which stays closed.
const city = world.assets.map("10:5"),
bottom = city.height - 1;
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
world.assets.permission(world.assets.collision(city, x, bottom)) === 0,
)!;
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
expect(world.move("up")).toBe(false);
expect(world.notice).toBe("Route 31 is not ported yet.");
world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
expect(world.move("down")).toBe(false);
expect(world.notice).toBe("Route 32 is not ported yet.");
const {
scenes,
phone,
+143 -1
View File
@@ -14,7 +14,23 @@ const NEW_BARK = "24:4",
ROUTE_29 = "24:3",
CHERRYGROVE = "26:3",
ROUTE_30 = "26:1",
MR_POKEMONS_HOUSE = "26:10";
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";
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
EVENT_GOT_HM05_FLASH = 0x14,
EVENT_BEAT_FALKNER = 0x2bd,
EVENT_GOT_TM31_MUD_SLAP = 0x8,
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
ENGINE_ZEPHYRBADGE = 0x1b;
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -196,6 +212,89 @@ describe.skipIf(!existsSync(romPath))(
expect(world.save()).toEqual(saved);
};
const lead = () => world.state.party[0];
/** PokecenterNurseScript from in front of the counter, then back out. */
const heal = (center: string, city: string) => {
enter(center, "up");
walk(3, 3);
world.move("up"); // the counter: this only turns the player
world.interact();
play();
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
exit(city);
};
/** Pace between two neighbouring grass tiles (the nearest pair) until
* the lead reaches `level`. `rest` heals and comes back here. */
const train = (level: number, rest: () => void) => {
const map = world.mapId,
grass = world.assets.grassCollisions();
const isGrass = (x: number, y: number) =>
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
grass.includes(world.assets.collision(world.map, x, y));
for (let round = 0; round < 2000 && lead().level < level; round++) {
if (lead().hp * 2 < lead().stats[0]) {
rest();
expect(world.mapId).toBe(map);
}
const pairs: { x: number; y: number; length: number }[] = [];
for (let y = 0; y < world.map.height; y++)
for (let x = 0; x < world.map.width; x++)
if (isGrass(x, y) && isGrass(x + 1, y))
try {
pairs.push({ x, y, length: findPath(world, x, y).length });
} catch {
/* unreachable */
}
pairs.sort((a, b) => a.length - b.length);
const { x, y } = pairs[0];
walk(x, y);
walk(x + 1, y);
}
expect(lead().level).toBeGreaterThanOrEqual(level);
};
/** Take the stairs (or another warp) to `target`, trying each. */
const stairs = (target: string) => {
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
walk(warp.x, warp.y);
if (world.mapId === target) return;
}
throw new Error(`No way from ${world.mapId} to ${target}`);
};
/** Find a way through warps among `maps` to `target`, as a player
* trying staircases would: depth first, backing out of dead ends. */
const route = (target: string, maps: string[], depth = 6): boolean => {
if (world.mapId === target) return true;
if (!depth) return false;
const here = world.save();
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
const from = world.mapId;
walk(warp.x, warp.y);
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
world.restore(here);
}
return false;
};
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
const restInViolet = () => {
exit(ROUTE_31_GATE, "left");
exit(VIOLET, "left");
heal(VIOLET_CENTER, VIOLET);
exit(ROUTE_31_GATE, "right");
exit(ROUTE_31, "right");
};
beforeEach(() => {
log.length = 0;
});
@@ -317,5 +416,48 @@ describe.skipIf(!existsSync(romPath))(
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(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
exit(VIOLET, "left");
expect(world.state.flags.size).toBeGreaterThan(0);
reload();
});
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);
world.move("up"); // Elder Li: this only turns the player
world.interact();
play();
const pages = log.join("\n").replace(/\n/g, " ");
expect(pages).toContain("FLASH");
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
reload();
});
},
);
+1 -1
View File
@@ -316,7 +316,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
it("shows the other party members' moves with left and right, skipping Eggs", () => {
const world = fixture();
const party = world.state.party;
party.push({ ...structuredClone(party[1]), species: 0xfd }); // EGG
party.push({ ...structuredClone(party[1]), egg: true, nickname: "EGG" });
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");