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