crop-namespace.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join, resolve } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. createEngine,
  7. type CropDef,
  8. type InventorySystem,
  9. type PlantingSystem,
  10. } from "@nengine/core";
  11. import { createEngineNamespaces, McpGateway } from "../src/index.js";
  12. const TIME_YAML = [
  13. "id: default",
  14. "dayLengthTicks: 480",
  15. "seasons:",
  16. " - { name: spring, dayCount: 28 }",
  17. "start: { day: 1, season: spring }",
  18. "",
  19. ].join("\n");
  20. const FARM_YAML = [
  21. "id: farm",
  22. "width: 2",
  23. "height: 2",
  24. 'tilesetRef: "tiles"',
  25. "spawn: { x: 0, y: 0 }",
  26. "layers:",
  27. " - name: ground",
  28. " data:",
  29. " - [a, b]",
  30. " - [b, a]",
  31. "",
  32. ].join("\n");
  33. const PARSNIP_DEF = {
  34. id: "parsnip",
  35. seedItem: "parsnip-seed",
  36. stages: [
  37. { name: "sprout", days: 2, waterRequired: true },
  38. { name: "grown", days: 2, waterRequired: true },
  39. { name: "mature", days: 1, waterRequired: false },
  40. ],
  41. seasons: ["spring"],
  42. drops: [{ itemId: "parsnip", qty: 1 }],
  43. };
  44. interface Setup {
  45. engine: ReturnType<typeof createEngine>;
  46. gateway: McpGateway;
  47. tmp: string;
  48. close: () => Promise<void>;
  49. }
  50. async function setup(): Promise<Setup> {
  51. const tmp = await mkdtemp(join(tmpdir(), "farm-crop-"));
  52. await mkdir(join(tmp, "maps"), { recursive: true });
  53. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  54. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  55. const engine = createEngine({ contentDir: tmp });
  56. const gateway = new McpGateway();
  57. for (const namespace of createEngineNamespaces(engine, { gateway })) {
  58. gateway.register(namespace);
  59. }
  60. await engine.start();
  61. return {
  62. engine,
  63. gateway,
  64. tmp,
  65. close: async () => {
  66. await engine.stop();
  67. await rm(tmp, { recursive: true, force: true });
  68. },
  69. };
  70. }
  71. async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
  72. ok: boolean;
  73. text: string;
  74. }> {
  75. const result = await gateway.callTool(name, args);
  76. return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
  77. }
  78. async function plantParsnip(
  79. engine: ReturnType<typeof createEngine>,
  80. ): Promise<void> {
  81. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  82. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  83. const inv = engine.systems.get<InventorySystem>("inventory");
  84. if (!inv) throw new Error("inventory system not booted");
  85. inv.give("parsnip-seed", 5);
  86. const planting = engine.systems.get<PlantingSystem>("planting");
  87. if (!planting) throw new Error("planting system not booted");
  88. planting.till(3, 3);
  89. planting.sow(3, 3, "parsnip-seed");
  90. }
  91. describe("crop namespace", () => {
  92. it("crop.create writes content/crops/<id>.yaml, registers live, and persists across restart", async () => {
  93. const { engine, gateway, tmp, close } = await setup();
  94. try {
  95. const created = await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  96. expect(created.ok).toBe(true);
  97. expect(JSON.parse(created.text) as CropDef).toEqual(PARSNIP_DEF);
  98. expect(engine.registry.get<CropDef>("crop", "parsnip")).toEqual(PARSNIP_DEF);
  99. const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8");
  100. expect(file).toContain("id: parsnip");
  101. expect(file).toContain("seedItem: parsnip-seed");
  102. expect(file).toContain("waterRequired: true");
  103. await engine.stop();
  104. const reloaded = createEngine({ contentDir: tmp });
  105. await reloaded.start();
  106. try {
  107. expect(reloaded.registry.get<CropDef>("crop", "parsnip")).toEqual(PARSNIP_DEF);
  108. } finally {
  109. await reloaded.stop();
  110. }
  111. } finally {
  112. await close();
  113. }
  114. });
  115. it("crop.validate accepts a valid def and rejects an invalid one", async () => {
  116. const { gateway, close } = await setup();
  117. try {
  118. const valid = await callText(gateway, "crop.validate", { def: PARSNIP_DEF });
  119. expect(valid.ok).toBe(true);
  120. expect(JSON.parse(valid.text)).toEqual(PARSNIP_DEF);
  121. const invalid = await callText(gateway, "crop.validate", {
  122. def: { id: "nope", seedItem: "x-seed", stages: [], seasons: ["spring"], drops: [] },
  123. });
  124. expect(invalid.ok).toBe(false);
  125. expect(invalid.text).toMatch(/stages/);
  126. expect(invalid.text).toMatch(/at least 1 element/);
  127. } finally {
  128. await close();
  129. }
  130. });
  131. it("crop.create rejects an invalid def and does not write or register", async () => {
  132. const { engine, gateway, tmp, close } = await setup();
  133. try {
  134. const bad = await callText(gateway, "crop.create", {
  135. def: { id: "broken", seedItem: "s", stages: [{ name: "x", days: 0, waterRequired: true }], seasons: [], drops: [] },
  136. });
  137. expect(bad.ok).toBe(false);
  138. expect(engine.registry.get("crop", "broken")).toBeUndefined();
  139. await expect(readFile(join(tmp, "crops", "broken.yaml"))).rejects.toThrow();
  140. } finally {
  141. await close();
  142. }
  143. });
  144. it("duplicate crop.create overwrites the existing definition", async () => {
  145. const { engine, gateway, tmp, close } = await setup();
  146. try {
  147. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  148. const recreated = await callText(gateway, "crop.create", {
  149. def: { ...PARSNIP_DEF, id: "parsnip", stages: [...PARSNIP_DEF.stages, { name: "bloom", days: 1, waterRequired: false }] },
  150. });
  151. expect(recreated.ok).toBe(true);
  152. const current = engine.registry.get<CropDef>("crop", "parsnip");
  153. expect(current?.stages).toHaveLength(4);
  154. const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8");
  155. expect(file).toContain("name: bloom");
  156. } finally {
  157. await close();
  158. }
  159. });
  160. it("crop.update merges a patch, validates, rewrites the file, and re-registers", async () => {
  161. const { engine, gateway, tmp, close } = await setup();
  162. try {
  163. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  164. const updated = await callText(gateway, "crop.update", {
  165. id: "parsnip",
  166. patch: { seasons: ["spring", "summer"] },
  167. });
  168. expect(updated.ok).toBe(true);
  169. const def = JSON.parse(updated.text) as CropDef;
  170. expect(def.seasons).toEqual(["spring", "summer"]);
  171. expect(def.seedItem).toBe("parsnip-seed");
  172. const file = await readFile(join(tmp, "crops", "parsnip.yaml"), "utf8");
  173. expect(file).toContain("- summer");
  174. const missing = await callText(gateway, "crop.update", { id: "nope", patch: { seasons: ["spring"] } });
  175. expect(missing.ok).toBe(false);
  176. expect(missing.text).toMatch(/crop not found: nope/);
  177. const badPatch = await callText(gateway, "crop.update", { id: "parsnip", patch: { stages: [] } });
  178. expect(badPatch.ok).toBe(false);
  179. expect(badPatch.text).toMatch(/stages/);
  180. expect(engine.registry.get<CropDef>("crop", "parsnip")?.seasons).toEqual(["spring", "summer"]);
  181. } finally {
  182. await close();
  183. }
  184. });
  185. it("crop.list returns one-line summaries", async () => {
  186. const { gateway, close } = await setup();
  187. try {
  188. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  189. const listed = await callText(gateway, "crop.list");
  190. expect(listed.ok).toBe(true);
  191. expect(JSON.parse(listed.text)).toEqual({
  192. crops: [{ id: "parsnip", seedItem: "parsnip-seed", stageCount: 3 }],
  193. });
  194. } finally {
  195. await close();
  196. }
  197. });
  198. it("crop.get returns the full def; unknown id is an error result", async () => {
  199. const { gateway, close } = await setup();
  200. try {
  201. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  202. const got = await callText(gateway, "crop.get", { cropId: "parsnip" });
  203. expect(got.ok).toBe(true);
  204. expect(JSON.parse(got.text)).toEqual(PARSNIP_DEF);
  205. const missing = await callText(gateway, "crop.get", { cropId: "nope" });
  206. expect(missing.ok).toBe(false);
  207. expect(missing.text).toMatch(/crop not found: nope/);
  208. } finally {
  209. await close();
  210. }
  211. });
  212. it("crop.water sets the watered state (mirrors PlantingSystem.water) and returns plot info", async () => {
  213. const { engine, gateway, close } = await setup();
  214. try {
  215. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  216. await plantParsnip(engine);
  217. const watered = await callText(gateway, "crop.water", { x: 3, y: 3 });
  218. expect(watered.ok).toBe(true);
  219. expect(JSON.parse(watered.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, watered: true });
  220. expect(engine.world.get("plot:3:3")).toMatchObject({ watered: true });
  221. const stillWatered = await callText(gateway, "crop.water", { plotId: "plot:3:3" });
  222. expect(stillWatered.ok).toBe(true);
  223. expect(JSON.parse(stillWatered.text)).toEqual({ plotId: "plot:3:3", x: 3, y: 3, watered: true });
  224. const planting = engine.systems.get<PlantingSystem>("planting");
  225. planting!.till(6, 6);
  226. const fresh = await callText(gateway, "crop.water", { x: 6, y: 6 });
  227. expect(fresh.ok).toBe(true);
  228. expect(JSON.parse(fresh.text)).toEqual({ plotId: "plot:6:6", x: 6, y: 6, watered: true });
  229. } finally {
  230. await close();
  231. }
  232. });
  233. it("crop.water on a missing or untilled plot is an error result", async () => {
  234. const { engine, gateway, close } = await setup();
  235. try {
  236. const missing = await callText(gateway, "crop.water", { x: 9, y: 9 });
  237. expect(missing.ok).toBe(false);
  238. expect(missing.text).toMatch(/no plot at \(9,9\)/);
  239. engine.world.create({ id: "plot:0:0", type: "plot", x: 0, y: 0, tilled: false, watered: false });
  240. const untilled = await callText(gateway, "crop.water", { x: 0, y: 0 });
  241. expect(untilled.ok).toBe(false);
  242. expect(untilled.text).toMatch(/not tilled/);
  243. } finally {
  244. await close();
  245. }
  246. });
  247. it("crop.stage forces a growth stage, fires the growth hook, and returns stage info", async () => {
  248. const { engine, gateway, close } = await setup();
  249. try {
  250. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  251. await plantParsnip(engine);
  252. const grew: number[] = [];
  253. engine.hooks.on("onCropGrow", (ctx) => {
  254. const plot = ctx.world.query({ type: "plot" })[0] as { stageIndex?: number } | undefined;
  255. grew.push(plot?.stageIndex ?? -1);
  256. });
  257. const staged = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 2 });
  258. expect(staged.ok).toBe(true);
  259. expect(JSON.parse(staged.text)).toEqual({
  260. plotId: "plot:3:3",
  261. x: 3,
  262. y: 3,
  263. cropItemId: "parsnip",
  264. stageIndex: 2,
  265. stage: "mature",
  266. });
  267. expect(grew).toEqual([2]);
  268. const byPlotId = await callText(gateway, "crop.stage", { plotId: "plot:3:3", stageIndex: 0 });
  269. expect(JSON.parse(byPlotId.text)).toMatchObject({ stageIndex: 0, stage: "sprout" });
  270. } finally {
  271. await close();
  272. }
  273. });
  274. it("crop.stage error paths: no plot, no crop, out-of-range index", async () => {
  275. const { engine, gateway, close } = await setup();
  276. try {
  277. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  278. const noPlot = await callText(gateway, "crop.stage", { x: 9, y: 9, stageIndex: 0 });
  279. expect(noPlot.ok).toBe(false);
  280. expect(noPlot.text).toMatch(/no plot found/);
  281. const planting = engine.systems.get<PlantingSystem>("planting");
  282. planting!.till(5, 5);
  283. const noCrop = await callText(gateway, "crop.stage", { x: 5, y: 5, stageIndex: 0 });
  284. expect(noCrop.ok).toBe(false);
  285. expect(noCrop.text).toMatch(/has no crop/);
  286. await plantParsnip(engine);
  287. const outOfRange = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 9 });
  288. expect(outOfRange.ok).toBe(false);
  289. expect(outOfRange.text).toMatch(/out of range/);
  290. const badIndex = await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: -1 });
  291. expect(badIndex.ok).toBe(false);
  292. expect(badIndex.text).toMatch(/non-negative integer/);
  293. } finally {
  294. await close();
  295. }
  296. });
  297. it("crop.till creates a tilled plot, is idempotent, and accepts plotId", async () => {
  298. const { engine, gateway, close } = await setup();
  299. try {
  300. const tilled = await callText(gateway, "crop.till", { x: 2, y: 2 });
  301. expect(tilled.ok).toBe(true);
  302. expect(JSON.parse(tilled.text)).toEqual({ plotId: "plot:2:2", x: 2, y: 2, tilled: true });
  303. expect(engine.world.get("plot:2:2")).toMatchObject({
  304. id: "plot:2:2",
  305. type: "plot",
  306. x: 2,
  307. y: 2,
  308. tilled: true,
  309. watered: false,
  310. });
  311. const again = await callText(gateway, "crop.till", { x: 2, y: 2 });
  312. expect(again.ok).toBe(true);
  313. expect(JSON.parse(again.text)).toEqual({ plotId: "plot:2:2", x: 2, y: 2, tilled: true });
  314. const byPlotId = await callText(gateway, "crop.till", { plotId: "plot:4:4" });
  315. expect(byPlotId.ok).toBe(true);
  316. expect(JSON.parse(byPlotId.text)).toEqual({ plotId: "plot:4:4", x: 4, y: 4, tilled: true });
  317. expect(engine.world.get("plot:4:4")).toMatchObject({ tilled: true });
  318. } finally {
  319. await close();
  320. }
  321. });
  322. it("crop.till error paths: missing coords/plotId, non-integer coords", async () => {
  323. const { gateway, close } = await setup();
  324. try {
  325. const none = await callText(gateway, "crop.till", {});
  326. expect(none.ok).toBe(false);
  327. expect(none.text).toMatch(/provide plotId or x\/y coordinates/);
  328. const nonInteger = await callText(gateway, "crop.till", { x: 1.5, y: 2 });
  329. expect(nonInteger.ok).toBe(false);
  330. expect(nonInteger.text).toMatch(/provide plotId or x\/y coordinates/);
  331. const negative = await callText(gateway, "crop.till", { x: -1, y: 2 });
  332. expect(negative.ok).toBe(false);
  333. expect(negative.text).toMatch(/non-negative integers/);
  334. } finally {
  335. await close();
  336. }
  337. });
  338. it("crop.sow consumes a seed, sets cropItemId, and accepts plotId", async () => {
  339. const { engine, gateway, close } = await setup();
  340. try {
  341. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  342. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  343. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  344. const inv = engine.systems.get<InventorySystem>("inventory");
  345. inv!.give("parsnip-seed", 5);
  346. await callText(gateway, "crop.till", { x: 3, y: 3 });
  347. const sown = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" });
  348. expect(sown.ok).toBe(true);
  349. expect(JSON.parse(sown.text)).toEqual({
  350. plotId: "plot:3:3",
  351. x: 3,
  352. y: 3,
  353. cropItemId: "parsnip",
  354. seedItemId: "parsnip-seed",
  355. });
  356. expect(inv!.count("parsnip-seed")).toBe(4);
  357. expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip", stageIndex: 0 });
  358. await callText(gateway, "crop.till", { plotId: "plot:5:5" });
  359. const byPlotId = await callText(gateway, "crop.sow", { plotId: "plot:5:5", seedItemId: "parsnip-seed" });
  360. expect(byPlotId.ok).toBe(true);
  361. expect(JSON.parse(byPlotId.text)).toEqual({
  362. plotId: "plot:5:5",
  363. x: 5,
  364. y: 5,
  365. cropItemId: "parsnip",
  366. seedItemId: "parsnip-seed",
  367. });
  368. expect(inv!.count("parsnip-seed")).toBe(3);
  369. } finally {
  370. await close();
  371. }
  372. });
  373. it("crop.sow error paths: untilled plot, no seed, unknown seed, not-a-seed, missing seedItemId, already-cropped", async () => {
  374. const { engine, gateway, close } = await setup();
  375. try {
  376. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  377. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  378. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  379. const inv = engine.systems.get<InventorySystem>("inventory");
  380. const missingArg = await callText(gateway, "crop.sow", { x: 3, y: 3 });
  381. expect(missingArg.ok).toBe(false);
  382. expect(missingArg.text).toMatch(/missing required arguments: seedItemId/);
  383. const blankArg = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "" });
  384. expect(blankArg.ok).toBe(false);
  385. expect(blankArg.text).toMatch(/seedItemId must be a non-empty string/);
  386. const noPlot = await callText(gateway, "crop.sow", { x: 8, y: 8, seedItemId: "parsnip-seed" });
  387. expect(noPlot.ok).toBe(false);
  388. expect(noPlot.text).toMatch(/no plot at \(8,8\)/);
  389. engine.world.create({ id: "plot:3:3", type: "plot", x: 3, y: 3, tilled: false, watered: false });
  390. const untilled = await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" });
  391. expect(untilled.ok).toBe(false);
  392. expect(untilled.text).toMatch(/not tilled/);
  393. await callText(gateway, "crop.till", { x: 4, y: 4 });
  394. const noSeed = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" });
  395. expect(noSeed.ok).toBe(false);
  396. expect(noSeed.text).toMatch(/no parsnip-seed in inventory/);
  397. inv!.give("parsnip-seed", 2);
  398. const unknownSeed = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "nope-seed" });
  399. expect(unknownSeed.ok).toBe(false);
  400. expect(unknownSeed.text).toMatch(/unknown item: nope-seed/);
  401. const wrongKind = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip" });
  402. expect(wrongKind.ok).toBe(false);
  403. expect(wrongKind.text).toMatch(/is not a seed/);
  404. const sown = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" });
  405. expect(sown.ok).toBe(true);
  406. const again = await callText(gateway, "crop.sow", { x: 4, y: 4, seedItemId: "parsnip-seed" });
  407. expect(again.ok).toBe(false);
  408. expect(again.text).toMatch(/already has a crop/);
  409. } finally {
  410. await close();
  411. }
  412. });
  413. it("crop.harvest rolls drops into the inventory, emits crop-harvest, and clears the crop", async () => {
  414. const { engine, gateway, close } = await setup();
  415. try {
  416. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  417. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  418. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  419. const inv = engine.systems.get<InventorySystem>("inventory");
  420. inv!.give("parsnip-seed", 5);
  421. await callText(gateway, "crop.till", { x: 3, y: 3 });
  422. await callText(gateway, "crop.sow", { x: 3, y: 3, seedItemId: "parsnip-seed" });
  423. await callText(gateway, "crop.stage", { x: 3, y: 3, stageIndex: 2 });
  424. let hookFired = 0;
  425. engine.hooks.on("onCropHarvest", () => {
  426. hookFired += 1;
  427. });
  428. const result = await callText(gateway, "crop.harvest", { x: 3, y: 3 });
  429. expect(result.ok).toBe(true);
  430. expect(JSON.parse(result.text)).toEqual({
  431. plotId: "plot:3:3",
  432. x: 3,
  433. y: 3,
  434. cropItemId: "parsnip",
  435. drops: [{ itemId: "parsnip", qty: 1 }],
  436. });
  437. expect(inv!.count("parsnip")).toBe(1);
  438. const plot = engine.world.get("plot:3:3") as { cropItemId?: string; stageIndex?: number } | undefined;
  439. expect(plot?.cropItemId).toBeUndefined();
  440. expect(plot?.stageIndex).toBeUndefined();
  441. expect(hookFired).toBe(1);
  442. } finally {
  443. await close();
  444. }
  445. });
  446. it("crop.harvest error paths: empty plot, immature crop, missing coords", async () => {
  447. const { engine, gateway, close } = await setup();
  448. try {
  449. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  450. engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
  451. engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
  452. const inv = engine.systems.get<InventorySystem>("inventory");
  453. inv!.give("parsnip-seed", 5);
  454. const noCoords = await callText(gateway, "crop.harvest", {});
  455. expect(noCoords.ok).toBe(false);
  456. expect(noCoords.text).toMatch(/provide plotId or x\/y coordinates/);
  457. const noPlot = await callText(gateway, "crop.harvest", { x: 9, y: 9 });
  458. expect(noPlot.ok).toBe(false);
  459. expect(noPlot.text).toMatch(/no plot at \(9,9\)/);
  460. await callText(gateway, "crop.till", { x: 2, y: 2 });
  461. const empty = await callText(gateway, "crop.harvest", { x: 2, y: 2 });
  462. expect(empty.ok).toBe(false);
  463. expect(empty.text).toMatch(/has no crop/);
  464. await callText(gateway, "crop.sow", { x: 2, y: 2, seedItemId: "parsnip-seed" });
  465. const immature = await callText(gateway, "crop.harvest", { x: 2, y: 2 });
  466. expect(immature.ok).toBe(false);
  467. expect(immature.text).toMatch(/is not mature/);
  468. } finally {
  469. await close();
  470. }
  471. });
  472. it("resources: crop://catalog and crop://{id}; missing id rejects", async () => {
  473. const { engine, gateway, close } = await setup();
  474. try {
  475. await callText(gateway, "crop.create", { def: PARSNIP_DEF });
  476. const catalog = await gateway.readResource("crop://catalog");
  477. expect(JSON.parse(catalog.contents[0]?.text ?? "{}")).toEqual({ crops: [PARSNIP_DEF] });
  478. const single = await gateway.readResource("crop://parsnip");
  479. expect(JSON.parse(single.contents[0]?.text ?? "{}")).toEqual(PARSNIP_DEF);
  480. await expect(gateway.readResource("crop://missing")).rejects.toThrow(/crop not found: missing/);
  481. expect(engine.registry.get("crop", "missing")).toBeUndefined();
  482. } finally {
  483. await close();
  484. }
  485. });
  486. it("serves the crop.create-prompt prompt", async () => {
  487. const { gateway, close } = await setup();
  488. try {
  489. const prompt = await gateway.getPrompt("crop.create-prompt", {});
  490. const text = prompt.messages[0]?.content.text ?? "";
  491. expect(text).toContain("stages");
  492. expect(text).toContain("waterRequired");
  493. expect(text).toContain("seasons");
  494. expect(text).toContain("drops");
  495. expect(text).toContain("crop.validate");
  496. expect(text).toContain("content/crops/<id>.yaml");
  497. } finally {
  498. await close();
  499. }
  500. });
  501. });