import { beforeAll, 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 type { Direction } from "../src/game/world/types"; import { walkTo } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const ROUTE_29 = "24:3"; // object_event ids are their index + 2 (maps/Route29.asm). const YOUNGSTER = 3; // 27,16 SPRITEMOVEDATA_WALK_UP_DOWN, radius 0, 1 const TEACHER = 4; // 15,11 SPRITEMOVEDATA_WALK_LEFT_RIGHT, radius 1, 0 const DUDE = 2; // 50,12 SPRITEMOVEDATA_SPINRANDOM_SLOW describe.skipIf(!existsSync(romPath))("NPC movement with the local ROM", () => { let assets: CrystalAssets; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); }); /** A seeded Random so every run takes the same steps. */ const at = (x: number, y: number, direction: Direction = "down") => { let seed = 7; const world = new GameWorld( assets, () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); world.restore({ ...world.save(), map: ROUTE_29, x, y, direction }); return world; }; const object = (world: GameWorld, id: number) => world.objects().find((candidate) => candidate.id === id)!; const track = (world: GameWorld, id: number, ticks: number) => { const seen = new Set(), facings = new Set(); for (let tick = 0; tick < ticks; tick++) { world.tick(); const { x, y, direction } = object(world, id); seen.add(`${x},${y}`); facings.add(direction); } return { seen, facings }; }; it("walks up and down within the object's radius", () => { const world = at(29, 16); const { seen, facings } = track(world, YOUNGSTER, 3000); // Radius 1: home and one tile either way, never off the column. expect(seen.size).toBeGreaterThan(1); for (const tile of seen) { const [x, y] = tile.split(",").map(Number); expect(x).toBe(27); expect(Math.abs(y - 16)).toBeLessThanOrEqual(1); } expect([...facings].every((facing) => facing === "up" || facing === "down")).toBe(true); expect(world.notice, world.diagnostic).toBe(""); }); it("walks left and right for WALK_LEFT_RIGHT", () => { const world = at(13, 12); const { seen } = track(world, TEACHER, 3000); expect(seen.size).toBeGreaterThan(1); for (const tile of seen) { const [x, y] = tile.split(",").map(Number); expect(y).toBe(11); expect(Math.abs(x - 15)).toBeLessThanOrEqual(1); } }); it("spins in place for SPINRANDOM_SLOW", () => { const world = at(52, 12); const { seen, facings } = track(world, DUDE, 3000); expect([...seen]).toEqual(["50,12"]); expect(facings.size).toBeGreaterThan(2); }); it("never steps onto the player, and freezes while text is open", () => { // Standing on the youngster's lower tile blocks that step. const world = at(27, 17, "up"); const { seen } = track(world, YOUNGSTER, 3000); expect(seen.has("27,17")).toBe(false); // Talk to the youngster once he stands next to the player. for (let tick = 0; tick < 5000; tick++) { const youngster = object(world, YOUNGSTER); if (youngster.x === 27 && youngster.y === 16 && !world.busy) break; world.tick(); } world.interact(); expect(world.dialogue).toBeDefined(); const frozen = world.objects().map(({ id, x, y, direction }) => [id, x, y, direction]); for (let tick = 0; tick < 500; tick++) world.tick(); expect(world.objects().map(({ id, x, y, direction }) => [id, x, y, direction])).toEqual(frozen); }); it("stays put while off screen and returns home once far away", () => { // Nine tiles east: the youngster's steps would leave the screen. const far = at(36, 16); const { seen } = track(far, YOUNGSTER, 2000); expect([...seen]).toEqual(["27,16"]); // Let him wander, then walk away until home and tile leave the window. const world = at(29, 16); for (let tick = 0; tick < 5000 && object(world, YOUNGSTER).y === 16; tick++) world.tick(); expect(object(world, YOUNGSTER).y).not.toBe(16); const wandered = [object(world, YOUNGSTER).x, object(world, YOUNGSTER).y]; // Walking east leaves both his tile and home beyond the object window. walkTo(world, 40, 16); world.tick(); expect(wandered).not.toEqual([27, 16]); expect([object(world, YOUNGSTER).x, object(world, YOUNGSTER).y]).toEqual([27, 16]); }); it("does not save wandering positions", () => { const world = at(29, 16); for (let tick = 0; tick < 5000 && object(world, YOUNGSTER).y === 16; tick++) world.tick(); const saved = world.save(); expect(saved.objects).toEqual([]); world.restore(saved); expect([object(world, YOUNGSTER).x, object(world, YOUNGSTER).y]).toEqual([27, 16]); }); });