Files
Crystal/tests/fly-dig-teleport.test.ts
T
kawaandClaude Opus 5.5 9c3579937c 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>
2026-10-13 14:19:02 +02:00

328 lines
13 KiB
TypeScript

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 { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK_TOWN = "24:4",
PLAYERS_HOUSE_1F = "24:6",
ROUTE_29 = "24:3",
CHERRYGROVE_CITY = "26:3",
ROUTE_31 = "26:2",
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
const SPAWN_NEW_BARK = 14,
SPAWN_CHERRYGROVE = 15,
SPAWN_MT_SILVER = 26;
const ENGINE_STORMBADGE = 32,
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
const FLY = 0x13,
DIG = 0x5b,
TELEPORT = 0x64;
const PIDGEOT = 18,
ESCAPE_ROPE = 0x13;
describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope", () => {
/** A world with one Pokémon that knows `moves`, on `map`. */
function fixture(
moves: number[],
map: string,
x: number,
y: number,
direction: Direction = "down",
ported: string[] = [],
) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => isPorted(id) || ported.includes(id);
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
world.state = emptyScriptState();
const mon = generatePartyMon(assets.pokemon, {
species: PIDGEOT,
level: 40,
item: 0,
otId: 1,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "BIRDY";
world.state.party = [mon];
world.state.scenes[NEW_BARK_TOWN] = 1;
// Route 31's NEWMAP callback would otherwise queue Mom's worried call.
world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
world.restore({ ...world.save(), map, x, y, direction, objects: [] });
return { assets, world };
}
const visit = (world: GameWorld, ...spawns: number[]) => {
for (const spawn of spawns)
world.state.flags.add(world.assets.spawnEngineFlag(spawn)!);
};
/** Acknowledge pages up to the next choice; returns the pages seen. */
function until(world: GameWorld) {
const pages: string[] = [];
for (let tick = 0; tick < 500; tick++) {
const dialogue = world.dialogue;
if (!dialogue) {
if (!world.busy) return pages;
world.tick();
continue;
}
const page = dialogue.pages[dialogue.index];
if (page && pages.at(-1) !== page) pages.push(page);
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) return pages;
if (last && dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
throw new Error("No choice appeared.");
}
function pick(world: GameWorld, label: string) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`);
const index = choice.items.findIndex((item) => item.startsWith(label));
expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
/** Start → POKéMON → BIRDY → `move`. */
function useMove(world: GameWorld, move: string) {
expect(world.openStartMenu()).toBe(true);
until(world);
pick(world, "POKéMON");
pick(world, "BIRDY");
return pick(world, move);
}
/** B out of every menu, then let any queued script finish. */
function close(world: GameWorld) {
for (let i = 0; i < 10 && world.dialogue?.choice; i++)
world.advanceDialogue();
return settle(world);
}
const flyMap = (world: GameWorld) => {
const choice = world.dialogue?.choice;
return choice?.kind === "flymap" ? choice.view : undefined;
};
it("needs the Storm Badge and an outdoor map to open the Fly map", () => {
const { world } = fixture([FLY], PLAYERS_HOUSE_1F, 7, 4);
// A failure prints its message and returns to the party menu.
expect(useMove(world, "FLY")).toEqual([
"Sorry! A new BADGE\nis required.",
"Choose a POKéMON.",
]);
close(world);
world.state.flags.add(ENGINE_STORMBADGE);
expect(useMove(world, "FLY")[0]).toBe("Can't use that\nhere.");
close(world);
expect(world.mapId).toBe(PLAYERS_HOUSE_1F);
expect(world.notice, world.diagnostic).toBe("");
});
it("cycles through visited flypoints on the Fly map, and B returns to the party", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10);
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE);
useMove(world, "FLY");
// New Bark Town is the default, with the player's icon on Route 29.
expect(flyMap(world)).toMatchObject({
kanto: false,
where: "Where?",
name: "NEW BARK TOWN",
cursor: 1,
player: world.map.landmark,
species: PIDGEOT,
});
// Up goes to the next visited flypoint, down back; both wrap around.
world.moveCursor("up");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
world.moveCursor("up");
expect(flyMap(world)?.name).toBe("NEW BARK TOWN");
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
// Other buttons do nothing.
world.choose("start");
expect(flyMap(world)?.name).toBe("CHERRYGROVE CITY");
world.advanceDialogue(); // B
expect(world.dialogue?.choice?.kind).toBe("menu");
close(world);
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
expect(world.notice, world.diagnostic).toBe("");
});
it("flies to a flypoint with the party icon's animations", () => {
const { world } = fixture([FLY], ROUTE_29, 50, 10, "left");
world.state.flags.add(ENGINE_STORMBADGE);
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE);
useMove(world, "FLY");
world.moveCursor("up");
world.choose("a");
// The menu closes and FlyScript starts: the icon replaces every object.
expect(world.dialogue).toBeUndefined();
expect(world.flyingIcon).toMatchObject({ species: PIDGEOT, x: 0, y: 0 });
expect(world.poses()).toEqual([]);
const heights: number[] = [];
while (world.busy && world.mapId === ROUTE_29) {
heights.push(world.flyingIcon?.y ?? 0);
world.tick();
}
// SpriteAnimFunc_FlyFrom: 64 frames in place, then two pixels a frame
// up to the top of the screen.
expect(heights.slice(0, 32).every((y) => y === 0)).toBe(true);
expect(Math.min(...heights)).toBe(-84);
// MAPSETUP_FLY: Cherrygrove's spawn point, facing down; the icon then
// comes down from above the screen onto the player.
expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]);
expect(world.flyingIcon?.y).toBeLessThan(-80);
const landing: number[] = [];
while (world.busy) {
landing.push(world.flyingIcon?.y ?? 0);
world.tick();
}
expect(landing.at(-1)).toBe(0);
expect(world.flyingIcon).toBeUndefined();
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
expect(world.notice, world.diagnostic).toBe("");
// The flight commits: a save is on Cherrygrove.
expect(world.save()).toMatchObject({ map: CHERRYGROVE_CITY, x: 29, y: 4 });
});
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_MT_SILVER);
useMove(world, "FLY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("SILVER CAVE");
world.choose("a");
until(world);
expect(world.notice).toBe("This destination is not ported yet.");
close(world);
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
});
it("teleports outdoors to the last Pokémon Center's town", () => {
const { world } = fixture([TELEPORT], ROUTE_29, 50, 10);
// No spawn recorded yet, or indoors: the move cannot be used.
expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere.");
close(world);
world.state.lastSpawnMap = CHERRYGROVE_CITY;
const shown = [...useMove(world, "TELEPORT"), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Return to the last\nPOKéMON CENTER.");
expect([world.mapId, world.x, world.y]).toEqual([CHERRYGROVE_CITY, 29, 4]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
expect(world.busy).toBe(false);
});
it("refuses Teleport indoors", () => {
const { world } = fixture([TELEPORT], PLAYERS_HOUSE_1F, 7, 4);
world.state.lastSpawnMap = CHERRYGROVE_CITY;
expect(useMove(world, "TELEPORT")[0]).toBe("Can't use that\nhere.");
close(world);
expect(world.mapId).toBe(PLAYERS_HOUSE_1F);
});
/** Walk from Route 31 into Dark Cave, which records the dig warp. */
function enterDarkCave(moves: number[]) {
const fixtureWorld = fixture(moves, ROUTE_31, 34, 6, "up", [
ROUTE_31,
DARK_CAVE_VIOLET_ENTRANCE,
]);
const { world } = fixtureWorld;
for (let tick = 0; tick < 40 && world.mapId === ROUTE_31; tick++)
world.tick("up");
settle(world);
expect(world.mapId).toBe(DARK_CAVE_VIOLET_ENTRANCE);
// EnterMapWarp's SaveDigWarp: Route 31's third warp, the cave entrance.
expect(world.state.digWarp).toEqual({ map: ROUTE_31, warp: 3 });
return fixtureWorld;
}
it("digs out of a cave to the entrance last used", () => {
const { world } = enterDarkCave([DIG]);
// The dig warp is saved with the game.
const saved = world.save();
expect(saved.digWarp).toEqual({ map: ROUTE_31, warp: 3 });
world.restore(saved);
const shown = [...useMove(world, "DIG"), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("BIRDY used\nDIG!");
// MAPSETUP_DOOR onto the cave entrance, which faces the player down;
// standing on it, the player then walks out.
expect(world.mapId).toBe(ROUTE_31);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
direction: "down",
});
for (let tick = 0; tick < 40 && world.y < 6; tick++) world.tick();
expect([world.x, world.y]).toEqual([34, 6]);
});
it("explains that Dig only works in caves", () => {
const { world } = fixture([DIG], ROUTE_29, 50, 10);
world.state.digWarp = { map: ROUTE_31, warp: 3 };
expect(useMove(world, "DIG")[0]).toMatch(/^Can't use that/);
close(world);
expect(world.mapId).toBe(ROUTE_29);
});
it("uses an Escape Rope in a cave, and only there", () => {
const { world } = enterDarkCave([]);
world.state.bag.items = [{ item: ESCAPE_ROPE, quantity: 2 }];
const useRope = () => {
expect(world.openStartMenu()).toBe(true);
until(world);
pick(world, "PACK");
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack") throw new Error("Expected the pack.");
const pocket = choice.pockets.findIndex(({ entries }) =>
entries.some((entry) => entry.item === ESCAPE_ROPE),
);
world.choosePackEntry(
pocket,
choice.pockets[pocket].entries.findIndex(
(entry) => entry.item === ESCAPE_ROPE,
),
);
until(world);
return pick(world, "USE");
};
const shown = [...useRope(), ...close(world)];
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/used an\nESCAPE ROPE/);
expect(world.mapId).toBe(ROUTE_31);
expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]);
// Outdoors it fails silently, then Oak warns; the rope is kept.
for (let tick = 0; tick < 40 && world.busy; tick++) world.tick();
expect(useRope()).toEqual([
"OAK: CHRIS!\nThis isn't the\ntime to use that!",
]);
close(world);
expect(world.state.bag.items).toEqual([{ item: ESCAPE_ROPE, quantity: 1 }]);
});
it("does not record a dig warp into Mount Moon Square or Tin Tower's roof", () => {
const { assets } = fixture([], ROUTE_29, 50, 10);
// SaveDigWarp's exclusions name these two outdoor maps.
expect(assets.map("15:10").location).toBe("Mt.Moon");
expect(assets.map("15:12").location).toBe("Tin Tower");
expect(assets.map("15:10").environment).not.toBe(3);
});
});