Files
Crystal/tests/roaming.test.ts
T
kawaandClaude Opus 5.5 2640fa2725 Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

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

421 lines
17 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { RomReader } from "../src/game/rom/reader";
import { OVERWORLD } from "../src/game/rom/profile";
import { GameWorld, migrateSave } from "../src/game/world/world";
import {
ScriptRun,
emptyScriptState,
type ScriptHost,
} from "../src/game/scripts/interpreter";
import { PokemonData } from "../src/game/rom/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
import { generatePartyMon } from "../src/game/party/pokemon";
import { C } from "../src/game/battle/constants";
import type { RoamMon } from "../src/game/battle/battle";
import {
initRoamMons,
checkEncounterRoamMon,
updateRoamMons,
jumpRoamMons,
} from "../src/game/world/roam-mons";
import { afterRestore } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// RAIKOU, ENTEI (constants/pokemon_constants.asm).
const RAIKOU = 243,
ENTEI = 244,
SUICUNE = 245;
// Open Johto routes that are also RoamMaps entries (confirmed against the
// ROM below): Route 29, Route 32 and Route 33, which directly connect.
const ROUTE_29 = "24:3",
ROUTE_32 = "10:1",
ROUTE_33 = "8:6";
// InitRoamMons' own starting maps: Route 42 and Route 37 (not open, but
// decodable like any of the ROM's 26 map groups).
const ROUTE_42 = "2:5",
ROUTE_37 = "10:4";
/** special InitRoamMons' index in SpecialsPointers (data/events/special_pointers.asm). */
const INIT_ROAM_MONS_SPECIAL = 105;
function assets() {
return new CrystalAssets(new Uint8Array(readFileSync(romPath)));
}
/** Every map RoamMaps lists, read straight from the ROM: a roaming beast's
* new position must always be one of these sixteen. */
function roamMapIds(r: RomReader): Set<string> {
const ids = new Set<string>();
let p = OVERWORLD.RoamMaps;
while (r.byte(p) !== 0xff) {
ids.add(`${r.byte(p)}:${r.byte(p + 1)}`);
p += 4 + 2 * r.byte(p + 2);
}
return ids;
}
const emptyRoamer = (): RoamMon => ({ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 });
describe.skipIf(!existsSync(romPath))("roaming Pokémon (Raikou, Entei, Suicune)", () => {
describe("the RoamMaps graph (direct ROM reads)", () => {
it("lists Route 29, Route 32 and Route 33 with Route 32 between the other two", () => {
const r = assets().reader;
const ids = roamMapIds(r);
expect(ids).toContain(ROUTE_29);
expect(ids).toContain(ROUTE_32);
expect(ids).toContain(ROUTE_33);
expect(ids.size).toBe(16);
});
});
describe("initRoamMons", () => {
it("starts Raikou on Route 42 and Entei on Route 37 at level 40 with HP 0, leaving Suicune untouched", () => {
const suicune: RoamMon = { species: SUICUNE, level: 12, mapId: "1:1", hp: 7, dvs: 0x55aa };
const result = initRoamMons([emptyRoamer(), emptyRoamer(), suicune]);
expect(result[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
expect(result[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
expect(result[2]).toEqual(suicune);
});
it("runs through the real special dispatch (special 105), as the ported interpreter resolves it", () => {
// special InitRoamMons; end, placed in an unused bank-0 spot, exactly
// as tests/zz-specials.local.test.ts probes every special routine.
const rom = new Uint8Array(new Uint8Array(readFileSync(romPath)));
rom.set(
[0x0f, INIT_ROAM_MONS_SPECIAL & 255, INIT_ROAM_MONS_SPECIAL >> 8, 0x91],
0x3ff0,
);
const reader = new RomReader(rom);
const host: ScriptHost = {
mapId: ROUTE_29,
lastTalked: 0,
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
clockDay: () => 0,
text: () => defaultTextContext("A"),
data: new PokemonData(reader),
player: () => ({ id: 1, gender: 0 }),
random: () => 0,
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
objectPosition: () => undefined,
playerCollision: () => 0,
environment: () => 0,
phoneService: () => 0,
facingObject: () => undefined,
};
const state = emptyScriptState();
state.roamMons[2] = { species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 };
const run = new ScriptRun(reader, 0x3ff0, state, host);
for (let i = 0; i < 10 && run.resume(0); i++);
expect(run.state.roamMons[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
expect(run.state.roamMons[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
expect(run.state.roamMons[2]).toEqual({ species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 });
});
});
describe("checkEncounterRoamMon", () => {
it("draws once: the top 100 values never encounter, a draw & 3 == 0 never encounters, and the rest pick Raikou/Entei/Suicune by index", () => {
const roamMons: RoamMon[] = [
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 },
{ species: ENTEI, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
{ species: SUICUNE, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
];
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 100)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 0)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 1)).toBe(roamMons[0]);
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 2)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 3)).toBeUndefined();
});
});
describe("UpdateRoamMons and JumpRoamMons (direct ROM reads)", () => {
it("picks a random connection of the beast's own RoamMaps entry, redrawing an out-of-range index", () => {
const r = assets().reader;
// Route 32 (10:1) lists three neighbors in this order: Route 36
// (10:3), Route 31 (26:2), Route 33 (8:6). The first draw's index (3)
// is past the connection count and must be redrawn.
const draws = [3, 4];
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
null,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("10:3");
});
it("redraws when the chosen connection repeats the last map it was on", () => {
const r = assets().reader;
const draws = [2, 1]; // index 2 (Route 33) repeats lastMapId, then index 1 (Route 31).
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
ROUTE_33,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("26:2");
});
it("a 1-in-32 draw jumps instead, avoiding the player's own map rather than the beast's", () => {
const r = assets().reader;
// draw & 0x1f == 0 triggers JumpRoamMon; its own index draw (5) lands
// on RoamMaps entry 5 (Route 34, 11:1), which is not the player's map.
const draws = [0, 5];
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
null,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("11:1");
});
it("leaves a beast on an unlisted map untouched and draws nothing", () => {
const r = assets().reader;
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: "9:9", hp: 0, dvs: 0 }],
null,
"9:9",
() => {
throw new Error("should not draw");
},
);
expect(moved[0].mapId).toBe("9:9");
});
it("skips a beast whose map is null (GROUP_N_A), drawing nothing", () => {
const r = assets().reader;
const moved = updateRoamMons(r, [emptyRoamer()], null, "9:9", () => {
throw new Error("should not draw");
});
expect(moved[0].mapId).toBeNull();
});
it("JumpRoamMons picks uniformly among the 16 listed maps, redrawing only the player's own map", () => {
const r = assets().reader;
// Entry 0 is Route 29 itself, the player's map: redraw. Entry 1 is
// Route 30 (26:1).
const draws = [0, 1];
const jumped = jumpRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 }],
ROUTE_29,
() => draws.shift()!,
);
expect(jumped[0].mapId).toBe("26:1");
});
});
describe("map transitions hook UpdateRoamMons/JumpRoamMons", () => {
it("a map connection crossing moves an active beast and records the map", () => {
const world = new GameWorld(assets());
// Column x=7 runs clear from y=86 to the map's southern edge (y=90),
// which connects straight into Route 33.
world.warpTo(ROUTE_32, 7, 86);
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
let crossed = false;
for (let tries = 0; tries < 6 && !crossed; tries++)
crossed = world.move("down") && world.mapId === ROUTE_33;
expect(crossed).toBe(true);
expect(world.state.roamMonsMap).toBe(ROUTE_33);
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("a door warp moves an active beast too (MAPSETUP_DOOR falls through into MAPSETUP_TRAIN's UpdateRoamMons)", () => {
const world = new GameWorld(assets());
const warp = world.assets.map(ROUTE_32).warps.find((w) => w.targetMap === "10:13")!;
world.warpTo(ROUTE_32, warp.x, warp.y - 1);
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
expect(world.move("down")).toBe(true);
expect(world.mapId).toBe("10:13");
expect(world.state.roamMonsMap).toBe("10:13");
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("continuing a save (JumpRoamMons) relocates an active beast off the loaded map", () => {
const world = new GameWorld(assets());
const saved = world.save();
saved.map = ROUTE_32;
saved.x = 10;
saved.y = 80;
saved.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
emptyRoamer(),
emptyRoamer(),
];
world.restore(saved);
expect(world.state.roamMonsMap).toBe(ROUTE_32);
expect(world.state.roamMons[0].mapId).not.toBe(ROUTE_32);
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("a rollback restore (not a Continue) leaves the beasts where they were", () => {
const world = new GameWorld(assets());
const saved = world.save();
saved.map = ROUTE_32;
saved.x = 10;
saved.y = 80;
saved.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
emptyRoamer(),
emptyRoamer(),
];
world.restore(saved, false);
expect(world.save()).toEqual(saved);
});
});
describe("a roaming encounter", () => {
/** An open Johto route (Route 29) with a forced, deterministic
* encounter rate: the roam check itself still runs for real. */
function roamEncounterWorld() {
const a = assets();
a.grassCollisions = () => Array.from({ length: 256 }, (_, i) => i);
a.grassWildMons = (id) =>
id === ROUTE_29
? {
rates: [256, 256, 256],
slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: 16, level: 3 }))),
}
: undefined;
let mode: "fixed" | "lcg" = "fixed";
let seed = 7;
const world = new GameWorld(
a,
() => new Date("2026-09-22T12:00:00"),
() => {
if (mode === "fixed") return 1;
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const mon = generatePartyMon(a.pokemon, {
species: 155, level: 30, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
world.state.party = [mon];
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 };
world.warpTo(ROUTE_29, 30, 9);
return { world, setMode: (next: "fixed" | "lcg") => (mode = next) };
}
/** Walk several steps in whichever direction is open, without depending
* on the route's exact terrain: SetUpFiveStepWildEncounterCooldown
* skips the encounter check on the first five steps after any map
* entry, so a single step is never enough to reach it. */
function stepSomewhere(world: GameWorld, steps = 6): boolean {
for (let i = 0; i < steps; i++) {
if (world.battle) return true;
let moved = false;
for (const direction of ["down", "right", "up", "left"] as const)
if (world.move(direction)) {
moved = true;
break;
}
if (!moved) return false;
}
return true;
}
it("CheckEncounterRoamMon starts a BATTLETYPE_ROAMING battle naming the beast on this map", () => {
const { world } = roamEncounterWorld();
expect(stepSomewhere(world)).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.battle).toBeDefined();
const engine = world.battle!.engine;
expect(engine.battleType).toBe(C.BATTLETYPE_ROAMING);
expect(engine.enemy.mon.species).toBe(RAIKOU);
});
it("catching it with a Master Ball clears the roaming slot and adds it to the party", () => {
const { world, setMode } = roamEncounterWorld();
world.state.bag.balls = [{ item: C.MASTER_BALL, quantity: 1 }];
expect(stepSomewhere(world)).toBe(true);
expect(world.battle).toBeDefined();
setMode("lcg");
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.MASTER_BALL, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(0);
else if (view.message?.prompt) world.interact();
else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party.some((p) => p.species === RAIKOU)).toBe(true);
expect(world.state.roamMons[0]).toMatchObject({ species: 0, mapId: null, hp: 0 });
});
});
describe("save format", () => {
it("round-trips a beast's species, level, HP and DVs (the map itself may jump on continue)", () => {
const world = new GameWorld(assets());
world.state.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 123, dvs: 0xabcd },
emptyRoamer(),
emptyRoamer(),
];
const saved = world.save();
world.restore(saved);
const [raikou, entei, suicune] = world.state.roamMons;
expect({ species: raikou.species, level: raikou.level, hp: raikou.hp, dvs: raikou.dvs }).toEqual(
{ species: RAIKOU, level: 40, hp: 123, dvs: 0xabcd },
);
expect(raikou.mapId).not.toBeNull();
expect(entei.mapId).toBeNull();
expect(suicune.mapId).toBeNull();
});
it("round-trips through save()/restore() with afterRestore's allowance", () => {
const world = new GameWorld(assets());
world.state.roamMons[1] = { species: ENTEI, level: 40, mapId: ROUTE_37, hp: 200, dvs: 0x4242 };
const saved = world.save();
world.restore(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("rejects a save with an invalid roamMons entry", () => {
const world = new GameWorld(assets());
const saved = world.save();
const bad = {
...saved,
roamMons: [{ species: 999, level: 0, mapId: null, hp: 0, dvs: 0 }, emptyRoamer(), emptyRoamer()],
};
expect(() => world.restore(bad)).toThrow("Invalid native save");
});
it("rejects a save missing a roam slot", () => {
const world = new GameWorld(assets());
const saved = world.save();
const bad = { ...saved, roamMons: [emptyRoamer(), emptyRoamer()] };
expect(() => world.restore(bad)).toThrow("Invalid native save");
});
it("migrates an older save to three empty, GROUP_N_A roam slots", () => {
const world = new GameWorld(assets());
const saved = world.save() as Record<string, unknown>;
delete saved.roamMons;
delete saved.roamMonsMap;
const migrated = migrateSave(saved, () => 1);
expect(migrated?.roamMons).toEqual([emptyRoamer(), emptyRoamer(), emptyRoamer()]);
expect(migrated?.roamMonsMap).toBeNull();
expect(() => world.restore(migrated)).not.toThrow();
});
});
});