Open the Radio Tower takeover, Ice Path, Blackthorn and Dragon's Den (#32)

The Rocket-held Radio Tower, the Underground's switch room and warehouse,
Ice Path, Blackthorn City and its gym, Routes 45 and 46, Dark Cave's
Blackthorn entrance, Dragon's Den and the Dragon Shrine are open. The story
plays through the Basement Key, the switches and the director, the Clear
Bell, Ice Path's falling boulder, Clair's gym and the shrine's test for the
RISINGBADGE, TM24 and the elder's Dratini.

New specials: GiveDratini and MoveDeletion. A save keeps an object that a
script made disappear (or appear against its flag) as Continue does, so the
Radio Tower director no longer blocks the 5F stairs after a reload. A
whiteout from a battle started in an scall no longer refuses.

The playthrough driver pushes boulders into their holes (planned breadth
first), clears whirlpools, chooses the move a TM replaces and heals with
the strongest potion; the run gains six stages to the Rising Badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-13 14:19:02 +02:00
1 parent 061ca4d59d
commit 9c3579937c
15 files changed
+855 -43

No files matched your search

+123 -13
View File
@@ -8,7 +8,7 @@ import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { applyTypeMatchups } from "../src/game/battle/battle";
import { BattleData } from "../src/game/battle/data";
import { afterRestore, findPath, landing } from "./world-driver";
import { afterRestore, findPath, isWhirlpool, landing } from "./world-driver";
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
@@ -93,8 +93,8 @@ export class Playthrough {
* the bag through the battle's own item action (BattleMenu_Pack), as a
* cautious player who restocked before a hard fight would: a carried
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
* it out of a whole fight, and a carried Super Potion heals it below
* half HP. New moves are learned when there is room, and other
* it out of a whole fight, and the strongest carried potion heals it
* below half HP. New moves are learned when there is room, and other
* questions take the queued answers or their defaults. */
play = (answers: (number | string)[] = []) => {
const world = this.world,
@@ -112,9 +112,10 @@ export class Playthrough {
const ball = world.liveState.bag.balls.findIndex(
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
);
const potion = world.liveState.bag.items.findIndex(
(slot) => slot.item === C.SUPER_POTION,
);
// The strongest potion carried: Max, then Hyper, then Super.
const potion = [C.MAX_POTION, C.HYPER_POTION, C.SUPER_POTION]
.map((item) => world.liveState.bag.items.findIndex((slot) => slot.item === item))
.find((index) => index >= 0) ?? -1;
const awakening = world.liveState.bag.items.findIndex(
(slot) => slot.item === C.AWAKENING,
);
@@ -142,7 +143,11 @@ export class Playthrough {
mon.hp * 2 < mon.maxHp &&
potion >= 0
)
world.battleAction({ action: "item", item: C.SUPER_POTION, slot: potion });
world.battleAction({
action: "item",
item: world.liveState.bag.items[potion].item,
slot: potion,
});
else
world.battleAction({
action: "fight",
@@ -240,18 +245,27 @@ export class Playthrough {
);
};
/** Walk and Surf to a tile: where a step leaves the shore, face the
* water and answer AskSurfScript's question with YES. */
sail = (x: number, y: number) => {
* water and answer AskSurfScript's question with YES; a whirlpool in
* the way is faced and cleared (TryWhirlpoolOW, then YES) when
* `whirlpools` is set. */
sail = (x: number, y: number, { whirlpools = false } = {}) => {
const world = this.world,
map = world.mapId;
for (let attempt = 0; attempt < 40; attempt++) {
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 })) {
for (const direction of findPath(world, x, y, { water: true, whirlpools })) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
if (isWhirlpool(ahead)) {
// Stepping onto a whirlpool spins the player back: turn first.
if (world.save().direction !== direction) world.move(direction);
world.interact();
this.play([1]);
break;
}
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
world.move(direction); // only turns to face the water
world.interact();
@@ -596,8 +610,8 @@ export class Playthrough {
this.play();
};
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
* a full move list forgets the first move. */
teach = (item: number, slot: number) => {
* a full move list forgets move `forget` (the first by default). */
teach = (item: number, slot: number, forget = 0) => {
const world = this.world;
expect(world.openStartMenu()).toBe(true);
this.choose("PACK");
@@ -612,7 +626,7 @@ export class Playthrough {
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
this.toChoice();
world.choose(0);
world.choose(forget);
}
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
this.toChoice();
@@ -623,6 +637,102 @@ export class Playthrough {
}
this.play();
};
/** Push Strength boulder `id` one tile in `direction` from its far
* side: the first bump only turns the player, a boulder Strength is not
* yet active for asks first (AskStrengthScript, then YES), and the
* boulder moves on its own movement function a few frames later. */
push = (id: number, direction: Direction) => {
const world = this.world;
const boulder = () => world.objects().find((object) => object.id === id);
const from = boulder()!;
const [dx, dy] = STEP[direction];
this.walk(from.x - dx, from.y - dy);
const moved = () => {
const now = boulder();
return !now || now.x !== from.x || now.y !== from.y;
};
for (let attempt = 0; attempt < 3 && !moved(); attempt++) {
world.move(direction);
for (let tick = 0; tick < 40; tick++) world.tick();
this.play();
if (moved()) break;
world.interact();
this.play([1]);
}
expect(moved(), `boulder ${id} pushed ${direction}`).toBe(true);
};
/** Push Strength boulders into their holes: each goal boulder must reach
* its own stone-table warp (a boulder on another pit only stops there),
* and `movable` boulders may be pushed out of the way. The pushes are
* planned breadth first over boulder positions and the player's reachable
* area, then made one by one with `push`. */
boulders = (goals: { id: number; x: number; y: number }[], movable: number[] = []) => {
const world = this.world,
map = world.map;
const ids = [...goals.map((goal) => goal.id), ...movable];
const land = (x: number, y: number) =>
x >= 0 &&
y >= 0 &&
x < map.width &&
y < map.height &&
world.assets.permission(world.assets.collision(map, x, y)) === 0;
const warps = new Set(map.warps.map((warp) => `${warp.x},${warp.y}`));
const fixed = new Set(
world
.objects()
.filter((object) => !ids.includes(object.id))
.map((object) => `${object.x},${object.y}`),
);
const at = (id: number) => {
const object = world.objects().find((candidate) => candidate.id === id);
return object ? `${object.x},${object.y}` : "gone";
};
/** The player's reachable tiles, walking around boulders and pits. */
const reach = (x: number, y: number, boulders: string[]) => {
const blocked = new Set([...boulders, ...fixed]);
const seen = new Set([`${x},${y}`]);
const queue = [[x, y]];
for (let i = 0; i < queue.length; i++)
for (const [dx, dy] of Object.values(STEP)) {
const nx = queue[i][0] + dx,
ny = queue[i][1] + dy,
key = `${nx},${ny}`;
if (seen.has(key) || !land(nx, ny) || blocked.has(key) || warps.has(key)) continue;
seen.add(key);
queue.push([nx, ny]);
}
return seen;
};
type Node = { boulders: string[]; x: number; y: number; pushes: [number, Direction][] };
const queue: Node[] = [{ boulders: ids.map(at), x: world.x, y: world.y, pushes: [] }];
const seen = new Set<string>();
let plan: Node | undefined;
for (let i = 0; i < queue.length && i < 500_000 && !plan; i++) {
const node = queue[i];
const area = reach(node.x, node.y, node.boulders);
const key = `${node.boulders.join(";")}|${[...area].sort()[0]}`;
if (seen.has(key)) continue;
seen.add(key);
if (goals.every((_, index) => node.boulders[index] === "gone")) plan = node;
node.boulders.forEach((boulder, index) => {
if (boulder === "gone") return;
const [bx, by] = boulder.split(",").map(Number);
for (const [direction, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
const target = `${bx + dx},${by + dy}`;
if (!area.has(`${bx - dx},${by - dy}`)) continue;
if (!land(bx + dx, by + dy) || node.boulders.includes(target) || fixed.has(target)) continue;
const goal = goals[index];
const falls = goal && goal.x === bx + dx && goal.y === by + dy;
if (warps.has(target) && !falls) continue;
const boulders = [...node.boulders];
boulders[index] = falls ? "gone" : target;
queue.push({ boulders, x: bx, y: by, pushes: [...node.pushes, [ids[index], direction]] });
}
});
}
expect(plan, "a way to push the boulders into their holes").toBeDefined();
for (const [id, direction] of plan!.pushes) this.push(id, direction);
};
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
cut = (x: number, y: number) => {
const world = this.world;