Files
Crystal/tests/route30.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 e00dfab42e Open Route 30 with map trainers and multi-Pokémon trainer battles
Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript.
_CheckTrainerBattle runs before tile events at the end of each step and
on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and
writeobjectxy keeps the trainer where it stopped. The interpreter gains
the trainer header commands, wRunningTrainerBattleScript and hidden
items (HiddenItemScript).

Trainers with several Pokémon send out the next one through
EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including
its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and
AI_Switch are translated with their original bugs, and wPlayerUsedMoves
is recorded. The Shift-mode switch offer, trainer items, Pursuit on a
switch, Perish Song and Spikes remain refused.

Route 30 and its Berry house are enabled; Mr. Pokémon's house and
Route 31 stay closed. A refused trainer sighting does not restart until
the player moves, so a rollback cannot trap the player in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:36:54 +02:00

198 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, but keeps Mr. Pokémon's house closed", () => {
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(false);
expect(world.notice).toMatch(/not ported/);
});
});