Files
Crystal/tests/route30.test.ts
T
Sébastien QUEROL 32d736ecae
Checks / web (push) Canceled after 0s
Port weather, copying moves, held items and obedience (#28)
Complete the weather, Substitute, Transform, called-move and forced-switch effects. Add held-item behavior, Exp. Share awards and badge-based traded Pokemon obedience, and persist trainer berry consumption.

Validated with 576 passing unit tests and 149 browser tests, native/reference builds, reference/vendor/release checks and 320 Metal Powder emulator comparisons per browser. Smart AI and special balls/battle types remain on #28.
2026-10-03 15:03:19 +02:00

202 lines
8.4 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" });
// Psywave at level 1 loops forever in the original, so it stays refused.
world.state.party[0].level = 1;
world.state.party[0].moves = [149, 0, 0, 0];
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("");
});
});