import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type HookContext, type TimeSystem } 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"); function modEntry(version: string): string { return [ "export default {", " manifest: { id: \"a-mod\", version: \"" + version + "\" },", " hooks: {", " onDayStart(ctx) { ctx.emit(\"mod:test-tick\", { modId: \"a-mod\", day: ctx.time.get().day }); }", " },", "};", "", ].join("\n"); } interface Setup { engine: ReturnType; gateway: McpGateway; tmp: string; modsDir: string; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-mod-")); await mkdir(join(tmp, "maps"), { recursive: true }); await mkdir(join(tmp, "mods", "a-mod"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); await writeFile(join(tmp, "mods", "a-mod", "index.mjs"), modEntry("0.1.0")); const engine = createEngine({ contentDir: tmp, modsDir: join(tmp, "mods") }); const gateway = new McpGateway(); for (const namespace of createEngineNamespaces(engine, { gateway })) { gateway.register(namespace); } await engine.start(); return { engine, gateway, tmp, modsDir: join(tmp, "mods"), 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 fireDayStart(engine: ReturnType, capture: (payload: unknown) => void): void { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); const ctx: HookContext = { world: engine.world, registry: engine.registry, time, emit: (event, payload) => { if (event === "mod:test-tick") capture(payload); }, }; engine.hooks.fire("onDayStart", ctx); } describe("mod namespace", () => { it("mod.list reports loaded Mods with manifest, status, and registered hooks", async () => { const { gateway, close } = await setup(); try { const listed = await callText(gateway, "mod.list"); expect(listed.ok).toBe(true); const parsed = JSON.parse(listed.text) as { mods: { id: string; version: string; loaded: boolean; hooks: string[] }[] }; expect(parsed.mods).toEqual([{ id: "a-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]); } finally { await close(); } }); it("mod.status returns one Mod's detail; unknown id is an error result", async () => { const { gateway, close } = await setup(); try { const got = await callText(gateway, "mod.status", { id: "a-mod" }); expect(got.ok).toBe(true); expect(JSON.parse(got.text)).toEqual({ id: "a-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }); const missing = await callText(gateway, "mod.status", { id: "nope" }); expect(missing.ok).toBe(false); expect(missing.text).toMatch(/mod not found: nope/); const blank = await callText(gateway, "mod.status", { id: "" }); expect(blank.ok).toBe(false); expect(blank.text).toMatch(/non-empty string/); } finally { await close(); } }); it("mod.reload re-imports a changed Mod with cache-busting and rebinds without duplicates", async () => { const { engine, gateway, tmp, close } = await setup(); try { await writeFile(join(tmp, "mods", "a-mod", "index.mjs"), modEntry("0.2.0")); const reloaded = await callText(gateway, "mod.reload", { id: "a-mod" }); expect(reloaded.ok).toBe(true); const parsed = JSON.parse(reloaded.text) as { reloaded: boolean; mods: { id: string; version: string }[] }; expect(parsed.reloaded).toBe(true); expect(parsed.mods).toEqual([{ id: "a-mod", version: "0.2.0", loaded: true, hooks: ["onDayStart"] }]); expect(engine.mods[0]).toMatchObject({ id: "a-mod", version: "0.2.0" }); const listed = await callText(gateway, "mod.list"); expect(JSON.parse(listed.text)).toEqual({ mods: [{ id: "a-mod", version: "0.2.0", loaded: true, hooks: ["onDayStart"] }], }); const ticked: unknown[] = []; fireDayStart(engine, (payload) => ticked.push(payload)); expect(ticked).toHaveLength(1); const file = await readFile(join(tmp, "mods", "a-mod", "index.mjs"), "utf8"); expect(file).toContain("0.2.0"); } finally { await close(); } }); it("mod.reload without id reloads all Mods and picks up newly added Mods", async () => { const { engine, gateway, tmp, close } = await setup(); try { await mkdir(join(tmp, "mods", "b-mod"), { recursive: true }); await writeFile( join(tmp, "mods", "b-mod", "index.mjs"), [ "export default {", " manifest: { id: \"b-mod\", version: \"1.0.0\" },", " hooks: { onSeasonChange() {} },", "};", "", ].join("\n"), ); const reloaded = await callText(gateway, "mod.reload"); expect(reloaded.ok).toBe(true); const parsed = JSON.parse(reloaded.text) as { reloaded: boolean; mods: { id: string; version: string; hooks: string[] }[]; }; expect(parsed.mods.map((m) => m.id).sort()).toEqual(["a-mod", "b-mod"]); expect(engine.mods).toHaveLength(2); expect(engine.hooks.bound("onSeasonChange")).toBe(true); } finally { await close(); } }); it("mod.reload validates id input and rejects unknown ids", async () => { const { gateway, close } = await setup(); try { const unknown = await callText(gateway, "mod.reload", { id: "nope" }); expect(unknown.ok).toBe(false); expect(unknown.text).toMatch(/mod not found: nope/); const blank = await callText(gateway, "mod.reload", { id: "" }); expect(blank.ok).toBe(false); expect(blank.text).toMatch(/non-empty string/); } finally { await close(); } }); it("mod://{id} resource returns Mod detail; missing id rejects with a gateway error", async () => { const { gateway, close } = await setup(); try { const single = await gateway.readResource("mod://a-mod"); expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual({ id: "a-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"], }); await expect(gateway.readResource("mod://nope")).rejects.toThrow(/mod not found: nope/); } finally { await close(); } }); it("mod namespace exposes no prompts", async () => { const { gateway, close } = await setup(); try { const prompts = gateway.listPrompts(); expect(prompts.some((p) => p.name.startsWith("mod."))).toBe(false); } finally { await close(); } }); });