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:
1 parent
9c3579937c
commit
3fbc6798eb
10 files changed
+369
-21
No files matched your search
+35
-11
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
@@ -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] });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user