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 { const ids = new Set(); 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; 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(); }); }); });