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>
464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import type { Direction } from "../src/game/world/types";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { findPath } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const NEW_BARK = "24:4",
|
|
ELMS_LAB = "24:5",
|
|
ROUTE_29 = "24:3",
|
|
CHERRYGROVE = "26:3",
|
|
ROUTE_30 = "26:1",
|
|
MR_POKEMONS_HOUSE = "26:10",
|
|
CHERRYGROVE_CENTER = "26:5",
|
|
ROUTE_31 = "26:2",
|
|
ROUTE_31_GATE = "26:11",
|
|
VIOLET = "10:5",
|
|
VIOLET_CENTER = "10:10",
|
|
VIOLET_GYM = "10:7",
|
|
SPROUT_1F = "3:1",
|
|
SPROUT_2F = "3:2",
|
|
SPROUT_3F = "3:3";
|
|
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
|
EVENT_GOT_HM05_FLASH = 0x14,
|
|
EVENT_BEAT_FALKNER = 0x2bd,
|
|
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
|
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
|
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
|
|
ENGINE_ZEPHYRBADGE = 0x1b;
|
|
|
|
/** The acceptance run for PLAN section 2: a new game played from the
|
|
* introduction to the catching tutorial through the world's own inputs,
|
|
* without fixtures or story flags set by hand. Every stage ends with a
|
|
* save serialized to JSON and loaded into a fresh world. The stages share
|
|
* one world and run in order, so run the file rather than a single stage. */
|
|
describe.skipIf(!existsSync(romPath))(
|
|
"a continuous opening playthrough",
|
|
() => {
|
|
const bytes = existsSync(romPath)
|
|
? new Uint8Array(readFileSync(romPath))
|
|
: new Uint8Array();
|
|
const now = () => new Date("2026-09-22T12:00:00");
|
|
let seed = 0x2a;
|
|
const random = () => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
};
|
|
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
|
|
let world: GameWorld;
|
|
const log: string[] = [];
|
|
|
|
/** Run scripts and battles until the player is free again. Battles use
|
|
* the first move with PP; a wild battle below half HP is run from, as
|
|
* a cautious player would. New moves are learned when there is room,
|
|
* and other questions take the queued answers or their defaults. */
|
|
const play = (answers: (number | string)[] = []) => {
|
|
for (let tick = 0; tick < 100_000; tick++) {
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
const battle = world.battle;
|
|
if (battle) {
|
|
if (battle.message && log.at(-1) !== battle.message.text)
|
|
log.push(battle.message.text);
|
|
const prompt = battle.prompt;
|
|
if (prompt?.kind === "action") {
|
|
const { engine } = battle,
|
|
mon = engine.player.mon;
|
|
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
|
world.battleAction({ action: "run" });
|
|
else
|
|
world.battleAction({
|
|
action: "fight",
|
|
move: Math.max(
|
|
0,
|
|
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
|
|
),
|
|
});
|
|
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
|
else if (prompt?.kind === "forget") world.battleChoose(4);
|
|
else if (prompt?.kind === "name") world.battleName("");
|
|
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
|
world.interact();
|
|
else world.tick();
|
|
continue;
|
|
}
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index];
|
|
const last = dialogue.index === dialogue.pages.length - 1;
|
|
if (log.at(-1) !== page) log.push(page);
|
|
if (last && dialogue.choice) {
|
|
const answer = answers.shift();
|
|
if (dialogue.choice.kind === "name")
|
|
world.choose(typeof answer === "string" ? answer : "");
|
|
else
|
|
world.choose(
|
|
typeof answer === "number"
|
|
? answer
|
|
: dialogue.choice.kind === "yesno"
|
|
? 1
|
|
: dialogue.choice.kind === "clock"
|
|
? dialogue.choice.selected
|
|
: 0,
|
|
);
|
|
} else if (last && dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!world.busy) {
|
|
world.tick(); // scene scripts and phone calls start on idle ticks
|
|
if (!world.busy) return;
|
|
continue;
|
|
}
|
|
world.tick();
|
|
}
|
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
|
};
|
|
|
|
const STEP: Record<Direction, [number, number]> = {
|
|
up: [0, -1],
|
|
down: [0, 1],
|
|
left: [-1, 0],
|
|
right: [1, 0],
|
|
};
|
|
/** Walk to a tile on the current map, replanning after ledges, roaming
|
|
* NPCs and scenes that move the player. A warp target leaves the map. */
|
|
const walk = (x: number, y: number) => {
|
|
const map = world.mapId,
|
|
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
play();
|
|
if (warp && world.mapId !== map) return;
|
|
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
|
if (world.x === x && world.y === y) return;
|
|
for (const direction of findPath(world, x, y)) {
|
|
const [dx, dy] = STEP[direction];
|
|
const from = [world.x, world.y];
|
|
if (!world.move(direction)) {
|
|
for (let tick = 0; tick < 64; tick++) world.tick();
|
|
break;
|
|
}
|
|
play();
|
|
if (
|
|
world.mapId !== map ||
|
|
world.x !== from[0] + dx ||
|
|
world.y !== from[1] + dy
|
|
)
|
|
break;
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`,
|
|
);
|
|
};
|
|
/** Step across a map connection from the nearest usable edge tile. */
|
|
const cross = (direction: Direction, target: string) => {
|
|
const map = world.mapId;
|
|
const [dx, dy] = STEP[direction];
|
|
const edge = Array.from(
|
|
{ length: dx ? world.map.height : world.map.width },
|
|
(_, i) =>
|
|
dx
|
|
? [dx < 0 ? 0 : world.map.width - 1, i]
|
|
: [i, dy < 0 ? 0 : world.map.height - 1],
|
|
).flatMap(([x, y]) => {
|
|
try {
|
|
return [{ x, y, length: findPath(world, x, y).length }];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
edge.sort((a, b) => a.length - b.length);
|
|
for (const { x, y } of edge) {
|
|
walk(x, y);
|
|
if (world.move(direction)) {
|
|
play();
|
|
if (world.mapId === target) return;
|
|
}
|
|
if (world.mapId !== map) break;
|
|
}
|
|
throw new Error(
|
|
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
|
|
);
|
|
};
|
|
/** Stand in front of a warp leading to `target` and take it. */
|
|
const enter = (target: string, direction: Direction) => {
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
expect(warp, `a warp to ${target}`).toBeDefined();
|
|
const [dx, dy] = STEP[direction];
|
|
walk(warp.x - dx, warp.y - dy);
|
|
expect(world.move(direction)).toBe(true);
|
|
play();
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
/** Leave through an exit mat, which needs the exit direction pressed. */
|
|
const exit = (target: string, direction: Direction = "down") => {
|
|
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
|
walk(warp.x, warp.y);
|
|
if (world.mapId !== target) {
|
|
expect(world.move(direction)).toBe(true);
|
|
play();
|
|
}
|
|
expect(world.mapId).toBe(target);
|
|
};
|
|
const reload = () => {
|
|
const saved = JSON.parse(JSON.stringify(world.save()));
|
|
world = new GameWorld(assets!, now, random);
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
};
|
|
|
|
const lead = () => world.state.party[0];
|
|
/** PokecenterNurseScript from in front of the counter, then back out. */
|
|
const heal = (center: string, city: string) => {
|
|
enter(center, "up");
|
|
walk(3, 3);
|
|
world.move("up"); // the counter: this only turns the player
|
|
world.interact();
|
|
play();
|
|
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
|
|
exit(city);
|
|
};
|
|
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
|
* the lead reaches `level`. `rest` heals and comes back here. */
|
|
const train = (level: number, rest: () => void) => {
|
|
const map = world.mapId,
|
|
grass = world.assets.grassCollisions();
|
|
const isGrass = (x: number, y: number) =>
|
|
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
|
|
grass.includes(world.assets.collision(world.map, x, y));
|
|
for (let round = 0; round < 2000 && lead().level < level; round++) {
|
|
if (lead().hp * 2 < lead().stats[0]) {
|
|
rest();
|
|
expect(world.mapId).toBe(map);
|
|
}
|
|
const pairs: { x: number; y: number; length: number }[] = [];
|
|
for (let y = 0; y < world.map.height; y++)
|
|
for (let x = 0; x < world.map.width; x++)
|
|
if (isGrass(x, y) && isGrass(x + 1, y))
|
|
try {
|
|
pairs.push({ x, y, length: findPath(world, x, y).length });
|
|
} catch {
|
|
/* unreachable */
|
|
}
|
|
pairs.sort((a, b) => a.length - b.length);
|
|
const { x, y } = pairs[0];
|
|
walk(x, y);
|
|
walk(x + 1, y);
|
|
}
|
|
expect(lead().level).toBeGreaterThanOrEqual(level);
|
|
};
|
|
|
|
/** Take the stairs (or another warp) to `target`, trying each. */
|
|
const stairs = (target: string) => {
|
|
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
|
try {
|
|
findPath(world, warp.x, warp.y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
walk(warp.x, warp.y);
|
|
if (world.mapId === target) return;
|
|
}
|
|
throw new Error(`No way from ${world.mapId} to ${target}`);
|
|
};
|
|
/** Find a way through warps among `maps` to `target`, as a player
|
|
* trying staircases would: depth first, backing out of dead ends. */
|
|
const route = (target: string, maps: string[], depth = 6): boolean => {
|
|
if (world.mapId === target) return true;
|
|
if (!depth) return false;
|
|
const here = world.save();
|
|
for (const warp of world.map.warps) {
|
|
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
|
try {
|
|
findPath(world, warp.x, warp.y);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const from = world.mapId;
|
|
walk(warp.x, warp.y);
|
|
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
|
|
world.restore(here);
|
|
}
|
|
return false;
|
|
};
|
|
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
|
const restInViolet = () => {
|
|
exit(ROUTE_31_GATE, "left");
|
|
exit(VIOLET, "left");
|
|
heal(VIOLET_CENTER, VIOLET);
|
|
exit(ROUTE_31_GATE, "right");
|
|
exit(ROUTE_31, "right");
|
|
};
|
|
|
|
beforeEach(() => {
|
|
log.length = 0;
|
|
});
|
|
const traceFailures = () =>
|
|
onTestFailed(() => {
|
|
console.log(
|
|
`${world.mapId} ${world.x},${world.y}; party ${JSON.stringify(
|
|
world.state.party.map(({ species, level, hp }) => ({
|
|
species,
|
|
level,
|
|
hp,
|
|
})),
|
|
)}\n${log.slice(-30).join("\n---\n")}`,
|
|
);
|
|
});
|
|
|
|
it("plays the introduction and Mom's scene", () => {
|
|
world = new GameWorld(assets!, now, random);
|
|
traceFailures();
|
|
world.beginIntro();
|
|
play([0, "", 1, "", 1, 0, "KAWA"]);
|
|
expect(world.intro).toBe(false);
|
|
expect(world.name).toBe("KAWA");
|
|
reload();
|
|
walk(7, 0);
|
|
expect(world.mapId).toBe("24:6");
|
|
walk(9, 3);
|
|
expect(world.move("down")).toBe(true);
|
|
play([1, 1, 1, 1, 1]);
|
|
expect(log).toContain("KAWA received\nPOKéGEAR.");
|
|
reload();
|
|
});
|
|
|
|
it("meets Elm, takes Cyndaquil and the aide's Potion", () => {
|
|
traceFailures();
|
|
walk(6, 7);
|
|
expect(world.move("down")).toBe(true);
|
|
play();
|
|
expect(world.mapId).toBe(NEW_BARK);
|
|
enter(ELMS_LAB, "up");
|
|
play([0, 1]);
|
|
walk(6, 4);
|
|
world.move("up");
|
|
world.interact();
|
|
play([1, 1, ""]);
|
|
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
|
|
walk(4, 7);
|
|
expect(world.move("down")).toBe(true);
|
|
play();
|
|
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
|
reload();
|
|
});
|
|
|
|
it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => {
|
|
traceFailures();
|
|
exit(NEW_BARK);
|
|
cross("left", ROUTE_29);
|
|
cross("left", CHERRYGROVE);
|
|
reload();
|
|
cross("up", ROUTE_30);
|
|
enter(MR_POKEMONS_HOUSE, "up");
|
|
play();
|
|
expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
|
|
expect(world.state.flags.has(11)).toBe(true); // the Pokédex
|
|
reload();
|
|
});
|
|
|
|
it("takes Elm's call outside and meets the rival in Cherrygrove", () => {
|
|
traceFailures();
|
|
exit(ROUTE_30);
|
|
expect(log.join("\n")).toContain("It's a disaster!");
|
|
expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
|
|
reload();
|
|
cross("down", CHERRYGROVE);
|
|
// The rival's scene blocks the way east to Route 29.
|
|
cross("right", ROUTE_29);
|
|
const rival = log.join("\n");
|
|
expect(rival).toContain("RIVAL ?");
|
|
expect(rival).toContain("My name's ???.");
|
|
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
|
|
reload();
|
|
});
|
|
|
|
it("names the rival for the officer and returns the Egg to Elm", () => {
|
|
traceFailures();
|
|
cross("right", NEW_BARK);
|
|
enter(ELMS_LAB, "up");
|
|
walk(4, 6);
|
|
expect(world.move("up")).toBe(true);
|
|
play(["SILVER"]);
|
|
expect(world.state.rivalName).toBe("SILVER");
|
|
reload();
|
|
walk(5, 3);
|
|
world.move("up"); // Elm stands at 5,2: this only turns the player.
|
|
world.interact();
|
|
play();
|
|
expect(world.state.bag.keyItems).toEqual([]);
|
|
walk(4, 8);
|
|
expect(world.state.bag.balls).toEqual([
|
|
{ item: C.POKE_BALL, quantity: 5 },
|
|
]);
|
|
reload();
|
|
});
|
|
|
|
it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => {
|
|
traceFailures();
|
|
exit(NEW_BARK);
|
|
cross("left", ROUTE_29);
|
|
cross("left", CHERRYGROVE);
|
|
const pages = log.join("\n").replace(/\n/g, " ");
|
|
expect(pages).toContain("show you how to catch POKéMON?");
|
|
expect(pages).toContain("That's how you do it");
|
|
expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
|
|
expect(world.state.scenes[ROUTE_29]).toBe(0);
|
|
reload();
|
|
const save = world.save();
|
|
expect(save.party[0].species).toBe(155);
|
|
expect(save.pokedex.caught).toContain(155);
|
|
expect(save.rivalName).toBe("SILVER");
|
|
expect(save.intro).toBe(false);
|
|
});
|
|
|
|
it("trains and walks Route 30 and Route 31 to Violet City", () => {
|
|
traceFailures();
|
|
const restInCherrygrove = () => {
|
|
cross("left", CHERRYGROVE);
|
|
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
|
cross("right", ROUTE_29);
|
|
};
|
|
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
|
cross("right", ROUTE_29);
|
|
train(10, restInCherrygrove);
|
|
cross("left", CHERRYGROVE);
|
|
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
|
cross("up", ROUTE_30);
|
|
cross("up", ROUTE_31);
|
|
exit(ROUTE_31_GATE, "left");
|
|
// Mom worries when the player has not come home since the Egg; the
|
|
// call rings on a step after Route 31's callback queued it.
|
|
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
|
|
exit(VIOLET, "left");
|
|
expect(world.state.flags.size).toBeGreaterThan(0);
|
|
reload();
|
|
});
|
|
|
|
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
|
|
traceFailures();
|
|
exit(ROUTE_31_GATE, "right");
|
|
exit(ROUTE_31, "right");
|
|
train(14, restInViolet);
|
|
restInViolet();
|
|
exit(ROUTE_31_GATE, "left");
|
|
exit(VIOLET, "left");
|
|
enter(SPROUT_1F, "up");
|
|
expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true);
|
|
walk(10, 3);
|
|
world.move("up"); // Elder Li: this only turns the player
|
|
world.interact();
|
|
play();
|
|
const pages = log.join("\n").replace(/\n/g, " ");
|
|
expect(pages).toContain("FLASH");
|
|
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
|
reload();
|
|
});
|
|
},
|
|
);
|