Port Fly, Dig, Teleport and the Escape Rope

FLY opens _FlyMap in the party menu: the region's Town Map with "Where?",
the visited flypoints in turn and the Fly user's icon, then FlyScript flies
the icon off along SpriteAnimFunc_FlyFrom's path, enters the spawn point and
lands it with SpriteAnimFunc_FlyTo. Teleport returns to the last Pokémon
Center's town through IsSpawnPoint. Dig and the Escape Rope lead out of a
cave to the warp EnterMapWarp's SaveDigWarp recorded, which saves now keep.

newloadmap stops its script as StopScript does and the world resumes it on
the new map, which the landing and return movements need. A destination
that is not ported yet gives a notice.

Closes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-02 11:18:57 +02:00
1 parent c4cc0e756c
commit a7aceda089
21 files changed
+1166 -97

No files matched your search

+326
View File
@@ -0,0 +1,326 @@
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_VIOLET = 16;
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_VIOLET);
useMove(world, "FLY");
world.moveCursor("down"); // wraps to Violet City
expect(flyMap(world)?.name).toBe("VIOLET CITY");
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);
});
});
+50 -1
View File
@@ -11,7 +11,14 @@ test.beforeEach(() =>
async function start(
page: Page,
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" = false,
room:
| boolean
| "poster"
| "center"
| "candy"
| "pokegear"
| "mom"
| "fly" = false,
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
@@ -431,6 +438,48 @@ test("the options change the battle style and the menu account", async ({
});
});
test("Fly picks a visited town on the Fly map and lands there", async ({
page,
}) => {
await start(page, "fly");
const choices = page.locator("#dialogue-choice"),
flyMap = page.locator("#fly-map"),
bubble = page.locator("#fly-map-bubble");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^POKéMON/ }).click();
await choices.getByRole("option", { name: /^PIDGEOT/ }).click();
await choices.getByRole("option", { name: "FLY", exact: true }).click();
await expect(flyMap).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(bubble).toContainText("Where?");
await expect(bubble).toContainText("NEW BARK TOWN");
await expect(page.locator("#fly-map-canvas")).toHaveAttribute(
"aria-label",
"Fly map: NEW BARK TOWN, you are here",
);
// Up skips to the next visited flypoint; B returns to the party menu.
await page.keyboard.press("ArrowUp");
await expect(bubble).toContainText("CHERRYGROVE CITY");
await page.keyboard.press("KeyZ");
await expect(flyMap).toBeHidden();
await expect(page.locator("#dialogue-text")).toHaveText("Choose a POKéMON.");
await choices.getByRole("option", { name: /^PIDGEOT/ }).click();
await choices.getByRole("option", { name: "FLY", exact: true }).click();
await page.keyboard.press("ArrowUp");
await expect(bubble).toContainText("CHERRYGROVE CITY");
await page.keyboard.press("KeyX");
await expect(flyMap).toBeHidden();
await page.clock.runFor(5_000);
const screen = page.locator("#game-screen");
await expect(screen).toHaveAttribute("data-map", "26:3");
await expect(screen).toHaveAttribute("data-x", "29");
await expect(screen).toHaveAttribute("data-y", "4");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
const { state } = await backup(page);
expect([state.map, state.x, state.y]).toEqual(["26:3", 29, 4]);
});
test("Mom keeps the player's money with the digit box", async ({ page }) => {
await start(page, "mom");
const text = page.locator("#dialogue-text"),
+22 -3
View File
@@ -8,10 +8,11 @@ import { generatePartyMon } from "../src/game/party/pokemon";
* level 13, with a Rare Candy); otherwise outdoors ("pokegear": with the
* Pokégear, its map and phone cards, and Mom's and Elm's numbers). "mom"
* faces Mom in the living room after the Mystery Egg, with her bank open
* and ¥2300 saved. */
* and ¥2300 saved. "fly" stands outdoors with a Pidgeot that knows Fly,
* the Storm Badge, and New Bark Town and Cherrygrove City visited. */
export function foundationFixture(
assets: CrystalAssets,
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom",
room: boolean | "poster" | "center" | "candy" | "pokegear" | "mom" | "fly",
) {
const world = new GameWorld(
assets,
@@ -54,6 +55,24 @@ export function foundationFixture(
// Walking outside clears the bedroom's temporary events.
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "fly") {
const mon = generatePartyMon(assets.pokemon, {
species: 18, // PIDGEOT
level: 40,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
mon.moves = [OW.FLY, 0, 0, 0];
mon.pp = [15, 0, 0, 0];
world.state.party = [mon];
world.state.flags.add(OW.ENGINE_STORMBADGE);
for (const spawn of [14, 15]) // SPAWN_NEW_BARK, SPAWN_CHERRYGROVE
world.state.flags.add(assets.spawnEngineFlag(spawn)!);
for (let event = 0; event < 8; event++) world.state.events.delete(event);
}
if (room === "mom") {
// EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM
// and EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST.
@@ -70,7 +89,7 @@ export function foundationFixture(
? ["24:7", 6, 1]
: room === "mom"
? ["24:6", 7, 3]
: room && room !== "pokegear"
: room && room !== "pokegear" && room !== "fly"
? ["24:7", 2, 2]
: ["24:4", 9, 6];
world.restore({