import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, type CropDef, type InventorySystem, type PlantingSystem, } from "@nengine/core"; import { createEngineNamespaces, McpGateway } from "../src/index.js"; const TIME_YAML = [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 28 }", "start: { day: 1, season: spring }", "", ].join("\n"); const FARM_YAML = [ "id: farm", "width: 2", "height: 2", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [a, b]", " - [b, a]", "", ].join("\n"); const PARSNIP_DEF = { 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 }], }; interface Setup { engine: ReturnType; gateway: McpGateway; tmp: string; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-crop-")); 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(); return { engine, gateway, tmp, 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 ?? "" }; } async function plantParsnip( engine: ReturnType, ): Promise { engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); inv.give("parsnip-seed", 5); const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); planting.till(3, 3); planting.sow(3, 3, "parsnip-seed"); } describe("crop namespace", () => { it("crop.create writes content/crops/.yaml, registers live, and persists across restart", async () => { const { engine, gateway, tmp, close } = await setup(); try { const created = await callText(gateway, "crop.create", { def: PARSNIP_DEF }); expect(created.ok).toBe(true); expect(JSON.parse(created.text) as CropDef).toEqual(PARSNIP_DEF); expect(engine.registry.get("crop", "parsnip")).toEqual(PARSNIP_DEF); const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8"); expect(file).toContain("id: parsnip"); expect(file).toContain("seedItem: parsnip-seed"); expect(file).toContain("waterRequired: true"); await engine.stop(); const reloaded = createEngine({ contentDir: tmp }); await reloaded.start(); try { expect(reloaded.registry.get("crop", "parsnip")).toEqual(PARSNIP_DEF); } finally { await reloaded.stop(); } } finally { await close(); } }); it("crop.validate accepts a valid def and rejects an invalid one", async () => { const { gateway, close } = await setup(); try { const valid = await callText(gateway, "crop.validate", { def: PARSNIP_DEF }); expect(valid.ok).toBe(true); expect(JSON.parse(valid.text)).toEqual(PARSNIP_DEF); const invalid = await callText(gateway, "crop.validate", { def: { id: "nope", seedItem: "x-seed", stages: [], seasons: ["spring"], drops: [] }, }); expect(invalid.ok).toBe(false); expect(invalid.text).toMatch(/stages/); expect(invalid.text).toMatch(/at least 1 element/); } finally { await close(); } }); it("crop.create rejects an invalid def and does not write or register", async () => { const { engine, gateway, tmp, close } = await setup(); try { const bad = await callText(gateway, "crop.create", { def: { id: "broken", seedItem: "s", stages: [{ name: "x", days: 0, waterRequired: true }], seasons: [], drops: [] }, }); expect(bad.ok).toBe(false); expect(engine.registry.get("crop", "broken")).toBeUndefined(); await expect(readFile(join(tmp, "crops", "broken.yaml"))).rejects.toThrow(); } finally { await close(); } }); it("duplicate crop.create overwrites the existing definition", async () => { const { engine, gateway, tmp, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const recreated = await callText(gateway, "crop.create", { def: { ...PARSNIP_DEF, id: "parsnip", stages: [...PARSNIP_DEF.stages, { name: "bloom", days: 1, waterRequired: false }] }, }); expect(recreated.ok).toBe(true); const current = engine.registry.get("crop", "parsnip"); expect(current?.stages).toHaveLength(4); const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8"); expect(file).toContain("name: bloom"); } finally { await close(); } }); it("crop.update merges a patch, validates, rewrites the file, and re-registers", async () => { const { engine, gateway, tmp, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const updated = await callText(gateway, "crop.update", { id: "parsnip", patch: { seasons: ["spring", "summer"] }, }); expect(updated.ok).toBe(true); const def = JSON.parse(updated.text) as CropDef; expect(def.seasons).toEqual(["spring", "summer"]); expect(def.seedItem).toBe("parsnip-seed"); const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8"); expect(file).toContain("- summer"); const missing = await callText(gateway, "crop.update", { id: "nope", patch: { seasons: ["spring"] } }); expect(missing.ok).toBe(false); expect(missing.text).toMatch(/crop not found: nope/); const badPatch = await callText(gateway, "crop.update", { id: "parsnip", patch: { stages: [] } }); expect(badPatch.ok).toBe(false); expect(badPatch.text).toMatch(/stages/); expect(engine.registry.get("crop", "parsnip")?.seasons).toEqual(["spring", "summer"]); } finally { await close(); } }); it("crop.list returns one-line summaries", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const listed = await callText(gateway, "crop.list"); expect(listed.ok).toBe(true); expect(JSON.parse(listed.text)).toEqual({ crops: [{ id: "parsnip", seedItem: "parsnip-seed", stageCount: 3 }], }); } finally { await close(); } }); it("crop.get returns the full def; unknown id is an error result", async () => { const { gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const got = await callText(gateway, "crop.get", { cropId: "parsnip" }); expect(got.ok).toBe(true); expect(JSON.parse(got.text)).toEqual(PARSNIP_DEF); const missing = await callText(gateway, "crop.get", { cropId: "nope" }); expect(missing.ok).toBe(false); expect(missing.text).toMatch(/crop not found: nope/); } finally { await close(); } }); it("crop.water sets the watered state (mirrors PlantingSystem.water) and returns plot info", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); await plantParsnip(engine); const watered = await callText(gateway, "crop.water", { x: 3, y: 3 }); expect(watered.ok).toBe(true); expect(JSON.parse(watered.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, watered: true }); expect(engine.world.get("plot:3:3")).toMatchObject({ watered: true }); const stillWatered = await callText(gateway, "crop.water", { plotId: "plot:3:3" }); expect(stillWatered.ok).toBe(true); expect(JSON.parse(stillWatered.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, watered: true }); const planting = engine.systems.get("planting"); planting!.till(6, 6); const fresh = await callText(gateway, "crop.water", { x: 6, y: 6 }); expect(fresh.ok).toBe(true); expect(JSON.parse(fresh.text)).toEqual({ plotId: "plot:6:6", x: 6, y: 6, watered: true }); } finally { await close(); } }); it("crop.water on a missing or untilled plot is an error result", async () => { const { engine, gateway, close } = await setup(); try { const missing = await callText(gateway, "crop.water", { x: 9, y: 9 }); expect(missing.ok).toBe(false); expect(missing.text).toMatch(/no plot at \(9,9\)/); engine.world.create({ id: "plot:0:0", type: "plot", x: 0, y: 0, tilled: false, watered: false }); const untilled = await callText(gateway, "crop.water", { x: 0, y: 0 }); expect(untilled.ok).toBe(false); expect(untilled.text).toMatch(/not tilled/); } finally { await close(); } }); it("crop.stage forces a growth stage, fires the growth hook, and returns stage info", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); await plantParsnip(engine); const grew: number[] = []; engine.hooks.on("onCropGrow", (ctx) => { const plot = ctx.world.query({ type: "plot" })[0] as { stageIndex?: number } | undefined; grew.push(plot?.stageIndex ?? -1); }); const staged = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 2 }); expect(staged.ok).toBe(true); expect(JSON.parse(staged.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, cropItemId: "parsnip", stageIndex: 2, stage: "mature", }); expect(grew).toEqual([2]); const byPlotId = await callText(gateway, "crop.stage", { plotId: "plot:3:3", stageIndex: 0 }); expect(JSON.parse(byPlotId.text)).toMatchObject({ stageIndex: 0, stage: "sprout" }); } finally { await close(); } }); it("crop.stage error paths: no plot, no crop, out-of-range index", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const noPlot = await callText(gateway, "crop.stage", { x: 9, y: 9, stageIndex: 0 }); expect(noPlot.ok).toBe(false); expect(noPlot.text).toMatch(/no plot found/); const planting = engine.systems.get("planting"); planting!.till(5, 5); const noCrop = await callText(gateway, "crop.stage", { x: 5, y: 5, stageIndex: 0 }); expect(noCrop.ok).toBe(false); expect(noCrop.text).toMatch(/has no crop/); await plantParsnip(engine); const outOfRange = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 9 }); expect(outOfRange.ok).toBe(false); expect(outOfRange.text).toMatch(/out of range/); const badIndex = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: -1 }); expect(badIndex.ok).toBe(false); expect(badIndex.text).toMatch(/non-negative integer/); } finally { await close(); } }); it("crop.till creates a tilled plot, is idempotent, and accepts plotId", async () => { const { engine, gateway, close } = await setup(); try { const tilled = await callText(gateway, "crop.till", { x: 2, y: 2 }); expect(tilled.ok).toBe(true); expect(JSON.parse(tilled.text)).toEqual({ plotId: "plot:2:2", x: 2, y: 2, tilled: true }); expect(engine.world.get("plot:2:2")).toMatchObject({ id: "plot:2:2", type: "plot", x: 2, y: 2, tilled: true, watered: false, }); const again = await callText(gateway, "crop.till", { x: 2, y: 2 }); expect(again.ok).toBe(true); expect(JSON.parse(again.text)).toEqual({ plotId: "plot:2:2", x: 2, y: 2, tilled: true }); const byPlotId = await callText(gateway, "crop.till", { plotId: "plot:4:4" }); expect(byPlotId.ok).toBe(true); expect(JSON.parse(byPlotId.text)).toEqual({ plotId: "plot:4:4", x: 4, y: 4, tilled: true }); expect(engine.world.get("plot:4:4")).toMatchObject({ tilled: true }); } finally { await close(); } }); it("crop.till error paths: missing coords/plotId, non-integer coords", async () => { const { gateway, close } = await setup(); try { const none = await callText(gateway, "crop.till", {}); expect(none.ok).toBe(false); expect(none.text).toMatch(/provide plotId or x\/y coordinates/); const nonInteger = await callText(gateway, "crop.till", { x: 1.5, y: 2 }); expect(nonInteger.ok).toBe(false); expect(nonInteger.text).toMatch(/provide plotId or x\/y coordinates/); const negative = await callText(gateway, "crop.till", { x: -1, y: 2 }); expect(negative.ok).toBe(false); expect(negative.text).toMatch(/non-negative integers/); } finally { await close(); } }); it("crop.sow consumes a seed, sets cropItemId, and accepts plotId", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); await callText(gateway, "crop.till", { x: 3, y: 3 }); const sown = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" }); expect(sown.ok).toBe(true); expect(JSON.parse(sown.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, cropItemId: "parsnip", seedItemId: "parsnip-seed", }); expect(inv!.count("parsnip-seed")).toBe(4); expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip", stageIndex: 0 }); await callText(gateway, "crop.till", { plotId: "plot:5:5" }); const byPlotId = await callText(gateway, "crop.sow", { plotId: "plot:5:5", seedItemId: "parsnip-seed" }); expect(byPlotId.ok).toBe(true); expect(JSON.parse(byPlotId.text)).toEqual({ plotId: "plot:5:5", x: 5, y: 5, cropItemId: "parsnip", seedItemId: "parsnip-seed", }); expect(inv!.count("parsnip-seed")).toBe(3); } finally { await close(); } }); it("crop.sow error paths: untilled plot, no seed, unknown seed, not-a-seed, missing seedItemId, already-cropped", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); const inv = engine.systems.get("inventory"); const missingArg = await callText(gateway, "crop.sow", { x: 3, y: 3 }); expect(missingArg.ok).toBe(false); expect(missingArg.text).toMatch(/missing required arguments: seedItemId/); const blankArg = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "" }); expect(blankArg.ok).toBe(false); expect(blankArg.text).toMatch(/seedItemId must be a non-empty string/); const noPlot = await callText(gateway, "crop.sow", { x: 8, y: 8, seedItemId: "parsnip-seed" }); expect(noPlot.ok).toBe(false); expect(noPlot.text).toMatch(/no plot at \(8,8\)/); engine.world.create({ id: "plot:3:3", type: "plot", x: 3, y: 3, tilled: false, watered: false }); const untilled = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" }); expect(untilled.ok).toBe(false); expect(untilled.text).toMatch(/not tilled/); await callText(gateway, "crop.till", { x: 4, y: 4 }); const noSeed = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" }); expect(noSeed.ok).toBe(false); expect(noSeed.text).toMatch(/no parsnip-seed in inventory/); inv!.give("parsnip-seed", 2); const unknownSeed = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "nope-seed" }); expect(unknownSeed.ok).toBe(false); expect(unknownSeed.text).toMatch(/unknown item: nope-seed/); const wrongKind = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip" }); expect(wrongKind.ok).toBe(false); expect(wrongKind.text).toMatch(/is not a seed/); const sown = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" }); expect(sown.ok).toBe(true); const again = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" }); expect(again.ok).toBe(false); expect(again.text).toMatch(/already has a crop/); } finally { await close(); } }); it("crop.harvest rolls drops into the inventory, emits crop-harvest, and clears the crop", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); await callText(gateway, "crop.till", { x: 3, y: 3 }); await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" }); await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 2 }); let hookFired = 0; engine.hooks.on("onCropHarvest", () => { hookFired += 1; }); const result = await callText(gateway, "crop.harvest", { x: 3, y: 3 }); expect(result.ok).toBe(true); expect(JSON.parse(result.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, cropItemId: "parsnip", drops: [{ itemId: "parsnip", qty: 1 }], }); expect(inv!.count("parsnip")).toBe(1); const plot = engine.world.get("plot:3:3") as { cropItemId?: string; stageIndex?: number } | undefined; expect(plot?.cropItemId).toBeUndefined(); expect(plot?.stageIndex).toBeUndefined(); expect(hookFired).toBe(1); } finally { await close(); } }); it("crop.harvest error paths: empty plot, immature crop, missing coords", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true }); const inv = engine.systems.get("inventory"); inv!.give("parsnip-seed", 5); const noCoords = await callText(gateway, "crop.harvest", {}); expect(noCoords.ok).toBe(false); expect(noCoords.text).toMatch(/provide plotId or x\/y coordinates/); const noPlot = await callText(gateway, "crop.harvest", { x: 9, y: 9 }); expect(noPlot.ok).toBe(false); expect(noPlot.text).toMatch(/no plot at \(9,9\)/); await callText(gateway, "crop.till", { x: 2, y: 2 }); const empty = await callText(gateway, "crop.harvest", { x: 2, y: 2 }); expect(empty.ok).toBe(false); expect(empty.text).toMatch(/has no crop/); await callText(gateway, "crop.sow", { x: 2, y: 2, seedItemId: "parsnip-seed" }); const immature = await callText(gateway, "crop.harvest", { x: 2, y: 2 }); expect(immature.ok).toBe(false); expect(immature.text).toMatch(/is not mature/); } finally { await close(); } }); it("resources: crop://catalog and crop://{id}; missing id rejects", async () => { const { engine, gateway, close } = await setup(); try { await callText(gateway, "crop.create", { def: PARSNIP_DEF }); const catalog = await gateway.readResource("crop://catalog"); expect(JSON.parse(catalog.contents[0]?.text ?? "{}")).toEqual({ crops: [PARSNIP_DEF] }); const single = await gateway.readResource("crop://parsnip"); expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual(PARSNIP_DEF); await expect(gateway.readResource("crop://missing")).rejects.toThrow(/crop not found: missing/); expect(engine.registry.get("crop", "missing")).toBeUndefined(); } finally { await close(); } }); it("serves the crop.create-prompt prompt", async () => { const { gateway, close } = await setup(); try { const prompt = await gateway.getPrompt("crop.create-prompt", {}); const text = prompt.messages[0]?.content.text ?? ""; expect(text).toContain("stages"); expect(text).toContain("waterRequired"); expect(text).toContain("seasons"); expect(text).toContain("drops"); expect(text).toContain("crop.validate"); expect(text).toContain("content/crops/.yaml"); } finally { await close(); } }); });