- 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:
1 parent
796fe8f6d5
commit
6a74f59e46
12 files changed
+386
-20
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user