Checks / web (push) Canceled after 0s
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>
271 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|