mod-namespace.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { createEngine, type HookContext, type TimeSystem } from "@nengine/core";
  6. import { createEngineNamespaces, McpGateway } from "../src/index.js";
  7. const TIME_YAML = [
  8. "id: default",
  9. "dayLengthTicks: 480",
  10. "seasons:",
  11. " - { name: spring, dayCount: 28 }",
  12. "start: { day: 1, season: spring }",
  13. "",
  14. ].join("\n");
  15. const FARM_YAML = [
  16. "id: farm",
  17. "width: 2",
  18. "height: 2",
  19. 'tilesetRef: "tiles"',
  20. "spawn: { x: 0, y: 0 }",
  21. "layers:",
  22. " - name: ground",
  23. " data:",
  24. " - [a, b]",
  25. " - [b, a]",
  26. "",
  27. ].join("\n");
  28. function modEntry(version: string): string {
  29. return [
  30. "export default {",
  31. " manifest: { id: \"a-mod\", version: \"" + version + "\" },",
  32. " hooks: {",
  33. " onDayStart(ctx) { ctx.emit(\"mod:test-tick\", { modId: \"a-mod\", day: ctx.time.get().day }); }",
  34. " },",
  35. "};",
  36. "",
  37. ].join("\n");
  38. }
  39. interface Setup {
  40. engine: ReturnType<typeof createEngine>;
  41. gateway: McpGateway;
  42. tmp: string;
  43. modsDir: string;
  44. close: () => Promise<void>;
  45. }
  46. async function setup(): Promise<Setup> {
  47. const tmp = await mkdtemp(join(tmpdir(), "farm-mod-"));
  48. await mkdir(join(tmp, "maps"), { recursive: true });
  49. await mkdir(join(tmp, "mods", "a-mod"), { recursive: true });
  50. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  51. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  52. await writeFile(join(tmp, "mods", "a-mod", "index.mjs"), modEntry("0.1.0"));
  53. const engine = createEngine({ contentDir: tmp, modsDir: join(tmp, "mods") });
  54. const gateway = new McpGateway();
  55. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  56. gateway.register(namespace);
  57. }
  58. await engine.start();
  59. return {
  60. engine,
  61. gateway,
  62. tmp,
  63. modsDir: join(tmp, "mods"),
  64. close: async () => {
  65. await engine.stop();
  66. await rm(tmp, { recursive: true, force: true });
  67. },
  68. };
  69. }
  70. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  71. ok: boolean;
  72. text: string;
  73. }> {
  74. const result = await gateway.callTool(name, args);
  75. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  76. }
  77. function fireDayStart(engine: ReturnType<typeof createEngine>, capture: (payload: unknown) => void): void {
  78. const time = engine.systems.get<TimeSystem>("time");
  79. if (!time) throw new Error("time system not booted");
  80. const ctx: HookContext = {
  81. world: engine.world,
  82. registry: engine.registry,
  83. time,
  84. emit: (event, payload) => {
  85. if (event === "mod:test-tick") capture(payload);
  86. },
  87. };
  88. engine.hooks.fire("onDayStart", ctx);
  89. }
  90. describe("mod namespace", () => {
  91. it("mod.list reports loaded Mods with manifest, status, and registered hooks", async () => {
  92. const { gateway, close } = await setup();
  93. try {
  94. const listed = await callText(gateway, "mod.list");
  95. expect(listed.ok).toBe(true);
  96. const parsed = JSON.parse(listed.text) as { mods: { id: string; version: string; loaded: boolean; hooks: string[] }[] };
  97. expect(parsed.mods).toEqual([{ id: "a-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]);
  98. } finally {
  99. await close();
  100. }
  101. });
  102. it("mod.status returns one Mod's detail; unknown id is an error result", async () => {
  103. const { gateway, close } = await setup();
  104. try {
  105. const got = await callText(gateway, "mod.status", { id: "a-mod" });
  106. expect(got.ok).toBe(true);
  107. expect(JSON.parse(got.text)).toEqual({ id: "a-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] });
  108. const missing = await callText(gateway, "mod.status", { id: "nope" });
  109. expect(missing.ok).toBe(false);
  110. expect(missing.text).toMatch(/mod not found: nope/);
  111. const blank = await callText(gateway, "mod.status", { id: "" });
  112. expect(blank.ok).toBe(false);
  113. expect(blank.text).toMatch(/non-empty string/);
  114. } finally {
  115. await close();
  116. }
  117. });
  118. it("mod.reload re-imports a changed Mod with cache-busting and rebinds without duplicates", async () => {
  119. const { engine, gateway, tmp, close } = await setup();
  120. try {
  121. await writeFile(join(tmp, "mods", "a-mod", "index.mjs"), modEntry("0.2.0"));
  122. const reloaded = await callText(gateway, "mod.reload", { id: "a-mod" });
  123. expect(reloaded.ok).toBe(true);
  124. const parsed = JSON.parse(reloaded.text) as { reloaded: boolean; mods: { id: string; version: string }[] };
  125. expect(parsed.reloaded).toBe(true);
  126. expect(parsed.mods).toEqual([{ id: "a-mod", version: "0.2.0", loaded: true, hooks: ["onDayStart"] }]);
  127. expect(engine.mods[0]).toMatchObject({ id: "a-mod", version: "0.2.0" });
  128. const listed = await callText(gateway, "mod.list");
  129. expect(JSON.parse(listed.text)).toEqual({
  130. mods: [{ id: "a-mod", version: "0.2.0", loaded: true, hooks: ["onDayStart"] }],
  131. });
  132. const ticked: unknown[] = [];
  133. fireDayStart(engine, (payload) => ticked.push(payload));
  134. expect(ticked).toHaveLength(1);
  135. const file = await readFile(join(tmp, "mods", "a-mod", "index.mjs"), "utf8");
  136. expect(file).toContain("0.2.0");
  137. } finally {
  138. await close();
  139. }
  140. });
  141. it("mod.reload without id reloads all Mods and picks up newly added Mods", async () => {
  142. const { engine, gateway, tmp, close } = await setup();
  143. try {
  144. await mkdir(join(tmp, "mods", "b-mod"), { recursive: true });
  145. await writeFile(
  146. join(tmp, "mods", "b-mod", "index.mjs"),
  147. [
  148. "export default {",
  149. " manifest: { id: \"b-mod\", version: \"1.0.0\" },",
  150. " hooks: { onSeasonChange() {} },",
  151. "};",
  152. "",
  153. ].join("\n"),
  154. );
  155. const reloaded = await callText(gateway, "mod.reload");
  156. expect(reloaded.ok).toBe(true);
  157. const parsed = JSON.parse(reloaded.text) as {
  158. reloaded: boolean;
  159. mods: { id: string; version: string; hooks: string[] }[];
  160. };
  161. expect(parsed.mods.map((m) => m.id).sort()).toEqual(["a-mod", "b-mod"]);
  162. expect(engine.mods).toHaveLength(2);
  163. expect(engine.hooks.bound("onSeasonChange")).toBe(true);
  164. } finally {
  165. await close();
  166. }
  167. });
  168. it("mod.reload validates id input and rejects unknown ids", async () => {
  169. const { gateway, close } = await setup();
  170. try {
  171. const unknown = await callText(gateway, "mod.reload", { id: "nope" });
  172. expect(unknown.ok).toBe(false);
  173. expect(unknown.text).toMatch(/mod not found: nope/);
  174. const blank = await callText(gateway, "mod.reload", { id: "" });
  175. expect(blank.ok).toBe(false);
  176. expect(blank.text).toMatch(/non-empty string/);
  177. } finally {
  178. await close();
  179. }
  180. });
  181. it("mod://{id} resource returns Mod detail; missing id rejects with a gateway error", async () => {
  182. const { gateway, close } = await setup();
  183. try {
  184. const single = await gateway.readResource("mod://a-mod");
  185. expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual({
  186. id: "a-mod",
  187. version: "0.1.0",
  188. loaded: true,
  189. hooks: ["onDayStart"],
  190. });
  191. await expect(gateway.readResource("mod://nope")).rejects.toThrow(/mod not found: nope/);
  192. } finally {
  193. await close();
  194. }
  195. });
  196. it("mod namespace exposes no prompts", async () => {
  197. const { gateway, close } = await setup();
  198. try {
  199. const prompts = gateway.listPrompts();
  200. expect(prompts.some((p) => p.name.startsWith("mod."))).toBe(false);
  201. } finally {
  202. await close();
  203. }
  204. });
  205. });