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

+3 -1
View File
@@ -46,6 +46,8 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
await expect(open.first()).toHaveText("NEW BARK TOWN");
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
await expect(page.locator("#atlas-open")).not.toContainText("BLACKTHORN CITY");
await expect(page.locator("#atlas-open")).toContainText("BLACKTHORN CITY");
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
await expect(page.locator("#atlas-open")).not.toContainText("SILVER CAVE");
expect(errors).toEqual([]);
});
+4 -1
View File
@@ -18,6 +18,7 @@ const NEW_BARK_TOWN = "24:4",
LANDMARK_VIOLET_CITY = 6,
LANDMARK_AZALEA_TOWN = 12,
LANDMARK_BLACKTHORN_CITY = 0x29,
LANDMARK_SILVER_CAVE = 0x2e,
LANDMARK_PALLET_TOWN = 47;
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
@@ -100,7 +101,9 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: false, flypoint: { visited: false } });
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: false, flypoint: { visited: false } });
// Kanto stays closed, so asking for it shows Johto.
expect(world.atlas("kanto").region).toBe("johto");
});
+148
View File
@@ -0,0 +1,148 @@
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 { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Blackthorn City with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const MOVE_DELETERS_HOUSE = "5:7",
DRAGON_SHRINE = "3:82";
const TYPHLOSION = 157,
DRATINI = 147,
SCENE_DRAGONSHRINE_NOOP = 1;
const WRAP = 35,
LEER = 43,
THUNDER_WAVE = 86,
TWISTER = 239,
EXTREMESPEED = 245;
const EVENT_GOT_DRATINI = 0xbd,
EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1;
const world = () => {
let seed = 13;
return new GameWorld(
new CrystalAssets(bytes),
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
};
const mon = (game: GameWorld, species: number, level = 30) =>
generatePartyMon(game.assets.pokemon, {
species,
level,
item: 0,
otId: game.playerId,
ot: "KAWA",
dvs: 0x8888,
caughtData: 0,
});
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
const talk = (
game: GameWorld,
map: string,
x: number,
y: number,
face: "up" | "down" | "left" | "right",
answers: (number | string)[] = [],
) => {
game.warpTo(map, x, y);
settle(game);
game.restore({ ...game.save(), x, y, direction: face });
game.interact();
const shown = settle(game, answers).join(" ");
expect(game.notice, game.diagnostic).toBe("");
return shown;
};
/** The Move Deleter stands at (2, 3). */
const deleter = (game: GameWorld, answers: number[]) =>
talk(game, MOVE_DELETERS_HOUSE, 2, 4, "up", answers);
describe("the Move Deleter (MoveDeletion)", () => {
it("deletes the chosen move and moves the later moves and PP up", () => {
const game = world();
const typhlosion = mon(game, TYPHLOSION);
typhlosion.moves = [33, 43, 108, 52];
typhlosion.pp = [35, 30 | 0xc0, 20, 25 | 0x40];
game.state.party = [typhlosion];
// YES, the first Pokémon, its second move (LEER), YES.
const shown = deleter(game, [1, 0, 1, 1]);
expect(shown).toContain("the MOVE DELETER.");
expect(shown).toContain("Oh, make it forget\nLEER?");
expect(shown).toContain("forgot the move.");
// The PP Up bits move with each move's PP.
expect(game.state.party[0].moves).toEqual([33, 108, 52, 0]);
expect(game.state.party[0].pp).toEqual([35, 20, 25 | 0x40, 0]);
});
it("refuses a Pokémon with one move and an Egg, and says goodbye to every no", () => {
const game = world();
const one = mon(game, TYPHLOSION);
one.moves = [33, 0, 0, 0];
one.pp = [35, 0, 0, 0];
const egg = { ...mon(game, TYPHLOSION, 5), egg: true };
game.state.party = [one, egg, mon(game, TYPHLOSION)];
expect(deleter(game, [1, 0])).toContain("knows\nonly one move.");
expect(deleter(game, [1, 1])).toContain("An EGG doesn't");
const before = structuredClone(game.state.party[2]);
expect(deleter(game, [0])).toContain("Come visit me");
expect(deleter(game, [1, -1])).toContain("Come visit me");
expect(deleter(game, [1, 2, -1])).toContain("Come visit me");
expect(deleter(game, [1, 2, 0, 0])).toContain("Come visit me");
expect(game.state.party[2]).toEqual(before);
});
});
describe("the Dragon Shrine elder's Dratini (GiveDratini)", () => {
/** After the test (the shrine's scene done), talk to the elder. */
const elder = (game: GameWorld) => {
game.state.scenes[DRAGON_SHRINE] = SCENE_DRAGONSHRINE_NOOP;
return talk(game, DRAGON_SHRINE, 5, 2, "up", [0]);
};
it("gives a Dratini with Extremespeed after a test without mistakes", () => {
const game = world();
game.state.party = [mon(game, TYPHLOSION)];
const shown = elder(game);
expect(shown).toContain("Take this DRATINI");
expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(true);
const dratini = game.state.party[1];
expect(dratini).toMatchObject({ species: DRATINI, level: 15 });
expect(dratini.moves).toEqual([WRAP, THUNDER_WAVE, TWISTER, EXTREMESPEED]);
// Each move's base PP, as .GiveMoves reads it from Moves.
expect(dratini.pp).toEqual([20, 20, 20, 5]);
// Re-entering clears EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7.
expect(elder(game)).toContain("CLAIR appears to");
});
it("gives the normal moves after a mistake, to the last Dratini only", () => {
const game = world();
game.state.events.add(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG);
const earlier = mon(game, DRATINI, 20);
game.state.party = [earlier, mon(game, TYPHLOSION)];
const before = structuredClone(earlier);
elder(game);
expect(game.state.party[2].moves).toEqual([WRAP, LEER, THUNDER_WAVE, TWISTER]);
expect(game.state.party[2].pp).toEqual([20, 30, 20, 20]);
expect(game.state.party[0]).toEqual(before);
});
it("keeps the Dratini for later when the party is full", () => {
const game = world();
game.state.party = Array.from({ length: 6 }, () => mon(game, TYPHLOSION));
expect(elder(game)).toContain("party is full.");
expect(game.state.events.has(EVENT_GOT_DRATINI)).toBe(false);
game.state.party.pop();
elder(game);
expect(game.state.party[5].species).toBe(DRATINI);
});
});
});
+4 -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_BLACKTHORN = 25;
SPAWN_MT_SILVER = 26;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
@@ -203,11 +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_BLACKTHORN);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_MT_SILVER);
useMove(world, "FLY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "BLACKTHORN CITY"; press++)
for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("BLACKTHORN CITY");
expect(flyMap(world)?.name).toBe("SILVER CAVE");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
+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;
+246
View File
@@ -172,6 +172,41 @@ const RED_SCALE = 0x42,
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
ENGINE_GLACIERBADGE = 0x21,
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
const RADIO_TOWER = ["3:17", "3:18", "3:19", "3:20", "3:21"],
UNDERGROUND = "3:53",
SWITCH_ROOM = "3:54",
DEPT_STORE_B1F = "3:55",
WAREHOUSE = "3:56",
DEPT_STORE_1F = "11:11",
ELEVATOR = "11:17",
ROUTE_44 = "2:6",
ICE_PATH = ["3:61", "3:62", "3:63", "3:64", "3:65"],
BLACKTHORN = "5:10",
BLACKTHORN_CENTER = "5:6",
BLACKTHORN_MART = "5:5",
BLACKTHORN_GYM = ["5:1", "5:2"],
ROUTE_45 = "5:8",
DRAGONS_DEN = ["3:80", "3:81"],
DRAGON_SHRINE = "3:82";
const BASEMENT_KEY = 0x85,
CARD_KEY = 0x7f,
CLEAR_BELL = 0x46,
HM06 = 0xf8,
DRATINI = 147,
EXTREMESPEED = 245,
EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 0x573,
EVENT_USED_BASEMENT_KEY = 0x49,
EVENT_RIVAL_GOLDENROD_UNDERGROUND = 0x6c1,
EVENT_RECEIVED_CARD_KEY = 0x4a,
EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 0x25,
EVENT_CLEARED_RADIO_TOWER = 0x21,
EVENT_GOT_CLEAR_BELL = 0x78,
EVENT_BOULDER_IN_ICE_PATH_2A = 0x70e,
EVENT_BEAT_CLAIR = 0x4c4,
EVENT_GOT_TM24_DRAGONBREATH = 0xf,
EVENT_GOT_DRATINI = 0xbd,
EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG = 0xc1,
ENGINE_RISINGBADGE = 0x22;
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -1185,5 +1220,216 @@ describe.skipIf(!existsSync(romPath))(
reload();
writeTestSave("mahogany-town");
}, 240_000);
/** Mahogany to Goldenrod the long way round: Route 42, Ecruteak,
* Routes 37 and 36, the National Park and Route 35. */
const mahoganyToGoldenrod = () => {
cross("left", ROUTE_42, { water: true });
sail(2, 8); // Route 42's pond, then ashore by the gate
stairs(ROUTE_42_GATE);
stairs(ECRUTEAK);
cross("down", ROUTE_37);
cross("down", ROUTE_36);
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_GATE);
stairs(GOLDENROD);
};
it("walks back to Goldenrod and climbs the Rocket-held Radio Tower to the fake director", () => {
traceFailures();
mahoganyToGoldenrod();
heal(GOLDENROD_CENTER, GOLDENROD);
enter(RADIO_TOWER[0], "up");
for (const floor of RADIO_TOWER.slice(1)) stairs(floor);
walk(0, 3); // FakeDirectorScript
expect(p.world.state.events.has(EVENT_BEAT_ROCKET_EXECUTIVEM_3)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(BASEMENT_KEY);
expect(p.log.join(" ")).toContain("UNDERGROUND\nWAREHOUSE.");
travel(GOLDENROD, RADIO_TOWER);
reload();
}, 240_000);
it("opens the basement door, works the Underground switches and frees the director", () => {
traceFailures();
// Fresh PP before the rival waiting in the switch room.
heal(GOLDENROD_CENTER, GOLDENROD);
stairs(SWITCH_ROOM);
stairs(UNDERGROUND);
walk(18, 7);
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_USED_BASEMENT_KEY)).toBe(true);
// The door leads to the Underground's other corner.
p.world.move("up");
play();
stairs(SWITCH_ROOM);
// From all off, switches 3, 2 and 1 leave doors 3, 5, 6, 8, 9 and 11
// open: a way from the switches down to the warehouse.
for (const [x, y] of [[2, 1], [10, 1], [16, 1]]) {
walk(x, y + 1);
p.world.move("up");
p.world.interact();
play([1]);
}
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 6 });
expect(p.world.state.events.has(EVENT_RIVAL_GOLDENROD_UNDERGROUND)).toBe(true);
stairs(WAREHOUSE);
// The warehouse resets the switches when entered.
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 0 });
talkTo(p.world.map.objects[3].id);
play();
expect(p.world.state.events.has(EVENT_RECEIVED_CARD_KEY)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(CARD_KEY);
// Out through the department store's basement and elevator.
stairs(DEPT_STORE_B1F);
stairs(ELEVATOR);
walk(3, 1);
p.world.move("up");
p.world.interact();
play([1]); // 1F
walk(2, 3);
p.world.move("down");
play();
expect(p.world.mapId).toBe(DEPT_STORE_1F);
exit(GOLDENROD);
reload();
}, 240_000);
it("opens the 3F shutters with the Card Key and beats the executive for the Clear Bell", () => {
traceFailures();
heal(GOLDENROD_CENTER, GOLDENROD);
enter(RADIO_TOWER[0], "up");
stairs(RADIO_TOWER[1]);
stairs(RADIO_TOWER[2]);
walk(14, 3); // CardKeySlotScript faces up
p.world.move("up");
p.world.interact();
play();
expect(p.world.state.events.has(EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER)).toBe(true);
walk(17, 0); // the stairs behind the shutters
expect(p.world.mapId).toBe(RADIO_TOWER[3]);
stairs(RADIO_TOWER[4]);
walk(16, 5); // RadioTower5FRocketBossScript
expect(p.world.state.events.has(EVENT_CLEARED_RADIO_TOWER)).toBe(true);
expect(p.world.state.flags.has(ENGINE_ROCKETS_IN_RADIO_TOWER)).toBe(false);
expect(p.world.state.events.has(EVENT_GOT_CLEAR_BELL)).toBe(true);
expect(p.world.state.bag.keyItems).toContain(CLEAR_BELL);
// The director walks out and disappears; a save keeps him gone.
reload();
travel(RADIO_TOWER[0], RADIO_TOWER.slice(1));
exit(GOLDENROD);
reload();
}, 240_000);
it("crosses Route 44 and Ice Path's boulders to Blackthorn City", () => {
traceFailures();
toRoute36();
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
stairs(ROUTE_42_GATE);
stairs(ROUTE_42);
cross("right", MAHOGANY, { water: true });
heal(MAHOGANY_CENTER, MAHOGANY);
cross("right", ROUTE_44);
stairs(ICE_PATH[0]);
stairs(ICE_PATH[1]);
// B1F: drop boulder 2 down its hole, then fall through another; on
// B2F's ice it stops a slide on the way to B3F.
p.boulders([{ id: 3, x: 4, y: 7 }], [2]);
expect(p.world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
walk(5, 12);
expect(p.world.mapId).toBe(ICE_PATH[2]);
walk(9, 11);
expect(p.world.mapId).toBe(ICE_PATH[4]);
travel(BLACKTHORN, [ICE_PATH[0], ICE_PATH[1], ICE_PATH[3], ICE_PATH[4]]);
reload();
}, 240_000);
it("trains on Route 45, drops the gym's boulders and beats Clair", () => {
traceFailures();
// Clair's Kingdra outclasses a level 53 lead; a player trains first.
cross("down", ROUTE_45);
train(62, () => {
cross("up", BLACKTHORN);
heal(BLACKTHORN_CENTER, BLACKTHORN);
cross("down", ROUTE_45);
});
cross("up", BLACKTHORN);
enter(BLACKTHORN_MART, "up");
buy(p.world.map.objects[0].id, C.MAX_POTION, 4);
exit(BLACKTHORN);
heal(BLACKTHORN_CENTER, BLACKTHORN);
enter(BLACKTHORN_GYM[0], "up");
stairs(BLACKTHORN_GYM[1]);
// Boulders 1 and 3 fall to 1F and bridge the way to Clair.
p.boulders(
[
{ id: 4, x: 8, y: 3 },
{ id: 6, x: 8, y: 7 },
],
[5, 7, 8, 9],
);
expect(route(BLACKTHORN_GYM[0], BLACKTHORN_GYM, 4, { x: 5, y: 4 })).toBe(true);
talkTo(p.world.map.objects[0].id);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_CLAIR)).toBe(true);
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(false);
expect(p.log.join(" ")).toContain("DRAGON'S DEN");
travel(BLACKTHORN, BLACKTHORN_GYM);
reload();
}, 360_000);
it("passes the Dragon Shrine's test for the RISINGBADGE, TM24 and Dratini", () => {
traceFailures();
heal(BLACKTHORN_CENTER, BLACKTHORN);
// Krabby learns Whirlpool in place of Leer for Dragon's Den.
p.teach(HM06, 4, 1);
expect(p.world.state.party[4].moves).toContain(250);
const den = p.world.map.warps.find((warp) => warp.targetMap === DRAGONS_DEN[0])!;
sail(den.x, den.y + 1);
p.world.move("up");
play();
walk(3, 4); // the ladder down to 1F's lower part
p.world.move("up");
play();
stairs(DRAGONS_DEN[1]);
sail(19, 31, { whirlpools: true });
walk(19, 30);
// DragonShrineTakeTestScript: five questions, all answered right.
p.world.move("up");
play([0, 0, 1, 0, 1]);
expect(p.world.mapId).toBe(DRAGON_SHRINE);
expect(p.world.state.flags.has(ENGINE_RISINGBADGE)).toBe(true);
expect(p.world.state.events.has(EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG)).toBe(false);
// Clair waits by the door with TM24 (DragonsDenB1F_ClairScene).
stairs(DRAGONS_DEN[1]);
expect(p.world.state.events.has(EVENT_GOT_TM24_DRAGONBREATH)).toBe(true);
expect(p.world.state.bag.tmsHms[23]).toBe(1); // TM24
// Back inside, the elder gives Dratini with Extremespeed.
p.world.move("up");
play();
talkTo(p.world.map.objects[0].id);
play();
expect(p.world.state.events.has(EVENT_GOT_DRATINI)).toBe(true);
const dratini = p.world.state.party.at(-1)!;
expect(dratini).toMatchObject({ species: DRATINI, level: 15 });
expect(dratini.moves).toEqual([35, 86, 239, EXTREMESPEED]);
expect(dratini.pp).toEqual([20, 20, 20, 5]);
// Out: the whirlpool is back after the map reload.
stairs(DRAGONS_DEN[1]);
sail(20, 4, { whirlpools: true });
p.world.move("up");
play();
walk(5, 14);
p.world.move("up");
play();
stairs(BLACKTHORN);
sail(17, 14);
reload();
writeTestSave("blackthorn-city");
}, 240_000);
},
);
+137
View File
@@ -0,0 +1,137 @@
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 { Playthrough } from "./playthrough-driver";
import { findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("the Radio Tower takeover with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const SWITCH_ROOM = "3:54",
WAREHOUSE = "3:56",
RADIO_TOWER_5F = "3:21",
GOLDENROD = "11:2";
const TYPHLOSION = 157,
SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_NOOP = 1,
SCENE_RADIOTOWER5F_ROCKET_BOSS = 1;
const EVENT_SWITCH_1 = 0x2d3,
EVENT_DOOR_1_OPEN = 0x2d7,
EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 0x6ce,
EVENT_CLEARED_RADIO_TOWER = 0x21,
EVENT_GOT_CLEAR_BELL = 0x78,
ENGINE_ROCKETS_IN_RADIO_TOWER = 0x13;
/** RADIOTOWER5F_DIRECTOR, the first object (no event flag). */
const DIRECTOR = 2;
/** A playthrough driver on a fresh world with one Pokémon at `level`. */
const driver = (level: number) => {
const p = new Playthrough(assets!);
p.world = new GameWorld(assets!, p.now, p.random);
p.world.state.party = [
generatePartyMon(assets!.pokemon, {
species: TYPHLOSION,
level,
item: 0,
otId: p.world.playerId,
ot: "KAWA",
dvs: 0xffff,
caughtData: 0,
}),
];
return p;
};
/** The switches, from switch 1 on the right, and the doors' events. */
const SWITCHES = [
[16, 1],
[10, 1],
[2, 1],
] as const;
const flags = (world: GameWorld) =>
Array.from({ length: 15 }, (_, i) => +world.state.events.has(EVENT_SWITCH_1 + i)).join("");
it("keeps the switches and doors through a reload until the warehouse resets them", () => {
const p = driver(70);
const { world } = p;
world.state.scenes[SWITCH_ROOM] = SCENE_GOLDENRODUNDERGROUNDSWITCHROOMENTRANCES_NOOP;
world.warpTo(SWITCH_ROOM, 23, 3);
p.play();
// Every door is shut, so the warehouse (22, 10) is out of reach.
expect(() => findPath(world, 22, 10)).toThrow();
for (const [x, y] of [SWITCHES[2], SWITCHES[1], SWITCHES[0]]) {
p.walk(x, y + 1);
world.move("up");
world.interact();
p.play([1]);
}
// Positions 3, 5 and 6 in turn: doors 3, 5, 6, 8, 9 and 11 stay open.
expect(world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 6 });
expect(flags(world)).toBe("111000101101101");
expect(findPath(world, 22, 10).length).toBeGreaterThan(0);
// The tiles callback reopens the doors after a reload.
p.reload();
expect(flags(p.world)).toBe("111000101101101");
expect(findPath(p.world, 22, 10).length).toBeGreaterThan(0);
p.walk(22, 10);
expect(p.world.mapId).toBe(WAREHOUSE);
// GoldenrodUndergroundWarehouseResetSwitchesCallback.
expect(flags(p.world)).toBe("0".repeat(15));
expect(p.world.save().memory).toMatchObject({ wUndergroundSwitchPositions: 0 });
expect(p.world.state.events.has(EVENT_DOOR_1_OPEN)).toBe(false);
});
it("whites out cleanly when the rival in the switch room wins", () => {
const p = driver(2);
const { world } = p;
world.state.money = 3000;
world.warpTo(SWITCH_ROOM, 23, 3);
p.play();
// UndergroundRivalScene1: the battle runs inside an scall, and the loss
// jumps to Script_Whiteout, whose endall drops the caller.
world.restore({ ...world.save(), x: 18, y: 4, direction: "right" });
world.move("right");
p.play();
expect(world.notice, world.diagnostic).toBe("");
expect(p.log.join(" ")).toContain("whited\nout!");
expect(world.mapId).not.toBe(SWITCH_ROOM);
expect(world.state.money).toBe(1500);
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
});
it("keeps the director gone after a save once he walks out on 5F", () => {
const p = driver(80);
const { world } = p;
world.state.flags.add(ENGINE_ROCKETS_IN_RADIO_TOWER);
world.state.scenes[RADIO_TOWER_5F] = SCENE_RADIOTOWER5F_ROCKET_BOSS;
world.warpTo(RADIO_TOWER_5F, 15, 5);
p.play();
p.world.move("right");
p.play();
p.world.move("right");
p.play();
expect(world.state.events.has(EVENT_CLEARED_RADIO_TOWER)).toBe(true);
expect(world.state.events.has(EVENT_GOT_CLEAR_BELL)).toBe(true);
expect(world.state.events.has(EVENT_RADIO_TOWER_ROCKET_TAKEOVER)).toBe(true);
// He has no event flag: disappear only removes his object struct,
// which Continue keeps as saved.
expect(world.objects().some((object) => object.id === DIRECTOR)).toBe(false);
expect(world.save().objects).toContainEqual(
expect.objectContaining({ id: DIRECTOR, hidden: true }),
);
p.reload();
expect(p.world.objects().some((object) => object.id === DIRECTOR)).toBe(false);
// The stairs he walked out by are free.
expect(findPath(p.world, 12, 0).length).toBeGreaterThan(0);
// A later visit rebuilds the objects from the map: he is back.
p.travel(GOLDENROD, ["3:17", "3:18", "3:19", "3:20", "3:21"]);
p.world.warpTo(RADIO_TOWER_5F, 0, 1);
p.play();
expect(p.world.objects().some((object) => object.id === DIRECTOR)).toBe(true);
});
});
+19 -7
View File
@@ -91,6 +91,8 @@ const isWarpHazard = (tile: number) =>
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
* freely, unlike currents, waterfalls and whirlpools. */
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
/** COLL_WHIRLPOOL and COLL_WHIRLPOOL_2C: water once Whirlpool clears it. */
export const isWhirlpool = (tile: number) => tile === 0x24 || tile === 0x2c;
/** COLL_ICE and COLL_ICE_2B: a step onto them keeps sliding (.CheckForced). */
const isIce = (tile: number) => tile === 0x23 || tile === 0x2b;
@@ -105,13 +107,18 @@ function open(
direction: Direction,
water: boolean,
target?: { x: number; y: number },
whirlpools = false,
) {
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
const x = fromX + dx,
y = fromY + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height) return false;
const tile = world.assets.collision(world.map, x, y);
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile))) return false;
if (
world.assets.permission(tile) !== 0 &&
!(water && (isOpenWater(tile) || (whirlpools && isWhirlpool(tile))))
)
return false;
if (
edges(world.assets.collision(world.map, fromX, fromY)).includes(direction) ||
edges(tile).includes(OPPOSITE[direction])
@@ -135,9 +142,13 @@ export function landing(
x: number,
y: number,
direction: Direction,
{ water = false, target }: { water?: boolean; target?: { x: number; y: number } } = {},
{
water = false,
target,
whirlpools = false,
}: { water?: boolean; target?: { x: number; y: number }; whirlpools?: boolean } = {},
): { x: number; y: number } | undefined {
if (!open(world, x, y, direction, water, target)) return undefined;
if (!open(world, x, y, direction, water, target, whirlpools)) return undefined;
const [, dx, dy] = DIRECTIONS.find(([d]) => d === direction)!;
x += dx;
y += dy;
@@ -152,13 +163,14 @@ export function landing(
}
/** Shortest walkable path on the current map, avoiding other warps.
* `water` also crosses open water, for a player who can Surf; a step onto
* ice slides on until blocked. */
* `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. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
{ water = false } = {},
{ water = false, whirlpools = false } = {},
): Direction[] {
const target = { x: targetX, y: targetY };
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
@@ -170,7 +182,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 });
const end = landing(world, node.x, node.y, direction, { water, target, whirlpools });
if (end) queue.push({ ...end, path: [...node.path, direction] });
}
}