client-surface.test.ts 63 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import { createEngine, type InventorySystem, type PlantingSystem, type TimeSystem } from "@nengine/core";
  4. import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  5. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
  6. import { createNengineMcpServer, createGateway } from "../src/server.js";
  7. const repoRoot = resolve(process.cwd(), "../..");
  8. const contentDir = resolve(repoRoot, "content");
  9. const modsDir = resolve(repoRoot, "mods");
  10. interface Harness {
  11. engine: ReturnType<typeof createEngine>;
  12. client: Client;
  13. close: () => Promise<void>;
  14. }
  15. async function setup(): Promise<Harness> {
  16. const engine = createEngine({ contentDir, modsDir });
  17. await engine.start();
  18. const gateway = createGateway(engine);
  19. const server = createNengineMcpServer(engine, gateway);
  20. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  21. await server.connect(serverTransport);
  22. const client = new Client({ name: "client-surface-test", version: "0.0.1" });
  23. await client.connect(clientTransport);
  24. return {
  25. engine,
  26. client,
  27. close: async () => {
  28. await client.close();
  29. await server.close();
  30. await engine.stop();
  31. },
  32. };
  33. }
  34. function textOf(result: { content: { type: string; text?: string }[] }): string {
  35. return result.content.find((block) => block.type === "text")?.text ?? "";
  36. }
  37. function contentsOf(result: { contents: { text?: string }[] }): string {
  38. return result.contents[0]?.text ?? "";
  39. }
  40. function jsonOf(text: string): Record<string, any> {
  41. return JSON.parse(text) as Record<string, any>;
  42. }
  43. describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => {
  44. it("handshake: initialize + server version", async () => {
  45. const { client, close } = await setup();
  46. try {
  47. expect(client.getServerVersion()).toMatchObject({ name: "nengine-mcp-server", version: "0.1.0" });
  48. } finally {
  49. await close();
  50. }
  51. });
  52. it("tools surface: exactly 124 tools, all with object inputSchema", async () => {
  53. const { client, close } = await setup();
  54. try {
  55. const tools = await client.listTools();
  56. const names = tools.tools.map((tool) => tool.name).sort();
  57. expect(names).toEqual(
  58. [
  59. "animal.create",
  60. "animal.entities",
  61. "animal.feed",
  62. "animal.get",
  63. "animal.list",
  64. "animal.spawn",
  65. "animal.stage",
  66. "animal.update",
  67. "animal.validate",
  68. "animation.create",
  69. "animation.get",
  70. "animation.list",
  71. "animation.update",
  72. "animation.validate",
  73. "behavior.condition.validate",
  74. "behavior.fire",
  75. "behavior.statemachine.create",
  76. "behavior.statemachine.get",
  77. "behavior.statemachine.list",
  78. "behavior.statemachine.status",
  79. "behavior.statemachine.transition",
  80. "behavior.statemachine.update",
  81. "behavior.statemachine.validate",
  82. "behavior.trigger.create",
  83. "behavior.trigger.get",
  84. "behavior.trigger.list",
  85. "behavior.trigger.update",
  86. "behavior.trigger.validate",
  87. "crop.create",
  88. "crop.get",
  89. "crop.harvest",
  90. "crop.list",
  91. "crop.sow",
  92. "crop.stage",
  93. "crop.till",
  94. "crop.update",
  95. "crop.validate",
  96. "crop.water",
  97. "dialogue.choose",
  98. "dialogue.create",
  99. "dialogue.get",
  100. "dialogue.list",
  101. "dialogue.start",
  102. "dialogue.update",
  103. "dialogue.validate",
  104. "item.create",
  105. "item.get",
  106. "item.give",
  107. "item.list",
  108. "item.query_inventory",
  109. "item.update",
  110. "item.validate",
  111. "map.create",
  112. "map.get",
  113. "map.list",
  114. "map.update",
  115. "map.validate",
  116. "mod.list",
  117. "mod.reload",
  118. "mod.status",
  119. "npc.get",
  120. "npc.list",
  121. "npc.move",
  122. "npc.path",
  123. "npc.spawn",
  124. "npc.teleport",
  125. "particle.create",
  126. "particle.get",
  127. "particle.list",
  128. "particle.update",
  129. "particle.validate",
  130. "placement.building.create",
  131. "placement.building.get",
  132. "placement.building.list",
  133. "placement.building.update",
  134. "placement.building.validate",
  135. "placement.list",
  136. "placement.occupies",
  137. "placement.place",
  138. "placement.remove",
  139. "player.intent",
  140. "rng.get",
  141. "rng.set",
  142. "save.migrate",
  143. "save.restore",
  144. "save.snapshot",
  145. "save.validate",
  146. "server.disable_subsystem",
  147. "server.enable_subsystem",
  148. "server.info",
  149. "server.status",
  150. "shader.create",
  151. "shader.get",
  152. "shader.list",
  153. "shader.update",
  154. "shader.validate",
  155. "scene.create",
  156. "scene.get",
  157. "scene.list",
  158. "scene.update",
  159. "scene.validate",
  160. "shop.buy",
  161. "shop.create",
  162. "shop.get",
  163. "shop.list",
  164. "shop.sell",
  165. "shop.update",
  166. "shop.validate",
  167. "sound.create",
  168. "sound.get",
  169. "sound.list",
  170. "sound.update",
  171. "sound.validate",
  172. "sprite.create",
  173. "sprite.get",
  174. "sprite.list",
  175. "sprite.update",
  176. "sprite.validate",
  177. "time.advance",
  178. "time.get",
  179. "time.set",
  180. "time.tick",
  181. "weather.get",
  182. "weather.set",
  183. ].sort(),
  184. );
  185. expect(tools.tools).toHaveLength(124);
  186. for (const tool of tools.tools) {
  187. expect(tool.inputSchema).toMatchObject({ type: "object" });
  188. }
  189. } finally {
  190. await close();
  191. }
  192. });
  193. it("server.info: identity, content counts, subsystems enabled, time state", async () => {
  194. const { client, close } = await setup();
  195. try {
  196. const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} })));
  197. expect(info.engine).toBe("nengine");
  198. expect(info.version).toBe("0.1.0");
  199. expect(info.content).toEqual({
  200. animal: ["chicken"],
  201. behavior: ["season-welcome", "spring-gift"],
  202. building: ["fence", "shed"],
  203. crop: ["parsnip"],
  204. dialogue: ["welcome"],
  205. map: ["farm"],
  206. time: ["default"],
  207. item: ["egg", "hoe", "parsnip-seed", "parsnip"],
  208. shop: ["general-store"],
  209. statemachine: ["chicken-egg", "daily-routine", "spring-quest"],
  210. weather: ["default"],
  211. });
  212. expect(info.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([
  213. "animal",
  214. "animation",
  215. "behavior",
  216. "crop",
  217. "dialogue",
  218. "item",
  219. "map",
  220. "mod",
  221. "npc",
  222. "particle",
  223. "placement",
  224. "player",
  225. "rng",
  226. "save",
  227. "scene",
  228. "server",
  229. "shader",
  230. "shop",
  231. "sound",
  232. "sprite",
  233. "time",
  234. "weather",
  235. ]);
  236. expect(info.subsystems.every((s: { enabled: boolean }) => s.enabled === true)).toBe(true);
  237. expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  238. } finally {
  239. await close();
  240. }
  241. });
  242. it("server.status: {subsystems:[...]} with all namespaces", async () => {
  243. const { client, close } = await setup();
  244. try {
  245. const status = jsonOf(textOf(await client.callTool({ name: "server.status", arguments: {} })));
  246. expect(status.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([
  247. "animal",
  248. "animation",
  249. "behavior",
  250. "crop",
  251. "dialogue",
  252. "item",
  253. "map",
  254. "mod",
  255. "npc",
  256. "particle",
  257. "placement",
  258. "player",
  259. "rng",
  260. "save",
  261. "scene",
  262. "server",
  263. "shader",
  264. "shop",
  265. "sound",
  266. "sprite",
  267. "time",
  268. "weather",
  269. ]);
  270. } finally {
  271. await close();
  272. }
  273. });
  274. it("server.enable_subsystem/disable_subsystem drive namespace availability through the real server", async () => {
  275. const { client, close } = await setup();
  276. try {
  277. const before = await client.listTools();
  278. expect(before.tools.some((t) => t.name === "time.get")).toBe(true);
  279. expect(before.tools.some((t) => t.name === "server.enable_subsystem")).toBe(true);
  280. const disabled = jsonOf(
  281. textOf(await client.callTool({ name: "server.disable_subsystem", arguments: { name: "time" } })),
  282. );
  283. expect(disabled).toEqual({ name: "time", enabled: false });
  284. const after = await client.listTools();
  285. expect(after.tools.some((t) => t.name === "time.get")).toBe(false);
  286. expect(after.tools.some((t) => t.name === "server.info")).toBe(true);
  287. const resources = await client.listResources();
  288. expect(resources.resources.some((r) => r.uri === "time://state")).toBe(false);
  289. const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} })));
  290. expect(info.subsystems.find((s: { name: string }) => s.name === "time")).toEqual({
  291. name: "time",
  292. enabled: false,
  293. });
  294. const rejected = await client.callTool({ name: "time.get", arguments: {} });
  295. expect(rejected.isError).toBe(true);
  296. expect(textOf(rejected)).toMatch(/namespace "time" is disabled/);
  297. const guard = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "server" } });
  298. expect(guard.isError).toBe(true);
  299. expect(textOf(guard)).toMatch(/server\.disable_subsystem: cannot disable the server subsystem/);
  300. const unknown = await client.callTool({ name: "server.enable_subsystem", arguments: { name: "nope" } });
  301. expect(unknown.isError).toBe(true);
  302. expect(textOf(unknown)).toMatch(/server\.enable_subsystem: unknown subsystem: nope/);
  303. const blank = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "" } });
  304. expect(blank.isError).toBe(true);
  305. expect(textOf(blank)).toMatch(/server\.disable_subsystem: name must be a non-empty string/);
  306. const enabled = jsonOf(
  307. textOf(await client.callTool({ name: "server.enable_subsystem", arguments: { name: "time" } })),
  308. );
  309. expect(enabled).toEqual({ name: "time", enabled: true });
  310. const restored = await client.listTools();
  311. expect(restored.tools.some((t) => t.name === "time.get")).toBe(true);
  312. const get = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
  313. expect(get).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  314. } finally {
  315. await close();
  316. }
  317. });
  318. it("item: seeded catalog, list/get, inventory give + query", async () => {
  319. const { client, close } = await setup();
  320. try {
  321. const catalog = jsonOf(contentsOf(await client.readResource({ uri: "item://catalog" })));
  322. expect(catalog.items).toHaveLength(4);
  323. const seed = catalog.items.find((i: { id: string }) => i.id === "parsnip-seed");
  324. expect(seed).toMatchObject({ id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  325. const list = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} })));
  326. expect(list.items).toEqual([
  327. { id: "egg", name: "Egg", kind: "material" },
  328. { id: "hoe", name: "Hoe", kind: "tool" },
  329. { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed" },
  330. { id: "parsnip", name: "Parsnip", kind: "material" },
  331. ]);
  332. const got = jsonOf(textOf(await client.callTool({ name: "item.get", arguments: { itemId: "hoe" } })));
  333. expect(got).toMatchObject({ id: "hoe", kind: "tool" });
  334. const gave = jsonOf(textOf(await client.callTool({ name: "item.give", arguments: { itemId: "hoe", qty: 2 } })));
  335. expect(gave).toEqual({ itemId: "hoe", qty: 2, total: 2 });
  336. const inventory = jsonOf(textOf(await client.callTool({ name: "item.query_inventory", arguments: {} })));
  337. expect(inventory).toEqual({ inventory: { hoe: 2 } });
  338. const missing = await client.callTool({ name: "item.get", arguments: { itemId: "nope" } });
  339. expect(missing.isError).toBe(true);
  340. expect(textOf(missing)).toMatch(/item not found: nope/);
  341. const unknownGive = await client.callTool({ name: "item.give", arguments: { itemId: "nope", qty: 1 } });
  342. expect(unknownGive.isError).toBe(true);
  343. expect(textOf(unknownGive)).toMatch(/unknown item: nope/);
  344. } finally {
  345. await close();
  346. }
  347. });
  348. it("crop: seeded catalog, list/get, plot water/stage debug tools", async () => {
  349. const { engine, client, close } = await setup();
  350. try {
  351. const catalog = jsonOf(contentsOf(await client.readResource({ uri: "crop://catalog" })));
  352. expect(catalog.crops).toHaveLength(1);
  353. const parsnip = catalog.crops.find((c: { id: string }) => c.id === "parsnip");
  354. expect(parsnip).toMatchObject({
  355. id: "parsnip",
  356. seedItem: "parsnip-seed",
  357. stages: [
  358. { name: "sprout", days: 2, waterRequired: true },
  359. { name: "grown", days: 2, waterRequired: true },
  360. { name: "mature", days: 1, waterRequired: false },
  361. ],
  362. seasons: ["spring"],
  363. drops: [{ itemId: "parsnip", qty: 1 }],
  364. });
  365. const list = jsonOf(textOf(await client.callTool({ name: "crop.list", arguments: {} })));
  366. expect(list.crops).toEqual([{ id: "parsnip", seedItem: "parsnip-seed", stageCount: 3 }]);
  367. const got = jsonOf(textOf(await client.callTool({ name: "crop.get", arguments: { cropId: "parsnip" } })));
  368. expect(got).toMatchObject({ id: "parsnip", seedItem: "parsnip-seed" });
  369. const missing = await client.callTool({ name: "crop.get", arguments: { cropId: "nope" } });
  370. expect(missing.isError).toBe(true);
  371. expect(textOf(missing)).toMatch(/crop not found: nope/);
  372. const inv = engine.systems.get<InventorySystem>("inventory");
  373. inv!.give("parsnip-seed", 5);
  374. const planting = engine.systems.get<PlantingSystem>("planting");
  375. planting!.till(6, 6);
  376. planting!.sow(6, 6, "parsnip-seed");
  377. const watered = jsonOf(textOf(await client.callTool({ name: "crop.water", arguments: { x: 6, y: 6 } })));
  378. expect(watered).toEqual({ plotId: "plot:6:6", x: 6, y: 6, watered: true });
  379. const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 6, y: 6, stageIndex: 2 } })));
  380. expect(staged).toEqual({
  381. plotId: "plot:6:6",
  382. x: 6,
  383. y: 6,
  384. cropItemId: "parsnip",
  385. stageIndex: 2,
  386. stage: "mature",
  387. });
  388. const singleCrop = jsonOf(contentsOf(await client.readResource({ uri: "crop://parsnip" })));
  389. expect(singleCrop.id).toBe("parsnip");
  390. } finally {
  391. await close();
  392. }
  393. });
  394. it("crop: till → sow → harvest debug tools drive the full farming loop through MCP", async () => {
  395. const { engine, client, close } = await setup();
  396. try {
  397. const inv = engine.systems.get<InventorySystem>("inventory");
  398. inv!.give("parsnip-seed", 5);
  399. const tilled = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { x: 7, y: 7 } })));
  400. expect(tilled).toEqual({ plotId: "plot:7:7", x: 7, y: 7, tilled: true });
  401. const byPlotId = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { plotId: "plot:8:8" } })));
  402. expect(byPlotId).toEqual({ plotId: "plot:8:8", x: 8, y: 8, tilled: true });
  403. const sown = jsonOf(
  404. textOf(await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7, seedItemId: "parsnip-seed" } })),
  405. );
  406. expect(sown).toEqual({ plotId: "plot:7:7", x: 7, y: 7, cropItemId: "parsnip", seedItemId: "parsnip-seed" });
  407. expect(inv!.count("parsnip-seed")).toBe(4);
  408. engine.world.create({ id: "plot:6:6", type: "plot", x: 6, y: 6, tilled: false, watered: false });
  409. const untilled = await client.callTool({ name: "crop.sow", arguments: { x: 6, y: 6, seedItemId: "parsnip-seed" } });
  410. expect(untilled.isError).toBe(true);
  411. expect(textOf(untilled)).toMatch(/not tilled/);
  412. const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 7, y: 7, stageIndex: 2 } })));
  413. expect(staged).toMatchObject({ stageIndex: 2, stage: "mature" });
  414. const harvested = jsonOf(textOf(await client.callTool({ name: "crop.harvest", arguments: { x: 7, y: 7 } })));
  415. expect(harvested).toEqual({
  416. plotId: "plot:7:7",
  417. x: 7,
  418. y: 7,
  419. cropItemId: "parsnip",
  420. drops: [{ itemId: "parsnip", qty: 1 }],
  421. });
  422. expect(inv!.count("parsnip")).toBe(1);
  423. const empty = await client.callTool({ name: "crop.harvest", arguments: { x: 8, y: 8 } });
  424. expect(empty.isError).toBe(true);
  425. expect(textOf(empty)).toMatch(/has no crop/);
  426. const missingSeed = await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7 } });
  427. expect(missingSeed.isError).toBe(true);
  428. expect(textOf(missingSeed)).toMatch(/missing required arguments: seedItemId/);
  429. } finally {
  430. await close();
  431. }
  432. });
  433. it("shop: seeded def, list/get, buy/sell debug transactions", async () => {
  434. const { engine, client, close } = await setup();
  435. try {
  436. const catalog = jsonOf(contentsOf(await client.readResource({ uri: "shop://catalog" })));
  437. expect(catalog.shops).toHaveLength(1);
  438. const store = catalog.shops.find((s: { id: string }) => s.id === "general-store");
  439. expect(store).toMatchObject({
  440. id: "general-store",
  441. buys: [{ itemId: "parsnip", price: 30 }],
  442. sells: [
  443. { itemId: "hoe", price: 500 },
  444. { itemId: "parsnip-seed", price: 20, stock: 99 },
  445. ],
  446. });
  447. const list = jsonOf(textOf(await client.callTool({ name: "shop.list", arguments: {} })));
  448. expect(list.shops).toEqual([{ id: "general-store", buyCount: 1, sellCount: 2 }]);
  449. const got = jsonOf(textOf(await client.callTool({ name: "shop.get", arguments: { shopId: "general-store" } })));
  450. expect(got).toMatchObject({ id: "general-store" });
  451. const missing = await client.callTool({ name: "shop.get", arguments: { shopId: "nope" } });
  452. expect(missing.isError).toBe(true);
  453. expect(textOf(missing)).toMatch(/shop not found: nope/);
  454. const player = engine.world.get("player");
  455. if (!player) throw new Error("player entity not seeded");
  456. player.gold = 100;
  457. const bought = jsonOf(
  458. textOf(await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "parsnip-seed", qty: 2 } })),
  459. );
  460. expect(bought).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
  461. const inv = engine.systems.get<InventorySystem>("inventory");
  462. expect(inv!.count("parsnip-seed")).toBe(2);
  463. const broke = await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "hoe", qty: 1 } });
  464. expect(broke.isError).toBe(true);
  465. expect(textOf(broke)).toMatch(/insufficient gold/);
  466. inv!.give("parsnip", 3);
  467. const sold = jsonOf(
  468. textOf(await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 2 } })),
  469. );
  470. expect(sold).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 120 });
  471. expect(inv!.count("parsnip")).toBe(1);
  472. const short = await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 9 } });
  473. expect(short.isError).toBe(true);
  474. expect(textOf(short)).toMatch(/insufficient parsnip/);
  475. const singleShop = jsonOf(contentsOf(await client.readResource({ uri: "shop://general-store" })));
  476. expect(singleShop.id).toBe("general-store");
  477. } finally {
  478. await close();
  479. }
  480. });
  481. it("behavior: seeded triggers, list/get, condition validate, fire debug, resource and prompt", async () => {
  482. const { engine, client, close } = await setup();
  483. try {
  484. const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://triggers" })));
  485. expect(resource.triggers).toHaveLength(2);
  486. const springGift = resource.triggers.find((t: { id: string }) => t.id === "spring-gift");
  487. expect(springGift).toMatchObject({
  488. id: "spring-gift",
  489. when: { op: "gte", path: "time.day", value: 5 },
  490. fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
  491. });
  492. const list = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.list", arguments: {} })));
  493. expect(list.triggers).toEqual([
  494. { id: "season-welcome", actionCount: 2 },
  495. { id: "spring-gift", actionCount: 1 },
  496. ]);
  497. const got = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "spring-gift" } })));
  498. expect(got).toMatchObject({ id: "spring-gift", when: { op: "gte", path: "time.day", value: 5 } });
  499. const missing = await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "nope" } });
  500. expect(missing.isError).toBe(true);
  501. expect(textOf(missing)).toMatch(/trigger not found: nope/);
  502. const validCondition = jsonOf(
  503. textOf(await client.callTool({
  504. name: "behavior.condition.validate",
  505. arguments: { condition: { and: [{ op: "gte", path: "time.day", value: 5 }, { op: "exists", path: "payload.itemId" }] } },
  506. })),
  507. );
  508. expect(validCondition).toEqual({ ok: true });
  509. const invalidCondition = jsonOf(
  510. textOf(await client.callTool({ name: "behavior.condition.validate", arguments: { condition: { op: "gte", path: "time.day" } } })),
  511. );
  512. expect(invalidCondition.ok).toBe(false);
  513. expect(Array.isArray(invalidCondition.errors)).toBe(true);
  514. const time = engine.systems.get<TimeSystem>("time");
  515. time!.set(5, "spring");
  516. const fired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "spring-gift" } })));
  517. expect(fired).toEqual({ triggerId: "spring-gift", fired: true });
  518. const inv = engine.systems.get<InventorySystem>("inventory");
  519. expect(inv!.count("parsnip-seed")).toBe(1);
  520. const notFired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "season-welcome" } })));
  521. expect(notFired).toEqual({ triggerId: "season-welcome", fired: false });
  522. const unknown = await client.callTool({ name: "behavior.fire", arguments: { triggerId: "nope" } });
  523. expect(unknown.isError).toBe(true);
  524. expect(textOf(unknown)).toMatch(/unknown trigger: nope/);
  525. const prompt = await client.getPrompt({ name: "behavior.trigger-prompt", arguments: {} });
  526. const promptText = String(prompt.messages[0]?.content.text);
  527. expect(promptText).toContain("give-item");
  528. expect(promptText).toContain("behavior.trigger.validate");
  529. expect(promptText).toContain("content/behaviors/<id>.yaml");
  530. } finally {
  531. await close();
  532. }
  533. });
  534. it("statemachine: seeded definitions, list/get/status/transition, resource and prompt", async () => {
  535. const { engine, client, close } = await setup();
  536. try {
  537. const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://statemachines" })));
  538. expect(resource.statemachines).toHaveLength(3);
  539. const routine = resource.statemachines.find((m: { id: string }) => m.id === "daily-routine");
  540. expect(routine).toMatchObject({
  541. id: "daily-routine",
  542. initial: "sleeping",
  543. states: [{ id: "sleeping" }, { id: "working" }, { id: "resting" }],
  544. });
  545. const list = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.list", arguments: {} })));
  546. expect(list.statemachines).toEqual([
  547. { id: "chicken-egg", stateCount: 2, transitionCount: 2 },
  548. { id: "daily-routine", stateCount: 3, transitionCount: 1 },
  549. { id: "spring-quest", stateCount: 3, transitionCount: 2 },
  550. ]);
  551. const got = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "spring-quest" } })));
  552. expect(got).toMatchObject({ id: "spring-quest", initial: "inactive" });
  553. const missing = await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "nope" } });
  554. expect(missing.isError).toBe(true);
  555. expect(textOf(missing)).toMatch(/machine not found: nope/);
  556. const before = jsonOf(
  557. textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })),
  558. );
  559. expect(before).toEqual({ machineId: "spring-quest", state: "inactive", started: false });
  560. const time = engine.systems.get<TimeSystem>("time");
  561. time!.set(3, "spring");
  562. time!.advance(480);
  563. const after = jsonOf(
  564. textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })),
  565. );
  566. expect(after).toEqual({ machineId: "spring-quest", state: "active", started: true });
  567. const forced = jsonOf(
  568. textOf(await client.callTool({ name: "behavior.statemachine.transition", arguments: { machineId: "spring-quest", to: "complete" } })),
  569. );
  570. expect(forced).toEqual({ machineId: "spring-quest", from: "active", to: "complete", transitioned: true });
  571. const player = engine.world.get("player");
  572. expect(player?.questProgress).toMatchObject({ "spring-quest": "complete" });
  573. const prompt = await client.getPrompt({ name: "behavior.statemachine-prompt", arguments: {} });
  574. const promptText = String(prompt.messages[0]?.content.text);
  575. expect(promptText).toContain("onSchedule");
  576. expect(promptText).toContain("behavior.statemachine.validate");
  577. expect(promptText).toContain("content/statemachines/<id>.yaml");
  578. } finally {
  579. await close();
  580. }
  581. });
  582. it("map.list: {maps:[...]} summaries", async () => {
  583. const { client, close } = await setup();
  584. try {
  585. const list = jsonOf(textOf(await client.callTool({ name: "map.list", arguments: {} })));
  586. expect(list.maps).toEqual([{ id: "farm", width: 12, height: 12, tilesetRef: "farm-tiles" }]);
  587. } finally {
  588. await close();
  589. }
  590. });
  591. it("map.get: full definition, layer layout, spawn, player entity", async () => {
  592. const { client, close } = await setup();
  593. try {
  594. const map = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: { mapId: "farm" } })));
  595. expect(map.id).toBe("farm");
  596. expect(map.width).toBe(12);
  597. expect(map.height).toBe(12);
  598. expect(map.tilesetRef).toBe("farm-tiles");
  599. const ground = map.layers[0];
  600. expect(ground.name).toBe("ground");
  601. expect(ground.data).toHaveLength(12);
  602. expect(ground.data[0]).toHaveLength(12);
  603. expect(ground.data[0][0]).toBe("water");
  604. expect(ground.data[4][4]).toBe("tilled");
  605. expect(ground.data[0][10]).toBe("grass");
  606. expect(map.spawn).toEqual({ x: 2, y: 2 });
  607. expect(map.player).toMatchObject({ id: "player", type: "player", x: 2, y: 2 });
  608. } finally {
  609. await close();
  610. }
  611. });
  612. it("map.get: defaults to first map; missing mapId is an error result", async () => {
  613. const { client, close } = await setup();
  614. try {
  615. const first = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: {} })));
  616. expect(first.id).toBe("farm");
  617. const missing = await client.callTool({ name: "map.get", arguments: { mapId: "nope" } });
  618. expect(missing.isError).toBe(true);
  619. expect(textOf(missing)).toMatch(/map not found: nope/);
  620. } finally {
  621. await close();
  622. }
  623. });
  624. it("time: get initial, advance across day, set, advance across season", async () => {
  625. const { client, close } = await setup();
  626. try {
  627. const initial = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} })));
  628. expect(initial).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  629. const day2 = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
  630. expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  631. const day28 = jsonOf(textOf(await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } })));
  632. expect(day28).toEqual({ day: 28, season: "spring", tickInDay: 0 });
  633. const summer = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } })));
  634. expect(summer).toEqual({ day: 1, season: "summer", tickInDay: 0 });
  635. } finally {
  636. await close();
  637. }
  638. });
  639. it("time.tick runs a full simulation step (drives growth + emits time-tick; defaults to 1 tick)", async () => {
  640. const { engine, client, close } = await setup();
  641. try {
  642. const stepped = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 120 } })));
  643. expect(stepped).toEqual({ day: 1, season: "spring", tickInDay: 120 });
  644. const defaulted = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: {} })));
  645. expect(defaulted).toEqual({ day: 1, season: "spring", tickInDay: 121 });
  646. const inv = engine.systems.get<InventorySystem>("inventory");
  647. inv!.give("parsnip-seed", 5);
  648. const planting = engine.systems.get<PlantingSystem>("planting");
  649. planting!.till(6, 6);
  650. planting!.sow(6, 6, "parsnip-seed");
  651. planting!.water(6, 6);
  652. const day2 = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 359 } })));
  653. expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  654. const plot = engine.world.get("plot:6:6") as { daysInStage?: number };
  655. expect(plot.daysInStage).toBe(1);
  656. const invalid = await client.callTool({ name: "time.tick", arguments: { ticks: 0 } });
  657. expect(invalid.isError).toBe(true);
  658. expect(textOf(invalid)).toMatch(/ticks must be a positive integer/);
  659. } finally {
  660. await close();
  661. }
  662. });
  663. it("error paths: unknown tool, missing args, negative ticks", async () => {
  664. const { client, close } = await setup();
  665. try {
  666. const unknown = await client.callTool({ name: "nope.nothing", arguments: {} });
  667. expect(unknown.isError).toBe(true);
  668. expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/);
  669. const missing = await client.callTool({ name: "time.advance", arguments: {} });
  670. expect(missing.isError).toBe(true);
  671. expect(textOf(missing)).toMatch(/missing required arguments: ticks/);
  672. const negative = await client.callTool({ name: "time.advance", arguments: { ticks: -5 } });
  673. expect(negative.isError).toBe(true);
  674. expect(textOf(negative)).toMatch(/ticks must be a positive integer/);
  675. } finally {
  676. await close();
  677. }
  678. });
  679. it("resources: 36 template URIs, all readable, live state reflected", async () => {
  680. const { client, close } = await setup();
  681. try {
  682. await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
  683. await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } });
  684. await client.callTool({ name: "time.advance", arguments: { ticks: 480 } });
  685. const resources = await client.listResources();
  686. expect(resources.resources.map((r) => r.uri).sort()).toEqual(
  687. ["animal://{id}", "animal://catalog", "animation://{id}", "animation://catalog", "behavior://statemachines", "behavior://triggers", "crop://{id}", "crop://catalog", "dialogue://{id}", "dialogue://catalog", "item://{id}", "item://catalog", "map://{mapId}", "mod://{id}", "npc://{npcId}", "particle://{id}", "particle://catalog", "placement://{id}", "placement://buildings", "placement://placed", "player://state", "rng://state", "save://state", "scene://{id}", "scene://catalog", "server://status", "shader://{id}", "shader://catalog", "shop://{shopId}", "shop://catalog", "sound://{id}", "sound://catalog", "sprite://{id}", "sprite://catalog", "time://state", "weather://state"].sort(),
  688. );
  689. const status = jsonOf(contentsOf(await client.readResource({ uri: "server://status" })));
  690. expect(status.engine).toBe("nengine");
  691. const map = jsonOf(contentsOf(await client.readResource({ uri: "map://farm" })));
  692. expect(map.id).toBe("farm");
  693. const time = jsonOf(contentsOf(await client.readResource({ uri: "time://state" })));
  694. expect(time).toEqual({ day: 1, season: "summer", tickInDay: 0 });
  695. } finally {
  696. await close();
  697. }
  698. });
  699. it("resources: missing template param rejects with a server error", async () => {
  700. const { client, close } = await setup();
  701. try {
  702. await expect(client.readResource({ uri: "map://missing" })).rejects.toThrow(/map not found: missing/);
  703. } finally {
  704. await close();
  705. }
  706. });
  707. it("prompts: 17 authoring prompts, get returns guidance; missing prompt rejects", async () => {
  708. const { client, close } = await setup();
  709. try {
  710. const prompts = await client.listPrompts();
  711. expect(prompts.prompts.map((p) => p.name).sort()).toEqual([
  712. "animal.create-prompt",
  713. "animation.create-prompt",
  714. "behavior.statemachine-prompt",
  715. "behavior.trigger-prompt",
  716. "crop.create-prompt",
  717. "dialogue.create-prompt",
  718. "item.create-prompt",
  719. "map.create-prompt",
  720. "particle.create-prompt",
  721. "placement.create-prompt",
  722. "save.authoring-prompt",
  723. "scene.create-prompt",
  724. "server.orient",
  725. "shader.create-prompt",
  726. "shop.create-prompt",
  727. "sound.create-prompt",
  728. "sprite.create-prompt",
  729. ]);
  730. const orient = await client.getPrompt({ name: "server.orient", arguments: {} });
  731. const text = orient.messages[0]?.content;
  732. expect(text).toMatchObject({ type: "text" });
  733. expect(String(text?.text)).toContain("nEngine MCP server");
  734. expect(String(text?.text)).toContain("time.advance");
  735. expect(String(text?.text)).toContain("content/maps/<id>.yaml");
  736. const itemPrompt = await client.getPrompt({ name: "item.create-prompt", arguments: {} });
  737. expect(String(itemPrompt.messages[0]?.content.text)).toContain("item.validate");
  738. expect(String(itemPrompt.messages[0]?.content.text)).toContain("content/items/<id>.yaml");
  739. const cropPrompt = await client.getPrompt({ name: "crop.create-prompt", arguments: {} });
  740. expect(String(cropPrompt.messages[0]?.content.text)).toContain("crop.validate");
  741. expect(String(cropPrompt.messages[0]?.content.text)).toContain("content/crops/<id>.yaml");
  742. expect(String(cropPrompt.messages[0]?.content.text)).toContain("waterRequired");
  743. const shopPrompt = await client.getPrompt({ name: "shop.create-prompt", arguments: {} });
  744. expect(String(shopPrompt.messages[0]?.content.text)).toContain("shop.validate");
  745. expect(String(shopPrompt.messages[0]?.content.text)).toContain("content/shops/<id>.yaml");
  746. expect(String(shopPrompt.messages[0]?.content.text)).toContain("stock");
  747. await expect(client.getPrompt({ name: "nope", arguments: {} })).rejects.toThrow();
  748. } finally {
  749. await close();
  750. }
  751. });
  752. it("save: snapshot/validate/restore/migrate, save://state resource, and authoring prompt", async () => {
  753. const { engine, client, close } = await setup();
  754. try {
  755. const snapshot = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
  756. expect(snapshot.format).toBe("nengine-save");
  757. expect(snapshot.version).toBe(1);
  758. expect(typeof snapshot.savedAt).toBe("string");
  759. const entities = snapshot.world as { id: string; type: string; x: number; y: number; width: number; height: number }[];
  760. expect(entities.find((e) => e.id === "farm")).toMatchObject({ type: "map", width: 12, height: 12 });
  761. expect(entities.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 2, y: 2 });
  762. const valid = jsonOf(textOf(await client.callTool({ name: "save.validate", arguments: { snapshot } })));
  763. expect(valid).toEqual({ ok: true });
  764. const invalid = jsonOf(
  765. textOf(await client.callTool({ name: "save.validate", arguments: { snapshot: { format: "nengine-save" } } })),
  766. );
  767. expect(invalid.ok).toBe(false);
  768. expect(Array.isArray(invalid.errors)).toBe(true);
  769. const inv = engine.systems.get<InventorySystem>("inventory");
  770. inv!.give("parsnip-seed", 4);
  771. const planting = engine.systems.get<PlantingSystem>("planting");
  772. planting!.till(9, 9);
  773. const mutated = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
  774. expect((mutated.world as { id: string }[]).some((e) => e.id === "plot:9:9")).toBe(true);
  775. const restored = jsonOf(
  776. textOf(await client.callTool({ name: "save.restore", arguments: { snapshot: mutated } })),
  777. );
  778. expect(restored).toEqual({ restored: 4 });
  779. const after = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} })));
  780. const afterEntities = after.world as { id: string; inventory?: Record<string, number> }[];
  781. expect(afterEntities.some((e) => e.id === "plot:9:9")).toBe(true);
  782. expect(afterEntities.find((e) => e.id === "player")?.inventory).toEqual({ "parsnip-seed": 4 });
  783. const badRestore = await client.callTool({ name: "save.restore", arguments: { snapshot: { format: "nengine-save" } } });
  784. expect(badRestore.isError).toBe(true);
  785. expect(textOf(badRestore)).toMatch(/invalid snapshot/);
  786. const migrated = jsonOf(
  787. textOf(await client.callTool({ name: "save.migrate", arguments: { snapshot, fromVersion: 1, toVersion: 2 } })),
  788. );
  789. expect(migrated.world).toEqual(snapshot.world);
  790. const state = jsonOf(contentsOf(await client.readResource({ uri: "save://state" })));
  791. expect(state.format).toBe("nengine-save");
  792. expect(state.version).toBe(1);
  793. const prompt = await client.getPrompt({ name: "save.authoring-prompt", arguments: {} });
  794. const promptText = String(prompt.messages[0]?.content.text);
  795. expect(promptText).toContain("nengine-save");
  796. expect(promptText).toContain("migration");
  797. expect(promptText).toContain("save.snapshot");
  798. } finally {
  799. await close();
  800. }
  801. });
  802. it("mod: list/status/reload over the real mods dir (example-mod), mod://{id} resource, no prompts", async () => {
  803. const { engine, client, close } = await setup();
  804. try {
  805. const list = jsonOf(textOf(await client.callTool({ name: "mod.list", arguments: {} })));
  806. expect(list.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
  807. const status = jsonOf(textOf(await client.callTool({ name: "mod.status", arguments: { id: "example-mod" } })));
  808. expect(status).toEqual({ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] });
  809. const missing = await client.callTool({ name: "mod.status", arguments: { id: "nope" } });
  810. expect(missing.isError).toBe(true);
  811. expect(textOf(missing)).toMatch(/mod not found: nope/);
  812. const singleMod = jsonOf(contentsOf(await client.readResource({ uri: "mod://example-mod" })));
  813. expect(singleMod).toMatchObject({ id: "example-mod", version: "0.1.0", loaded: true });
  814. const reloaded = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: { id: "example-mod" } })));
  815. expect(reloaded.reloaded).toBe(true);
  816. expect(reloaded.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
  817. expect(engine.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
  818. const reloadAll = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: {} })));
  819. expect(reloadAll.mods).toHaveLength(1);
  820. expect(reloadAll.mods[0].id).toBe("example-mod");
  821. const prompts = await client.listPrompts();
  822. expect(prompts.prompts.some((p) => p.name.startsWith("mod."))).toBe(false);
  823. } finally {
  824. await close();
  825. }
  826. });
  827. it("placement: seeded building defs, place/list/occupies/remove, resources and prompt", async () => {
  828. const { client, close } = await setup();
  829. try {
  830. const catalog = jsonOf(contentsOf(await client.readResource({ uri: "placement://buildings" })));
  831. expect(catalog.buildings.map((b: { id: string }) => b.id)).toEqual(["fence", "shed"]);
  832. const list = jsonOf(textOf(await client.callTool({ name: "placement.building.list", arguments: {} })));
  833. expect(list.buildings).toEqual([
  834. { id: "fence", name: "Fence", w: 1, h: 1, blocksMovement: true },
  835. { id: "shed", name: "Shed", w: 2, h: 2, blocksMovement: true },
  836. ]);
  837. const placed = jsonOf(
  838. textOf(await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 3, y: 3 } })),
  839. );
  840. expect(placed).toEqual({
  841. id: "building:shed:3:3",
  842. type: "building",
  843. buildingId: "shed",
  844. x: 3,
  845. y: 3,
  846. w: 2,
  847. h: 2,
  848. blocksMovement: true,
  849. });
  850. await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 10 } });
  851. const all = jsonOf(textOf(await client.callTool({ name: "placement.list", arguments: {} })));
  852. expect(all.buildings).toHaveLength(2);
  853. const inside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 4, y: 3 } })));
  854. expect(inside.id).toBe("building:shed:3:3");
  855. const outside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 11, y: 11 } })));
  856. expect(outside).toBeNull();
  857. const onWater = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 0 } });
  858. expect(onWater.isError).toBe(true);
  859. expect(textOf(onWater)).toMatch(/tile \(0,0\) is not buildable for fence/);
  860. const overlap = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 3, y: 3 } });
  861. expect(overlap.isError).toBe(true);
  862. expect(textOf(overlap)).toMatch(/overlaps existing building/);
  863. const removed = jsonOf(textOf(await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } })));
  864. expect(removed.id).toBe("building:shed:3:3");
  865. const missing = await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } });
  866. expect(missing.isError).toBe(true);
  867. expect(textOf(missing)).toMatch(/no building at \(3,3\)/);
  868. const placedResource = jsonOf(contentsOf(await client.readResource({ uri: "placement://placed" })));
  869. expect(placedResource.buildings).toHaveLength(1);
  870. const shedDef = jsonOf(contentsOf(await client.readResource({ uri: "placement://shed" })));
  871. expect(shedDef).toMatchObject({ id: "shed", footprint: { w: 2, h: 2 } });
  872. const prompt = await client.getPrompt({ name: "placement.create-prompt", arguments: {} });
  873. const promptText = String(prompt.messages[0]?.content.text);
  874. expect(promptText).toContain("buildable");
  875. expect(promptText).toContain("content/buildings/<id>.yaml");
  876. } finally {
  877. await close();
  878. }
  879. });
  880. it("npc: spawn/list/get/move/teleport, npc-move event, blocking by building, resource, no prompts", async () => {
  881. const { engine, client, close } = await setup();
  882. try {
  883. const spawned = jsonOf(textOf(await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 2, y: 2 } })));
  884. expect(spawned).toEqual({ id: "leah", type: "npc", x: 2, y: 2 });
  885. const listed = jsonOf(textOf(await client.callTool({ name: "npc.list", arguments: {} })));
  886. expect(listed.npcs).toEqual([{ id: "leah", x: 2, y: 2 }]);
  887. const got = jsonOf(textOf(await client.callTool({ name: "npc.get", arguments: { npcId: "leah" } })));
  888. expect(got).toMatchObject({ id: "leah", type: "npc", x: 2, y: 2 });
  889. const moved = jsonOf(textOf(await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 4, y: 4 } })));
  890. expect(moved).toEqual({ npcId: "leah", from: { x: 2, y: 2 }, to: { x: 4, y: 4 } });
  891. const unknown = await client.callTool({ name: "npc.move", arguments: { npcId: "ghost", x: 0, y: 0 } });
  892. expect(unknown.isError).toBe(true);
  893. expect(textOf(unknown)).toMatch(/unknown npc: ghost/);
  894. const onWater = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 0, y: 0 } });
  895. expect(onWater.isError).toBe(true);
  896. expect(textOf(onWater)).toMatch(/tile \(0,0\) is not walkable/);
  897. const bounds = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 12, y: 0 } });
  898. expect(bounds.isError).toBe(true);
  899. expect(textOf(bounds)).toMatch(/target out of bounds: \(12,0\)/);
  900. await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 8, y: 8 } });
  901. const blocked = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 8, y: 8 } });
  902. expect(blocked.isError).toBe(true);
  903. expect(textOf(blocked)).toMatch(/blocked by building building:shed:8:8 at \(8,8\)/);
  904. const teleported = jsonOf(
  905. textOf(await client.callTool({ name: "npc.teleport", arguments: { npcId: "leah", x: 0, y: 0 } })),
  906. );
  907. expect(teleported).toEqual({ npcId: "leah", from: { x: 4, y: 4 }, to: { x: 0, y: 0 } });
  908. const leah = jsonOf(contentsOf(await client.readResource({ uri: "npc://leah" })));
  909. expect(leah).toMatchObject({ id: "leah", type: "npc", x: 0, y: 0 });
  910. await expect(client.readResource({ uri: "npc://ghost" })).rejects.toThrow(/npc not found: ghost/);
  911. const prompts = await client.listPrompts();
  912. expect(prompts.prompts.some((p) => p.name.startsWith("npc."))).toBe(false);
  913. expect(engine.world.get("leah")).toMatchObject({ x: 0, y: 0 });
  914. } finally {
  915. await close();
  916. }
  917. });
  918. it("npc.path: A* path result, null reasons (blocked/out of bounds), and validation errors", async () => {
  919. const { client, close } = await setup();
  920. try {
  921. await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 4, y: 4 } });
  922. const found = jsonOf(
  923. textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 6, y: 6 } } })),
  924. );
  925. expect(found).toEqual({
  926. npcId: "leah",
  927. from: { x: 4, y: 4 },
  928. to: { x: 6, y: 6 },
  929. path: [
  930. { x: 4, y: 4 },
  931. { x: 5, y: 4 },
  932. { x: 6, y: 4 },
  933. { x: 6, y: 5 },
  934. { x: 6, y: 6 },
  935. ],
  936. });
  937. const onWater = jsonOf(
  938. textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 0, y: 0 } } })),
  939. );
  940. expect(onWater).toEqual({ npcId: "leah", path: null, reason: "blocked" });
  941. const bounds = jsonOf(
  942. textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 12, y: 0 } } })),
  943. );
  944. expect(bounds).toEqual({ npcId: "leah", path: null, reason: "out of bounds" });
  945. const unknown = await client.callTool({ name: "npc.path", arguments: { npcId: "ghost", to: { x: 5, y: 5 } } });
  946. expect(unknown.isError).toBe(true);
  947. expect(textOf(unknown)).toMatch(/movement: unknown npc: ghost/);
  948. const blank = await client.callTool({ name: "npc.path", arguments: { npcId: "", to: { x: 5, y: 5 } } });
  949. expect(blank.isError).toBe(true);
  950. expect(textOf(blank)).toMatch(/npc\.path: npcId must be a non-empty string/);
  951. const badTo = await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 1.5, y: 0 } } });
  952. expect(badTo.isError).toBe(true);
  953. expect(textOf(badTo)).toMatch(/npc\.path: to must have integer x and y/);
  954. } finally {
  955. await close();
  956. }
  957. });
  958. it("animal: seeded def, list/get, spawn/feed/stage debug tools, resources and prompt", async () => {
  959. const { engine, client, close } = await setup();
  960. try {
  961. const catalog = jsonOf(contentsOf(await client.readResource({ uri: "animal://catalog" })));
  962. expect(catalog.animals).toHaveLength(1);
  963. const chicken = catalog.animals.find((a: { id: string }) => a.id === "chicken");
  964. expect(chicken).toMatchObject({
  965. id: "chicken",
  966. stages: [
  967. { name: "chick", days: 2, feedRequired: false },
  968. { name: "grown", days: 2, feedRequired: true },
  969. { name: "laying", days: 1, feedRequired: true },
  970. ],
  971. produces: [{ itemId: "egg", qty: 1 }],
  972. });
  973. const list = jsonOf(textOf(await client.callTool({ name: "animal.list", arguments: {} })));
  974. expect(list.animals).toEqual([{ id: "chicken", stageCount: 3 }]);
  975. const got = jsonOf(textOf(await client.callTool({ name: "animal.get", arguments: { animalId: "chicken" } })));
  976. expect(got).toMatchObject({ id: "chicken", produces: [{ itemId: "egg", qty: 1 }] });
  977. const missing = await client.callTool({ name: "animal.get", arguments: { animalId: "cow" } });
  978. expect(missing.isError).toBe(true);
  979. expect(textOf(missing)).toMatch(/animal not found: cow/);
  980. const spawned = jsonOf(textOf(await client.callTool({ name: "animal.spawn", arguments: { animalId: "chicken", x: 3, y: 3 } })));
  981. expect(spawned).toMatchObject({
  982. id: "animal:chicken:1",
  983. type: "animal",
  984. animalId: "chicken",
  985. x: 3,
  986. y: 3,
  987. stageIndex: 0,
  988. fed: false,
  989. });
  990. const fed = jsonOf(textOf(await client.callTool({ name: "animal.feed", arguments: { id: "animal:chicken:1" } })));
  991. expect(fed).toEqual({ id: "animal:chicken:1", animalId: "chicken", fed: true });
  992. const staged = jsonOf(
  993. textOf(await client.callTool({ name: "animal.stage", arguments: { id: "animal:chicken:1", stageIndex: 2 } })),
  994. );
  995. expect(staged).toEqual({
  996. id: "animal:chicken:1",
  997. animalId: "chicken",
  998. animalDefId: "chicken",
  999. stageIndex: 2,
  1000. stage: "laying",
  1001. });
  1002. const entities = jsonOf(textOf(await client.callTool({ name: "animal.entities", arguments: {} })));
  1003. expect(entities.animals).toHaveLength(1);
  1004. expect(entities.animals[0].id).toBe("animal:chicken:1");
  1005. const unknownSpawn = await client.callTool({ name: "animal.spawn", arguments: { animalId: "cow", x: 0, y: 0 } });
  1006. expect(unknownSpawn.isError).toBe(true);
  1007. expect(textOf(unknownSpawn)).toMatch(/unknown animal def: cow/);
  1008. const singleAnimal = jsonOf(contentsOf(await client.readResource({ uri: "animal://chicken" })));
  1009. expect(singleAnimal.id).toBe("chicken");
  1010. await expect(client.readResource({ uri: "animal://ghost" })).rejects.toThrow(/animal not found: ghost/);
  1011. const prompt = await client.getPrompt({ name: "animal.create-prompt", arguments: {} });
  1012. const promptText = String(prompt.messages[0]?.content.text);
  1013. expect(promptText).toContain("feedRequired");
  1014. expect(promptText).toContain("content/animals/<id>.yaml");
  1015. } finally {
  1016. await close();
  1017. }
  1018. });
  1019. it("player: intent drives the player entity, player://state reads it", async () => {
  1020. const { engine, client, close } = await setup();
  1021. try {
  1022. const state = jsonOf(contentsOf(await client.readResource({ uri: "player://state" })));
  1023. expect(state).toMatchObject({ id: "player", type: "player", x: 2, y: 2 });
  1024. const moved = jsonOf(
  1025. textOf(await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 3, y: 2 } } })),
  1026. );
  1027. expect(moved).toMatchObject({ action: "move", tile: { x: 3, y: 2 }, ok: true, detail: "moved to (3,2)" });
  1028. expect(engine.world.get("player")).toMatchObject({ x: 3, y: 2 });
  1029. const tilled = jsonOf(
  1030. textOf(await client.callTool({ name: "player.intent", arguments: { action: "interact", tile: { x: 5, y: 5 } } })),
  1031. );
  1032. expect(tilled).toMatchObject({ action: "interact", ok: true, detail: "tilled plot" });
  1033. expect(engine.world.get("plot:5:5")).toMatchObject({ type: "plot", x: 5, y: 5, tilled: true });
  1034. const onWater = await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 0, y: 0 } } });
  1035. expect(onWater.isError).toBe(true);
  1036. expect(textOf(onWater)).toMatch(/player\.intent: intent\.move: tile \(0,0\) is not walkable/);
  1037. const badAction = await client.callTool({
  1038. name: "player.intent",
  1039. arguments: { action: "dance", tile: { x: 1, y: 1 } },
  1040. });
  1041. expect(badAction.isError).toBe(true);
  1042. expect(textOf(badAction)).toMatch(
  1043. /player\.intent: action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/,
  1044. );
  1045. const badTile = await client.callTool({
  1046. name: "player.intent",
  1047. arguments: { action: "move", tile: { x: -1, y: 0 } },
  1048. });
  1049. expect(badTile.isError).toBe(true);
  1050. expect(textOf(badTile)).toMatch(/player\.intent: tile x and y must be non-negative integers/);
  1051. const movedState = jsonOf(contentsOf(await client.readResource({ uri: "player://state" })));
  1052. expect(movedState).toMatchObject({ x: 3, y: 2 });
  1053. } finally {
  1054. await close();
  1055. }
  1056. });
  1057. it("player.intent buy/sell/talk/choose/select-seed drive the extended actions through the real server", async () => {
  1058. const { engine, client, close } = await setup();
  1059. try {
  1060. const player = engine.world.get("player");
  1061. if (!player) throw new Error("player entity not seeded");
  1062. player.gold = 100;
  1063. const inv = engine.systems.get<InventorySystem>("inventory");
  1064. if (!inv) throw new Error("inventory system not booted");
  1065. const noActive = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } });
  1066. expect(noActive.isError).toBe(true);
  1067. expect(textOf(noActive)).toMatch(/no active dialogue/);
  1068. const bought = jsonOf(
  1069. textOf(
  1070. await client.callTool({
  1071. name: "player.intent",
  1072. arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 2 },
  1073. }),
  1074. ),
  1075. );
  1076. expect(bought.detail).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
  1077. expect(inv.count("parsnip-seed")).toBe(2);
  1078. const missingItem = await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store" } });
  1079. expect(missingItem.isError).toBe(true);
  1080. expect(textOf(missingItem)).toMatch(/buy requires itemId/);
  1081. const missingShop = await client.callTool({ name: "player.intent", arguments: { action: "buy", itemId: "parsnip-seed" } });
  1082. expect(missingShop.isError).toBe(true);
  1083. expect(textOf(missingShop)).toMatch(/buy requires shopId/);
  1084. const badQty = await client.callTool({
  1085. name: "player.intent",
  1086. arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 0 },
  1087. });
  1088. expect(badQty.isError).toBe(true);
  1089. expect(textOf(badQty)).toMatch(/qty must be a positive integer/);
  1090. inv.give("parsnip", 3);
  1091. const sold = jsonOf(
  1092. textOf(
  1093. await client.callTool({
  1094. name: "player.intent",
  1095. arguments: { action: "sell", shopId: "general-store", itemId: "parsnip", qty: 1 },
  1096. }),
  1097. ),
  1098. );
  1099. expect(sold.detail).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 1, total: 30, gold: 90 });
  1100. expect(inv.count("parsnip")).toBe(2);
  1101. const broke = await client.callTool({
  1102. name: "player.intent",
  1103. arguments: { action: "buy", shopId: "general-store", itemId: "hoe", qty: 1 },
  1104. });
  1105. expect(broke.isError).toBe(true);
  1106. expect(textOf(broke)).toMatch(/insufficient gold/);
  1107. engine.world.create({ id: "npc:clerk", type: "npc", x: 3, y: 3, dialogue: "welcome" });
  1108. const talked = jsonOf(
  1109. textOf(await client.callTool({ name: "player.intent", arguments: { action: "talk", npcId: "npc:clerk" } })),
  1110. );
  1111. expect(talked.detail).toMatchObject({ dialogueId: "welcome", nodeId: "hello" });
  1112. const chosen = jsonOf(
  1113. textOf(await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } })),
  1114. );
  1115. expect(chosen.detail).toMatchObject({ dialogueId: "welcome", nodeId: "seed-tip" });
  1116. const outOfRange = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 9 } });
  1117. expect(outOfRange.isError).toBe(true);
  1118. expect(textOf(outOfRange)).toMatch(/choice index out of range/);
  1119. const selected = jsonOf(
  1120. textOf(
  1121. await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "parsnip-seed" } }),
  1122. ),
  1123. );
  1124. expect(selected.detail).toEqual({ selectedSeed: "parsnip-seed" });
  1125. expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" });
  1126. const notASeed = await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "hoe" } });
  1127. expect(notASeed.isError).toBe(true);
  1128. expect(textOf(notASeed)).toMatch(/hoe is not a seed item/);
  1129. } finally {
  1130. await close();
  1131. }
  1132. });
  1133. it("rng: get/set the engine RNG, determinism through MCP, rng://state resource, no prompts", async () => {
  1134. const { engine, client, close } = await setup();
  1135. try {
  1136. const initial = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} })));
  1137. expect(initial.seed).toEqual(engine.rng.state().seed);
  1138. expect(initial.calls).toBe(0);
  1139. engine.rng.next();
  1140. const afterDraw = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} })));
  1141. expect(afterDraw.calls).toBe(1);
  1142. const set = jsonOf(textOf(await client.callTool({ name: "rng.set", arguments: { seed: 2026 } })));
  1143. expect(set).toEqual({ seed: 2026, calls: 0 });
  1144. const first = engine.rng.next();
  1145. const second = engine.rng.next();
  1146. expect(first).toBeGreaterThanOrEqual(0);
  1147. expect(second).toBeGreaterThanOrEqual(0);
  1148. const state = jsonOf(contentsOf(await client.readResource({ uri: "rng://state" })));
  1149. expect(state).toEqual({ seed: 2026, calls: 2 });
  1150. const badSeed = await client.callTool({ name: "rng.set", arguments: { seed: Infinity } });
  1151. expect(badSeed.isError).toBe(true);
  1152. expect(textOf(badSeed)).toMatch(/seed must be a finite number/);
  1153. const prompts = await client.listPrompts();
  1154. expect(prompts.prompts.some((p) => p.name.startsWith("rng."))).toBe(false);
  1155. } finally {
  1156. await close();
  1157. }
  1158. });
  1159. it("animation.*: lifecycle validate + empty catalog over the root content dir (no writes)", async () => {
  1160. const { client, close } = await setup();
  1161. try {
  1162. const animationList = jsonOf(textOf(await client.callTool({ name: "animation.list", arguments: {} })));
  1163. expect(animationList).toEqual({ animations: [] });
  1164. const missingAnimation = await client.callTool({ name: "animation.get", arguments: { animationId: "player" } });
  1165. expect(missingAnimation.isError).toBe(true);
  1166. expect(textOf(missingAnimation)).toMatch(/animation not found: player/);
  1167. const validAnimation = jsonOf(
  1168. textOf(await client.callTool({
  1169. name: "animation.validate",
  1170. arguments: {
  1171. def: {
  1172. id: "player",
  1173. initial: "idle",
  1174. states: [
  1175. { id: "idle", spriteRef: "player", loop: true },
  1176. { id: "walk", spriteRef: "player-walk", fps: 8 },
  1177. ],
  1178. },
  1179. },
  1180. })),
  1181. );
  1182. expect(validAnimation).toEqual({
  1183. id: "player",
  1184. initial: "idle",
  1185. states: [
  1186. { id: "idle", spriteRef: "player", loop: true },
  1187. { id: "walk", spriteRef: "player-walk", fps: 8 },
  1188. ],
  1189. });
  1190. const badAnimation = await client.callTool({
  1191. name: "animation.validate",
  1192. arguments: { def: { id: "bad", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] } },
  1193. });
  1194. expect(badAnimation.isError).toBe(true);
  1195. expect(textOf(badAnimation)).toMatch(/initial state must be a declared state/);
  1196. const animationCatalog = jsonOf(contentsOf(await client.readResource({ uri: "animation://catalog" })));
  1197. expect(animationCatalog).toEqual({ animations: [] });
  1198. await expect(client.readResource({ uri: "animation://missing" })).rejects.toThrow(/animation not found: missing/);
  1199. const animationPrompt = await client.getPrompt({ name: "animation.create-prompt", arguments: {} });
  1200. expect(String(animationPrompt.messages[0]?.content.text)).toContain("content/animations/<id>.yaml");
  1201. } finally {
  1202. await close();
  1203. }
  1204. });
  1205. it("particle.* and shader.*: lifecycle validate + empty catalogs over the root content dir (no writes)", async () => {
  1206. const { client, close } = await setup();
  1207. try {
  1208. const particleList = jsonOf(textOf(await client.callTool({ name: "particle.list", arguments: {} })));
  1209. expect(particleList).toEqual({ particles: [] });
  1210. const missingParticle = await client.callTool({ name: "particle.get", arguments: { particleId: "dust" } });
  1211. expect(missingParticle.isError).toBe(true);
  1212. expect(textOf(missingParticle)).toMatch(/particle not found: dust/);
  1213. const validParticle = jsonOf(
  1214. textOf(await client.callTool({ name: "particle.validate", arguments: { def: { id: "dust", colors: ["#d7ccc8"] } } })),
  1215. );
  1216. expect(validParticle).toEqual({ id: "dust", colors: ["#d7ccc8"] });
  1217. const badParticle = await client.callTool({
  1218. name: "particle.validate",
  1219. arguments: { def: { id: "bad", colors: ["green"] } },
  1220. });
  1221. expect(badParticle.isError).toBe(true);
  1222. expect(textOf(badParticle)).toMatch(/colors/);
  1223. const shaderList = jsonOf(textOf(await client.callTool({ name: "shader.list", arguments: {} })));
  1224. expect(shaderList).toEqual({ shaders: [] });
  1225. const missingShader = await client.callTool({ name: "shader.get", arguments: { shaderId: "rain-tint" } });
  1226. expect(missingShader.isError).toBe(true);
  1227. expect(textOf(missingShader)).toMatch(/shader not found: rain-tint/);
  1228. const validShader = jsonOf(
  1229. textOf(await client.callTool({ name: "shader.validate", arguments: { def: { id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } } } })),
  1230. );
  1231. expect(validShader).toEqual({ id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } });
  1232. const badShader = await client.callTool({ name: "shader.validate", arguments: { def: { id: "bad", filter: "vignette" } } });
  1233. expect(badShader.isError).toBe(true);
  1234. expect(textOf(badShader)).toMatch(/filter/);
  1235. const particleCatalog = jsonOf(contentsOf(await client.readResource({ uri: "particle://catalog" })));
  1236. expect(particleCatalog).toEqual({ particles: [] });
  1237. const shaderCatalog = jsonOf(contentsOf(await client.readResource({ uri: "shader://catalog" })));
  1238. expect(shaderCatalog).toEqual({ shaders: [] });
  1239. await expect(client.readResource({ uri: "particle://missing" })).rejects.toThrow(/particle not found: missing/);
  1240. await expect(client.readResource({ uri: "shader://missing" })).rejects.toThrow(/shader not found: missing/);
  1241. const particlePrompt = await client.getPrompt({ name: "particle.create-prompt", arguments: {} });
  1242. expect(String(particlePrompt.messages[0]?.content.text)).toContain("content/particles/<id>.yaml");
  1243. const shaderPrompt = await client.getPrompt({ name: "shader.create-prompt", arguments: {} });
  1244. expect(String(shaderPrompt.messages[0]?.content.text)).toContain("content/shaders/<id>.yaml");
  1245. } finally {
  1246. await close();
  1247. }
  1248. });
  1249. });