| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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<typeof createEngine>;
- gateway: McpGateway;
- close: () => Promise<void>;
- }
- async function setup(): Promise<Setup> {
- 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<string, unknown> = {}): 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();
- }
- });
- });
|