statemachine-namespace.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 StateMachineDef, 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. const MACHINE_DEF = {
  29. id: "greet-machine",
  30. initial: "idle",
  31. states: [
  32. { id: "idle" },
  33. { id: "greeted", onEnter: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  34. ],
  35. transitions: [{ from: "idle", to: "greeted", on: "day-start" }],
  36. };
  37. interface Setup {
  38. engine: ReturnType<typeof createEngine>;
  39. gateway: McpGateway;
  40. tmp: string;
  41. close: () => Promise<void>;
  42. }
  43. async function setup(): Promise<Setup> {
  44. const tmp = await mkdtemp(join(tmpdir(), "farm-statemachine-"));
  45. await mkdir(join(tmp, "maps"), { recursive: true });
  46. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  47. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  48. const engine = createEngine({ contentDir: tmp });
  49. const gateway = new McpGateway();
  50. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  51. gateway.register(namespace);
  52. }
  53. await engine.start();
  54. return {
  55. engine,
  56. gateway,
  57. tmp,
  58. close: async () => {
  59. await engine.stop();
  60. await rm(tmp, { recursive: true, force: true });
  61. },
  62. };
  63. }
  64. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  65. ok: boolean;
  66. text: string;
  67. }> {
  68. const result = await gateway.callTool(name, args);
  69. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  70. }
  71. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  72. const time = engine.systems.get<TimeSystem>("time");
  73. if (!time) throw new Error("time system not booted");
  74. return time;
  75. }
  76. describe("behavior.statemachine namespace", () => {
  77. it("behavior.statemachine.create writes content/statemachines/<id>.yaml, registers live, and persists across restart", async () => {
  78. const { engine, gateway, tmp, close } = await setup();
  79. try {
  80. const created = await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  81. expect(created.ok).toBe(true);
  82. expect(JSON.parse(created.text) as StateMachineDef).toEqual(MACHINE_DEF);
  83. expect(engine.registry.get<StateMachineDef>("statemachine", "greet-machine")).toEqual(MACHINE_DEF);
  84. const file = await readFile(join(tmp, "statemachines", "greet-machine.yaml"), "utf8");
  85. expect(file).toContain("id: greet-machine");
  86. expect(file).toContain("day-start");
  87. expect(file).toContain("give-item");
  88. await engine.stop();
  89. const reloaded = createEngine({ contentDir: tmp });
  90. await reloaded.start();
  91. try {
  92. expect(reloaded.registry.get<StateMachineDef>("statemachine", "greet-machine")).toEqual(MACHINE_DEF);
  93. } finally {
  94. await reloaded.stop();
  95. }
  96. } finally {
  97. await close();
  98. }
  99. });
  100. it("behavior.statemachine.validate accepts a valid def and rejects invalid ones", async () => {
  101. const { gateway, close } = await setup();
  102. try {
  103. const valid = await callText(gateway, "behavior.statemachine.validate", { def: MACHINE_DEF });
  104. expect(valid.ok).toBe(true);
  105. expect(JSON.parse(valid.text)).toEqual(MACHINE_DEF);
  106. const badInitial = await callText(gateway, "behavior.statemachine.validate", {
  107. def: { ...MACHINE_DEF, initial: "ghost" },
  108. });
  109. expect(badInitial.ok).toBe(false);
  110. expect(badInitial.text).toMatch(/initial/);
  111. const badTransition = await callText(gateway, "behavior.statemachine.validate", {
  112. def: { ...MACHINE_DEF, transitions: [{ from: "idle", to: "ghost", on: "day-start" }] },
  113. });
  114. expect(badTransition.ok).toBe(false);
  115. expect(badTransition.text).toMatch(/transitions\.0\.to/);
  116. const noTrigger = await callText(gateway, "behavior.statemachine.validate", {
  117. def: { ...MACHINE_DEF, transitions: [{ from: "idle", to: "greeted" }] },
  118. });
  119. expect(noTrigger.ok).toBe(false);
  120. expect(noTrigger.text).toMatch(/when or on/);
  121. const emptyStates = await callText(gateway, "behavior.statemachine.validate", {
  122. def: { ...MACHINE_DEF, states: [] },
  123. });
  124. expect(emptyStates.ok).toBe(false);
  125. } finally {
  126. await close();
  127. }
  128. });
  129. it("behavior.statemachine.create rejects an invalid def and does not write or register", async () => {
  130. const { engine, gateway, tmp, close } = await setup();
  131. try {
  132. const bad = await callText(gateway, "behavior.statemachine.create", {
  133. def: { ...MACHINE_DEF, initial: "nope" },
  134. });
  135. expect(bad.ok).toBe(false);
  136. expect(engine.registry.get("statemachine", "greet-machine")).toBeUndefined();
  137. await expect(readFile(join(tmp, "statemachines", "greet-machine.yaml"))).rejects.toThrow();
  138. } finally {
  139. await close();
  140. }
  141. });
  142. it("duplicate behavior.statemachine.create overwrites the existing definition", async () => {
  143. const { engine, gateway, tmp, close } = await setup();
  144. try {
  145. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  146. const recreated = await callText(gateway, "behavior.statemachine.create", {
  147. def: { ...MACHINE_DEF, states: [...MACHINE_DEF.states, { id: "extra" }] },
  148. });
  149. expect(recreated.ok).toBe(true);
  150. const current = engine.registry.get<StateMachineDef>("statemachine", "greet-machine");
  151. expect(current?.states).toHaveLength(3);
  152. const file = await readFile(join(tmp, "statemachines", "greet-machine.yaml"), "utf8");
  153. expect(file).toContain("extra");
  154. } finally {
  155. await close();
  156. }
  157. });
  158. it("behavior.statemachine.update merges a patch, validates, rewrites the file, and re-registers", async () => {
  159. const { engine, gateway, tmp, close } = await setup();
  160. try {
  161. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  162. const updated = await callText(gateway, "behavior.statemachine.update", {
  163. id: "greet-machine",
  164. patch: { initial: "greeted" },
  165. });
  166. expect(updated.ok).toBe(true);
  167. const def = JSON.parse(updated.text) as StateMachineDef;
  168. expect(def.initial).toBe("greeted");
  169. expect(def.states).toHaveLength(2);
  170. const file = await readFile(join(tmp, "statemachines", "greet-machine.yaml"), "utf8");
  171. expect(file).toContain("initial: greeted");
  172. const missing = await callText(gateway, "behavior.statemachine.update", {
  173. id: "nope",
  174. patch: { initial: "greeted" },
  175. });
  176. expect(missing.ok).toBe(false);
  177. expect(missing.text).toMatch(/machine not found: nope/);
  178. const badPatch = await callText(gateway, "behavior.statemachine.update", {
  179. id: "greet-machine",
  180. patch: { initial: "ghost" },
  181. });
  182. expect(badPatch.ok).toBe(false);
  183. expect(engine.registry.get<StateMachineDef>("statemachine", "greet-machine")?.initial).toBe("greeted");
  184. } finally {
  185. await close();
  186. }
  187. });
  188. it("behavior.statemachine.list returns {statemachines:[...]} summaries", async () => {
  189. const { gateway, close } = await setup();
  190. try {
  191. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  192. await callText(gateway, "behavior.statemachine.create", {
  193. def: { id: "other", initial: "a", states: [{ id: "a" }, { id: "b" }], transitions: [{ from: "a", to: "b", on: "kick" }] },
  194. });
  195. const listed = await callText(gateway, "behavior.statemachine.list");
  196. expect(listed.ok).toBe(true);
  197. expect(JSON.parse(listed.text)).toEqual({
  198. statemachines: [
  199. { id: "greet-machine", stateCount: 2, transitionCount: 1 },
  200. { id: "other", stateCount: 2, transitionCount: 1 },
  201. ],
  202. });
  203. } finally {
  204. await close();
  205. }
  206. });
  207. it("behavior.statemachine.get returns the full def; unknown id is an error result", async () => {
  208. const { gateway, close } = await setup();
  209. try {
  210. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  211. const got = await callText(gateway, "behavior.statemachine.get", { machineId: "greet-machine" });
  212. expect(got.ok).toBe(true);
  213. expect(JSON.parse(got.text)).toEqual(MACHINE_DEF);
  214. const missing = await callText(gateway, "behavior.statemachine.get", { machineId: "nope" });
  215. expect(missing.ok).toBe(false);
  216. expect(missing.text).toMatch(/machine not found: nope/);
  217. const blank = await callText(gateway, "behavior.statemachine.get", { machineId: "" });
  218. expect(blank.ok).toBe(false);
  219. expect(blank.text).toMatch(/non-empty string/);
  220. } finally {
  221. await close();
  222. }
  223. });
  224. it("behavior.statemachine.status reports started:false before the first transition and the live state after", async () => {
  225. const { engine, gateway, close } = await setup();
  226. try {
  227. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  228. const before = await callText(gateway, "behavior.statemachine.status", { machineId: "greet-machine" });
  229. expect(before.ok).toBe(true);
  230. expect(JSON.parse(before.text)).toEqual({ machineId: "greet-machine", state: "idle", started: false });
  231. const time = timeOf(engine);
  232. time.advance(480);
  233. const after = await callText(gateway, "behavior.statemachine.status", { machineId: "greet-machine" });
  234. expect(JSON.parse(after.text)).toEqual({ machineId: "greet-machine", state: "greeted", started: true });
  235. } finally {
  236. await close();
  237. }
  238. });
  239. it("behavior.statemachine.transition forces a transition; unknown machine/state are error results", async () => {
  240. const { engine, gateway, close } = await setup();
  241. try {
  242. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  243. const forced = await callText(gateway, "behavior.statemachine.transition", {
  244. machineId: "greet-machine",
  245. to: "greeted",
  246. });
  247. expect(forced.ok).toBe(true);
  248. expect(JSON.parse(forced.text)).toEqual({
  249. machineId: "greet-machine",
  250. from: "idle",
  251. to: "greeted",
  252. transitioned: true,
  253. });
  254. expect(engine.world.get("sm:greet-machine")?.state).toBe("greeted");
  255. const noop = await callText(gateway, "behavior.statemachine.transition", {
  256. machineId: "greet-machine",
  257. to: "greeted",
  258. });
  259. expect(JSON.parse(noop.text)).toEqual({
  260. machineId: "greet-machine",
  261. from: "greeted",
  262. to: "greeted",
  263. transitioned: false,
  264. });
  265. const unknownMachine = await callText(gateway, "behavior.statemachine.transition", {
  266. machineId: "nope",
  267. to: "idle",
  268. });
  269. expect(unknownMachine.ok).toBe(false);
  270. expect(unknownMachine.text).toMatch(/unknown machine: nope/);
  271. const unknownState = await callText(gateway, "behavior.statemachine.transition", {
  272. machineId: "greet-machine",
  273. to: "ghost",
  274. });
  275. expect(unknownState.ok).toBe(false);
  276. expect(unknownState.text).toMatch(/unknown state: ghost/);
  277. } finally {
  278. await close();
  279. }
  280. });
  281. it("resource behavior://statemachines returns all state machine definitions", async () => {
  282. const { gateway, close } = await setup();
  283. try {
  284. await callText(gateway, "behavior.statemachine.create", { def: MACHINE_DEF });
  285. const resource = await gateway.readResource("behavior://statemachines");
  286. expect(JSON.parse(resource.contents[0]?.text ?? "{}")).toEqual({ statemachines: [MACHINE_DEF] });
  287. } finally {
  288. await close();
  289. }
  290. });
  291. it("serves the behavior.statemachine-prompt prompt", async () => {
  292. const { gateway, close } = await setup();
  293. try {
  294. const prompt = await gateway.getPrompt("behavior.statemachine-prompt", {});
  295. const text = prompt.messages[0]?.content.text ?? "";
  296. expect(text).toContain("states");
  297. expect(text).toContain("transitions");
  298. expect(text).toContain("onSchedule");
  299. expect(text).toContain("content/statemachines/<id>.yaml");
  300. expect(text).toContain("behavior.statemachine.validate");
  301. } finally {
  302. await close();
  303. }
  304. });
  305. });