|
|
@@ -0,0 +1,1435 @@
|
|
|
+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<typeof createEngine>;
|
|
|
+ client: Client;
|
|
|
+ close: () => Promise<void>;
|
|
|
+}
|
|
|
+
|
|
|
+async function setup(): Promise<Harness> {
|
|
|
+ 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<string, any> {
|
|
|
+ return JSON.parse(text) as Record<string, any>;
|
|
|
+}
|
|
|
+
|
|
|
+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 116 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",
|
|
|
+ "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.get",
|
|
|
+ "item.give",
|
|
|
+ "item.list",
|
|
|
+ "item.query_inventory",
|
|
|
+ "item.update",
|
|
|
+ "item.validate",
|
|
|
+ "map.get",
|
|
|
+ "map.list",
|
|
|
+ "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",
|
|
|
+ "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(116);
|
|
|
+ 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",
|
|
|
+ "crop",
|
|
|
+ "dialogue",
|
|
|
+ "item",
|
|
|
+ "map",
|
|
|
+ "mod",
|
|
|
+ "npc",
|
|
|
+ "particle",
|
|
|
+ "placement",
|
|
|
+ "player",
|
|
|
+ "rng",
|
|
|
+ "save",
|
|
|
+ "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",
|
|
|
+ "crop",
|
|
|
+ "dialogue",
|
|
|
+ "item",
|
|
|
+ "map",
|
|
|
+ "mod",
|
|
|
+ "npc",
|
|
|
+ "particle",
|
|
|
+ "placement",
|
|
|
+ "player",
|
|
|
+ "rng",
|
|
|
+ "save",
|
|
|
+ "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<InventorySystem>("inventory");
|
|
|
+ inv!.give("parsnip-seed", 5);
|
|
|
+ const planting = engine.systems.get<PlantingSystem>("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<InventorySystem>("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<InventorySystem>("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<TimeSystem>("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<InventorySystem>("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/<id>.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<TimeSystem>("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/<id>.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<InventorySystem>("inventory");
|
|
|
+ inv!.give("parsnip-seed", 5);
|
|
|
+ const planting = engine.systems.get<PlantingSystem>("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: 34 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", "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: 15 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",
|
|
|
+ "particle.create-prompt",
|
|
|
+ "placement.create-prompt",
|
|
|
+ "save.authoring-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/<id>.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/<id>.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/<id>.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/<id>.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<InventorySystem>("inventory");
|
|
|
+ inv!.give("parsnip-seed", 4);
|
|
|
+ const planting = engine.systems.get<PlantingSystem>("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<string, number> }[];
|
|
|
+ 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/<id>.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/<id>.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<InventorySystem>("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/<id>.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/<id>.yaml");
|
|
|
+ const shaderPrompt = await client.getPrompt({ name: "shader.create-prompt", arguments: {} });
|
|
|
+ expect(String(shaderPrompt.messages[0]?.content.text)).toContain("content/shaders/<id>.yaml");
|
|
|
+ } finally {
|
|
|
+ await close();
|
|
|
+ }
|
|
|
+ });
|
|
|
+});
|