sound-namespace.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { mkdtemp, mkdir, 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 SoundDef } 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. interface Setup {
  29. engine: ReturnType<typeof createEngine>;
  30. gateway: McpGateway;
  31. tmp: string;
  32. close: () => Promise<void>;
  33. }
  34. async function setup(): Promise<Setup> {
  35. const tmp = await mkdtemp(join(tmpdir(), "farm-sound-"));
  36. await mkdir(join(tmp, "maps"), { recursive: true });
  37. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  38. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  39. const engine = createEngine({ contentDir: tmp });
  40. const gateway = new McpGateway();
  41. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  42. gateway.register(namespace);
  43. }
  44. await engine.start();
  45. return {
  46. engine,
  47. gateway,
  48. tmp,
  49. close: async () => {
  50. await engine.stop();
  51. await rm(tmp, { recursive: true, force: true });
  52. },
  53. };
  54. }
  55. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  56. ok: boolean;
  57. text: string;
  58. }> {
  59. const result = await gateway.callTool(name, args);
  60. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  61. }
  62. const COIN_DEF: SoundDef = { id: "coin", mode: "sfx", synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.5, slideTo: 1320 } };
  63. describe("sound namespace", () => {
  64. it("sound.create writes content/sounds/<id>.yaml, registers live, and persists across restart", async () => {
  65. const { engine, gateway, tmp, close } = await setup();
  66. try {
  67. const created = await callText(gateway, "sound.create", { def: COIN_DEF });
  68. expect(created.ok).toBe(true);
  69. expect(JSON.parse(created.text) as SoundDef).toEqual(COIN_DEF);
  70. expect(engine.registry.get<SoundDef>("sound", "coin")).toEqual(COIN_DEF);
  71. const file = await readFile(join(tmp, "sounds", "coin.yaml"), "utf8");
  72. expect(file).toContain("id: coin");
  73. expect(file).toContain("wave: sine");
  74. await engine.stop();
  75. const reloaded = createEngine({ contentDir: tmp });
  76. await reloaded.start();
  77. try {
  78. expect(reloaded.registry.get<SoundDef>("sound", "coin")).toEqual(COIN_DEF);
  79. } finally {
  80. await reloaded.stop();
  81. }
  82. } finally {
  83. await close();
  84. }
  85. });
  86. it("sound.validate accepts a valid def and rejects an invalid one", async () => {
  87. const { gateway, close } = await setup();
  88. try {
  89. const valid = await callText(gateway, "sound.validate", { def: COIN_DEF });
  90. expect(valid.ok).toBe(true);
  91. expect(JSON.parse(valid.text)).toEqual(COIN_DEF);
  92. const invalid = await callText(gateway, "sound.validate", { def: { id: "silent" } });
  93. expect(invalid.ok).toBe(false);
  94. expect(invalid.text).toMatch(/at least one of synth or src/);
  95. const badWave = await callText(gateway, "sound.validate", {
  96. def: { id: "nope", synth: { wave: "noise", freq: 1, durationMs: 1 } },
  97. });
  98. expect(badWave.ok).toBe(false);
  99. expect(badWave.text).toMatch(/Invalid enum value/);
  100. } finally {
  101. await close();
  102. }
  103. });
  104. it("sound.create rejects an invalid def and does not write or register", async () => {
  105. const { engine, gateway, tmp, close } = await setup();
  106. try {
  107. const bad = await callText(gateway, "sound.create", { def: { id: "broken", synth: { wave: "x", freq: 1, durationMs: 1 } } });
  108. expect(bad.ok).toBe(false);
  109. expect(engine.registry.get("sound", "broken")).toBeUndefined();
  110. await expect(readFile(join(tmp, "sounds", "broken.yaml"))).rejects.toThrow();
  111. } finally {
  112. await close();
  113. }
  114. });
  115. it("sound.update merges a patch, validates, rewrites the file, and re-registers", async () => {
  116. const { engine, gateway, tmp, close } = await setup();
  117. try {
  118. await callText(gateway, "sound.create", { def: COIN_DEF });
  119. const updated = await callText(gateway, "sound.update", {
  120. id: "coin",
  121. patch: { synth: { wave: "sine", freq: 880, durationMs: 120, gain: 0.2, slideTo: 1320 } },
  122. });
  123. expect(updated.ok).toBe(true);
  124. const def = JSON.parse(updated.text) as SoundDef;
  125. expect(def.synth).toEqual({ wave: "sine", freq: 880, durationMs: 120, gain: 0.2, slideTo: 1320 });
  126. const file = await readFile(join(tmp, "sounds", "coin.yaml"), "utf8");
  127. expect(file).toContain("gain: 0.2");
  128. const missing = await callText(gateway, "sound.update", { id: "nope", patch: { id: "x" } });
  129. expect(missing.ok).toBe(false);
  130. expect(missing.text).toMatch(/sound not found: nope/);
  131. } finally {
  132. await close();
  133. }
  134. });
  135. it("sound.list returns one-line summaries", async () => {
  136. const { gateway, close } = await setup();
  137. try {
  138. await callText(gateway, "sound.create", { def: COIN_DEF });
  139. await callText(gateway, "sound.create", { def: { id: "water", synth: { wave: "sine", freq: 520, durationMs: 160 } } });
  140. await callText(gateway, "sound.create", { def: { id: "file-only", src: "sfx/coin.ogg" } });
  141. const listed = await callText(gateway, "sound.list");
  142. expect(listed.ok).toBe(true);
  143. expect(JSON.parse(listed.text)).toEqual({
  144. sounds: [
  145. { id: "coin", hasSynth: true, hasSrc: false },
  146. { id: "water", hasSynth: true, hasSrc: false },
  147. { id: "file-only", hasSynth: false, hasSrc: true },
  148. ],
  149. });
  150. } finally {
  151. await close();
  152. }
  153. });
  154. it("sound.get returns the full def; unknown id is an error result", async () => {
  155. const { gateway, close } = await setup();
  156. try {
  157. await callText(gateway, "sound.create", { def: COIN_DEF });
  158. const got = await callText(gateway, "sound.get", { soundId: "coin" });
  159. expect(got.ok).toBe(true);
  160. expect(JSON.parse(got.text)).toEqual(COIN_DEF);
  161. const missing = await callText(gateway, "sound.get", { soundId: "nope" });
  162. expect(missing.ok).toBe(false);
  163. expect(missing.text).toMatch(/sound not found: nope/);
  164. } finally {
  165. await close();
  166. }
  167. });
  168. it("resources: sound://catalog and sound://{id}; missing id rejects", async () => {
  169. const { gateway, close } = await setup();
  170. try {
  171. await callText(gateway, "sound.create", { def: COIN_DEF });
  172. const catalog = await gateway.readResource("sound://catalog");
  173. expect(JSON.parse(catalog.contents[0]?.text ?? "{}")).toEqual({ sounds: [COIN_DEF] });
  174. const single = await gateway.readResource("sound://coin");
  175. expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual(COIN_DEF);
  176. await expect(gateway.readResource("sound://missing")).rejects.toThrow(/sound not found: missing/);
  177. } finally {
  178. await close();
  179. }
  180. });
  181. it("serves the sound.create-prompt prompt", async () => {
  182. const { gateway, close } = await setup();
  183. try {
  184. const prompt = await gateway.getPrompt("sound.create-prompt", {});
  185. const text = prompt.messages[0]?.content.text ?? "";
  186. expect(text).toContain("wave");
  187. expect(text).toContain("sound.validate");
  188. expect(text).toContain("content/sounds/<id>.yaml");
  189. } finally {
  190. await close();
  191. }
  192. });
  193. });