Files
Crystal/tests/npc-movement.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 7c1c0cc45b Port Reflect and Light Screen
BattleCommand_Screen and HandleScreens run natively. Each side owns
independent five-turn counters that include the setup turn, do not
refresh on repeated use and survive enemy send-outs. Screens expire in
the original non-Link order with ROM messages; the existing damage path
already doubles the right defense and keeps the critical/stage rule.
This closes the only refusal found by the Route 30 stress run
(Chikorita's Reflect).

The NPC movement test now reads the ROM in beforeAll, so it skips
cleanly without one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:38:13 +02:00

128 lines
5.0 KiB
TypeScript

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