client-surface.test.ts 63 KB

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