Files
Crystal/tests/npc-movement.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 6a74f59e46
Checks / web (push) Canceled after 0s
Let NPCs roam as HandleNPCStep does
- 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>
2026-09-23 17:40:12 +02:00

125 lines
4.9 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 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]);
});
});