import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type BuildingDef, type MovementSystem, type NpcMoveEvent, type PlacementSystem, type System, } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 5", "height: 5", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass, water, water, water]", " - [grass, grass, water, water, water]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", "", ].join("\n"); const SHED: BuildingDef = { id: "shed", footprint: { w: 2, h: 2 }, buildable: "map", blocksMovement: true, }; const PATH: BuildingDef = { id: "path", footprint: { w: 1, h: 1 }, buildable: "any", blocksMovement: false, }; interface Setup { engine: ReturnType; close: () => Promise; } async function setup(spy?: System): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-movement-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); const systems: System[] = []; if (spy) systems.push(spy); const engine = createEngine({ contentDir: tmp, systems }); await engine.start(); engine.registry.register("building", SHED); engine.registry.register("building", PATH); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } function movementOf(engine: ReturnType): MovementSystem { const movement = engine.systems.get("movement"); if (!movement) throw new Error("movement system not booted"); return movement; } function spawnNpc(engine: ReturnType, id: string, x: number, y: number): void { engine.world.create({ id, type: "npc", x, y }); } function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } { const seen: { event: string; payload: unknown }[] = []; const system: System = { id: "spy", init() {}, on(event, payload) { seen.push({ event, payload }); }, }; return { system, seen }; } describe("MovementSystem.moveTo", () => { it("moves an npc tile-to-tile and emits npc-move with from/to", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { spawnNpc(engine, "villager", 0, 0); const result = movementOf(engine).moveTo("villager", 2, 2); expect(result).toEqual({ npcId: "villager", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }); const npc = engine.world.get("villager") as { x: number; y: number }; expect(npc.x).toBe(2); expect(npc.y).toBe(2); const moves = seen.filter((entry) => entry.event === "npc-move").map((entry) => entry.payload as NpcMoveEvent); expect(moves).toEqual([{ npcId: "villager", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }]); } finally { await close(); } }); it("throws on an unknown or non-npc entity", async () => { const { engine, close } = await setup(); try { expect(() => movementOf(engine).moveTo("ghost", 0, 0)).toThrow(/unknown npc: ghost/); engine.world.create({ id: "player", type: "player", x: 0, y: 0 }); expect(() => movementOf(engine).moveTo("player", 1, 1)).toThrow(/unknown npc: player/); } finally { await close(); } }); it("throws when the target is out of map bounds", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); expect(() => movementOf(engine).moveTo("villager", 5, 0)).toThrow(/target out of bounds: \(5,0\)/); expect(() => movementOf(engine).moveTo("villager", 0, 5)).toThrow(/target out of bounds: \(0,5\)/); } finally { await close(); } }); it("throws on an unwalkable tile (the water convention)", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); expect(() => movementOf(engine).moveTo("villager", 2, 0)).toThrow(/tile \(2,0\) is not walkable/); const npc = engine.world.get("villager") as { x: number; y: number }; expect(npc.x).toBe(0); } finally { await close(); } }); it("throws when the target is blocked by a blocksMovement building", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 3, 3); const placement = engine.systems.get("placement"); if (!placement) throw new Error("placement system not booted"); placement.place("shed", 0, 0); expect(() => movementOf(engine).moveTo("villager", 0, 0)).toThrow(/blocked by building building:shed:0:0 at \(0,0\)/); expect(() => movementOf(engine).moveTo("villager", 1, 1)).toThrow(/blocked by building/); } finally { await close(); } }); it("allows movement onto a non-blocking building and onto another npc", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "leah", 4, 4); spawnNpc(engine, "sam", 0, 0); const placement = engine.systems.get("placement"); if (!placement) throw new Error("placement system not booted"); placement.place("path", 2, 2); expect(movementOf(engine).moveTo("leah", 2, 2)).toMatchObject({ to: { x: 2, y: 2 } }); expect(movementOf(engine).moveTo("leah", 0, 0)).toMatchObject({ to: { x: 0, y: 0 } }); } finally { await close(); } }); it("rejects bad coords", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); expect(() => movementOf(engine).moveTo("villager", -1, 0)).toThrow(/non-negative integers/); expect(() => movementOf(engine).moveTo("villager", 0, 1.5)).toThrow(/non-negative integers/); } finally { await close(); } }); }); describe("MovementSystem.findPath (A* entity API)", () => { it("returns an A* path from the npc position to a reachable tile, including start and goal", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); const path = movementOf(engine).findPath("villager", { x: 2, y: 2 }); expect(path).toEqual([ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 2 }, ]); const npc = engine.world.get("villager") as { x: number; y: number }; expect(npc.x).toBe(0); expect(npc.y).toBe(0); } finally { await close(); } }); it("returns null when the goal is not walkable (water fallback)", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); expect(movementOf(engine).findPath("villager", { x: 2, y: 0 })).toBeNull(); } finally { await close(); } }); it("returns null when the goal is blocked by a blocksMovement building", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 4, 4); const placement = engine.systems.get("placement"); if (!placement) throw new Error("placement system not booted"); placement.place("shed", 0, 0); expect(movementOf(engine).findPath("villager", { x: 0, y: 0 })).toBeNull(); } finally { await close(); } }); it("returns null when the goal is occupied by another grid entity", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "leah", 0, 0); spawnNpc(engine, "sam", 1, 0); expect(movementOf(engine).findPath("leah", { x: 1, y: 0 })).toBeNull(); expect(movementOf(engine).findPath("leah", { x: 2, y: 2 })).not.toBeNull(); } finally { await close(); } }); it("returns null for an unreachable goal and validates the npc and coords", async () => { const { engine, close } = await setup(); try { spawnNpc(engine, "villager", 0, 0); expect(() => movementOf(engine).findPath("ghost", { x: 1, y: 1 })).toThrow(/unknown npc: ghost/); expect(() => movementOf(engine).findPath("villager", { x: -1, y: 0 })).toThrow(/non-negative integers/); expect(movementOf(engine).findPath("villager", { x: 5, y: 0 })).toBeNull(); } finally { await close(); } }); it("respects a map walkable declaration over the UNWALKABLE_TILES fallback", async () => { const { engine, close } = await setup(); try { engine.registry.register("map", { id: "farm", width: 5, height: 5, tilesetRef: "tiles", spawn: { x: 0, y: 0 }, layers: [ { name: "ground", data: [ ["grass", "grass", "water", "grass", "grass"], ["grass", "grass", "water", "grass", "grass"], ["grass", "grass", "water", "grass", "grass"], ["grass", "grass", "water", "grass", "grass"], ["grass", "grass", "grass", "grass", "grass"], ] }, ], walkable: { water: true }, }); spawnNpc(engine, "villager", 0, 0); const path = movementOf(engine).findPath("villager", { x: 4, y: 0 }); expect(path).not.toBeNull(); expect(path![path!.length - 1]).toEqual({ x: 4, y: 0 }); } finally { await close(); } }); }); describe("MovementSystem.teleport", () => { it("bypasses bounds, walkability, and blocking checks but still emits npc-move", async () => { const { system: spy, seen } = eventSpy(); const { engine, close } = await setup(spy); try { spawnNpc(engine, "villager", 3, 3); const placement = engine.systems.get("placement"); if (!placement) throw new Error("placement system not booted"); placement.place("shed", 0, 0); const result = movementOf(engine).teleport("villager", 0, 0); expect(result).toEqual({ npcId: "villager", from: { x: 3, y: 3 }, to: { x: 0, y: 0 } }); expect(movementOf(engine).teleport("villager", 99, 99)).toMatchObject({ to: { x: 99, y: 99 } }); expect(movementOf(engine).teleport("villager", 2, 0)).toMatchObject({ to: { x: 2, y: 0 } }); const moves = seen.filter((entry) => entry.event === "npc-move"); expect(moves).toHaveLength(3); } finally { await close(); } }); it("still requires a valid npc and non-negative coords", async () => { const { engine, close } = await setup(); try { expect(() => movementOf(engine).teleport("ghost", 0, 0)).toThrow(/unknown npc: ghost/); spawnNpc(engine, "villager", 0, 0); expect(() => movementOf(engine).teleport("villager", -2, 0)).toThrow(/non-negative integers/); } finally { await close(); } }); });