npc-namespace.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { createEngine } from "@nengine/core";
  6. import { createEngineNamespaces, McpGateway } from "../src/index.js";
  7. const TIME_YAML = [
  8. "id: default",
  9. "dayLengthTicks: 480",
  10. "seasons:",
  11. " - { name: spring, dayCount: 28 }",
  12. "start: { day: 1, season: spring }",
  13. "",
  14. ].join("\n");
  15. const FARM_YAML = [
  16. "id: farm",
  17. "width: 5",
  18. "height: 5",
  19. 'tilesetRef: "tiles"',
  20. "spawn: { x: 0, y: 0 }",
  21. "layers:",
  22. " - name: ground",
  23. " data:",
  24. " - [grass, grass, water, water, water]",
  25. " - [grass, grass, water, water, water]",
  26. " - [grass, grass, grass, grass, grass]",
  27. " - [grass, grass, grass, grass, grass]",
  28. " - [grass, grass, grass, grass, grass]",
  29. "",
  30. ].join("\n");
  31. const SHED_YAML = [
  32. "id: shed",
  33. "footprint: { w: 2, h: 2 }",
  34. "buildable: map",
  35. "blocksMovement: true",
  36. "",
  37. ].join("\n");
  38. interface Setup {
  39. engine: ReturnType<typeof createEngine>;
  40. gateway: McpGateway;
  41. close: () => Promise<void>;
  42. }
  43. async function setup(): Promise<Setup> {
  44. const tmp = await mkdtemp(join(tmpdir(), "farm-npc-ns-"));
  45. await mkdir(join(tmp, "maps"), { recursive: true });
  46. await mkdir(join(tmp, "buildings"), { recursive: true });
  47. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  48. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  49. await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML);
  50. const engine = createEngine({ contentDir: tmp });
  51. const gateway = new McpGateway();
  52. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  53. gateway.register(namespace);
  54. }
  55. await engine.start();
  56. return {
  57. engine,
  58. gateway,
  59. close: async () => {
  60. await engine.stop();
  61. await rm(tmp, { recursive: true, force: true });
  62. },
  63. };
  64. }
  65. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  66. ok: boolean;
  67. text: string;
  68. }> {
  69. const result = await gateway.callTool(name, args);
  70. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  71. }
  72. describe("npc namespace", () => {
  73. it("npc.spawn creates an npc entity and npc.list/npc.get read it back", async () => {
  74. const { engine, gateway, close } = await setup();
  75. try {
  76. const spawned = await callText(gateway, "npc.spawn", { id: "villager-leah", x: 0, y: 0 });
  77. expect(spawned.ok).toBe(true);
  78. expect(JSON.parse(spawned.text)).toEqual({ id: "villager-leah", type: "npc", x: 0, y: 0 });
  79. expect(engine.world.get("villager-leah")).toMatchObject({ type: "npc", x: 0, y: 0 });
  80. const listed = await callText(gateway, "npc.list");
  81. expect(JSON.parse(listed.text)).toEqual({ npcs: [{ id: "villager-leah", x: 0, y: 0 }] });
  82. const got = await callText(gateway, "npc.get", { npcId: "villager-leah" });
  83. expect(JSON.parse(got.text)).toEqual({ id: "villager-leah", type: "npc", x: 0, y: 0 });
  84. const missing = await callText(gateway, "npc.get", { npcId: "ghost" });
  85. expect(missing.ok).toBe(false);
  86. expect(missing.text).toMatch(/npc not found: ghost/);
  87. } finally {
  88. await close();
  89. }
  90. });
  91. it("npc.spawn rejects blank ids, bad coords, and duplicates", async () => {
  92. const { gateway, close } = await setup();
  93. try {
  94. const blank = await callText(gateway, "npc.spawn", { id: "", x: 0, y: 0 });
  95. expect(blank.ok).toBe(false);
  96. expect(blank.text).toMatch(/non-empty string/);
  97. const badCoord = await callText(gateway, "npc.spawn", { id: "leah", x: -1, y: 0 });
  98. expect(badCoord.ok).toBe(false);
  99. expect(badCoord.text).toMatch(/non-negative integers/);
  100. await callText(gateway, "npc.spawn", { id: "leah", x: 1, y: 1 });
  101. const dup = await callText(gateway, "npc.spawn", { id: "leah", x: 2, y: 2 });
  102. expect(dup.ok).toBe(false);
  103. expect(dup.text).toMatch(/entity already exists: leah/);
  104. } finally {
  105. await close();
  106. }
  107. });
  108. it("npc.move moves the npc and returns from/to", async () => {
  109. const { engine, gateway, close } = await setup();
  110. try {
  111. await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 });
  112. const moved = await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 2 });
  113. expect(moved.ok).toBe(true);
  114. expect(JSON.parse(moved.text)).toEqual({ npcId: "leah", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } });
  115. const npc = engine.world.get("leah") as { x: number; y: number };
  116. expect(npc.x).toBe(2);
  117. expect(npc.y).toBe(2);
  118. } finally {
  119. await close();
  120. }
  121. });
  122. it("npc.move error paths: unknown npc, out of bounds, unwalkable tile, blocked by building", async () => {
  123. const { gateway, close } = await setup();
  124. try {
  125. await callText(gateway, "npc.spawn", { id: "leah", x: 3, y: 3 });
  126. const unknown = await callText(gateway, "npc.move", { npcId: "ghost", x: 0, y: 0 });
  127. expect(unknown.ok).toBe(false);
  128. expect(unknown.text).toMatch(/unknown npc: ghost/);
  129. const bounds = await callText(gateway, "npc.move", { npcId: "leah", x: 5, y: 0 });
  130. expect(bounds.ok).toBe(false);
  131. expect(bounds.text).toMatch(/target out of bounds: \(5,0\)/);
  132. const water = await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 0 });
  133. expect(water.ok).toBe(false);
  134. expect(water.text).toMatch(/tile \(2,0\) is not walkable/);
  135. await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 });
  136. const blocked = await callText(gateway, "npc.move", { npcId: "leah", x: 0, y: 0 });
  137. expect(blocked.ok).toBe(false);
  138. expect(blocked.text).toMatch(/blocked by building building:shed:0:0 at \(0,0\)/);
  139. } finally {
  140. await close();
  141. }
  142. });
  143. it("npc.teleport bypasses bounds, walkability, and blocking", async () => {
  144. const { gateway, close } = await setup();
  145. try {
  146. await callText(gateway, "npc.spawn", { id: "leah", x: 3, y: 3 });
  147. await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 });
  148. const ontoBlocked = await callText(gateway, "npc.teleport", { npcId: "leah", x: 0, y: 0 });
  149. expect(ontoBlocked.ok).toBe(true);
  150. expect(JSON.parse(ontoBlocked.text)).toEqual({ npcId: "leah", from: { x: 3, y: 3 }, to: { x: 0, y: 0 } });
  151. const offMap = await callText(gateway, "npc.teleport", { npcId: "leah", x: 99, y: 99 });
  152. expect(offMap.ok).toBe(true);
  153. const ontoWater = await callText(gateway, "npc.teleport", { npcId: "leah", x: 2, y: 0 });
  154. expect(ontoWater.ok).toBe(true);
  155. const unknown = await callText(gateway, "npc.teleport", { npcId: "ghost", x: 0, y: 0 });
  156. expect(unknown.ok).toBe(false);
  157. expect(unknown.text).toMatch(/unknown npc: ghost/);
  158. } finally {
  159. await close();
  160. }
  161. });
  162. it("resource npc://{npcId} reads the live entity; missing id rejects", async () => {
  163. const { gateway, close } = await setup();
  164. try {
  165. await callText(gateway, "npc.spawn", { id: "leah", x: 1, y: 1 });
  166. await callText(gateway, "npc.move", { npcId: "leah", x: 2, y: 2 });
  167. const npc = await gateway.readResource("npc://leah");
  168. expect(JSON.parse(npc.contents[0]?.text ?? "{}")).toEqual({ id: "leah", type: "npc", x: 2, y: 2 });
  169. await expect(gateway.readResource("npc://ghost")).rejects.toThrow(/npc not found: ghost/);
  170. } finally {
  171. await close();
  172. }
  173. });
  174. it("npc.path computes an A* path including start and goal", async () => {
  175. const { gateway, close } = await setup();
  176. try {
  177. await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 });
  178. const found = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 2, y: 2 } });
  179. expect(found.ok).toBe(true);
  180. expect(JSON.parse(found.text)).toEqual({
  181. npcId: "leah",
  182. from: { x: 0, y: 0 },
  183. to: { x: 2, y: 2 },
  184. path: [
  185. { x: 0, y: 0 },
  186. { x: 1, y: 0 },
  187. { x: 1, y: 1 },
  188. { x: 1, y: 2 },
  189. { x: 2, y: 2 },
  190. ],
  191. });
  192. } finally {
  193. await close();
  194. }
  195. });
  196. it("npc.path reports blocked/out-of-bounds reasons and validates args", async () => {
  197. const { gateway, close } = await setup();
  198. try {
  199. await callText(gateway, "npc.spawn", { id: "leah", x: 4, y: 4 });
  200. const water = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 2, y: 0 } });
  201. expect(JSON.parse(water.text)).toEqual({ npcId: "leah", path: null, reason: "blocked" });
  202. const bounds = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 5, y: 0 } });
  203. expect(JSON.parse(bounds.text)).toEqual({ npcId: "leah", path: null, reason: "out of bounds" });
  204. await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 0 });
  205. const blockedByBuilding = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 0, y: 0 } });
  206. expect(JSON.parse(blockedByBuilding.text)).toEqual({ npcId: "leah", path: null, reason: "blocked" });
  207. const unknown = await callText(gateway, "npc.path", { npcId: "ghost", to: { x: 1, y: 1 } });
  208. expect(unknown.ok).toBe(false);
  209. expect(unknown.text).toMatch(/movement: unknown npc: ghost/);
  210. const blank = await callText(gateway, "npc.path", { npcId: "", to: { x: 1, y: 1 } });
  211. expect(blank.ok).toBe(false);
  212. expect(blank.text).toMatch(/npc\.path: npcId must be a non-empty string/);
  213. const float = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 1.5, y: 0 } });
  214. expect(float.ok).toBe(false);
  215. expect(float.text).toMatch(/npc\.path: to must have integer x and y/);
  216. const nonObject = await callText(gateway, "npc.path", { npcId: "leah", to: "nope" });
  217. expect(nonObject.ok).toBe(false);
  218. expect(nonObject.text).toMatch(/npc\.path: to must have integer x and y/);
  219. } finally {
  220. await close();
  221. }
  222. });
  223. it("npc.path reports reason \"unreachable\" when the goal is walkable but sealed off", async () => {
  224. const { gateway, close } = await setup();
  225. try {
  226. await callText(gateway, "npc.spawn", { id: "leah", x: 0, y: 0 });
  227. await callText(gateway, "placement.place", { buildingId: "shed", x: 0, y: 2 });
  228. await callText(gateway, "placement.place", { buildingId: "shed", x: 2, y: 2 });
  229. const sealed = await callText(gateway, "npc.path", { npcId: "leah", to: { x: 4, y: 4 } });
  230. expect(JSON.parse(sealed.text)).toEqual({ npcId: "leah", path: null, reason: "unreachable" });
  231. } finally {
  232. await close();
  233. }
  234. });
  235. it("npc.* exposes no prompts (runtime-entity namespace)", async () => {
  236. const { gateway, close } = await setup();
  237. try {
  238. const prompts = gateway.listPrompts().map((prompt) => prompt.name);
  239. expect(prompts.some((name) => name.startsWith("npc."))).toBe(false);
  240. } finally {
  241. await close();
  242. }
  243. });
  244. });