Checks / web (push) Canceled after 0s
Mr. Pokémon's ROM scene gives the Mystery Egg and Pokédex, heals the party, sets the Cherrygrove spawn with blackoutmod and queues Elm's call. Cherrygrove's rival scene runs both triggers and all three RIVAL1_1 parties as a CANLOSE battle; reloadmap returns to the script. Smart AI is accepted for effects with no AI_Smart_EffectHandlers entry (Normal Hit, Attack Down, Defense Down) and still refuses others before any random draw, including for reserve Pokémon. Special calls read SpecialPhoneCallList: the entry's condition decides, and the ROM's CheckSpecialPhoneCall.script and Script_ReceivePhoneCall run its caller script through memcall, ending with HangUp's timed "Click!". Ringing and the caller box are not presented yet. In Elm's lab, NameRival shows the ROM's "RIVAL'S NAME?" title (the prompt previously had no page and soft-locked) and applies InitName's default to blank names. Handing over the Egg and the aide's Poké Balls run from the ROM. Saves gain rivalName and specialPhoneCall. The quest fixtures use a seeded RNG (a constant byte never ends the damage-variation loop) and no longer carry the house's moved objects into Cherrygrove. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
200 lines
8.3 KiB
TypeScript
200 lines
8.3 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 } from "../src/game/party/pokemon";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const ROUTE_30 = "26:1";
|
|
const MIKEY = 4,
|
|
DON = 5;
|
|
const EVENT_BEAT_YOUNGSTER_MIKEY = 1450,
|
|
EVENT_BEAT_BUG_CATCHER_DON = 1336;
|
|
const POTION = 0x12;
|
|
|
|
describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", () => {
|
|
const fixture = (position: { x: number; y: number; direction: "up" | "down" | "left" | "right" }) => {
|
|
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
let seed = 7;
|
|
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
});
|
|
const mon = generatePartyMon(assets.pokemon, {
|
|
species: 158, level: 20, item: 0, otId: world.playerId,
|
|
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
|
});
|
|
mon.moves = [10, 0, 0, 0]; // Scratch
|
|
mon.pp = [35, 0, 0, 0];
|
|
world.state.party = [mon];
|
|
world.restore({ ...world.save(), map: ROUTE_30, ...position });
|
|
return world;
|
|
};
|
|
/** Play through scripts and battles, fighting with the first move. */
|
|
const drive = (world: GameWorld) => {
|
|
const shown: string[] = [];
|
|
let emote = false;
|
|
for (let tick = 0; tick < 50_000; tick++) {
|
|
if (world.emote) emote = true;
|
|
const view = world.battle;
|
|
if (view) {
|
|
if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text);
|
|
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
|
else if (view.prompt?.kind === "yesno") world.battleChoose(0);
|
|
else if (view.prompt?.kind === "forget") world.battleChoose(4);
|
|
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
|
else world.tick();
|
|
continue;
|
|
}
|
|
const dialogue = world.dialogue;
|
|
if (dialogue) {
|
|
shown.push(dialogue.pages[dialogue.index]);
|
|
if (dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!world.busy) return { text: shown.join("\n"), emote };
|
|
world.tick();
|
|
}
|
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
|
};
|
|
const object = (world: GameWorld, id: number) =>
|
|
world.objects().find((candidate) => candidate.id === id)!;
|
|
|
|
it("Bug Catcher Don sees the player, walks over, sends out both Caterpie and stays beaten", () => {
|
|
const world = fixture({ x: 1, y: 12, direction: "up" });
|
|
const money = world.state.money;
|
|
expect(world.move("up")).toBe(true); // (1, 11): four tiles away
|
|
expect(world.busy).toBe(false);
|
|
expect(world.move("up")).toBe(true); // (1, 10): within his range of three
|
|
expect(world.activity).toBe("scene");
|
|
const { text, emote } = drive(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(emote).toBe(true);
|
|
// TrainerWalkToPlayer stops beside the player; writeobjectxy keeps it there.
|
|
expect(object(world, DON)).toMatchObject({ x: 1, y: 9, direction: "down" });
|
|
expect(world.direction).toBe("up");
|
|
expect(text.match(/sent out/g)?.length).toBe(2);
|
|
expect(text).toContain("CATERPIE");
|
|
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
|
|
expect(world.state.money).toBe(money + 48);
|
|
expect(world.state.pokedex.seen.has(10)).toBe(true);
|
|
// endifjustbattled: no after-battle text straight after the battle.
|
|
const after = text.slice(text.lastIndexOf("48"));
|
|
expect(after.split("\n").length).toBeLessThanOrEqual(2);
|
|
|
|
// The position survives a save, and a beaten trainer no longer sees.
|
|
const saved = world.save();
|
|
expect(saved.objects).toContainEqual({ id: DON, x: 1, y: 9, direction: "down" });
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
world.move("down");
|
|
world.move("up");
|
|
world.tick();
|
|
expect(world.busy).toBe(false);
|
|
|
|
// TalkToTrainer: already beaten, so only the after-battle text.
|
|
world.interact();
|
|
const talk = drive(world);
|
|
expect(talk.text.length).toBeGreaterThan(0);
|
|
expect(talk.text).not.toMatch(/sent out/);
|
|
expect(world.state.money).toBe(money + 48);
|
|
});
|
|
|
|
it("replays the whole sighting from committed state after a mid-battle reload", () => {
|
|
const world = fixture({ x: 1, y: 11, direction: "up" });
|
|
const before = world.save();
|
|
world.move("up");
|
|
for (let tick = 0; tick < 5000 && !world.battle?.prompt; tick++) {
|
|
if (world.dialogue) world.advanceDialogue();
|
|
else if (world.battle?.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
expect(world.battle?.prompt?.kind).toBe("action");
|
|
const midBattle = world.save();
|
|
expect(midBattle.events).toEqual(before.events);
|
|
expect(midBattle.objects).toEqual(before.objects);
|
|
expect(midBattle).toMatchObject({ x: 1, y: 10 });
|
|
world.restore(midBattle);
|
|
expect(world.battle).toBeUndefined();
|
|
expect(object(world, DON)).toMatchObject({ x: 1, y: 7 });
|
|
// The first idle check sees the player again.
|
|
world.tick();
|
|
expect(world.activity).toBe("scene");
|
|
drive(world);
|
|
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
|
|
});
|
|
|
|
it("does not re-trigger a refused sighting until the player moves", () => {
|
|
const world = fixture({ x: 1, y: 11, direction: "up" });
|
|
world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused
|
|
world.state.party[0].pp = [10, 0, 0, 0];
|
|
const before = world.save();
|
|
world.move("up");
|
|
drive(world);
|
|
expect(world.notice).toContain("not been ported");
|
|
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(false);
|
|
expect(world.save().objects).toEqual(before.objects);
|
|
for (let tick = 0; tick < 10; tick++) world.tick();
|
|
expect(world.activity).toBe("idle");
|
|
expect(world.move("down")).toBe(true);
|
|
expect(world.move("up")).toBe(true);
|
|
expect(world.activity).toBe("scene");
|
|
});
|
|
|
|
it("battles Youngster Mikey when spoken to, then gives his after-battle text", () => {
|
|
const world = fixture({ x: 5, y: 22, direction: "down" });
|
|
expect(world.busy).toBe(false); // Mikey faces his Rattata, not the player.
|
|
world.interact();
|
|
const { text } = drive(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(object(world, MIKEY).direction).toBe("up"); // faceplayer
|
|
expect(text).toContain("PIDGEY");
|
|
expect(text).toContain("RATTATA");
|
|
expect(world.state.events.has(EVENT_BEAT_YOUNGSTER_MIKEY)).toBe(true);
|
|
world.interact();
|
|
expect(drive(world).text).not.toMatch(/sent out/);
|
|
});
|
|
|
|
it("finds the hidden Potion once", () => {
|
|
const world = fixture({ x: 14, y: 10, direction: "up" });
|
|
world.interact();
|
|
expect(settle(world).join("\n")).toContain("POTION");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
const potions = () =>
|
|
world.state.bag.items.find((slot) => slot.item === POTION)?.quantity ?? 0;
|
|
expect(potions()).toBe(1);
|
|
world.interact();
|
|
expect(settle(world)).toEqual([]);
|
|
expect(potions()).toBe(1);
|
|
});
|
|
|
|
it("runs the Route 30 Pokémon battle scene and the Berry house, and enters Mr. Pokémon's house", () => {
|
|
const world = fixture({ x: 5, y: 27, direction: "up" });
|
|
world.interact();
|
|
expect(settle(world).length).toBeGreaterThan(1);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
|
|
world.restore({ ...world.save(), x: 7, y: 40, direction: "up" });
|
|
expect(world.move("up")).toBe(true);
|
|
settle(world);
|
|
expect(world.mapId).toBe("26:9");
|
|
const berry = world.state.bag.items.length;
|
|
// Facing the Pokéfan M, who gives a Berry once.
|
|
const pokefan = world.objects()[0];
|
|
world.restore({ ...world.save(), x: pokefan.x, y: pokefan.y + 1, direction: "up" });
|
|
world.interact();
|
|
expect(settle(world).join("\n")).toMatch(/BERRY/);
|
|
expect(world.state.bag.items.length).toBe(berry + 1);
|
|
|
|
world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" });
|
|
expect(world.move("up")).toBe(true);
|
|
settle(world);
|
|
expect(world.mapId).toBe("26:10");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
});
|
|
});
|