import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type CropDef, type DialogueDef, type ItemDef, type PlantingSystem, type ShopDef } from "@nengine/core"; import { createEngineNamespaces, McpGateway } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 5", "height: 5", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass, water, water, water]", " - [grass, grass, water, water, water]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", "", ].join("\n"); const PARSNIP_SEED: ItemDef = { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }; const PARSNIP: ItemDef = { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }; const PARSNIP_CROP: CropDef = { id: "parsnip", seedItem: "parsnip-seed", stages: [ { name: "sprout", days: 1, waterRequired: true }, { name: "mature", days: 1, waterRequired: false }, ], seasons: ["spring"], drops: [{ itemId: "parsnip", qty: 1 }], }; const GENERAL_STORE: ShopDef = { id: "general-store", buys: [{ itemId: "parsnip", price: 30 }], sells: [{ itemId: "parsnip-seed", price: 20, stock: 99 }], }; const WELCOME: DialogueDef = { id: "welcome", startNode: "hello", nodes: [ { id: "hello", text: "Welcome!", choices: [{ text: "Thanks", to: "farewell" }] }, { id: "farewell", text: "See ya" }, ], }; interface Setup { engine: ReturnType; gateway: McpGateway; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-player-ns-")); await mkdir(join(tmp, "maps"), { recursive: true }); await writeFile(join(tmp, "time.yaml"), TIME_YAML); await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML); const engine = createEngine({ contentDir: tmp }); const gateway = new McpGateway(); for (const namespace of createEngineNamespaces(engine, { gateway })) { gateway.register(namespace); } await engine.start(); engine.registry.register("item", PARSNIP_SEED); engine.registry.register("item", PARSNIP); engine.registry.register("crop", PARSNIP_CROP); engine.registry.register("shop", GENERAL_STORE); engine.registry.register("dialogue", WELCOME); return { engine, gateway, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } async function callText(gateway: McpGateway, name: string, args: Record = {}): Promise<{ ok: boolean; text: string; }> { const result = await gateway.callTool(name, args); return { ok: result.isError !== true, text: result.content[0]?.text ?? "" }; } describe("player namespace", () => { it("player.intent move relocates the player entity", async () => { const { engine, gateway, close } = await setup(); try { const moved = await callText(gateway, "player.intent", { action: "move", tile: { x: 3, y: 2 } }); expect(moved.ok).toBe(true); expect(JSON.parse(moved.text)).toEqual({ action: "move", tile: { x: 3, y: 2 }, ok: true, detail: "moved to (3,2)" }); expect(engine.world.get("player")).toMatchObject({ x: 3, y: 2 }); } finally { await close(); } }); it("player.intent interact follows the priority order: till → sow → water → harvest", async () => { const { engine, gateway, close } = await setup(); try { const inv = engine.systems.get("inventory"); const growth = engine.systems.get("growth"); if (!inv || !growth) throw new Error("systems not booted"); const tilled = await callText(gateway, "player.intent", { action: "interact", tile: { x: 2, y: 2 } }); expect(JSON.parse(tilled.text)).toMatchObject({ ok: true, detail: "tilled plot" }); inv.give("parsnip-seed", 1); const sown = await callText(gateway, "player.intent", { action: "interact", tile: { x: 2, y: 2 } }); expect(JSON.parse(sown.text)).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" }); expect(inv.count("parsnip-seed")).toBe(0); const watered = await callText(gateway, "player.intent", { action: "interact", tile: { x: 2, y: 2 } }); expect(JSON.parse(watered.text)).toMatchObject({ ok: true, detail: "watered plot" }); const notReady = await callText(gateway, "player.intent", { action: "interact", tile: { x: 2, y: 2 } }); expect(notReady.ok).toBe(false); expect(notReady.text).toMatch(/player\.intent: crop parsnip is not ready to harvest/); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); growth.forceStage("plot:2:2", 1); const harvested = await callText(gateway, "player.intent", { action: "interact", tile: { x: 2, y: 2 } }); expect(JSON.parse(harvested.text)).toMatchObject({ ok: true, detail: "harvested parsnip" }); expect(inv.count("parsnip")).toBe(1); } finally { await close(); } }); it("player.intent validates action and tile arguments", async () => { const { gateway, close } = await setup(); try { const badAction = await callText(gateway, "player.intent", { action: "dance", tile: { x: 1, y: 1 } }); expect(badAction.ok).toBe(false); expect(badAction.text).toMatch(/action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/); const badTile = await callText(gateway, "player.intent", { action: "move", tile: { x: 1.5, y: 0 } }); expect(badTile.ok).toBe(false); expect(badTile.text).toMatch(/non-negative integers/); const water = await callText(gateway, "player.intent", { action: "move", tile: { x: 2, y: 0 } }); expect(water.ok).toBe(false); expect(water.text).toMatch(/player\.intent: intent\.move: tile \(2,0\) is not walkable/); } finally { await close(); } }); it("resource player://state reads the live player entity", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "player.intent", { action: "move", tile: { x: 4, y: 4 } }); const state = await gateway.readResource("player://state"); expect(JSON.parse(state.contents[0]?.text ?? "{}")).toMatchObject({ id: "player", type: "player", x: 4, y: 4 }); } finally { await close(); } }); it("player.* exposes no prompts (runtime-entity namespace)", async () => { const { gateway, close } = await setup(); try { const prompts = gateway.listPrompts().map((prompt) => prompt.name); expect(prompts.some((name) => name.startsWith("player."))).toBe(false); } finally { await close(); } }); it("player.intent buy/sell/talk/choose/select-seed drive the extended actions", async () => { const { engine, gateway, close } = await setup(); try { const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 100; const bought = await callText(gateway, "player.intent", { action: "buy", shopId: "general-store", itemId: "parsnip-seed", qty: 2, }); expect(bought.ok).toBe(true); expect(JSON.parse(bought.text)).toMatchObject({ action: "buy", ok: true, detail: { shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 }, }); const missingItem = await callText(gateway, "player.intent", { action: "buy", shopId: "general-store" }); expect(missingItem.ok).toBe(false); expect(missingItem.text).toMatch(/buy requires itemId/); engine.world.create({ id: "npc:clerk", type: "npc", x: 3, y: 3, dialogue: "welcome" }); const talked = await callText(gateway, "player.intent", { action: "talk", npcId: "npc:clerk" }); expect(talked.ok).toBe(true); expect(JSON.parse(talked.text)).toMatchObject({ action: "talk", ok: true, detail: { dialogueId: "welcome" } }); const chosen = await callText(gateway, "player.intent", { action: "choose", index: 0 }); expect(chosen.ok).toBe(true); expect(JSON.parse(chosen.text)).toMatchObject({ action: "choose", ok: true, detail: { nodeId: "farewell" } }); const selected = await callText(gateway, "player.intent", { action: "select-seed", seedItemId: "parsnip-seed" }); expect(selected.ok).toBe(true); expect(JSON.parse(selected.text)).toMatchObject({ action: "select-seed", ok: true, detail: { selectedSeed: "parsnip-seed" }, }); expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" }); const notASeed = await callText(gateway, "player.intent", { action: "select-seed", seedItemId: "hoe" }); expect(notASeed.ok).toBe(false); expect(notASeed.text).toMatch(/hoe is not a seed item/); } finally { await close(); } }); });