Files
Crystal/tests/fly-dig-teleport.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 7909302db1 Open Route 31, Violet City and Sprout Tower (#31)
Enable Route 31 and its gate, Dark Cave's Violet entrance, Violet City with its buildings and Sprout Tower. Port the script commands they use: gettrainername, askforphonenumber (with AddPhoneNumber's room for Mom and Elm; addcellnum now reports failure as the original does), checkpokemail, loadmenu/_2dmenu/closewindow, giveegg and trade (NPCTrade, with wTradeFlags saved).

Eggs keep their species with an egg flag, as wPartySpecies does. The pack's TM/HM counts receive, check and toss TMs and HMs, which are named TM01-HM07.

The playthrough continues through Route 31 (with Mom's call), Violet City and Sprout Tower to Elder Li's Flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:24:29 +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_AZALEA = 18;
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_AZALEA);
useMove(world, "FLY");
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
world.moveCursor("down");
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
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);
});
});