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:
1 parent
c4cc0e756c
commit
a7aceda089
21 files changed
+1166
-97
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user