Let NPCs roam as HandleNPCStep does
Checks / web (push) Canceled after 0s

- Random walks and spins from SpriteMovementData, with random sleeps.
- CanObjectMoveInDirection: side walls, land, objects' current and
  previous tiles, the radius (stored plus one) and the screen edge.
- Objects freeze while text is open and during applymovement, and
  return home when both they and their home leave the object window.
- Wandering positions are not saved; test helpers replan around NPCs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 17:40:12 +02:00
1 parent 796fe8f6d5
commit 6a74f59e46
12 files changed
+386 -20

No files matched your search

+2 -1
View File
@@ -320,8 +320,9 @@ describe.skipIf(!existsSync(romPath))(
expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!");
// The teacher runs over and leads the player back east.
expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]);
// Back home, SPRITEMOVEDATA_SPINRANDOM_SLOW spins her again at once.
const teacher = world.objects().find((object) => object.id === 2)!;
expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]);
expect([teacher.x, teacher.y]).toEqual([6, 8]);
expect(world.save().events).toEqual(events);
walkTo(world, 2, 2);
expect(world.move("right")).toBe(false);
+124
View File
@@ -0,0 +1,124 @@
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 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", () => {
const 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<string>(),
facings = new Set<Direction>();
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]);
});
});
+3
View File
@@ -111,6 +111,9 @@ describe.skipIf(!existsSync(romPath))("Route 29 with the local ROM", () => {
expect(world.notice, world.diagnostic).toBe("");
};
crossWest(ROUTE_29);
// Route29TuscanyCallback hides Tuscany without the Zephyr Badge, even on
// a Tuesday (2026-09-22).
expect(world.objects().some((object) => object.id === 8)).toBe(false);
crossWest("26:3");
walkTo(world, 17, 8);
expect(world.move("up")).toBe(true);
+23 -8
View File
@@ -96,7 +96,8 @@ export function findPath(
throw new Error(`No route to ${targetX},${targetY}`);
}
/** Walk to a tile, settling any scene that a step triggers. */
/** Walk to a tile, settling any scene that a step triggers. A roaming NPC
* that steps into the way is waited out, then the route is planned again. */
export function walkTo(
world: GameWorld,
targetX: number,
@@ -104,12 +105,26 @@ export function walkTo(
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
for (const direction of findPath(world, targetX, targetY)) {
if (!world.move(direction))
throw new Error(
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
);
shown.push(...settle(world, answers));
for (let attempt = 0; attempt < 20; attempt++) {
let blocked = false;
for (const direction of findPath(world, targetX, targetY)) {
if (!world.move(direction)) {
const [, dx, dy] = DIRECTIONS.find(([name]) => name === direction)!;
const blocker = world
.objects()
.some((object) => object.x === world.x + dx && object.y === world.y + dy);
if (!blocker)
throw new Error(
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
);
for (let tick = 0; tick < 64; tick++) world.tick();
blocked = true;
break;
}
shown.push(...settle(world, answers));
}
// A warp target leaves the map, so a completed path is the success test.
if (!blocked) return shown;
}
return shown;
throw new Error(`Could not reach ${targetX},${targetY}`);
}