Open Route 32 to Route 34, the Ruins of Alph and Azalea Town (#32)

Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four
chambers, item and word rooms, inner chamber and research center, Union
Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym,
the Slowpoke Well, Ilex Forest and Route 34.

Port what their scripts need: saved WRAM bytes by label (readmem,
writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity),
VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and
verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures,
CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber
puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and
OmanyteChamber.

Minigames and specials that draw a whole screen in tiles now share a
tile screen effect with ROM graphics, palettes and sprites, answered
with buttons; the standard font and textbox frame load for them.

The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival
and the Farfetch'd to the Route 34 gate; its helpers move to
tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through
the world, catch Unown and make Kurt's balls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-06 15:35:27 +02:00
1 parent 15a31f95e4
commit f4ef244fc7
31 files changed
+2334 -528

No files matched your search

+488
View File
@@ -0,0 +1,488 @@
import { expect } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { encodeSave } from "../src/storage/saves";
import { CRYSTAL_ROM } from "../src/rom/validate";
import type { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, type NativeSave } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Warp mats and edges that need their direction pressed (.CheckWarp). */
const WARP_EXITS: Partial<Record<number, Direction>> = {
0x70: "down",
0x76: "left",
0x78: "up",
0x7e: "right",
};
/** Plays the game through the world's own inputs, as tests/playthrough
* does: scripts, battles and walking, with a seeded Random. A stage's save
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
export class Playthrough {
world!: GameWorld;
readonly log: string[] = [];
/** A wild species to throw Poké Balls at instead of fighting. */
wanted = 0;
seed = 0x2a;
private stage = 0;
readonly now = () => new Date("2026-09-22T12:00:00");
readonly random = () => {
this.seed = (this.seed * 1103515245 + 12345) >>> 0;
return (this.seed >>> 16) & 255;
};
constructor(readonly assets: CrystalAssets) {}
/** A new world for the introduction. */
start() {
this.world = new GameWorld(this.assets, this.now, this.random);
}
/** Continue from a dumped stage. */
resume(dump: { save: NativeSave; seed: number }) {
this.seed = dump.seed;
this.world = new GameWorld(this.assets, this.now, this.random);
this.world.restore(dump.save);
}
/** The usable move with the most power, or the first with PP. */
strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
let best = -1;
mon.moves.forEach((move, i) => {
if (!move || !(mon.pp[i] & 63)) return;
if (
best < 0 ||
this.assets.pokemon.move(move).power >
this.assets.pokemon.move(mon.moves[best]).power
)
best = i;
});
return Math.max(0, best);
};
/** Run scripts and battles until the player is free again. Battles use
* the strongest move with PP; a wild battle below half HP is run from, as
* a cautious player would. 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,
log = this.log;
for (let tick = 0; tick < 100_000; tick++) {
expect(world.notice, world.diagnostic).toBe("");
const battle = world.battle;
if (battle) {
if (battle.message && log.at(-1) !== battle.message.text)
log.push(battle.message.text);
const prompt = battle.prompt;
if (prompt?.kind === "action") {
const { engine } = battle,
mon = engine.player.mon;
const ball = world.state.bag.balls.findIndex(
(slot) => slot.item === C.POKE_BALL,
);
if (
engine.battleMode === C.WILD_BATTLE &&
engine.enemy.mon.species === this.wanted &&
!world.state.party.some((member) => member.species === this.wanted) &&
ball >= 0
)
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else world.battleAction({ action: "fight", move: this.strongest(mon) });
} else if (prompt?.kind === "yesno") world.battleChoose(1);
// SHIFT's party menu: keep the Pokémon out (or send the first
// that can fight when one must go).
else if (prompt?.kind === "party")
world.battleChoose(
prompt.cancel
? -1
: world.state.party.findIndex(
(mon, i) =>
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
),
);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || battle.message?.prompt)
world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (log.at(-1) !== page) log.push(page);
if (last && dialogue.choice) {
const answer = answers.shift();
if (dialogue.choice.kind === "name")
world.choose(typeof answer === "string" ? answer : "");
else
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
continue;
}
if (!world.busy) {
world.tick(); // scene scripts and phone calls start on idle ticks
if (!world.busy) return;
continue;
}
world.tick();
}
throw new Error(`The world did not settle: ${world.diagnostic}`);
};
/** Walk to a tile on the current map, replanning after ledges, roaming
* NPCs and scenes that move the player. A warp target leaves the map. */
walk = (x: number, y: number) => {
const world = this.world,
map = world.mapId,
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
for (let attempt = 0; attempt < 40; attempt++) {
this.play();
if (warp && world.mapId !== map) return;
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
if (world.x === x && world.y === y) return;
for (const direction of findPath(world, x, y)) {
const [dx, dy] = STEP[direction];
const from = [world.x, world.y];
if (!world.move(direction)) {
for (let tick = 0; tick < 64; tick++) world.tick();
break;
}
this.play();
if (
world.mapId !== map ||
world.x !== from[0] + dx ||
world.y !== from[1] + dy
)
break;
}
}
throw new Error(
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
);
};
/** Step across a map connection from the nearest usable edge tile. */
cross = (direction: Direction, target: string) => {
const world = this.world,
map = world.mapId;
const [dx, dy] = STEP[direction];
const edge = Array.from(
{ length: dx ? world.map.height : world.map.width },
(_, i) =>
dx
? [dx < 0 ? 0 : world.map.width - 1, i]
: [i, dy < 0 ? 0 : world.map.height - 1],
).flatMap(([x, y]) => {
try {
return [{ x, y, length: findPath(world, x, y).length }];
} catch {
return [];
}
});
edge.sort((a, b) => a.length - b.length);
for (const { x, y } of edge) {
this.walk(x, y);
if (world.move(direction)) {
this.play();
if (world.mapId === target) return;
}
if (world.mapId !== map) break;
}
throw new Error(
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
);
};
/** Stand in front of a warp leading to `target` and take it. */
enter = (target: string, direction: Direction) => {
const world = this.world;
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
expect(warp, `a warp to ${target}`).toBeDefined();
const [dx, dy] = STEP[direction];
this.walk(warp.x - dx, warp.y - dy);
expect(world.move(direction)).toBe(true);
this.play();
expect(world.mapId).toBe(target);
};
/** Leave through an exit mat, which needs the exit direction pressed. */
exit = (target: string, direction: Direction = "down") => {
const world = this.world;
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
this.walk(warp.x, warp.y);
if (world.mapId !== target) {
expect(world.move(direction)).toBe(true);
this.play();
}
expect(world.mapId).toBe(target);
};
/** Serialize the save to JSON and load it into a fresh world. */
reload = () => {
const saved = JSON.parse(JSON.stringify(this.world.save()));
// PLAYTHROUGH_SAVES=dir keeps every stage's save, for debugging a
// later stage from where the run reached.
if (process.env.PLAYTHROUGH_SAVES) {
mkdirSync(process.env.PLAYTHROUGH_SAVES, { recursive: true });
writeFileSync(
`${process.env.PLAYTHROUGH_SAVES}/${String(++this.stage).padStart(3, "0")}.json`,
JSON.stringify({ save: saved, seed: this.seed }),
);
}
this.world = new GameWorld(this.assets, this.now, this.random);
this.world.restore(saved);
expect(this.world.save()).toEqual(saved);
};
lead = () => this.world.state.party[0];
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
* tests/human-test-saves.test.ts writes them (kept out of git). */
writeTestSave = (name: string) => {
if (!process.env.MAKE_TEST_SAVES) return;
mkdirSync("test-saves", { recursive: true });
writeFileSync(
`test-saves/${name}.crystal-save.json`,
encodeSave({
version: 1,
romHash: CRYSTAL_ROM.sha1,
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: new TextEncoder().encode(JSON.stringify(this.world.save())),
}),
);
};
/** PokecenterNurseScript from in front of the counter, then back out. */
heal = (center: string, city: string) => {
this.enter(center, "up");
this.walk(3, 3);
this.world.move("up"); // the counter: this only turns the player
this.world.interact();
this.play();
expect(
this.world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0]),
).toBe(true);
this.exit(city);
};
/** Pace between two neighbouring grass tiles (the nearest pair) until
* the lead reaches `level`. `rest` heals and comes back here. */
train = (level: number, rest: () => void) => {
const world = this.world,
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 && this.lead().level < level; round++) {
if (this.lead().hp * 2 < this.lead().stats[0]) {
rest();
expect(this.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];
this.walk(x, y);
this.walk(x + 1, y);
}
expect(this.lead().level).toBeGreaterThanOrEqual(level);
};
/** Take the stairs (or another warp) to `target`, trying each. A warp
* mat or cave edge needs its exit direction pressed (.CheckWarp). */
stairs = (target: string) => {
const world = this.world;
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
try {
findPath(world, warp.x, warp.y);
} catch {
continue;
}
this.walk(warp.x, warp.y);
if (world.mapId === target) return;
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
if (exit && world.move(exit)) {
this.play();
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. */
route = (
target: string,
maps: string[],
depth = 6,
goal?: { x: number; y: number },
): boolean => {
const world = this.world;
const reachable = () => {
if (!goal) return true;
try {
findPath(world, goal.x, goal.y);
return true;
} catch {
return false;
}
};
if (world.mapId === target && reachable()) 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;
this.walk(warp.x, warp.y);
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
return true;
world.restore(here);
}
return false;
};
/** Advance to the next menu choice, keeping its pages in the log. */
toChoice = () => {
const world = this.world,
log = this.log;
for (let tick = 0; tick < 2000; tick++) {
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
if (log.at(-1) !== page) log.push(page);
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
return dialogue.choice;
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else world.tick();
}
throw new Error(`No choice appeared: ${world.diagnostic}`);
};
/** Choose a menu item by its label (or a pattern). */
choose = (label: string | RegExp) => {
const choice = this.toChoice();
if (choice.kind !== "menu")
throw new Error(`Expected a menu, got ${choice.kind}`);
const index = choice.items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
this.world.choose(index);
};
/** Choose an item in the pack. */
choosePack = (item: number) => {
const choice = this.toChoice();
if (choice.kind !== "pack")
throw new Error(`Expected the pack, got ${choice.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) return this.world.choosePackEntry(pocket, index);
}
throw new Error(`Item ${item} is not in the pack.`);
};
/** Talk to an object wherever it stands. A trainer who spots the player
* on the way battles and moves, so the approach is planned again. */
talkTo = (id: number) => {
const world = this.world;
for (let attempt = 0; attempt < 4; attempt++)
for (const [dx, dy, face] of [
[-1, 0, "right"],
[1, 0, "left"],
[0, 1, "up"],
[0, -1, "down"],
] as const) {
const target = world.objects().find((object) => object.id === id)!;
const x = target.x + dx,
y = target.y + dy;
try {
if (!(world.x === x && world.y === y)) this.walk(x, y);
} catch {
continue;
}
const now = world.objects().find((object) => object.id === id)!;
if (now.x !== target.x || now.y !== target.y) break;
world.move(face);
world.interact();
return;
}
throw new Error(`No way to talk to object ${id}`);
};
/** 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) => {
const world = this.world;
expect(world.openStartMenu()).toBe(true);
this.choose("PACK");
this.choosePack(item);
this.choose("USE");
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
const party = this.toChoice();
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
world.choose(slot);
if (world.liveState.party[slot].moves.every(Boolean)) {
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
this.toChoice();
world.choose(0);
}
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
this.toChoice();
// The menus work on a draft that is kept once they close.
for (let i = 0; i < 10 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
else world.tick();
}
this.play();
};
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
cut = (x: number, y: number) => {
const world = this.world;
for (const [face, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
try {
this.walk(x - dx, y - dy);
} catch {
continue;
}
world.move(face);
world.interact();
this.play([1]);
return;
}
throw new Error(`No side to cut ${x},${y} from`);
};
/** The current location and party, then the last pages, for a failure. */
trace = () =>
`${this.world.mapId} ${this.world.x},${this.world.y}; party ${JSON.stringify(
this.world.state.party.map(({ species, level, hp }) => ({
species,
level,
hp,
})),
)}\n${this.log.slice(-30).join("\n---\n")}`;
}