Files
Crystal/tests/catch-tutorial.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 3bdca0099e
Checks / web (push) Canceled after 0s
Port the PC and boxes
Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs
PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name
boxes), the player's item storage (withdraw, deposit, toss, mail box) and
Oak's Pokédex rating. The house PC now opens the full player's PC menu.

A catch with a full party goes to the current box through SendMonIntoBox;
a full box keeps the ball, and filling it queues Bill's call as a memory
script after the battle. Saves gain boxes, box names and PC items,
migrated by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:12:26 +02:00

271 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 { 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);
expect(world.save()).toEqual(saved);
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);
expect(world.save()).toEqual(saved);
});
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);
});
});