Open Tin Tower and Whirl Islands: Suicune, Ho-Oh and Lugia (#32)

Tin Tower's floors and roof and the Whirl Islands are open. The Clear Bell
gets the player past the sages to Suicune in Tin Tower; after the League,
the Rainbow Wing leads up to Ho-Oh, and the Silver Wing to Lugia.

New specials: MonCheck and BeastsCheck (CheckOwnMonAnywhere, with its
five-letter OT comparison and no Day-Care search).

The playthrough driver plans ledge jumps for the later stages, waterfall
descents, warp panels and Surf between warps; the run gains two stages,
Routes 45 and 46 back to Ecruteak and Tin Tower's Suicune.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-13 14:47:45 +02:00
1 parent 9c3579937c
commit 3fbc6798eb
10 files changed
+369 -21

No files matched your search

+35 -11
View File
@@ -36,6 +36,9 @@ export class Playthrough {
readonly log: string[] = [];
/** A wild species to throw Poké Balls at instead of fighting. */
wanted = 0;
/** Plan ledge jumps (Tin Tower, Route 45). Off for the earlier stages,
* whose seeded runs were recorded walking around ledges. */
ledges = false;
seed = 0x2a;
private stage = 0;
readonly now = () => new Date("2026-09-22T12:00:00");
@@ -227,10 +230,11 @@ export class Playthrough {
if (world.x === x && world.y === y) return;
// A surfer crosses water until it steps ashore.
const water = world.state.playerState === PLAYER_SURF;
for (const direction of findPath(world, x, y, { water })) {
for (const direction of findPath(world, x, y, { water, ledges: this.ledges })) {
const end = landing(world, world.x, world.y, direction, {
water,
target: { x, y },
ledges: this.ledges,
});
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
@@ -255,7 +259,11 @@ export class Playthrough {
this.play();
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y, { water: true, whirlpools })) {
for (const direction of findPath(world, x, y, {
water: true,
whirlpools,
ledges: this.ledges,
})) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
@@ -298,8 +306,10 @@ export class Playthrough {
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
// A warp on the edge (a gate's doorway) is not the connection.
if (world.map.warps.some((warp) => warp.x === x && warp.y === y)) return [];
try {
return [{ x, y, length: findPath(world, x, y, { water }).length }];
return [{ x, y, length: findPath(world, x, y, { water, ledges: this.ledges }).length }];
} catch {
return [];
}
@@ -426,7 +436,7 @@ export class Playthrough {
const world = this.world;
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
findPath(world, warp.x, warp.y, { ledges: this.ledges });
} catch {
continue;
}
@@ -452,7 +462,7 @@ export class Playthrough {
const reachable = () => {
if (!goal) return true;
try {
findPath(world, goal.x, goal.y);
findPath(world, goal.x, goal.y, { ledges: this.ledges });
return true;
} catch {
return false;
@@ -464,7 +474,7 @@ export class Playthrough {
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
try {
findPath(world, warp.x, warp.y);
findPath(world, warp.x, warp.y, { ledges: this.ledges });
} catch {
continue;
}
@@ -480,23 +490,37 @@ export class Playthrough {
};
/** The fewest warps (stairs, holes, doors) among `maps` to `target`:
* breadth first over the places each warp lands, so a maze of floors
* whose holes drop into cut-off corners is solved without looping. */
travel = (target: string, maps: string[]) => {
* whose holes drop into cut-off corners is solved without looping.
* `water` lets a player with Surf and Whirlpool sail to the warps. */
travel = (target: string, maps: string[], { water = false } = {}) => {
const world = this.world;
const queue = [world.save()];
const seen = new Set([`${world.mapId} ${world.x},${world.y}`]);
for (let i = 0; i < queue.length && i < 200; i++) {
for (let i = 0; i < queue.length && i < 1000; i++) {
world.restore(queue[i]);
const from = world.mapId;
for (const warp of world.map.warps) {
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
world.restore(queue[i]);
try {
findPath(world, warp.x, warp.y);
findPath(world, warp.x, warp.y, { water, whirlpools: water, ledges: this.ledges });
} catch {
continue;
}
this.walk(warp.x, warp.y);
// A surfer reaches a cave's stairs over water, then steps on.
if (water) {
try {
this.sail(warp.x, warp.y, { whirlpools: true });
} catch {
if (world.mapId === from) continue;
}
} else
try {
this.walk(warp.x, warp.y);
} catch {
// A warp panel on the way can land elsewhere on the same map.
if (world.mapId === from) continue;
}
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
if (world.mapId === from && exit && world.move(exit)) this.play();
if (world.mapId === from) continue;
+55
View File
@@ -207,6 +207,13 @@ const BASEMENT_KEY = 0x85,
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;
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -1431,5 +1438,53 @@ describe.skipIf(!existsSync(romPath))(
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);
},
);
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { toBoxMon } from "../src/game/party/storage";
import { Playthrough } from "./playthrough-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Tin Tower and Whirl Islands with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const TIN_TOWER = ["3:4", "3:5", "3:6", "3:7", "3:8", "3:9", "3:10", "3:11", "3:12"],
TIN_TOWER_ROOF = "15:12",
ROUTE_41 = "22:2",
WHIRL_ISLANDS = ["3:66", "3:67", "3:68", "3:69", "3:70", "3:71", "3:72"],
LUGIA_CHAMBER = "3:73";
const TYPHLOSION = 157,
FERALIGATR = 160,
RAIKOU = 243,
ENTEI = 244,
SUICUNE = 245,
SURF = 57,
WHIRLPOOL = 250,
WATERFALL = 127,
RAINBOW_WING = 0xb2,
SILVER_WING = 0x47;
const EVENT_FOUGHT_SUICUNE = 0x335,
EVENT_BEAT_ELITE_FOUR = 0x44,
EVENT_GOT_RAINBOW_WING = 0x336,
EVENT_FOUGHT_HO_OH = 0x317,
EVENT_FOUGHT_LUGIA = 0x318,
SCENE_TINTOWER1F_NOOP = 1;
/** TINTOWER1F_RAIKOU and TINTOWER1F_ENTEI (objects 2 and 3). */
const RAIKOU_OBJECT = 3,
ENTEI_OBJECT = 4;
const mon = (world: GameWorld, species: number, { level = 100, ot = "KAWA", otId = world.playerId } = {}) =>
generatePartyMon(assets!.pokemon, {
species,
level,
item: 0,
otId,
ot,
dvs: 0xffff,
caughtData: 0,
});
/** A playthrough driver on a fresh world named KAWA with all badges. */
const driver = () => {
const p = new Playthrough(assets!);
p.world = new GameWorld(assets!, p.now, p.random);
// Tin Tower's floors are joined by ledges.
p.ledges = true;
for (let badge = 0x1b; badge <= 0x22; badge++) p.world.state.flags.add(badge);
return p;
};
it("hides the beasts the player owns, by ID and the OT's first five letters (MonCheck)", () => {
const p = driver();
const { world } = p;
world.name = "KRISTAL";
const name = world.name;
world.state.party = [mon(world, TYPHLOSION, { ot: name })];
const beasts = () => {
world.warpTo(TIN_TOWER[0], 9, 14);
world.state.scenes[TIN_TOWER[0]] = SCENE_TINTOWER1F_NOOP;
p.play();
return [RAIKOU_OBJECT, ENTEI_OBJECT].map((id) =>
world.objects().some((object) => object.id === id),
);
};
expect(beasts()).toEqual([true, true]);
// A Raikou in a box with the player's ID and OT counts.
world.state.storage.boxes[3].push(toBoxMon(mon(world, RAIKOU, { ot: name })));
expect(beasts()).toEqual([false, true]);
// CheckOwnMon compares only five letters of the OT.
world.state.party.push(mon(world, ENTEI, { ot: "KRISTOF" }));
expect(beasts()).toEqual([false, false]);
// Another trainer's ID does not.
world.state.party.pop();
world.state.party.push(mon(world, ENTEI, { ot: name, otId: world.playerId ^ 1 }));
expect(beasts()).toEqual([false, true]);
// Without any party Pokémon, CheckOwnMonAnywhere finds nothing.
world.state.party = [];
expect(beasts()).toEqual([true, true]);
});
it("gives the Rainbow Wing after the League with all three beasts, and Ho-Oh waits on the roof", () => {
const p = driver();
const { world } = p;
const name = world.name;
world.state.party = [
mon(world, TYPHLOSION, { ot: name }),
mon(world, RAIKOU, { ot: name }),
mon(world, ENTEI, { ot: name }),
mon(world, SUICUNE, { ot: name }),
];
world.state.events.add(EVENT_FOUGHT_SUICUNE);
world.state.events.add(EVENT_BEAT_ELITE_FOUR);
world.state.scenes[TIN_TOWER[0]] = SCENE_TINTOWER1F_NOOP;
world.warpTo(TIN_TOWER[0], 9, 14);
p.play();
// BeastsCheck: the second trio appears; the one at (9, 1) has the wing.
p.talkTo(world.map.objects[8].id);
p.play();
expect(world.state.events.has(EVENT_GOT_RAINBOW_WING)).toBe(true);
expect(world.state.bag.keyItems).toContain(RAINBOW_WING);
// The stairs it reveals lead up through the floors to the roof.
p.travel(TIN_TOWER_ROOF, TIN_TOWER.slice(1));
expect(p.world.mapId).toBe(TIN_TOWER_ROOF);
p.talkTo(p.world.map.objects[0].id);
p.play();
expect(p.world.notice, p.world.diagnostic).toBe("");
expect(p.world.state.events.has(EVENT_FOUGHT_HO_OH)).toBe(true);
expect(p.log.join(" ")).toContain("HO-OH");
});
it("leads through Whirl Islands to Lugia with the Silver Wing", () => {
const p = driver();
const { world } = p;
const feraligatr = mon(world, FERALIGATR);
feraligatr.moves = [SURF, WHIRLPOOL, WATERFALL, 0];
feraligatr.pp = [15, 15, 15, 0];
world.state.party = [mon(world, TYPHLOSION), feraligatr];
world.state.bag.keyItems.push(SILVER_WING);
// On the island in front of the north-west entrance.
world.warpTo(ROUTE_41, 12, 18);
p.play();
p.travel(LUGIA_CHAMBER, [ROUTE_41, ...WHIRL_ISLANDS], { water: true });
expect(p.world.mapId).toBe(LUGIA_CHAMBER);
// Lugia waits in the pool at (9, 5).
p.sail(9, 6);
if (p.world.save().direction !== "up") p.world.move("up");
p.world.interact();
p.play();
expect(p.world.notice, p.world.diagnostic).toBe("");
expect(p.world.state.events.has(EVENT_FOUGHT_LUGIA)).toBe(true);
expect(p.log.join(" ")).toContain("LUGIA");
});
});
+59 -9
View File
@@ -134,9 +134,22 @@ function open(
return true;
}
/** Where a step in `direction` from (x, y) ends: the next tile, or, on
* ice, the last tile of the slide before something blocks it. Undefined
* when the step is blocked. */
/** .ledge_table: the directions each COLL_HOP_* tile jumps toward. */
const LEDGES: readonly (readonly Direction[])[] = [
["right"],
["left"],
["up"],
["down"],
["down", "right"],
["down", "left"],
["up", "right"],
["up", "left"],
];
/** Where a step in `direction` from (x, y) ends: the next tile, two tiles
* on when standing on a ledge that jumps that way (.TryJump), or, on ice,
* the last tile of the slide before something blocks it. Undefined when
* the step is blocked. */
export function landing(
world: GameWorld,
x: number,
@@ -146,10 +159,47 @@ export function landing(
water = false,
target,
whirlpools = false,
}: { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean } = {},
ledges = false,
}: {
water?: boolean;
target?: { x: number; y: number };
whirlpools?: boolean;
ledges?: boolean;
} = {},
): { x: number; y: number } | undefined {
if (!open(world, x, y, direction, water, target, whirlpools)) return undefined;
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
const standing = world.assets.collision(world.map, x, y);
if (ledges && (standing & 0xf0) === 0xa0 && LEDGES[standing & 7].includes(direction)) {
const lx = x + dx * 2,
ly = y + dy * 2;
const occupied = (ox: number, oy: number) =>
world.objects().some((object) => object.x === ox && object.y === oy);
if (
lx >= 0 &&
ly >= 0 &&
lx < world.map.width &&
ly < world.map.height &&
world.assets.permission(world.assets.collision(world.map, lx, ly)) === 0 &&
!occupied(x + dx, y + dy) &&
!occupied(lx, ly) &&
!(
isWarpHazard(world.assets.collision(world.map, lx, ly)) &&
world.map.warps.some((warp) => warp.x === lx && warp.y === ly) &&
(lx !== target?.x || ly !== target?.y)
)
)
return { x: lx, y: ly };
}
// COLL_WATERFALL: a surfer entering from above is carried down it.
const ahead = world.assets.collision(world.map, x + dx, y + dy);
if (water && ahead === 0x33) {
if (direction !== "down") return undefined;
let fy = y + 1;
while (world.assets.collision(world.map, x, fy) === 0x33) fy++;
if (!open(world, x, fy - 1, "down", water, target, whirlpools)) return undefined;
return { x, y: fy };
}
if (!open(world, x, y, direction, water, target, whirlpools)) return undefined;
x += dx;
y += dy;
while (
@@ -164,13 +214,13 @@ export function landing(
/** Shortest walkable path on the current map, avoiding other warps.
* `water` also crosses open water, for a player who can Surf, and
* `whirlpools` the whirlpools a player with Whirlpool can clear; a step
* onto ice slides on until blocked. */
* `whirlpools` the whirlpools a player with Whirlpool can clear; `ledges`
* takes ledge jumps; a step onto ice slides on until blocked. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
{ water = false, whirlpools = false } = {},
{ water = false, whirlpools = false, ledges = false } = {},
): Direction[] {
const target = { x: targetX, y: targetY };
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
@@ -182,7 +232,7 @@ export function findPath(
visited.add(key);
if (node.x === targetX && node.y === targetY) return node.path;
for (const [direction] of DIRECTIONS) {
const end = landing(world, node.x, node.y, direction, { water, target, whirlpools });
const end = landing(world, node.x, node.y, direction, { water, target, whirlpools, ledges });
if (end) queue.push({ ...end, path: [...node.path, direction] });
}
}