rng-namespace.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import { createEngine, seedRandom } 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(seed?: number): Promise<Setup> {
  35. const tmp = await mkdtemp(join(tmpdir(), "farm-rng-ns-"));
  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, seed });
  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. describe("rng namespace", () => {
  63. it("rng.get reports {seed, calls} anchored to the engine seed", async () => {
  64. const { gateway, close } = await setup(42);
  65. try {
  66. const got = await callText(gateway, "rng.get");
  67. expect(got.ok).toBe(true);
  68. expect(JSON.parse(got.text)).toEqual({ seed: 42, calls: 0 });
  69. } finally {
  70. await close();
  71. }
  72. });
  73. it("rng.set resets the RNG to a seed and makes subsequent world randomness deterministic", async () => {
  74. const { engine, gateway, close } = await setup();
  75. try {
  76. engine.rng.next();
  77. const set = await callText(gateway, "rng.set", { seed: 123 });
  78. expect(set.ok).toBe(true);
  79. expect(JSON.parse(set.text)).toEqual({ seed: 123, calls: 0 });
  80. const expected = seedRandom(123);
  81. expect(engine.rng.next()).toBe(expected.next());
  82. expect(engine.rng.nextInt(10)).toBe(expected.nextInt(10));
  83. expect(JSON.parse((await callText(gateway, "rng.get")).text)).toEqual({ seed: 123, calls: 2 });
  84. } finally {
  85. await close();
  86. }
  87. });
  88. it("rng.set rejects non-finite and non-number seeds", async () => {
  89. const { gateway, close } = await setup();
  90. try {
  91. const nan = await callText(gateway, "rng.set", { seed: Number.NaN });
  92. expect(nan.ok).toBe(false);
  93. expect(nan.text).toMatch(/seed must be a finite number/);
  94. const inf = await callText(gateway, "rng.set", { seed: Infinity });
  95. expect(inf.ok).toBe(false);
  96. expect(inf.text).toMatch(/seed must be a finite number/);
  97. const string = await callText(gateway, "rng.set", { seed: "123" });
  98. expect(string.ok).toBe(false);
  99. expect(string.text).toMatch(/seed must be a finite number/);
  100. const missing = await callText(gateway, "rng.set", {});
  101. expect(missing.ok).toBe(false);
  102. expect(missing.text).toMatch(/missing required arguments: seed/);
  103. } finally {
  104. await close();
  105. }
  106. });
  107. it("rng://state resource reflects the live RNG state", async () => {
  108. const { engine, gateway, close } = await setup(7);
  109. try {
  110. const state = await gateway.readResource("rng://state");
  111. expect(JSON.parse(state.contents[0]?.text ?? "{}")).toEqual({ seed: 7, calls: 0 });
  112. engine.rng.next();
  113. const after = await gateway.readResource("rng://state");
  114. expect(JSON.parse(after.contents[0]?.text ?? "{}")).toEqual({ seed: 7, calls: 1 });
  115. } finally {
  116. await close();
  117. }
  118. });
  119. it("rng namespace has no prompts", async () => {
  120. const { gateway, close } = await setup();
  121. try {
  122. const tools = gateway.listTools().map((tool) => tool.name);
  123. expect(tools).toContain("rng.get");
  124. expect(tools).toContain("rng.set");
  125. const prompts = gateway.listPrompts().map((prompt) => prompt.name);
  126. expect(prompts.some((name) => name.startsWith("rng."))).toBe(false);
  127. } finally {
  128. await close();
  129. }
  130. });
  131. });