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>
328 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|