import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, CURRENT_SAVE_VERSION, SAVE_FORMAT, type InventorySystem, type PlantingSystem, } from "@nengine/core"; import { createEngineNamespaces, McpGateway } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"); interface Setup { engine: ReturnType; gateway: McpGateway; tmp: string; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "nengine-save-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); const engine = createEngine({ contentDir: tmp }); const gateway = new McpGateway(); for (const namespace of createEngineNamespaces(engine, { gateway })) { gateway.register(namespace); } await engine.start(); return { engine, gateway, tmp, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } async function callText(gateway: McpGateway, name: string, args: Record = {}): Promise<{ ok: boolean; text: string; }> { const result = await gateway.callTool(name, args); return { ok: result.isError !== true, text: result.content[0]?.text ?? "" }; } function registerItems(engine: ReturnType): void { engine.registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", stackable: false }); engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); } function registerCrop(engine: ReturnType): void { engine.registry.register("crop", { id: "parsnip", seedItem: "parsnip-seed", stages: [ { name: "sprout", days: 2, waterRequired: true }, { name: "grown", days: 2, waterRequired: true }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "parsnip", qty: 1 }], }); } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } function plantingOf(engine: ReturnType): PlantingSystem { const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); return planting; } describe("save namespace", () => { it("save.snapshot serializes the full world state as a WorldSnapshot", async () => { const { gateway, close } = await setup(); try { const snap = await callText(gateway, "save.snapshot"); expect(snap.ok).toBe(true); const parsed = JSON.parse(snap.text) as { format: string; version: number; savedAt: string; world: { id: string; type: string }[]; }; expect(parsed.format).toBe(SAVE_FORMAT); expect(parsed.version).toBe(CURRENT_SAVE_VERSION); expect(typeof parsed.savedAt).toBe("string"); expect(parsed.world.map((e) => e.type).sort()).toEqual(["map", "player"]); expect(parsed.world.find((e) => e.type === "map")).toMatchObject({ id: "farm", width: 2, height: 2 }); expect(parsed.world.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 0, y: 0 }); } finally { await close(); } }); it("save.validate reports valid and invalid snapshots as normal results", async () => { const { gateway, close } = await setup(); try { const snap = await callText(gateway, "save.snapshot"); const snapshot = JSON.parse(snap.text); const valid = await callText(gateway, "save.validate", { snapshot }); expect(valid.ok).toBe(true); expect(JSON.parse(valid.text)).toEqual({ ok: true }); const invalid = await callText(gateway, "save.validate", { snapshot: { format: "nengine-save" } }); expect(invalid.ok).toBe(true); const report = JSON.parse(invalid.text) as { ok: boolean; errors?: string[] }; expect(report.ok).toBe(false); expect(report.errors?.length).toBeGreaterThan(0); const nonObject = await callText(gateway, "save.validate", { snapshot: "nonsense" }); expect((JSON.parse(nonObject.text) as { ok: boolean }).ok).toBe(false); } finally { await close(); } }); it("save.restore replaces world state and a subsequent snapshot matches", async () => { const { engine, gateway, close } = await setup(); try { registerItems(engine); registerCrop(engine); inventoryOf(engine).give("parsnip", 2); inventoryOf(engine).give("parsnip-seed", 4); plantingOf(engine).till(1, 1); plantingOf(engine).sow(1, 1, "parsnip-seed"); plantingOf(engine).water(1, 1); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 42; const snap = await callText(gateway, "save.snapshot"); const snapshot = JSON.parse(snap.text) as { world: unknown[] }; expect(snapshot.world).toHaveLength(3); const restored = await callText(gateway, "save.restore", { snapshot }); expect(restored.ok).toBe(true); expect(JSON.parse(restored.text)).toEqual({ restored: 3 }); const after = await callText(gateway, "save.snapshot"); const afterParsed = JSON.parse(after.text) as { world: { id: string }[] }; expect(afterParsed.world).toEqual(snapshot.world); expect(afterParsed.world.find((e) => e.id === "plot:1:1")).toMatchObject({ type: "plot", x: 1, y: 1, tilled: true, watered: true, cropItemId: "parsnip", stageIndex: 0, daysInStage: 0, }); } finally { await close(); } }); it("save.restore rejects malformed snapshots with an error result", async () => { const { gateway, close } = await setup(); try { const bad = await callText(gateway, "save.restore", { snapshot: { format: "nengine-save" } }); expect(bad.ok).toBe(false); expect(bad.text).toMatch(/invalid snapshot/); const nullSnap = await callText(gateway, "save.restore", { snapshot: null }); expect(nullSnap.ok).toBe(false); expect(nullSnap.text).toMatch(/invalid snapshot/); } finally { await close(); } }); it("save.migrate returns the snapshot unchanged with an empty registry and rejects bad args", async () => { const { gateway, close } = await setup(); try { const snap = await callText(gateway, "save.snapshot"); const snapshot = JSON.parse(snap.text); const same = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 1 }); expect(same.ok).toBe(true); expect(JSON.parse(same.text)).toEqual(snapshot); const forward = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 2 }); expect(forward.ok).toBe(true); expect(JSON.parse(forward.text)).toEqual(snapshot); const badSnap = await callText(gateway, "save.migrate", { snapshot: { format: "nengine-save" } }); expect(badSnap.ok).toBe(false); expect(badSnap.text).toMatch(/invalid snapshot/); const backwards = await callText(gateway, "save.migrate", { snapshot, fromVersion: 2, toVersion: 1 }); expect(backwards.ok).toBe(false); expect(backwards.text).toMatch(/backwards/); const badVersion = await callText(gateway, "save.migrate", { snapshot, fromVersion: 0, toVersion: 1 }); expect(badVersion.ok).toBe(false); expect(badVersion.text).toMatch(/positive integer/); } finally { await close(); } }); it("save://state resource returns the latest snapshot and reflects live mutations", async () => { const { engine, gateway, close } = await setup(); try { const state = await gateway.readResource("save://state"); const parsed = JSON.parse(state.contents[0]?.text ?? "{}") as { format: string; version: number; world: { id: string }[]; }; expect(parsed.format).toBe(SAVE_FORMAT); expect(parsed.version).toBe(CURRENT_SAVE_VERSION); expect(parsed.world.map((e) => e.id)).toEqual(expect.arrayContaining(["farm", "player"])); inventoryOf(engine).give("parsnip", 1); const updated = await gateway.readResource("save://state"); const updatedParsed = JSON.parse(updated.contents[0]?.text ?? "{}") as { world: { id: string; inventory: Record }[]; }; expect(updatedParsed.world.find((e) => e.id === "player")?.inventory).toEqual({ parsnip: 1 }); } finally { await close(); } }); it("serves the save.authoring-prompt prompt", async () => { const { gateway, close } = await setup(); try { const prompt = await gateway.getPrompt("save.authoring-prompt", {}); const text = prompt.messages[0]?.content.text ?? ""; expect(text).toContain("nengine-save"); expect(text).toContain("version"); expect(text).toContain("migration"); expect(text).toContain("save.snapshot"); expect(text).toContain("CURRENT_SAVE_VERSION"); } finally { await close(); } }); });