Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
12 KiB
TypeScript
273 lines
12 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 { generatePartyMon, validPartyMon } from "../src/game/party/pokemon";
|
|
import { C } from "../src/game/battle/constants";
|
|
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
|
|
import { afterRestore, settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const ROUTE_29 = "24:3",
|
|
DUDE = 2,
|
|
RATTATA = 19,
|
|
PIDGEY = 16;
|
|
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
|
|
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
|
|
EVENT_DUDE_TALKED_TO_YOU = 65,
|
|
EVENT_LEARNED_TO_CATCH_POKEMON = 66;
|
|
|
|
describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the world", () => {
|
|
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
/** After returning the Egg: Route 29 is in its catching-tutorial scene. */
|
|
const tutorialWorld = (position = { x: 54, y: 8 }) => {
|
|
let seed = 3;
|
|
const world = new GameWorld(assets(), () => new Date("2026-09-24T12:00:00"), () => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
});
|
|
const mon = generatePartyMon(world.assets.pokemon, {
|
|
species: 158, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
|
});
|
|
world.state.party = [mon];
|
|
world.state.pokedex.seen.add(158);
|
|
world.state.pokedex.caught.add(158);
|
|
for (const event of [EVENT_GOT_A_POKEMON_FROM_ELM, EVENT_GAVE_MYSTERY_EGG_TO_ELM])
|
|
world.state.events.add(event);
|
|
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 5 }];
|
|
world.restore({ ...world.save(), map: ROUTE_29, ...position, direction: "left", scenes: { [ROUTE_29]: 1 } });
|
|
return world;
|
|
};
|
|
const dude = (world: GameWorld) => world.objects().find((object) => object.id === DUDE)!;
|
|
|
|
it.each([
|
|
[8, "Tutorial1"],
|
|
[9, "Tutorial2"],
|
|
])("runs the Dude's demonstration from the trigger at y=%i (%s)", (y) => {
|
|
const world = tutorialWorld({ x: 54, y });
|
|
const before = world.save();
|
|
expect(world.move("left")).toBe(true);
|
|
const pages = settle(world).join("\n").replace(/\n/g, " ");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(pages).toContain("show you how to catch POKéMON?");
|
|
expect(pages).toContain("That's how you do it");
|
|
expect(world.state.events.has(EVENT_DUDE_TALKED_TO_YOU)).toBe(true);
|
|
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
|
|
expect(world.state.scenes[ROUTE_29]).toBe(0);
|
|
// Only the Pokédex sighting remains; the Dude's catch is not kept.
|
|
expect(world.state.pokedex.seen.has(RATTATA)).toBe(true);
|
|
expect(world.state.pokedex.caught.has(RATTATA)).toBe(false);
|
|
expect(world.state.party).toEqual(before.party);
|
|
expect(world.state.bag).toEqual(before.bag);
|
|
expect(dude(world)).toMatchObject({ x: 50, y: 12 });
|
|
// Nothing replays after reload.
|
|
world.restore(world.save());
|
|
expect(settle(world)).toEqual([]);
|
|
});
|
|
|
|
it("shows the battle as the Dude with automatic messages", () => {
|
|
const world = tutorialWorld();
|
|
expect(world.move("left")).toBe(true);
|
|
const messages: string[] = [];
|
|
for (let tick = 0; tick < 5000 && !world.battle; tick++) {
|
|
if (world.dialogue?.choice) world.choose(1);
|
|
else if (world.dialogue) world.advanceDialogue();
|
|
else world.tick();
|
|
}
|
|
expect(world.battle?.engine.player.mon).toBeUndefined();
|
|
// A save during the battle holds the state before the scene.
|
|
const saved = world.save();
|
|
expect(saved.events).not.toContain(EVENT_DUDE_TALKED_TO_YOU);
|
|
expect(saved.pokedex.seen).not.toContain(RATTATA);
|
|
while (world.battle) {
|
|
const text = world.battle.message?.text;
|
|
if (text && messages.at(-1) !== text) messages.push(text);
|
|
expect(world.battle.prompt).toBeUndefined();
|
|
expect(world.battle.message?.prompt).not.toBe(true);
|
|
world.tick();
|
|
}
|
|
expect(messages.map((text) => text.replace(/\n/g, " "))).toEqual([
|
|
"Wild RATTATA appeared!",
|
|
"DUDE used the POKé BALL.",
|
|
"Gotcha! RATTATA was caught!",
|
|
]);
|
|
settle(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
// Reloading the mid-battle save replays the whole scene.
|
|
world.restore(saved);
|
|
expect(world.x).toBe(54);
|
|
expect(world.move("left")).toBe(true);
|
|
settle(world);
|
|
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
|
|
});
|
|
|
|
it("lets the player decline, then offers the demonstration again when talked to", () => {
|
|
const world = tutorialWorld();
|
|
expect(world.move("left")).toBe(true);
|
|
const declined = settle(world, [0]).join("\n").replace(/\n/g, " ");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(declined).toContain("Oh. Fine, then");
|
|
expect(world.state.scenes[ROUTE_29]).toBe(0);
|
|
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(false);
|
|
expect(dude(world)).toMatchObject({ x: 50, y: 12 });
|
|
|
|
// CatchingTutorialDudeScript: stand below him and talk.
|
|
world.restore({ ...world.save(), x: 50, y: 13, direction: "up" });
|
|
world.interact();
|
|
const pages = settle(world).join("\n").replace(/\n/g, " ");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(pages).toContain("That's how you do it");
|
|
expect(world.state.events.has(EVENT_LEARNED_TO_CATCH_POKEMON)).toBe(true);
|
|
expect(world.state.pokedex.seen.has(RATTATA)).toBe(true);
|
|
});
|
|
|
|
it("catches a wild Pokémon through the world and keeps it after reload", () => {
|
|
const world = battleFixtureWorld(assets());
|
|
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
|
|
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
|
expect(world.battle).toBeDefined();
|
|
let named = false;
|
|
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
|
|
const view = world.battle;
|
|
if (!view) {
|
|
if (world.dialogue) world.advanceDialogue();
|
|
else world.tick();
|
|
continue;
|
|
}
|
|
const prompt = view.prompt;
|
|
if (prompt?.kind === "action")
|
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
|
else if (prompt?.kind === "yesno") world.battleChoose(1);
|
|
else if (prompt?.kind === "name") {
|
|
expect(prompt.title).toBe("PIDGEY'S\nNICKNAME?");
|
|
world.battleName("PIDGE");
|
|
named = true;
|
|
} else if (view.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(named).toBe(true);
|
|
expect(world.state.party).toHaveLength(2);
|
|
const caught = world.state.party[1];
|
|
expect(caught).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name });
|
|
// SetCaughtData: day (2 << 6 | level), Chris, New Bark Town's landmark.
|
|
expect(caught.caughtData >> 8).toBe((2 << 6) | 3);
|
|
expect(caught.caughtData & 0x80).toBe(0);
|
|
expect(caught.caughtData & 0x7f).toBe(world.assets.map("24:7").landmark);
|
|
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
|
|
expect(world.state.bag.balls[0].quantity).toBeLessThan(10);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
const reloaded = world.save();
|
|
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
|
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
|
|
});
|
|
|
|
/** Throw balls until the battle ends, answering prompts; returns the
|
|
* battle messages and the pages shown afterwards. */
|
|
const catchAll = (world: GameWorld, nickname?: string) => {
|
|
const messages: string[] = [], pages: string[] = [];
|
|
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
|
|
const view = world.battle;
|
|
if (!view) {
|
|
if (world.dialogue) {
|
|
const page = world.dialogue.pages[world.dialogue.index];
|
|
if (pages.at(-1) !== page) pages.push(page);
|
|
world.advanceDialogue();
|
|
} else world.tick();
|
|
continue;
|
|
}
|
|
const text = view.message?.text;
|
|
if (text && messages.at(-1) !== text) messages.push(text);
|
|
const prompt = view.prompt;
|
|
if (prompt?.kind === "action")
|
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
|
else if (prompt?.kind === "yesno") world.battleChoose(nickname ? 1 : 0);
|
|
else if (prompt?.kind === "name") world.battleName(nickname!);
|
|
else if (view.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
return {
|
|
messages: messages.map((text) => text.replace(/\n/g, " ")),
|
|
pages: pages.map((text) => text.replace(/\n/g, " ")),
|
|
};
|
|
};
|
|
const fullParty = (boxed = 0) => {
|
|
const world = battleFixtureWorld(assets());
|
|
const mon = world.state.party[0];
|
|
world.state.party = Array.from({ length: 6 }, () => structuredClone(mon));
|
|
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
|
|
const { status: _s, hp: _h, stats: _t, ...box } = mon;
|
|
world.state.storage.boxes[0] = Array.from({ length: boxed }, () => structuredClone(box));
|
|
return world;
|
|
};
|
|
|
|
it("sends a catch with a full party to the current box", () => {
|
|
const world = fullParty();
|
|
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
|
const { messages } = catchAll(world, "PIDGE");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toContain("PIDGE was sent to BILL's PC.");
|
|
expect(world.state.party).toHaveLength(6);
|
|
// SendMonIntoBox: first in the box, with full PP and base happiness.
|
|
const [boxed] = world.state.storage.boxes[0];
|
|
expect(boxed).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name, happiness: 70, statExp: [0, 0, 0, 0, 0] });
|
|
expect(boxed).not.toHaveProperty("hp");
|
|
const data = world.assets.pokemon;
|
|
expect(boxed.pp).toEqual(boxed.moves.map((move) => (move ? data.move(move).pp : 0)));
|
|
expect(boxed.caughtData >> 8).toBe((2 << 6) | 3);
|
|
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
const reloaded = world.save();
|
|
expect(reloaded).toEqual(afterRestore(saved, reloaded));
|
|
});
|
|
|
|
it("keeps the ball when the party and the box are full", () => {
|
|
const world = fullParty(20);
|
|
const before = world.save();
|
|
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
|
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++)
|
|
if (world.battle?.message?.prompt) world.interact();
|
|
else world.tick();
|
|
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
|
const messages: string[] = [];
|
|
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) {
|
|
const text = world.battle?.message?.text;
|
|
if (text && messages.at(-1) !== text) messages.push(text.replace(/\n/g, " "));
|
|
if (world.battle?.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
// Ball_BoxIsFullMessage: the item is not used and the turn is not taken.
|
|
expect(messages).toEqual(["The POKéMON BOX is full. That can't be used now."]);
|
|
expect(world.battle?.prompt?.kind).toBe("action");
|
|
expect(world.battle?.engine.enemy.mon.hp).toBe(world.battle?.engine.enemy.mon.maxHp);
|
|
for (let tick = 0; tick < 2000 && world.battle; tick++) {
|
|
if (world.battle.prompt?.kind === "action") world.battleAction({ action: "run" });
|
|
else if (world.battle.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
settle(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.bag).toEqual(before.bag);
|
|
expect(world.state.storage).toEqual(before.storage);
|
|
});
|
|
|
|
it("has Bill call after a catch fills the box", () => {
|
|
const world = fullParty(19);
|
|
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
|
const { messages } = catchAll(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toContain("PIDGEY was sent to BILL's PC.");
|
|
expect(world.state.storage.boxes[0]).toHaveLength(20);
|
|
// Script_SpecialBillCall runs at the next PlayerEvents.
|
|
expect(world.busy).toBe(false);
|
|
world.tick();
|
|
const call = settle(world).join(" ").replace(/\n/g, " ");
|
|
expect(call).toContain("It's me, BILL!");
|
|
expect(call).toContain("Click!");
|
|
expect(world.busy).toBe(false);
|
|
});
|
|
});
|