save-namespace.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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 {
  6. createEngine,
  7. CURRENT_SAVE_VERSION,
  8. SAVE_FORMAT,
  9. type InventorySystem,
  10. type PlantingSystem,
  11. } from "@nengine/core";
  12. import { createEngineNamespaces, McpGateway } from "../src/index.js";
  13. const TIME_YAML = [
  14. "id: default",
  15. "dayLengthTicks: 480",
  16. "seasons:",
  17. " - { name: spring, dayCount: 28 }",
  18. "start: { day: 1, season: spring }",
  19. "",
  20. ].join("\n");
  21. const FARM_YAML = [
  22. "id: farm",
  23. "width: 2",
  24. "height: 2",
  25. 'tilesetRef: "tiles"',
  26. "spawn: { x: 0, y: 0 }",
  27. "layers:",
  28. " - name: ground",
  29. " data:",
  30. " - [a, b]",
  31. " - [b, a]",
  32. "",
  33. ].join("\n");
  34. interface Setup {
  35. engine: ReturnType<typeof createEngine>;
  36. gateway: McpGateway;
  37. tmp: string;
  38. close: () => Promise<void>;
  39. }
  40. async function setup(): Promise<Setup> {
  41. const tmp = await mkdtemp(join(tmpdir(), "nengine-save-"));
  42. await mkdir(join(tmp, "maps"), { recursive: true });
  43. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  44. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  45. const engine = createEngine({ contentDir: tmp });
  46. const gateway = new McpGateway();
  47. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  48. gateway.register(namespace);
  49. }
  50. await engine.start();
  51. return {
  52. engine,
  53. gateway,
  54. tmp,
  55. close: async () => {
  56. await engine.stop();
  57. await rm(tmp, { recursive: true, force: true });
  58. },
  59. };
  60. }
  61. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  62. ok: boolean;
  63. text: string;
  64. }> {
  65. const result = await gateway.callTool(name, args);
  66. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  67. }
  68. function registerItems(engine: ReturnType<typeof createEngine>): void {
  69. engine.registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", stackable: false });
  70. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  71. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  72. }
  73. function registerCrop(engine: ReturnType<typeof createEngine>): void {
  74. engine.registry.register("crop", {
  75. id: "parsnip",
  76. seedItem: "parsnip-seed",
  77. stages: [
  78. { name: "sprout", days: 2, waterRequired: true },
  79. { name: "grown", days: 2, waterRequired: true },
  80. { name: "mature", days: 1, waterRequired: false },
  81. ],
  82. seasons: ["spring"],
  83. drops: [{ itemId: "parsnip", qty: 1 }],
  84. });
  85. }
  86. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  87. const inv = engine.systems.get<InventorySystem>("inventory");
  88. if (!inv) throw new Error("inventory system not booted");
  89. return inv;
  90. }
  91. function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
  92. const planting = engine.systems.get<PlantingSystem>("planting");
  93. if (!planting) throw new Error("planting system not booted");
  94. return planting;
  95. }
  96. describe("save namespace", () => {
  97. it("save.snapshot serializes the full world state as a WorldSnapshot", async () => {
  98. const { gateway, close } = await setup();
  99. try {
  100. const snap = await callText(gateway, "save.snapshot");
  101. expect(snap.ok).toBe(true);
  102. const parsed = JSON.parse(snap.text) as {
  103. format: string;
  104. version: number;
  105. savedAt: string;
  106. world: { id: string; type: string }[];
  107. };
  108. expect(parsed.format).toBe(SAVE_FORMAT);
  109. expect(parsed.version).toBe(CURRENT_SAVE_VERSION);
  110. expect(typeof parsed.savedAt).toBe("string");
  111. expect(parsed.world.map((e) => e.type).sort()).toEqual(["map", "player"]);
  112. expect(parsed.world.find((e) => e.type === "map")).toMatchObject({ id: "farm", width: 2, height: 2 });
  113. expect(parsed.world.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 0, y: 0 });
  114. } finally {
  115. await close();
  116. }
  117. });
  118. it("save.validate reports valid and invalid snapshots as normal results", async () => {
  119. const { gateway, close } = await setup();
  120. try {
  121. const snap = await callText(gateway, "save.snapshot");
  122. const snapshot = JSON.parse(snap.text);
  123. const valid = await callText(gateway, "save.validate", { snapshot });
  124. expect(valid.ok).toBe(true);
  125. expect(JSON.parse(valid.text)).toEqual({ ok: true });
  126. const invalid = await callText(gateway, "save.validate", { snapshot: { format: "nengine-save" } });
  127. expect(invalid.ok).toBe(true);
  128. const report = JSON.parse(invalid.text) as { ok: boolean; errors?: string[] };
  129. expect(report.ok).toBe(false);
  130. expect(report.errors?.length).toBeGreaterThan(0);
  131. const nonObject = await callText(gateway, "save.validate", { snapshot: "nonsense" });
  132. expect((JSON.parse(nonObject.text) as { ok: boolean }).ok).toBe(false);
  133. } finally {
  134. await close();
  135. }
  136. });
  137. it("save.restore replaces world state and a subsequent snapshot matches", async () => {
  138. const { engine, gateway, close } = await setup();
  139. try {
  140. registerItems(engine);
  141. registerCrop(engine);
  142. inventoryOf(engine).give("parsnip", 2);
  143. inventoryOf(engine).give("parsnip-seed", 4);
  144. plantingOf(engine).till(1, 1);
  145. plantingOf(engine).sow(1, 1, "parsnip-seed");
  146. plantingOf(engine).water(1, 1);
  147. const player = engine.world.get("player");
  148. if (!player) throw new Error("player entity not seeded");
  149. player.gold = 42;
  150. const snap = await callText(gateway, "save.snapshot");
  151. const snapshot = JSON.parse(snap.text) as { world: unknown[] };
  152. expect(snapshot.world).toHaveLength(3);
  153. const restored = await callText(gateway, "save.restore", { snapshot });
  154. expect(restored.ok).toBe(true);
  155. expect(JSON.parse(restored.text)).toEqual({ restored: 3 });
  156. const after = await callText(gateway, "save.snapshot");
  157. const afterParsed = JSON.parse(after.text) as { world: { id: string }[] };
  158. expect(afterParsed.world).toEqual(snapshot.world);
  159. expect(afterParsed.world.find((e) => e.id === "plot:1:1")).toMatchObject({
  160. type: "plot",
  161. x: 1,
  162. y: 1,
  163. tilled: true,
  164. watered: true,
  165. cropItemId: "parsnip",
  166. stageIndex: 0,
  167. daysInStage: 0,
  168. });
  169. } finally {
  170. await close();
  171. }
  172. });
  173. it("save.restore rejects malformed snapshots with an error result", async () => {
  174. const { gateway, close } = await setup();
  175. try {
  176. const bad = await callText(gateway, "save.restore", { snapshot: { format: "nengine-save" } });
  177. expect(bad.ok).toBe(false);
  178. expect(bad.text).toMatch(/invalid snapshot/);
  179. const nullSnap = await callText(gateway, "save.restore", { snapshot: null });
  180. expect(nullSnap.ok).toBe(false);
  181. expect(nullSnap.text).toMatch(/invalid snapshot/);
  182. } finally {
  183. await close();
  184. }
  185. });
  186. it("save.migrate returns the snapshot unchanged with an empty registry and rejects bad args", async () => {
  187. const { gateway, close } = await setup();
  188. try {
  189. const snap = await callText(gateway, "save.snapshot");
  190. const snapshot = JSON.parse(snap.text);
  191. const same = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 1 });
  192. expect(same.ok).toBe(true);
  193. expect(JSON.parse(same.text)).toEqual(snapshot);
  194. const forward = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 2 });
  195. expect(forward.ok).toBe(true);
  196. expect(JSON.parse(forward.text)).toEqual(snapshot);
  197. const badSnap = await callText(gateway, "save.migrate", { snapshot: { format: "nengine-save" } });
  198. expect(badSnap.ok).toBe(false);
  199. expect(badSnap.text).toMatch(/invalid snapshot/);
  200. const backwards = await callText(gateway, "save.migrate", { snapshot, fromVersion: 2, toVersion: 1 });
  201. expect(backwards.ok).toBe(false);
  202. expect(backwards.text).toMatch(/backwards/);
  203. const badVersion = await callText(gateway, "save.migrate", { snapshot, fromVersion: 0, toVersion: 1 });
  204. expect(badVersion.ok).toBe(false);
  205. expect(badVersion.text).toMatch(/positive integer/);
  206. } finally {
  207. await close();
  208. }
  209. });
  210. it("save://state resource returns the latest snapshot and reflects live mutations", async () => {
  211. const { engine, gateway, close } = await setup();
  212. try {
  213. const state = await gateway.readResource("save://state");
  214. const parsed = JSON.parse(state.contents[0]?.text ?? "{}") as {
  215. format: string;
  216. version: number;
  217. world: { id: string }[];
  218. };
  219. expect(parsed.format).toBe(SAVE_FORMAT);
  220. expect(parsed.version).toBe(CURRENT_SAVE_VERSION);
  221. expect(parsed.world.map((e) => e.id)).toEqual(expect.arrayContaining(["farm", "player"]));
  222. inventoryOf(engine).give("parsnip", 1);
  223. const updated = await gateway.readResource("save://state");
  224. const updatedParsed = JSON.parse(updated.contents[0]?.text ?? "{}") as {
  225. world: { id: string; inventory: Record<string, number> }[];
  226. };
  227. expect(updatedParsed.world.find((e) => e.id === "player")?.inventory).toEqual({ parsnip: 1 });
  228. } finally {
  229. await close();
  230. }
  231. });
  232. it("serves the save.authoring-prompt prompt", async () => {
  233. const { gateway, close } = await setup();
  234. try {
  235. const prompt = await gateway.getPrompt("save.authoring-prompt", {});
  236. const text = prompt.messages[0]?.content.text ?? "";
  237. expect(text).toContain("nengine-save");
  238. expect(text).toContain("version");
  239. expect(text).toContain("migration");
  240. expect(text).toContain("save.snapshot");
  241. expect(text).toContain("CURRENT_SAVE_VERSION");
  242. } finally {
  243. await close();
  244. }
  245. });
  246. });