import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type IntentSystem, type InventorySystem, type PlantingSystem } from "@nengine/core"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { DEFAULT_DIRS, resolveDirs } from "../src/index.js"; import { createNengineMcpServer, createGateway } from "../src/server.js"; const repoRoot = resolve(process.cwd(), "../.."); const sampleGameDir = resolve(repoRoot, "examples", "sample-game"); const contentDir = resolve(sampleGameDir, "content"); const modsDir = resolve(sampleGameDir, "mods"); interface Harness { engine: ReturnType; client: Client; close: () => Promise; } async function setup(): Promise { const dirs = resolveDirs({ argv: ["--content-dir", contentDir, "--mods-dir", modsDir], env: {}, cwd: repoRoot }); const engine = createEngine(dirs); 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: "sample-game-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 jsonOf(text: string): Record { return JSON.parse(text) as Record; } async function callTool(client: Client, name: string, arguments_: Record): Promise> { const result = await client.callTool({ name, arguments: arguments_ }); if (result.isError) { throw new Error(`tool ${name} failed: ${textOf(result)}`); } return jsonOf(textOf(result)); } describe("apps/mcp-server resolveDirs (parameterized entry)", () => { it("defaults to the monorepo content/mods when nothing is provided", () => { expect(resolveDirs({ argv: [], env: {}, cwd: repoRoot })).toEqual(DEFAULT_DIRS); }); it("resolves CLI paths relative to cwd", () => { const dirs = resolveDirs({ argv: ["--content-dir", "examples/sample-game/content", "--mods-dir", "examples/sample-game/mods"], env: {}, cwd: repoRoot, }); expect(dirs.contentDir).toBe(contentDir); expect(dirs.modsDir).toBe(modsDir); }); it("reads env vars and lets CLI win over env, env win over default", () => { const envDirs = resolveDirs({ argv: [], env: { NENGINE_CONTENT_DIR: "env-content", NENGINE_MODS_DIR: "env-mods" }, cwd: repoRoot, }); expect(envDirs.contentDir).toBe(resolve(repoRoot, "env-content")); expect(envDirs.modsDir).toBe(resolve(repoRoot, "env-mods")); const cliWins = resolveDirs({ argv: ["--content-dir", "cli-content"], env: { NENGINE_CONTENT_DIR: "env-content" }, cwd: repoRoot, }); expect(cliWins.contentDir).toBe(resolve(repoRoot, "cli-content")); expect(cliWins.modsDir).toBe(DEFAULT_DIRS.modsDir); }); it("leaves absolute CLI paths untouched", () => { const dirs = resolveDirs({ argv: ["--content-dir", "/abs/content"], env: {}, cwd: repoRoot }); expect(dirs.contentDir).toBe("/abs/content"); }); it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => { const viaCli = resolveDirs({ argv: ["--ws-port", "8787"], env: {}, cwd: repoRoot }); expect(viaCli.wsPort).toBe(8787); const viaEnv = resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot }); expect(viaEnv.wsPort).toBe(9000); const cliWins = resolveDirs({ argv: ["--ws-port", "8787"], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot }); expect(cliWins.wsPort).toBe(8787); const unset = resolveDirs({ argv: [], env: {}, cwd: repoRoot }); expect(unset.wsPort).toBeUndefined(); expect(resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "" }, cwd: repoRoot }).wsPort).toBeUndefined(); }); it("rejects invalid ws port values", () => { for (const bad of ["abc", "0", "-1", "8787.5", "70000"]) { expect(() => resolveDirs({ argv: ["--ws-port", bad], env: {}, cwd: repoRoot })).toThrow(/invalid ws port/); } }); }); describe("examples/sample-game end-to-end through the parameterized entry", () => { it("loads every content kind and server.info reflects the sample game", async () => { const { client, close } = await setup(); try { const info = await callTool(client, "server.info", {}); expect(info.content).toEqual({ animal: ["chicken"], animation: ["player"], behavior: ["starter-gift"], building: ["coop", "fence", "shop"], crop: ["carrot", "parsnip"], dialogue: ["mayor"], interact: ["default"], map: ["farm"], time: ["default"], item: ["carrot-seed", "carrot", "egg", "fish", "hoe", "parsnip-seed", "parsnip", "watering-can"], particle: ["dust", "rain", "sparkle", "wind"], shader: ["rain-tint", "sparkle-glow"], shop: ["general-store"], sound: ["coin", "harvest", "till", "water"], sprite: ["carrot-grown", "carrot-mature", "carrot-sprout", "chicken-chick", "chicken-grown", "chicken-laying", "coop", "fence", "parsnip-grown", "parsnip-mature", "parsnip-sprout", "player-walk", "player", "shop"], statemachine: ["carrot-quest", "chicken-egg", "daily-stamina", "fishing-treat", "mayor-routine", "mayor-talk-quest", "starter-quest"], weather: ["default"], }); expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 }); const map = await callTool(client, "map.get", { mapId: "farm" }); expect(map.id).toBe("farm"); expect(map.width).toBe(16); expect(map.height).toBe(16); } finally { await close(); } }); it("loads the sample-game binary assets into the engine asset table (D31)", async () => { const { engine, client, close } = await setup(); try { expect(engine.assets.size).toBe(21); expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" }); expect(engine.assets.get("water-1")).toMatchObject({ kind: "image", mime: "image/png" }); expect(engine.assets.get("tilled-1")).toMatchObject({ kind: "image", mime: "image/png" }); expect(engine.assets.get("plant-sprout")).toMatchObject({ kind: "image" }); expect(engine.assets.get("coop-1")).toMatchObject({ kind: "image" }); expect(engine.assets.get("fence-1")).toMatchObject({ kind: "image" }); for (const id of ["click-1", "confirm-1", "error-1", "drop-1", "pluck-1"]) { expect(engine.assets.get(id)).toMatchObject({ kind: "audio", mime: "audio/ogg" }); } const info = await callTool(client, "server.info", {}); expect(info.content.sound).toEqual(["coin", "harvest", "till", "water"]); } finally { await close(); } }); it("plays the full gameplay arc: gift, grow, harvest, sell, buy, build, chicken, egg, quest, dialogue", async () => { const { engine, client, close } = await setup(); try { const inv = engine.systems.get("inventory"); const planting = engine.systems.get("planting"); if (!inv || !planting) throw new Error("systems not booted"); const player = () => engine.world.get("player"); expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [] }); expect(inv.items()).toEqual({}); const day2 = await callTool(client, "time.advance", { ticks: 480 }); expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 }); expect(inv.items()).toEqual({ "parsnip-seed": 3, "carrot-seed": 2 }); expect(player()?.flag).toEqual({ "starter-gift": "given" }); expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [{ id: "npc:mayor", x: 6, y: 5 }] }); expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 }); expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({ machineId: "starter-quest", state: "active", started: true, }); const weather = await callTool(client, "weather.get", {}); expect(["sunny", "rain", "wind", "storm"]).toContain(weather.weatherId); const plots: [number, number][] = [ [6, 4], [7, 4], [8, 4], ]; for (const [x, y] of plots) { planting.till(x, y); planting.sow(x, y, "parsnip-seed"); await ensureWatered(engine, client, x, y); } await callTool(client, "time.tick", { ticks: 480 }); await callTool(client, "time.tick", { ticks: 480 }); for (const [x, y] of plots) { await ensureWatered(engine, client, x, y); } await callTool(client, "time.tick", { ticks: 480 }); await callTool(client, "time.tick", { ticks: 480 }); for (const [x, y] of plots) { expect(engine.world.get(`plot:${x}:${y}`)).toMatchObject({ stageIndex: 2 }); } for (const [x, y] of plots) { planting.harvest(x, y); } expect(inv.count("parsnip")).toBe(3); expect(inv.count("parsnip-seed")).toBe(2); expect(player()?.questProgress).toEqual({ "starter-quest": "complete" }); expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({ machineId: "starter-quest", state: "complete", started: true, }); expect( await callTool(client, "shop.sell", { shopId: "general-store", itemId: "parsnip", qty: 3 }), ).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 3, total: 90, gold: 90 }); expect( await callTool(client, "shop.buy", { shopId: "general-store", itemId: "carrot-seed", qty: 1 }), ).toEqual({ shopId: "general-store", itemId: "carrot-seed", qty: 1, total: 25, gold: 65 }); expect(await callTool(client, "placement.place", { buildingId: "coop", x: 10, y: 4 })).toMatchObject({ id: "building:coop:10:4", type: "building", buildingId: "coop", x: 10, y: 4, w: 3, h: 3, blocksMovement: true, }); expect(await callTool(client, "placement.place", { buildingId: "fence", x: 9, y: 8 })).toMatchObject({ id: "building:fence:9:8", }); expect(await callTool(client, "animal.spawn", { animalId: "chicken", x: 9, y: 3 })).toMatchObject({ id: "animal:chicken:1", type: "animal", animalId: "chicken", stageIndex: 0, }); expect(await callTool(client, "animal.stage", { id: "animal:chicken:1", stageIndex: 2 })).toMatchObject({ stageIndex: 2, stage: "laying", }); expect(await callTool(client, "animal.feed", { id: "animal:chicken:1" })).toEqual({ id: "animal:chicken:1", animalId: "chicken", fed: true, }); await callTool(client, "time.tick", { ticks: 480 }); expect((await callTool(client, "item.query_inventory", {})).inventory.egg).toBe(1); expect(engine.world.get("mod:sample-stats")).toMatchObject({ greetings: 6 }); expect(await callTool(client, "npc.get", { npcId: "npc:mayor" })).toMatchObject({ id: "npc:mayor", type: "npc", name: "Mayor", x: 6, y: 6, }); const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" }); expect(started.nodeId).toBe("greeting"); expect(started.choices.map((choice: { text: string }) => choice.text)).toEqual([ "Any farming tips?", "Tell me about chickens.", "Got anything for me?", "I am just passing through.", ]); const rewarded = await callTool(client, "dialogue.choose", { index: 2 }); expect(rewarded.nodeId).toBe("quest-reward"); expect(inv.count("egg")).toBe(3); const farewell = await callTool(client, "dialogue.choose", { index: 0 }); expect(farewell.nodeId).toBe("farewell"); expect(farewell.choices).toEqual([]); } finally { await close(); } }); }); describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills, fishing minigame", () => { it("chains the quests: starter-quest complete → carrot-quest active → harvest carrots → complete + reward", async () => { const { engine, client, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); const player = () => engine.world.get("player"); await callTool(client, "time.advance", { ticks: 480 }); // day 2: starter gift + starter-quest active await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); expect(player()?.questProgress).toEqual({ "starter-quest": "complete" }); await callTool(client, "time.tick", { ticks: 480 }); // next day-start activates carrot-quest expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({ machineId: "carrot-quest", state: "active", started: true, }); const carrotSeedBefore = inv.count("carrot-seed"); await growAndHarvest(engine, client, [[6, 4]], "carrot-seed", 2, 3); expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete" }); expect(inv.count("carrot")).toBe(1); expect(inv.count("carrot-seed")).toBe(carrotSeedBefore + 1); // -1 sown, +2 quest reward expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({ machineId: "carrot-quest", state: "complete", started: true, }); } finally { await close(); } }); it("completes mayor-talk-quest by finishing the mayor dialogue and gates a new dialogue branch", async () => { const { engine, client, close } = await setup(); try { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); const player = () => engine.world.get("player"); await callTool(client, "time.advance", { ticks: 480 }); await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); // starter-quest complete await callTool(client, "time.tick", { ticks: 480 }); // carrot-quest active await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3); // carrot-quest complete await callTool(client, "time.tick", { ticks: 480 }); // mayor-talk-quest active expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({ machineId: "mayor-talk-quest", state: "active", started: true, }); const eggBefore = inv.count("egg"); const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" }); expect(started.choices.map((choice: { text: string }) => choice.text)).not.toContain("Tell me about fishing."); await callTool(client, "dialogue.choose", { index: started.choices.length - 1 }); // farewell → dialogue-end expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete", "mayor-talk-quest": "complete", }); expect(inv.count("egg")).toBe(eggBefore + 1); expect(player()?.gold).toBe(100); expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({ machineId: "mayor-talk-quest", state: "complete", started: true, }); const again = await callTool(client, "dialogue.start", { dialogueId: "mayor" }); expect(again.choices.map((choice: { text: string }) => choice.text)).toContain("Tell me about fishing."); } finally { await close(); } }); it("fishes at the pond via IntentSystem: fish gained, stamina drained, skill incremented, mod event + treat reward", async () => { const { engine, client, close } = await setup(); try { const inv = engine.systems.get("inventory"); const intent = engine.systems.get("intent"); if (!inv || !intent) throw new Error("systems not booted"); const player = () => engine.world.get("player"); const caught: unknown[] = []; engine.events.on("mod:fish-caught", (payload) => caught.push(payload)); const tired: unknown[] = []; engine.events.on("mod:fish-too-tired", (payload) => tired.push(payload)); expect(player()).toMatchObject({ stamina: 100, skills: {} }); await callTool(client, "time.advance", { ticks: 480 }); // day 2: daily-stamina machine restores to ready expect(player()?.stamina).toBe(100); expect(intent.handle("move", { tile: { x: 3, y: 3 } })).toMatchObject({ ok: true }); for (let i = 0; i < 3; i++) { expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ action: "interact", tile: { x: 3, y: 2 }, ok: true, }); } expect(inv.count("fish")).toBe(3); expect(player()).toMatchObject({ stamina: 85, skills: { fishing: 3 } }); expect(caught).toHaveLength(3); expect(caught[0]).toMatchObject({ fish: "fish", size: 1, x: 3, y: 2, stamina: 95 }); expect(caught[2]).toMatchObject({ fish: "fish", size: 3, catches: 3, stamina: 85 }); expect(inv.count("egg")).toBe(1); // every third catch: fishing-treat machine matches mod:fish-caught expect(engine.world.get("plot:3:2")).toBeUndefined(); // the Mod normalizes the default till-on-water (player() as { stamina: number }).stamina = 3; expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ ok: true }); expect(inv.count("fish")).toBe(3); expect(player()).toMatchObject({ stamina: 3, skills: { fishing: 3 } }); expect(tired).toHaveLength(1); expect(tired[0]).toMatchObject({ x: 3, y: 2, stamina: 3 }); expect(await callTool(client, "behavior.statemachine.status", { machineId: "daily-stamina" })).toEqual({ machineId: "daily-stamina", state: "low", started: true, }); } finally { await close(); } }); }); async function ensureWatered( engine: ReturnType, client: Client, x: number, y: number, ): Promise { const plot = engine.world.get(`plot:${x}:${y}`) as { watered?: boolean } | undefined; if (!plot || plot.watered) return; await callTool(client, "crop.water", { x, y }); } async function growAndHarvest( engine: ReturnType, client: Client, plots: [number, number][], seedItemId: string, sproutDays: number, grownDays: number, ): Promise { const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); for (const [x, y] of plots) { planting.till(x, y); planting.sow(x, y, seedItemId); await ensureWatered(engine, client, x, y); } await callTool(client, "time.tick", { ticks: 480 * sproutDays }); for (const [x, y] of plots) { await ensureWatered(engine, client, x, y); } await callTool(client, "time.tick", { ticks: 480 * grownDays }); for (const [x, y] of plots) { planting.harvest(x, y); } }