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 } from "@nengine/core"; import { createEngineNamespaces, McpGateway } 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_YAML = [ "id: shed", "footprint: { w: 2, h: 2 }", "buildable: map", "blocksMovement: true", "", ].join("\n"); interface Setup { engine: ReturnType; gateway: McpGateway; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-npc-ns-")); await mkdir(join(tmp, "maps"), { recursive: true }); await mkdir(join(tmp, "buildings"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML); const engine = createEngine({ contentDir: tmp }); const gateway = new McpGateway(); for (const namespace of createEngineNamespaces(engine, { gateway })) { gateway.register(namespace); } await engine.start(); return { engine, gateway, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } async function callText(gateway: McpGateway, name: string, args: Record = {}): Promise<{ ok: boolean; text: string; }> { const result = await gateway.callTool(name, args); return { ok: result.isError !== true, text: result.content[0]?.text ?? "" }; } describe("npc namespace", () => { it("npc.spawn creates an npc entity and npc.list/npc.get read it back", async () => { const { engine, gateway, close } = await setup(); try { const spawned = await callText(gateway, "npc.spawn", { id: "villager-leah", x: 0, y: 0 }); expect(spawned.ok).toBe(true); expect(JSON.parse(spawned.text)).toEqual({ id: "villager-leah", type: "npc", x: 0, y: 0 }); expect(engine.world.get("villager-leah")).toMatchObject({ type: "npc", x: 0, y: 0 }); const listed = await callText(gateway, "npc.list"); expect(JSON.parse(listed.text)).toEqual({ npcs: [{ id: "villager-leah", x: 0, y: 0 }] }); const got = await callText(gateway, "npc.get", { npcId: "villager-leah" }); expect(JSON.parse(got.text)).toEqual({ id: "villager-leah", type: "npc", x: 0, y: 0 }); const missing = await callText(gateway, "npc.get", { npcId: "ghost" }); expect(missing.ok).toBe(false); expect(missing.text).toMatch(/npc not found: ghost/); } finally { await close(); } }); it("npc.spawn rejects blank ids, bad coords, and duplicates", async () => { const { gateway, close } = await setup(); try { const blank = await callText(gateway, "npc.spawn", { id: "", x: 0, y: 0 }); expect(blank.ok).toBe(false); expect(blank.text).toMatch(/non-empty string/); const badCoord = await callText(gateway, "npc.spawn", { id: "leah", x: -1, y: 0 }); expect(badCoord.ok).toBe(false); expect(badCoord.text).toMatch(/non-negative integers/); await callText(gateway, "npc.spawn", { id: "leah", x: 1, y: 1 }); const dup = await callText(gateway, "npc.spawn", { id: "leah", x: 2, y: 2 }); expect(dup.ok).toBe(false); expect(dup.text).toMatch(/entity already exists: leah/); } finally { await close(); } }); it("npc.move moves the npc and returns from/to", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 }); const moved = await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 2 }); expect(moved.ok).toBe(true); expect(JSON.parse(moved.text)).toEqual({ npcId: "leah", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }); const npc = engine.world.get("leah") as { x: number; y: number }; expect(npc.x).toBe(2); expect(npc.y).toBe(2); } finally { await close(); } }); it("npc.move error paths: unknown npc, out of bounds, unwalkable tile, blocked by building", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 3, y: 3 }); const unknown = await callText(gateway, "npc.move", { npcId: "ghost", x: 0, y: 0 }); expect(unknown.ok).toBe(false); expect(unknown.text).toMatch(/unknown npc: ghost/); const bounds = await callText(gateway, "npc.move", { npcId: "leah", x: 5, y: 0 }); expect(bounds.ok).toBe(false); expect(bounds.text).toMatch(/target out of bounds: \(5,0\)/); const water = await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 0 }); expect(water.ok).toBe(false); expect(water.text).toMatch(/tile \(2,0\) is not walkable/); await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 }); const blocked = await callText(gateway, "npc.move", { npcId: "leah", x: 0, y: 0 }); expect(blocked.ok).toBe(false); expect(blocked.text).toMatch(/blocked by building building:shed:0:0 at \(0,0\)/); } finally { await close(); } }); it("npc.teleport bypasses bounds, walkability, and blocking", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 3, y: 3 }); await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 }); const ontoBlocked = await callText(gateway, "npc.teleport", { npcId: "leah", x: 0, y: 0 }); expect(ontoBlocked.ok).toBe(true); expect(JSON.parse(ontoBlocked.text)).toEqual({ npcId: "leah", from: { x: 3, y: 3 }, to: { x: 0, y: 0 } }); const offMap = await callText(gateway, "npc.teleport", { npcId: "leah", x: 99, y: 99 }); expect(offMap.ok).toBe(true); const ontoWater = await callText(gateway, "npc.teleport", { npcId: "leah", x: 2, y: 0 }); expect(ontoWater.ok).toBe(true); const unknown = await callText(gateway, "npc.teleport", { npcId: "ghost", x: 0, y: 0 }); expect(unknown.ok).toBe(false); expect(unknown.text).toMatch(/unknown npc: ghost/); } finally { await close(); } }); it("resource npc://{npcId} reads the live entity; missing id rejects", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 1, y: 1 }); await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 2 }); const npc = await gateway.readResource("npc://leah"); expect(JSON.parse(npc.contents[0]?.text ?? "{}")).toEqual({ id: "leah", type: "npc", x: 2, y: 2 }); await expect(gateway.readResource("npc://ghost")).rejects.toThrow(/npc not found: ghost/); } finally { await close(); } }); it("npc.path computes an A* path including start and goal", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 }); const found = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 2, y: 2 } }); expect(found.ok).toBe(true); expect(JSON.parse(found.text)).toEqual({ npcId: "leah", from: { x: 0, y: 0 }, to: { x: 2, y: 2 }, path: [ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 2 }, ], }); } finally { await close(); } }); it("npc.path reports blocked/out-of-bounds reasons and validates args", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 4, y: 4 }); const water = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 2, y: 0 } }); expect(JSON.parse(water.text)).toEqual({ npcId: "leah", path: null, reason: "blocked" }); const bounds = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 5, y: 0 } }); expect(JSON.parse(bounds.text)).toEqual({ npcId: "leah", path: null, reason: "out of bounds" }); await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 }); const blockedByBuilding = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 0, y: 0 } }); expect(JSON.parse(blockedByBuilding.text)).toEqual({ npcId: "leah", path: null, reason: "blocked" }); const unknown = await callText(gateway, "npc.path", { npcId: "ghost", to: { x: 1, y: 1 } }); expect(unknown.ok).toBe(false); expect(unknown.text).toMatch(/movement: unknown npc: ghost/); const blank = await callText(gateway, "npc.path", { npcId: "", to: { x: 1, y: 1 } }); expect(blank.ok).toBe(false); expect(blank.text).toMatch(/npc\.path: npcId must be a non-empty string/); const float = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 1.5, y: 0 } }); expect(float.ok).toBe(false); expect(float.text).toMatch(/npc\.path: to must have integer x and y/); const nonObject = await callText(gateway, "npc.path", { npcId: "leah", to: "nope" }); expect(nonObject.ok).toBe(false); expect(nonObject.text).toMatch(/npc\.path: to must have integer x and y/); } finally { await close(); } }); it("npc.path reports reason \"unreachable\" when the goal is walkable but sealed off", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 }); await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 2 }); await callText(gateway, "placement.place", { buildingId: "shed", x: 2, y: 2 }); const sealed = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 4, y: 4 } }); expect(JSON.parse(sealed.text)).toEqual({ npcId: "leah", path: null, reason: "unreachable" }); } finally { await close(); } }); it("npc.* exposes no prompts (runtime-entity namespace)", async () => { const { gateway, close } = await setup(); try { const prompts = gateway.listPrompts().map((prompt) => prompt.name); expect(prompts.some((name) => name.startsWith("npc."))).toBe(false); } finally { await close(); } }); });