import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type InventorySystem, type PlantingSystem, type TimeSystem } from "@nengine/core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createNengineMcpServer, createGateway } from "../src/server.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); interface Harness { engine: ReturnType; client: Client; close: () => Promise; } async function setup(): Promise { const engine = createEngine({ contentDir, modsDir }); await engine.start(); const gateway = createGateway(engine); const server = createNengineMcpServer(engine, gateway); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); const client = new Client({ name: "client-surface-test", version: "0.0.1" }); await client.connect(clientTransport); return { engine, client, close: async () => { await client.close(); await server.close(); await engine.stop(); }, }; } function textOf(result: { content?: { type: string; text?: string }[] }): string { return result.content?.find((block) => block.type === "text")?.text ?? ""; } function contentsOf(result: { contents?: { text?: string }[] }): string { return result.contents?.[0]?.text ?? ""; } function jsonOf(text: string): Record { return JSON.parse(text) as Record; } describe("apps/mcp-server client surface (gateway-backed, in-memory)", () => { it("handshake: initialize + server version", async () => { const { client, close } = await setup(); try { expect(client.getServerVersion()).toMatchObject({ name: "nengine-mcp-server", version: "0.1.0" }); } finally { await close(); } }); it("tools surface: exactly 132 tools, all with object inputSchema", async () => { const { client, close } = await setup(); try { const tools = await client.listTools(); const names = tools.tools.map((tool) => tool.name).sort(); expect(names).toEqual( [ "animal.create", "animal.entities", "animal.feed", "animal.get", "animal.list", "animal.spawn", "animal.stage", "animal.update", "animal.validate", "animation.create", "animation.get", "animation.list", "animation.update", "animation.validate", "behavior.condition.validate", "behavior.fire", "behavior.statemachine.create", "behavior.statemachine.get", "behavior.statemachine.list", "behavior.statemachine.status", "behavior.statemachine.transition", "behavior.statemachine.update", "behavior.statemachine.validate", "behavior.trigger.create", "behavior.trigger.get", "behavior.trigger.list", "behavior.trigger.update", "behavior.trigger.validate", "craft.create", "craft.do_craft", "craft.get", "craft.list", "craft.update", "craft.validate", "crop.create", "crop.get", "crop.harvest", "crop.list", "crop.sow", "crop.stage", "crop.till", "crop.update", "crop.validate", "crop.water", "dialogue.choose", "dialogue.create", "dialogue.get", "dialogue.list", "dialogue.start", "dialogue.update", "dialogue.validate", "item.create", "item.damage", "item.get", "item.give", "item.list", "item.query_inventory", "item.update", "item.use", "item.validate", "map.create", "map.get", "map.list", "map.update", "map.validate", "mod.list", "mod.reload", "mod.status", "npc.get", "npc.list", "npc.move", "npc.path", "npc.spawn", "npc.teleport", "particle.create", "particle.get", "particle.list", "particle.update", "particle.validate", "placement.building.create", "placement.building.get", "placement.building.list", "placement.building.update", "placement.building.validate", "placement.list", "placement.occupies", "placement.place", "placement.remove", "player.intent", "rng.get", "rng.set", "save.migrate", "save.restore", "save.snapshot", "save.validate", "server.disable_subsystem", "server.enable_subsystem", "server.info", "server.status", "shader.create", "shader.get", "shader.list", "shader.update", "shader.validate", "scene.create", "scene.get", "scene.list", "scene.update", "scene.validate", "shop.buy", "shop.create", "shop.get", "shop.list", "shop.sell", "shop.update", "shop.validate", "sound.create", "sound.get", "sound.list", "sound.update", "sound.validate", "sprite.create", "sprite.get", "sprite.list", "sprite.update", "sprite.validate", "time.advance", "time.get", "time.set", "time.tick", "weather.get", "weather.set", ].sort(), ); expect(tools.tools).toHaveLength(132); for (const tool of tools.tools) { expect(tool.inputSchema).toMatchObject({ type: "object" }); } } finally { await close(); } }); it("server.info: identity, content counts, subsystems enabled, time state", async () => { const { client, close } = await setup(); try { const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} }))); expect(info.engine).toBe("nengine"); expect(info.version).toBe("0.1.0"); expect(info.content).toEqual({ animal: ["chicken"], behavior: ["season-welcome", "spring-gift"], building: ["fence", "shed"], crop: ["parsnip"], dialogue: ["welcome"], map: ["farm"], time: ["default"], item: ["egg", "hoe", "parsnip-seed", "parsnip"], shop: ["general-store"], statemachine: ["chicken-egg", "daily-routine", "spring-quest"], weather: ["default"], }); expect(info.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([ "animal", "animation", "behavior", "craft", "crop", "dialogue", "item", "map", "mod", "npc", "particle", "placement", "player", "rng", "save", "scene", "server", "shader", "shop", "sound", "sprite", "time", "weather", ]); expect(info.subsystems.every((s: { enabled: boolean }) => s.enabled === true)).toBe(true); expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 }); } finally { await close(); } }); it("server.status: {subsystems:[...]} with all namespaces", async () => { const { client, close } = await setup(); try { const status = jsonOf(textOf(await client.callTool({ name: "server.status", arguments: {} }))); expect(status.subsystems.map((s: { name: string }) => s.name).sort()).toEqual([ "animal", "animation", "behavior", "craft", "crop", "dialogue", "item", "map", "mod", "npc", "particle", "placement", "player", "rng", "save", "scene", "server", "shader", "shop", "sound", "sprite", "time", "weather", ]); } finally { await close(); } }); it("server.enable_subsystem/disable_subsystem drive namespace availability through the real server", async () => { const { client, close } = await setup(); try { const before = await client.listTools(); expect(before.tools.some((t) => t.name === "time.get")).toBe(true); expect(before.tools.some((t) => t.name === "server.enable_subsystem")).toBe(true); const disabled = jsonOf( textOf(await client.callTool({ name: "server.disable_subsystem", arguments: { name: "time" } })), ); expect(disabled).toEqual({ name: "time", enabled: false }); const after = await client.listTools(); expect(after.tools.some((t) => t.name === "time.get")).toBe(false); expect(after.tools.some((t) => t.name === "server.info")).toBe(true); const resources = await client.listResources(); expect(resources.resources.some((r) => r.uri === "time://state")).toBe(false); const info = jsonOf(textOf(await client.callTool({ name: "server.info", arguments: {} }))); expect(info.subsystems.find((s: { name: string }) => s.name === "time")).toEqual({ name: "time", enabled: false, }); const rejected = await client.callTool({ name: "time.get", arguments: {} }); expect(rejected.isError).toBe(true); expect(textOf(rejected)).toMatch(/namespace "time" is disabled/); const guard = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "server" } }); expect(guard.isError).toBe(true); expect(textOf(guard)).toMatch(/server\.disable_subsystem: cannot disable the server subsystem/); const unknown = await client.callTool({ name: "server.enable_subsystem", arguments: { name: "nope" } }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/server\.enable_subsystem: unknown subsystem: nope/); const blank = await client.callTool({ name: "server.disable_subsystem", arguments: { name: "" } }); expect(blank.isError).toBe(true); expect(textOf(blank)).toMatch(/server\.disable_subsystem: name must be a non-empty string/); const enabled = jsonOf( textOf(await client.callTool({ name: "server.enable_subsystem", arguments: { name: "time" } })), ); expect(enabled).toEqual({ name: "time", enabled: true }); const restored = await client.listTools(); expect(restored.tools.some((t) => t.name === "time.get")).toBe(true); const get = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} }))); expect(get).toEqual({ day: 1, season: "spring", tickInDay: 0 }); } finally { await close(); } }); it("item: seeded catalog, list/get, inventory give + query", async () => { const { client, close } = await setup(); try { const catalog = jsonOf(contentsOf(await client.readResource({ uri: "item://catalog" }))); expect(catalog.items).toHaveLength(4); const seed = catalog.items.find((i: { id: string }) => i.id === "parsnip-seed"); expect(seed).toMatchObject({ id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); const list = jsonOf(textOf(await client.callTool({ name: "item.list", arguments: {} }))); expect(list.items).toEqual([ { id: "egg", name: "Egg", kind: "material" }, { id: "hoe", name: "Hoe", kind: "tool" }, { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed" }, { id: "parsnip", name: "Parsnip", kind: "material" }, ]); const got = jsonOf(textOf(await client.callTool({ name: "item.get", arguments: { itemId: "hoe" } }))); expect(got).toMatchObject({ id: "hoe", kind: "tool" }); const gave = jsonOf(textOf(await client.callTool({ name: "item.give", arguments: { itemId: "hoe", qty: 2 } }))); expect(gave).toEqual({ itemId: "hoe", qty: 2, total: 2 }); const inventory = jsonOf(textOf(await client.callTool({ name: "item.query_inventory", arguments: {} }))); expect(inventory).toEqual({ inventory: { hoe: 2 } }); const missing = await client.callTool({ name: "item.get", arguments: { itemId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/item not found: nope/); const unknownGive = await client.callTool({ name: "item.give", arguments: { itemId: "nope", qty: 1 } }); expect(unknownGive.isError).toBe(true); expect(textOf(unknownGive)).toMatch(/unknown item: nope/); } finally { await close(); } }); it("crop: seeded catalog, list/get, plot water/stage debug tools", async () => { const { engine, client, close } = await setup(); try { const catalog = jsonOf(contentsOf(await client.readResource({ uri: "crop://catalog" }))); expect(catalog.crops).toHaveLength(1); const parsnip = catalog.crops.find((c: { id: string }) => c.id === "parsnip"); expect(parsnip).toMatchObject({ 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 }], }); const list = jsonOf(textOf(await client.callTool({ name: "crop.list", arguments: {} }))); expect(list.crops).toEqual([{ id: "parsnip", seedItem: "parsnip-seed", stageCount: 3 }]); const got = jsonOf(textOf(await client.callTool({ name: "crop.get", arguments: { cropId: "parsnip" } }))); expect(got).toMatchObject({ id: "parsnip", seedItem: "parsnip-seed" }); const missing = await client.callTool({ name: "crop.get", arguments: { cropId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/crop not found: nope/); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); const planting = engine.systems.get("planting"); planting!.till(6, 6); planting!.sow(6, 6, "parsnip-seed"); const watered = jsonOf(textOf(await client.callTool({ name: "crop.water", arguments: { x: 6, y: 6 } }))); expect(watered).toEqual({ plotId: "plot:6:6", x: 6, y: 6, watered: true }); const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 6, y: 6, stageIndex: 2 } }))); expect(staged).toEqual({ plotId: "plot:6:6", x: 6, y: 6, cropItemId: "parsnip", stageIndex: 2, stage: "mature", }); const singleCrop = jsonOf(contentsOf(await client.readResource({ uri: "crop://parsnip" }))); expect(singleCrop.id).toBe("parsnip"); } finally { await close(); } }); it("crop: till → sow → harvest debug tools drive the full farming loop through MCP", async () => { const { engine, client, close } = await setup(); try { const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); const tilled = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { x: 7, y: 7 } }))); expect(tilled).toEqual({ plotId: "plot:7:7", x: 7, y: 7, tilled: true }); const byPlotId = jsonOf(textOf(await client.callTool({ name: "crop.till", arguments: { plotId: "plot:8:8" } }))); expect(byPlotId).toEqual({ plotId: "plot:8:8", x: 8, y: 8, tilled: true }); const sown = jsonOf( textOf(await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7, seedItemId: "parsnip-seed" } })), ); expect(sown).toEqual({ plotId: "plot:7:7", x: 7, y: 7, cropItemId: "parsnip", seedItemId: "parsnip-seed" }); expect(inv!.count("parsnip-seed")).toBe(4); engine.world.create({ id: "plot:6:6", type: "plot", x: 6, y: 6, tilled: false, watered: false }); const untilled = await client.callTool({ name: "crop.sow", arguments: { x: 6, y: 6, seedItemId: "parsnip-seed" } }); expect(untilled.isError).toBe(true); expect(textOf(untilled)).toMatch(/not tilled/); const staged = jsonOf(textOf(await client.callTool({ name: "crop.stage", arguments: { x: 7, y: 7, stageIndex: 2 } }))); expect(staged).toMatchObject({ stageIndex: 2, stage: "mature" }); const harvested = jsonOf(textOf(await client.callTool({ name: "crop.harvest", arguments: { x: 7, y: 7 } }))); expect(harvested).toEqual({ plotId: "plot:7:7", x: 7, y: 7, cropItemId: "parsnip", drops: [{ itemId: "parsnip", qty: 1 }], }); expect(inv!.count("parsnip")).toBe(1); const empty = await client.callTool({ name: "crop.harvest", arguments: { x: 8, y: 8 } }); expect(empty.isError).toBe(true); expect(textOf(empty)).toMatch(/has no crop/); const missingSeed = await client.callTool({ name: "crop.sow", arguments: { x: 7, y: 7 } }); expect(missingSeed.isError).toBe(true); expect(textOf(missingSeed)).toMatch(/missing required arguments: seedItemId/); } finally { await close(); } }); it("shop: seeded def, list/get, buy/sell debug transactions", async () => { const { engine, client, close } = await setup(); try { const catalog = jsonOf(contentsOf(await client.readResource({ uri: "shop://catalog" }))); expect(catalog.shops).toHaveLength(1); const store = catalog.shops.find((s: { id: string }) => s.id === "general-store"); expect(store).toMatchObject({ id: "general-store", buys: [{ itemId: "parsnip", price: 30 }], sells: [ { itemId: "hoe", price: 500 }, { itemId: "parsnip-seed", price: 20, stock: 99 }, ], }); const list = jsonOf(textOf(await client.callTool({ name: "shop.list", arguments: {} }))); expect(list.shops).toEqual([{ id: "general-store", buyCount: 1, sellCount: 2 }]); const got = jsonOf(textOf(await client.callTool({ name: "shop.get", arguments: { shopId: "general-store" } }))); expect(got).toMatchObject({ id: "general-store" }); const missing = await client.callTool({ name: "shop.get", arguments: { shopId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/shop not found: nope/); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; const bought = jsonOf( textOf(await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "parsnip-seed", qty: 2 } })), ); expect(bought).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 }); const inv = engine.systems.get("inventory"); expect(inv!.count("parsnip-seed")).toBe(2); const broke = await client.callTool({ name: "shop.buy", arguments: { shopId: "general-store", itemId: "hoe", qty: 1 } }); expect(broke.isError).toBe(true); expect(textOf(broke)).toMatch(/insufficient gold/); inv!.give("parsnip", 3); const sold = jsonOf( textOf(await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 2 } })), ); expect(sold).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 120 }); expect(inv!.count("parsnip")).toBe(1); const short = await client.callTool({ name: "shop.sell", arguments: { shopId: "general-store", itemId: "parsnip", qty: 9 } }); expect(short.isError).toBe(true); expect(textOf(short)).toMatch(/insufficient parsnip/); const singleShop = jsonOf(contentsOf(await client.readResource({ uri: "shop://general-store" }))); expect(singleShop.id).toBe("general-store"); } finally { await close(); } }); it("behavior: seeded triggers, list/get, condition validate, fire debug, resource and prompt", async () => { const { engine, client, close } = await setup(); try { const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://triggers" }))); expect(resource.triggers).toHaveLength(2); const springGift = resource.triggers.find((t: { id: string }) => t.id === "spring-gift"); expect(springGift).toMatchObject({ id: "spring-gift", when: { op: "gte", path: "time.day", value: 5 }, fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }], }); const list = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.list", arguments: {} }))); expect(list.triggers).toEqual([ { id: "season-welcome", actionCount: 2 }, { id: "spring-gift", actionCount: 1 }, ]); const got = jsonOf(textOf(await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "spring-gift" } }))); expect(got).toMatchObject({ id: "spring-gift", when: { op: "gte", path: "time.day", value: 5 } }); const missing = await client.callTool({ name: "behavior.trigger.get", arguments: { triggerId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/trigger not found: nope/); const validCondition = jsonOf( textOf(await client.callTool({ name: "behavior.condition.validate", arguments: { condition: { and: [{ op: "gte", path: "time.day", value: 5 }, { op: "exists", path: "payload.itemId" }] } }, })), ); expect(validCondition).toEqual({ ok: true }); const invalidCondition = jsonOf( textOf(await client.callTool({ name: "behavior.condition.validate", arguments: { condition: { op: "gte", path: "time.day" } } })), ); expect(invalidCondition.ok).toBe(false); expect(Array.isArray(invalidCondition.errors)).toBe(true); const time = engine.systems.get("time"); time!.set(5, "spring"); const fired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "spring-gift" } }))); expect(fired).toEqual({ triggerId: "spring-gift", fired: true }); const inv = engine.systems.get("inventory"); expect(inv!.count("parsnip-seed")).toBe(1); const notFired = jsonOf(textOf(await client.callTool({ name: "behavior.fire", arguments: { triggerId: "season-welcome" } }))); expect(notFired).toEqual({ triggerId: "season-welcome", fired: false }); const unknown = await client.callTool({ name: "behavior.fire", arguments: { triggerId: "nope" } }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/unknown trigger: nope/); const prompt = await client.getPrompt({ name: "behavior.trigger-prompt", arguments: {} }); const promptText = String(prompt.messages[0]?.content.text); expect(promptText).toContain("give-item"); expect(promptText).toContain("behavior.trigger.validate"); expect(promptText).toContain("content/behaviors/.yaml"); } finally { await close(); } }); it("statemachine: seeded definitions, list/get/status/transition, resource and prompt", async () => { const { engine, client, close } = await setup(); try { const resource = jsonOf(contentsOf(await client.readResource({ uri: "behavior://statemachines" }))); expect(resource.statemachines).toHaveLength(3); const routine = resource.statemachines.find((m: { id: string }) => m.id === "daily-routine"); expect(routine).toMatchObject({ id: "daily-routine", initial: "sleeping", states: [{ id: "sleeping" }, { id: "working" }, { id: "resting" }], }); const list = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.list", arguments: {} }))); expect(list.statemachines).toEqual([ { id: "chicken-egg", stateCount: 2, transitionCount: 2 }, { id: "daily-routine", stateCount: 3, transitionCount: 1 }, { id: "spring-quest", stateCount: 3, transitionCount: 2 }, ]); const got = jsonOf(textOf(await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "spring-quest" } }))); expect(got).toMatchObject({ id: "spring-quest", initial: "inactive" }); const missing = await client.callTool({ name: "behavior.statemachine.get", arguments: { machineId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/machine not found: nope/); const before = jsonOf( textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })), ); expect(before).toEqual({ machineId: "spring-quest", state: "inactive", started: false }); const time = engine.systems.get("time"); time!.set(3, "spring"); time!.advance(480); const after = jsonOf( textOf(await client.callTool({ name: "behavior.statemachine.status", arguments: { machineId: "spring-quest" } })), ); expect(after).toEqual({ machineId: "spring-quest", state: "active", started: true }); const forced = jsonOf( textOf(await client.callTool({ name: "behavior.statemachine.transition", arguments: { machineId: "spring-quest", to: "complete" } })), ); expect(forced).toEqual({ machineId: "spring-quest", from: "active", to: "complete", transitioned: true }); const player = engine.world.get("player"); expect(player?.questProgress).toMatchObject({ "spring-quest": "complete" }); const prompt = await client.getPrompt({ name: "behavior.statemachine-prompt", arguments: {} }); const promptText = String(prompt.messages[0]?.content.text); expect(promptText).toContain("onSchedule"); expect(promptText).toContain("behavior.statemachine.validate"); expect(promptText).toContain("content/statemachines/.yaml"); } finally { await close(); } }); it("map.list: {maps:[...]} summaries", async () => { const { client, close } = await setup(); try { const list = jsonOf(textOf(await client.callTool({ name: "map.list", arguments: {} }))); expect(list.maps).toEqual([{ id: "farm", width: 12, height: 12, tilesetRef: "farm-tiles" }]); } finally { await close(); } }); it("map.get: full definition, layer layout, spawn, player entity", async () => { const { client, close } = await setup(); try { const map = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: { mapId: "farm" } }))); expect(map.id).toBe("farm"); expect(map.width).toBe(12); expect(map.height).toBe(12); expect(map.tilesetRef).toBe("farm-tiles"); const ground = map.layers[0]; expect(ground.name).toBe("ground"); expect(ground.data).toHaveLength(12); expect(ground.data[0]).toHaveLength(12); expect(ground.data[0][0]).toBe("water"); expect(ground.data[4][4]).toBe("tilled"); expect(ground.data[0][10]).toBe("grass"); expect(map.spawn).toEqual({ x: 2, y: 2 }); expect(map.player).toMatchObject({ id: "player", type: "player", x: 2, y: 2 }); } finally { await close(); } }); it("map.get: defaults to first map; missing mapId is an error result", async () => { const { client, close } = await setup(); try { const first = jsonOf(textOf(await client.callTool({ name: "map.get", arguments: {} }))); expect(first.id).toBe("farm"); const missing = await client.callTool({ name: "map.get", arguments: { mapId: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/map not found: nope/); } finally { await close(); } }); it("time: get initial, advance across day, set, advance across season", async () => { const { client, close } = await setup(); try { const initial = jsonOf(textOf(await client.callTool({ name: "time.get", arguments: {} }))); expect(initial).toEqual({ day: 1, season: "spring", tickInDay: 0 }); const day2 = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } }))); expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 }); const day28 = jsonOf(textOf(await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } }))); expect(day28).toEqual({ day: 28, season: "spring", tickInDay: 0 }); const summer = jsonOf(textOf(await client.callTool({ name: "time.advance", arguments: { ticks: 480 } }))); expect(summer).toEqual({ day: 1, season: "summer", tickInDay: 0 }); } finally { await close(); } }); it("time.tick runs a full simulation step (drives growth + emits time-tick; defaults to 1 tick)", async () => { const { engine, client, close } = await setup(); try { const stepped = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 120 } }))); expect(stepped).toEqual({ day: 1, season: "spring", tickInDay: 120 }); const defaulted = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: {} }))); expect(defaulted).toEqual({ day: 1, season: "spring", tickInDay: 121 }); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); const planting = engine.systems.get("planting"); planting!.till(6, 6); planting!.sow(6, 6, "parsnip-seed"); planting!.water(6, 6); const day2 = jsonOf(textOf(await client.callTool({ name: "time.tick", arguments: { ticks: 359 } }))); expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 }); const plot = engine.world.get("plot:6:6") as { daysInStage?: number }; expect(plot.daysInStage).toBe(1); const invalid = await client.callTool({ name: "time.tick", arguments: { ticks: 0 } }); expect(invalid.isError).toBe(true); expect(textOf(invalid)).toMatch(/ticks must be a positive integer/); } finally { await close(); } }); it("error paths: unknown tool, missing args, negative ticks", async () => { const { client, close } = await setup(); try { const unknown = await client.callTool({ name: "nope.nothing", arguments: {} }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/tool not found: nope.nothing/); const missing = await client.callTool({ name: "time.advance", arguments: {} }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/missing required arguments: ticks/); const negative = await client.callTool({ name: "time.advance", arguments: { ticks: -5 } }); expect(negative.isError).toBe(true); expect(textOf(negative)).toMatch(/ticks must be a positive integer/); } finally { await close(); } }); it("resources: 36 template URIs, all readable, live state reflected", async () => { const { client, close } = await setup(); try { await client.callTool({ name: "time.advance", arguments: { ticks: 480 } }); await client.callTool({ name: "time.set", arguments: { day: 28, season: "spring" } }); await client.callTool({ name: "time.advance", arguments: { ticks: 480 } }); const resources = await client.listResources(); expect(resources.resources.map((r) => r.uri).sort()).toEqual( ["animal://{id}", "animal://catalog", "animation://{id}", "animation://catalog", "behavior://statemachines", "behavior://triggers", "crop://{id}", "crop://catalog", "dialogue://{id}", "dialogue://catalog", "item://{id}", "item://catalog", "map://{mapId}", "mod://{id}", "npc://{npcId}", "particle://{id}", "particle://catalog", "placement://{id}", "placement://buildings", "placement://placed", "player://state", "rng://state", "save://state", "scene://{id}", "scene://catalog", "server://status", "shader://{id}", "shader://catalog", "shop://{shopId}", "shop://catalog", "sound://{id}", "sound://catalog", "sprite://{id}", "sprite://catalog", "time://state", "weather://state"].sort(), ); const status = jsonOf(contentsOf(await client.readResource({ uri: "server://status" }))); expect(status.engine).toBe("nengine"); const map = jsonOf(contentsOf(await client.readResource({ uri: "map://farm" }))); expect(map.id).toBe("farm"); const time = jsonOf(contentsOf(await client.readResource({ uri: "time://state" }))); expect(time).toEqual({ day: 1, season: "summer", tickInDay: 0 }); } finally { await close(); } }); it("resources: missing template param rejects with a server error", async () => { const { client, close } = await setup(); try { await expect(client.readResource({ uri: "map://missing" })).rejects.toThrow(/map not found: missing/); } finally { await close(); } }); it("prompts: 17 authoring prompts, get returns guidance; missing prompt rejects", async () => { const { client, close } = await setup(); try { const prompts = await client.listPrompts(); expect(prompts.prompts.map((p) => p.name).sort()).toEqual([ "animal.create-prompt", "animation.create-prompt", "behavior.statemachine-prompt", "behavior.trigger-prompt", "crop.create-prompt", "dialogue.create-prompt", "item.create-prompt", "map.create-prompt", "particle.create-prompt", "placement.create-prompt", "save.authoring-prompt", "scene.create-prompt", "server.orient", "shader.create-prompt", "shop.create-prompt", "sound.create-prompt", "sprite.create-prompt", ]); const orient = await client.getPrompt({ name: "server.orient", arguments: {} }); const text = orient.messages[0]?.content; expect(text).toMatchObject({ type: "text" }); expect(String(text?.text)).toContain("nEngine MCP server"); expect(String(text?.text)).toContain("time.advance"); expect(String(text?.text)).toContain("content/maps/.yaml"); const itemPrompt = await client.getPrompt({ name: "item.create-prompt", arguments: {} }); expect(String(itemPrompt.messages[0]?.content.text)).toContain("item.validate"); expect(String(itemPrompt.messages[0]?.content.text)).toContain("content/items/.yaml"); const cropPrompt = await client.getPrompt({ name: "crop.create-prompt", arguments: {} }); expect(String(cropPrompt.messages[0]?.content.text)).toContain("crop.validate"); expect(String(cropPrompt.messages[0]?.content.text)).toContain("content/crops/.yaml"); expect(String(cropPrompt.messages[0]?.content.text)).toContain("waterRequired"); const shopPrompt = await client.getPrompt({ name: "shop.create-prompt", arguments: {} }); expect(String(shopPrompt.messages[0]?.content.text)).toContain("shop.validate"); expect(String(shopPrompt.messages[0]?.content.text)).toContain("content/shops/.yaml"); expect(String(shopPrompt.messages[0]?.content.text)).toContain("stock"); await expect(client.getPrompt({ name: "nope", arguments: {} })).rejects.toThrow(); } finally { await close(); } }); it("save: snapshot/validate/restore/migrate, save://state resource, and authoring prompt", async () => { const { engine, client, close } = await setup(); try { const snapshot = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} }))); expect(snapshot.format).toBe("nengine-save"); expect(snapshot.version).toBe(1); expect(typeof snapshot.savedAt).toBe("string"); const entities = snapshot.world as { id: string; type: string; x: number; y: number; width: number; height: number }[]; expect(entities.find((e) => e.id === "farm")).toMatchObject({ type: "map", width: 12, height: 12 }); expect(entities.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 2, y: 2 }); const valid = jsonOf(textOf(await client.callTool({ name: "save.validate", arguments: { snapshot } }))); expect(valid).toEqual({ ok: true }); const invalid = jsonOf( textOf(await client.callTool({ name: "save.validate", arguments: { snapshot: { format: "nengine-save" } } })), ); expect(invalid.ok).toBe(false); expect(Array.isArray(invalid.errors)).toBe(true); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 4); const planting = engine.systems.get("planting"); planting!.till(9, 9); const mutated = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} }))); expect((mutated.world as { id: string }[]).some((e) => e.id === "plot:9:9")).toBe(true); const restored = jsonOf( textOf(await client.callTool({ name: "save.restore", arguments: { snapshot: mutated } })), ); expect(restored).toEqual({ restored: 4 }); const after = jsonOf(textOf(await client.callTool({ name: "save.snapshot", arguments: {} }))); const afterEntities = after.world as { id: string; inventory?: Record }[]; expect(afterEntities.some((e) => e.id === "plot:9:9")).toBe(true); expect(afterEntities.find((e) => e.id === "player")?.inventory).toEqual({ "parsnip-seed": 4 }); const badRestore = await client.callTool({ name: "save.restore", arguments: { snapshot: { format: "nengine-save" } } }); expect(badRestore.isError).toBe(true); expect(textOf(badRestore)).toMatch(/invalid snapshot/); const migrated = jsonOf( textOf(await client.callTool({ name: "save.migrate", arguments: { snapshot, fromVersion: 1, toVersion: 2 } })), ); expect(migrated.world).toEqual(snapshot.world); const state = jsonOf(contentsOf(await client.readResource({ uri: "save://state" }))); expect(state.format).toBe("nengine-save"); expect(state.version).toBe(1); const prompt = await client.getPrompt({ name: "save.authoring-prompt", arguments: {} }); const promptText = String(prompt.messages[0]?.content.text); expect(promptText).toContain("nengine-save"); expect(promptText).toContain("migration"); expect(promptText).toContain("save.snapshot"); } finally { await close(); } }); it("mod: list/status/reload over the real mods dir (example-mod), mod://{id} resource, no prompts", async () => { const { engine, client, close } = await setup(); try { const list = jsonOf(textOf(await client.callTool({ name: "mod.list", arguments: {} }))); expect(list.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]); const status = jsonOf(textOf(await client.callTool({ name: "mod.status", arguments: { id: "example-mod" } }))); expect(status).toEqual({ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }); const missing = await client.callTool({ name: "mod.status", arguments: { id: "nope" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/mod not found: nope/); const singleMod = jsonOf(contentsOf(await client.readResource({ uri: "mod://example-mod" }))); expect(singleMod).toMatchObject({ id: "example-mod", version: "0.1.0", loaded: true }); const reloaded = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: { id: "example-mod" } }))); expect(reloaded.reloaded).toBe(true); expect(reloaded.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]); expect(engine.mods).toEqual([{ id: "example-mod", version: "0.1.0", loaded: true, hooks: ["onDayStart"] }]); const reloadAll = jsonOf(textOf(await client.callTool({ name: "mod.reload", arguments: {} }))); expect(reloadAll.mods).toHaveLength(1); expect(reloadAll.mods[0].id).toBe("example-mod"); const prompts = await client.listPrompts(); expect(prompts.prompts.some((p) => p.name.startsWith("mod."))).toBe(false); } finally { await close(); } }); it("placement: seeded building defs, place/list/occupies/remove, resources and prompt", async () => { const { client, close } = await setup(); try { const catalog = jsonOf(contentsOf(await client.readResource({ uri: "placement://buildings" }))); expect(catalog.buildings.map((b: { id: string }) => b.id)).toEqual(["fence", "shed"]); const list = jsonOf(textOf(await client.callTool({ name: "placement.building.list", arguments: {} }))); expect(list.buildings).toEqual([ { id: "fence", name: "Fence", w: 1, h: 1, blocksMovement: true }, { id: "shed", name: "Shed", w: 2, h: 2, blocksMovement: true }, ]); const placed = jsonOf( textOf(await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 3, y: 3 } })), ); expect(placed).toEqual({ id: "building:shed:3:3", type: "building", buildingId: "shed", x: 3, y: 3, w: 2, h: 2, blocksMovement: true, }); await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 10 } }); const all = jsonOf(textOf(await client.callTool({ name: "placement.list", arguments: {} }))); expect(all.buildings).toHaveLength(2); const inside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 4, y: 3 } }))); expect(inside.id).toBe("building:shed:3:3"); const outside = jsonOf(textOf(await client.callTool({ name: "placement.occupies", arguments: { x: 11, y: 11 } }))); expect(outside).toBeNull(); const onWater = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 0, y: 0 } }); expect(onWater.isError).toBe(true); expect(textOf(onWater)).toMatch(/tile \(0,0\) is not buildable for fence/); const overlap = await client.callTool({ name: "placement.place", arguments: { buildingId: "fence", x: 3, y: 3 } }); expect(overlap.isError).toBe(true); expect(textOf(overlap)).toMatch(/overlaps existing building/); const removed = jsonOf(textOf(await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } }))); expect(removed.id).toBe("building:shed:3:3"); const missing = await client.callTool({ name: "placement.remove", arguments: { x: 3, y: 3 } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/no building at \(3,3\)/); const placedResource = jsonOf(contentsOf(await client.readResource({ uri: "placement://placed" }))); expect(placedResource.buildings).toHaveLength(1); const shedDef = jsonOf(contentsOf(await client.readResource({ uri: "placement://shed" }))); expect(shedDef).toMatchObject({ id: "shed", footprint: { w: 2, h: 2 } }); const prompt = await client.getPrompt({ name: "placement.create-prompt", arguments: {} }); const promptText = String(prompt.messages[0]?.content.text); expect(promptText).toContain("buildable"); expect(promptText).toContain("content/buildings/.yaml"); } finally { await close(); } }); it("npc: spawn/list/get/move/teleport, npc-move event, blocking by building, resource, no prompts", async () => { const { engine, client, close } = await setup(); try { const spawned = jsonOf(textOf(await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 2, y: 2 } }))); expect(spawned).toEqual({ id: "leah", type: "npc", x: 2, y: 2 }); const listed = jsonOf(textOf(await client.callTool({ name: "npc.list", arguments: {} }))); expect(listed.npcs).toEqual([{ id: "leah", x: 2, y: 2 }]); const got = jsonOf(textOf(await client.callTool({ name: "npc.get", arguments: { npcId: "leah" } }))); expect(got).toMatchObject({ id: "leah", type: "npc", x: 2, y: 2 }); const moved = jsonOf(textOf(await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 4, y: 4 } }))); expect(moved).toEqual({ npcId: "leah", from: { x: 2, y: 2 }, to: { x: 4, y: 4 } }); const unknown = await client.callTool({ name: "npc.move", arguments: { npcId: "ghost", x: 0, y: 0 } }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/unknown npc: ghost/); const onWater = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 0, y: 0 } }); expect(onWater.isError).toBe(true); expect(textOf(onWater)).toMatch(/tile \(0,0\) is not walkable/); const bounds = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 12, y: 0 } }); expect(bounds.isError).toBe(true); expect(textOf(bounds)).toMatch(/target out of bounds: \(12,0\)/); await client.callTool({ name: "placement.place", arguments: { buildingId: "shed", x: 8, y: 8 } }); const blocked = await client.callTool({ name: "npc.move", arguments: { npcId: "leah", x: 8, y: 8 } }); expect(blocked.isError).toBe(true); expect(textOf(blocked)).toMatch(/blocked by building building:shed:8:8 at \(8,8\)/); const teleported = jsonOf( textOf(await client.callTool({ name: "npc.teleport", arguments: { npcId: "leah", x: 0, y: 0 } })), ); expect(teleported).toEqual({ npcId: "leah", from: { x: 4, y: 4 }, to: { x: 0, y: 0 } }); const leah = jsonOf(contentsOf(await client.readResource({ uri: "npc://leah" }))); expect(leah).toMatchObject({ id: "leah", type: "npc", x: 0, y: 0 }); await expect(client.readResource({ uri: "npc://ghost" })).rejects.toThrow(/npc not found: ghost/); const prompts = await client.listPrompts(); expect(prompts.prompts.some((p) => p.name.startsWith("npc."))).toBe(false); expect(engine.world.get("leah")).toMatchObject({ x: 0, y: 0 }); } finally { await close(); } }); it("npc.path: A* path result, null reasons (blocked/out of bounds), and validation errors", async () => { const { client, close } = await setup(); try { await client.callTool({ name: "npc.spawn", arguments: { id: "leah", x: 4, y: 4 } }); const found = jsonOf( textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 6, y: 6 } } })), ); expect(found).toEqual({ npcId: "leah", from: { x: 4, y: 4 }, to: { x: 6, y: 6 }, path: [ { x: 4, y: 4 }, { x: 5, y: 4 }, { x: 6, y: 4 }, { x: 6, y: 5 }, { x: 6, y: 6 }, ], }); const onWater = jsonOf( textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 0, y: 0 } } })), ); expect(onWater).toEqual({ npcId: "leah", path: null, reason: "blocked" }); const bounds = jsonOf( textOf(await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 12, y: 0 } } })), ); expect(bounds).toEqual({ npcId: "leah", path: null, reason: "out of bounds" }); const unknown = await client.callTool({ name: "npc.path", arguments: { npcId: "ghost", to: { x: 5, y: 5 } } }); expect(unknown.isError).toBe(true); expect(textOf(unknown)).toMatch(/movement: unknown npc: ghost/); const blank = await client.callTool({ name: "npc.path", arguments: { npcId: "", to: { x: 5, y: 5 } } }); expect(blank.isError).toBe(true); expect(textOf(blank)).toMatch(/npc\.path: npcId must be a non-empty string/); const badTo = await client.callTool({ name: "npc.path", arguments: { npcId: "leah", to: { x: 1.5, y: 0 } } }); expect(badTo.isError).toBe(true); expect(textOf(badTo)).toMatch(/npc\.path: to must have integer x and y/); } finally { await close(); } }); it("animal: seeded def, list/get, spawn/feed/stage debug tools, resources and prompt", async () => { const { engine, client, close } = await setup(); try { const catalog = jsonOf(contentsOf(await client.readResource({ uri: "animal://catalog" }))); expect(catalog.animals).toHaveLength(1); const chicken = catalog.animals.find((a: { id: string }) => a.id === "chicken"); expect(chicken).toMatchObject({ id: "chicken", stages: [ { name: "chick", days: 2, feedRequired: false }, { name: "grown", days: 2, feedRequired: true }, { name: "laying", days: 1, feedRequired: true }, ], produces: [{ itemId: "egg", qty: 1 }], }); const list = jsonOf(textOf(await client.callTool({ name: "animal.list", arguments: {} }))); expect(list.animals).toEqual([{ id: "chicken", stageCount: 3 }]); const got = jsonOf(textOf(await client.callTool({ name: "animal.get", arguments: { animalId: "chicken" } }))); expect(got).toMatchObject({ id: "chicken", produces: [{ itemId: "egg", qty: 1 }] }); const missing = await client.callTool({ name: "animal.get", arguments: { animalId: "cow" } }); expect(missing.isError).toBe(true); expect(textOf(missing)).toMatch(/animal not found: cow/); const spawned = jsonOf(textOf(await client.callTool({ name: "animal.spawn", arguments: { animalId: "chicken", x: 3, y: 3 } }))); expect(spawned).toMatchObject({ id: "animal:chicken:1", type: "animal", animalId: "chicken", x: 3, y: 3, stageIndex: 0, fed: false, }); const fed = jsonOf(textOf(await client.callTool({ name: "animal.feed", arguments: { id: "animal:chicken:1" } }))); expect(fed).toEqual({ id: "animal:chicken:1", animalId: "chicken", fed: true }); const staged = jsonOf( textOf(await client.callTool({ name: "animal.stage", arguments: { id: "animal:chicken:1", stageIndex: 2 } })), ); expect(staged).toEqual({ id: "animal:chicken:1", animalId: "chicken", animalDefId: "chicken", stageIndex: 2, stage: "laying", }); const entities = jsonOf(textOf(await client.callTool({ name: "animal.entities", arguments: {} }))); expect(entities.animals).toHaveLength(1); expect(entities.animals[0].id).toBe("animal:chicken:1"); const unknownSpawn = await client.callTool({ name: "animal.spawn", arguments: { animalId: "cow", x: 0, y: 0 } }); expect(unknownSpawn.isError).toBe(true); expect(textOf(unknownSpawn)).toMatch(/unknown animal def: cow/); const singleAnimal = jsonOf(contentsOf(await client.readResource({ uri: "animal://chicken" }))); expect(singleAnimal.id).toBe("chicken"); await expect(client.readResource({ uri: "animal://ghost" })).rejects.toThrow(/animal not found: ghost/); const prompt = await client.getPrompt({ name: "animal.create-prompt", arguments: {} }); const promptText = String(prompt.messages[0]?.content.text); expect(promptText).toContain("feedRequired"); expect(promptText).toContain("content/animals/.yaml"); } finally { await close(); } }); it("player: intent drives the player entity, player://state reads it", async () => { const { engine, client, close } = await setup(); try { const state = jsonOf(contentsOf(await client.readResource({ uri: "player://state" }))); expect(state).toMatchObject({ id: "player", type: "player", x: 2, y: 2 }); const moved = jsonOf( textOf(await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 3, y: 2 } } })), ); expect(moved).toMatchObject({ action: "move", tile: { x: 3, y: 2 }, ok: true, detail: "moved to (3,2)" }); expect(engine.world.get("player")).toMatchObject({ x: 3, y: 2 }); const tilled = jsonOf( textOf(await client.callTool({ name: "player.intent", arguments: { action: "interact", tile: { x: 5, y: 5 } } })), ); expect(tilled).toMatchObject({ action: "interact", ok: true, detail: "tilled plot" }); expect(engine.world.get("plot:5:5")).toMatchObject({ type: "plot", x: 5, y: 5, tilled: true }); const onWater = await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: 0, y: 0 } } }); expect(onWater.isError).toBe(true); expect(textOf(onWater)).toMatch(/player\.intent: intent\.move: tile \(0,0\) is not walkable/); const badAction = await client.callTool({ name: "player.intent", arguments: { action: "dance", tile: { x: 1, y: 1 } }, }); expect(badAction.isError).toBe(true); expect(textOf(badAction)).toMatch( /player\.intent: action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/, ); const badTile = await client.callTool({ name: "player.intent", arguments: { action: "move", tile: { x: -1, y: 0 } }, }); expect(badTile.isError).toBe(true); expect(textOf(badTile)).toMatch(/player\.intent: tile x and y must be non-negative integers/); const movedState = jsonOf(contentsOf(await client.readResource({ uri: "player://state" }))); expect(movedState).toMatchObject({ x: 3, y: 2 }); } finally { await close(); } }); it("player.intent buy/sell/talk/choose/select-seed drive the extended actions through the real server", async () => { const { engine, client, close } = await setup(); try { const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); const noActive = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } }); expect(noActive.isError).toBe(true); expect(textOf(noActive)).toMatch(/no active dialogue/); const bought = jsonOf( textOf( await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 2 }, }), ), ); expect(bought.detail).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 }); expect(inv.count("parsnip-seed")).toBe(2); const missingItem = await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store" } }); expect(missingItem.isError).toBe(true); expect(textOf(missingItem)).toMatch(/buy requires itemId/); const missingShop = await client.callTool({ name: "player.intent", arguments: { action: "buy", itemId: "parsnip-seed" } }); expect(missingShop.isError).toBe(true); expect(textOf(missingShop)).toMatch(/buy requires shopId/); const badQty = await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 0 }, }); expect(badQty.isError).toBe(true); expect(textOf(badQty)).toMatch(/qty must be a positive integer/); inv.give("parsnip", 3); const sold = jsonOf( textOf( await client.callTool({ name: "player.intent", arguments: { action: "sell", shopId: "general-store", itemId: "parsnip", qty: 1 }, }), ), ); expect(sold.detail).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 1, total: 30, gold: 90 }); expect(inv.count("parsnip")).toBe(2); const broke = await client.callTool({ name: "player.intent", arguments: { action: "buy", shopId: "general-store", itemId: "hoe", qty: 1 }, }); expect(broke.isError).toBe(true); expect(textOf(broke)).toMatch(/insufficient gold/); engine.world.create({ id: "npc:clerk", type: "npc", x: 3, y: 3, dialogue: "welcome" }); const talked = jsonOf( textOf(await client.callTool({ name: "player.intent", arguments: { action: "talk", npcId: "npc:clerk" } })), ); expect(talked.detail).toMatchObject({ dialogueId: "welcome", nodeId: "hello" }); const chosen = jsonOf( textOf(await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 0 } })), ); expect(chosen.detail).toMatchObject({ dialogueId: "welcome", nodeId: "seed-tip" }); const outOfRange = await client.callTool({ name: "player.intent", arguments: { action: "choose", index: 9 } }); expect(outOfRange.isError).toBe(true); expect(textOf(outOfRange)).toMatch(/choice index out of range/); const selected = jsonOf( textOf( await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "parsnip-seed" } }), ), ); expect(selected.detail).toEqual({ selectedSeed: "parsnip-seed" }); expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" }); const notASeed = await client.callTool({ name: "player.intent", arguments: { action: "select-seed", seedItemId: "hoe" } }); expect(notASeed.isError).toBe(true); expect(textOf(notASeed)).toMatch(/hoe is not a seed item/); } finally { await close(); } }); it("rng: get/set the engine RNG, determinism through MCP, rng://state resource, no prompts", async () => { const { engine, client, close } = await setup(); try { const initial = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} }))); expect(initial.seed).toEqual(engine.rng.state().seed); expect(initial.calls).toBe(0); engine.rng.next(); const afterDraw = jsonOf(textOf(await client.callTool({ name: "rng.get", arguments: {} }))); expect(afterDraw.calls).toBe(1); const set = jsonOf(textOf(await client.callTool({ name: "rng.set", arguments: { seed: 2026 } }))); expect(set).toEqual({ seed: 2026, calls: 0 }); const first = engine.rng.next(); const second = engine.rng.next(); expect(first).toBeGreaterThanOrEqual(0); expect(second).toBeGreaterThanOrEqual(0); const state = jsonOf(contentsOf(await client.readResource({ uri: "rng://state" }))); expect(state).toEqual({ seed: 2026, calls: 2 }); const badSeed = await client.callTool({ name: "rng.set", arguments: { seed: Infinity } }); expect(badSeed.isError).toBe(true); expect(textOf(badSeed)).toMatch(/seed must be a finite number/); const prompts = await client.listPrompts(); expect(prompts.prompts.some((p) => p.name.startsWith("rng."))).toBe(false); } finally { await close(); } }); it("animation.*: lifecycle validate + empty catalog over the root content dir (no writes)", async () => { const { client, close } = await setup(); try { const animationList = jsonOf(textOf(await client.callTool({ name: "animation.list", arguments: {} }))); expect(animationList).toEqual({ animations: [] }); const missingAnimation = await client.callTool({ name: "animation.get", arguments: { animationId: "player" } }); expect(missingAnimation.isError).toBe(true); expect(textOf(missingAnimation)).toMatch(/animation not found: player/); const validAnimation = jsonOf( textOf(await client.callTool({ name: "animation.validate", arguments: { def: { id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player", loop: true }, { id: "walk", spriteRef: "player-walk", fps: 8 }, ], }, }, })), ); expect(validAnimation).toEqual({ id: "player", initial: "idle", states: [ { id: "idle", spriteRef: "player", loop: true }, { id: "walk", spriteRef: "player-walk", fps: 8 }, ], }); const badAnimation = await client.callTool({ name: "animation.validate", arguments: { def: { id: "bad", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] } }, }); expect(badAnimation.isError).toBe(true); expect(textOf(badAnimation)).toMatch(/initial state must be a declared state/); const animationCatalog = jsonOf(contentsOf(await client.readResource({ uri: "animation://catalog" }))); expect(animationCatalog).toEqual({ animations: [] }); await expect(client.readResource({ uri: "animation://missing" })).rejects.toThrow(/animation not found: missing/); const animationPrompt = await client.getPrompt({ name: "animation.create-prompt", arguments: {} }); expect(String(animationPrompt.messages[0]?.content.text)).toContain("content/animations/.yaml"); } finally { await close(); } }); it("particle.* and shader.*: lifecycle validate + empty catalogs over the root content dir (no writes)", async () => { const { client, close } = await setup(); try { const particleList = jsonOf(textOf(await client.callTool({ name: "particle.list", arguments: {} }))); expect(particleList).toEqual({ particles: [] }); const missingParticle = await client.callTool({ name: "particle.get", arguments: { particleId: "dust" } }); expect(missingParticle.isError).toBe(true); expect(textOf(missingParticle)).toMatch(/particle not found: dust/); const validParticle = jsonOf( textOf(await client.callTool({ name: "particle.validate", arguments: { def: { id: "dust", colors: ["#d7ccc8"] } } })), ); expect(validParticle).toEqual({ id: "dust", colors: ["#d7ccc8"] }); const badParticle = await client.callTool({ name: "particle.validate", arguments: { def: { id: "bad", colors: ["green"] } }, }); expect(badParticle.isError).toBe(true); expect(textOf(badParticle)).toMatch(/colors/); const shaderList = jsonOf(textOf(await client.callTool({ name: "shader.list", arguments: {} }))); expect(shaderList).toEqual({ shaders: [] }); const missingShader = await client.callTool({ name: "shader.get", arguments: { shaderId: "rain-tint" } }); expect(missingShader.isError).toBe(true); expect(textOf(missingShader)).toMatch(/shader not found: rain-tint/); const validShader = jsonOf( textOf(await client.callTool({ name: "shader.validate", arguments: { def: { id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } } } })), ); expect(validShader).toEqual({ id: "rain-tint", filter: "grayscale", params: { factor: 0.7 } }); const badShader = await client.callTool({ name: "shader.validate", arguments: { def: { id: "bad", filter: "vignette" } } }); expect(badShader.isError).toBe(true); expect(textOf(badShader)).toMatch(/filter/); const particleCatalog = jsonOf(contentsOf(await client.readResource({ uri: "particle://catalog" }))); expect(particleCatalog).toEqual({ particles: [] }); const shaderCatalog = jsonOf(contentsOf(await client.readResource({ uri: "shader://catalog" }))); expect(shaderCatalog).toEqual({ shaders: [] }); await expect(client.readResource({ uri: "particle://missing" })).rejects.toThrow(/particle not found: missing/); await expect(client.readResource({ uri: "shader://missing" })).rejects.toThrow(/shader not found: missing/); const particlePrompt = await client.getPrompt({ name: "particle.create-prompt", arguments: {} }); expect(String(particlePrompt.messages[0]?.content.text)).toContain("content/particles/.yaml"); const shaderPrompt = await client.getPrompt({ name: "shader.create-prompt", arguments: {} }); expect(String(shaderPrompt.messages[0]?.content.text)).toContain("content/shaders/.yaml"); } finally { await close(); } }); });