sample-game.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import { createEngine, type IntentSystem, type InventorySystem, type PlantingSystem } from "@nengine/core";
  4. import { Client } from "@modelcontextprotocol/sdk/client/index.js";
  5. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
  6. import { DEFAULT_DIRS, resolveDirs } from "../src/index.js";
  7. import { createNengineMcpServer, createGateway } from "../src/server.js";
  8. const repoRoot = resolve(process.cwd(), "../..");
  9. const sampleGameDir = resolve(repoRoot, "examples", "sample-game");
  10. const contentDir = resolve(sampleGameDir, "content");
  11. const modsDir = resolve(sampleGameDir, "mods");
  12. interface Harness {
  13. engine: ReturnType<typeof createEngine>;
  14. client: Client;
  15. close: () => Promise<void>;
  16. }
  17. async function setup(): Promise<Harness> {
  18. const dirs = resolveDirs({ argv: ["--content-dir", contentDir, "--mods-dir", modsDir], env: {}, cwd: repoRoot });
  19. const engine = createEngine(dirs);
  20. await engine.start();
  21. const gateway = createGateway(engine);
  22. const server = createNengineMcpServer(engine, gateway);
  23. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  24. await server.connect(serverTransport);
  25. const client = new Client({ name: "sample-game-test", version: "0.0.1" });
  26. await client.connect(clientTransport);
  27. return {
  28. engine,
  29. client,
  30. close: async () => {
  31. await client.close();
  32. await server.close();
  33. await engine.stop();
  34. },
  35. };
  36. }
  37. function textOf(result: { content: { type: string; text?: string }[] }): string {
  38. return result.content.find((block) => block.type === "text")?.text ?? "";
  39. }
  40. function jsonOf(text: string): Record<string, any> {
  41. return JSON.parse(text) as Record<string, any>;
  42. }
  43. async function callTool(client: Client, name: string, arguments_: Record<string, unknown>): Promise<Record<string, any>> {
  44. const result = await client.callTool({ name, arguments: arguments_ });
  45. if (result.isError) {
  46. throw new Error(`tool ${name} failed: ${textOf(result)}`);
  47. }
  48. return jsonOf(textOf(result));
  49. }
  50. describe("apps/mcp-server resolveDirs (parameterized entry)", () => {
  51. it("defaults to the monorepo content/mods when nothing is provided", () => {
  52. expect(resolveDirs({ argv: [], env: {}, cwd: repoRoot })).toEqual(DEFAULT_DIRS);
  53. });
  54. it("resolves CLI paths relative to cwd", () => {
  55. const dirs = resolveDirs({
  56. argv: ["--content-dir", "examples/sample-game/content", "--mods-dir", "examples/sample-game/mods"],
  57. env: {},
  58. cwd: repoRoot,
  59. });
  60. expect(dirs.contentDir).toBe(contentDir);
  61. expect(dirs.modsDir).toBe(modsDir);
  62. });
  63. it("reads env vars and lets CLI win over env, env win over default", () => {
  64. const envDirs = resolveDirs({
  65. argv: [],
  66. env: { NENGINE_CONTENT_DIR: "env-content", NENGINE_MODS_DIR: "env-mods" },
  67. cwd: repoRoot,
  68. });
  69. expect(envDirs.contentDir).toBe(resolve(repoRoot, "env-content"));
  70. expect(envDirs.modsDir).toBe(resolve(repoRoot, "env-mods"));
  71. const cliWins = resolveDirs({
  72. argv: ["--content-dir", "cli-content"],
  73. env: { NENGINE_CONTENT_DIR: "env-content" },
  74. cwd: repoRoot,
  75. });
  76. expect(cliWins.contentDir).toBe(resolve(repoRoot, "cli-content"));
  77. expect(cliWins.modsDir).toBe(DEFAULT_DIRS.modsDir);
  78. });
  79. it("leaves absolute CLI paths untouched", () => {
  80. const dirs = resolveDirs({ argv: ["--content-dir", "/abs/content"], env: {}, cwd: repoRoot });
  81. expect(dirs.contentDir).toBe("/abs/content");
  82. });
  83. it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => {
  84. const viaCli = resolveDirs({ argv: ["--ws-port", "8787"], env: {}, cwd: repoRoot });
  85. expect(viaCli.wsPort).toBe(8787);
  86. const viaEnv = resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
  87. expect(viaEnv.wsPort).toBe(9000);
  88. const cliWins = resolveDirs({ argv: ["--ws-port", "8787"], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
  89. expect(cliWins.wsPort).toBe(8787);
  90. const unset = resolveDirs({ argv: [], env: {}, cwd: repoRoot });
  91. expect(unset.wsPort).toBeUndefined();
  92. expect(resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "" }, cwd: repoRoot }).wsPort).toBeUndefined();
  93. });
  94. it("rejects invalid ws port values", () => {
  95. for (const bad of ["abc", "0", "-1", "8787.5", "70000"]) {
  96. expect(() => resolveDirs({ argv: ["--ws-port", bad], env: {}, cwd: repoRoot })).toThrow(/invalid ws port/);
  97. }
  98. });
  99. });
  100. describe("examples/sample-game end-to-end through the parameterized entry", () => {
  101. it("loads every content kind and server.info reflects the sample game", async () => {
  102. const { client, close } = await setup();
  103. try {
  104. const info = await callTool(client, "server.info", {});
  105. expect(info.content).toEqual({
  106. animal: ["chicken"],
  107. animation: ["player"],
  108. behavior: ["starter-gift"],
  109. building: ["coop", "fence", "shop"],
  110. crop: ["carrot", "parsnip"],
  111. dialogue: ["mayor"],
  112. interact: ["default"],
  113. map: ["farm"],
  114. time: ["default"],
  115. item: ["carrot-seed", "carrot", "egg", "fish", "hoe", "parsnip-seed", "parsnip", "watering-can"],
  116. particle: ["dust", "rain", "sparkle", "wind"],
  117. shader: ["rain-tint", "sparkle-glow"],
  118. shop: ["general-store"],
  119. sound: ["coin", "harvest", "till", "water"],
  120. 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"],
  121. statemachine: ["carrot-quest", "chicken-egg", "daily-stamina", "fishing-treat", "mayor-routine", "mayor-talk-quest", "starter-quest"],
  122. weather: ["default"],
  123. });
  124. expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  125. const map = await callTool(client, "map.get", { mapId: "farm" });
  126. expect(map.id).toBe("farm");
  127. expect(map.width).toBe(16);
  128. expect(map.height).toBe(16);
  129. } finally {
  130. await close();
  131. }
  132. });
  133. it("loads the sample-game binary assets into the engine asset table (D31)", async () => {
  134. const { engine, client, close } = await setup();
  135. try {
  136. expect(engine.assets.size).toBe(21);
  137. expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
  138. expect(engine.assets.get("water-1")).toMatchObject({ kind: "image", mime: "image/png" });
  139. expect(engine.assets.get("tilled-1")).toMatchObject({ kind: "image", mime: "image/png" });
  140. expect(engine.assets.get("plant-sprout")).toMatchObject({ kind: "image" });
  141. expect(engine.assets.get("coop-1")).toMatchObject({ kind: "image" });
  142. expect(engine.assets.get("fence-1")).toMatchObject({ kind: "image" });
  143. for (const id of ["click-1", "confirm-1", "error-1", "drop-1", "pluck-1"]) {
  144. expect(engine.assets.get(id)).toMatchObject({ kind: "audio", mime: "audio/ogg" });
  145. }
  146. const info = await callTool(client, "server.info", {});
  147. expect(info.content.sound).toEqual(["coin", "harvest", "till", "water"]);
  148. } finally {
  149. await close();
  150. }
  151. });
  152. it("plays the full gameplay arc: gift, grow, harvest, sell, buy, build, chicken, egg, quest, dialogue", async () => {
  153. const { engine, client, close } = await setup();
  154. try {
  155. const inv = engine.systems.get<InventorySystem>("inventory");
  156. const planting = engine.systems.get<PlantingSystem>("planting");
  157. if (!inv || !planting) throw new Error("systems not booted");
  158. const player = () => engine.world.get("player");
  159. expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [] });
  160. expect(inv.items()).toEqual({});
  161. const day2 = await callTool(client, "time.advance", { ticks: 480 });
  162. expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  163. expect(inv.items()).toEqual({ "parsnip-seed": 3, "carrot-seed": 2 });
  164. expect(player()?.flag).toEqual({ "starter-gift": "given" });
  165. expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [{ id: "npc:mayor", x: 6, y: 5 }] });
  166. expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 });
  167. expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
  168. machineId: "starter-quest",
  169. state: "active",
  170. started: true,
  171. });
  172. const weather = await callTool(client, "weather.get", {});
  173. expect(["sunny", "rain", "wind", "storm"]).toContain(weather.weatherId);
  174. const plots: [number, number][] = [
  175. [6, 4],
  176. [7, 4],
  177. [8, 4],
  178. ];
  179. for (const [x, y] of plots) {
  180. planting.till(x, y);
  181. planting.sow(x, y, "parsnip-seed");
  182. await ensureWatered(engine, client, x, y);
  183. }
  184. await callTool(client, "time.tick", { ticks: 480 });
  185. await callTool(client, "time.tick", { ticks: 480 });
  186. for (const [x, y] of plots) {
  187. await ensureWatered(engine, client, x, y);
  188. }
  189. await callTool(client, "time.tick", { ticks: 480 });
  190. await callTool(client, "time.tick", { ticks: 480 });
  191. for (const [x, y] of plots) {
  192. expect(engine.world.get(`plot:${x}:${y}`)).toMatchObject({ stageIndex: 2 });
  193. }
  194. for (const [x, y] of plots) {
  195. planting.harvest(x, y);
  196. }
  197. expect(inv.count("parsnip")).toBe(3);
  198. expect(inv.count("parsnip-seed")).toBe(2);
  199. expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
  200. expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
  201. machineId: "starter-quest",
  202. state: "complete",
  203. started: true,
  204. });
  205. expect(
  206. await callTool(client, "shop.sell", { shopId: "general-store", itemId: "parsnip", qty: 3 }),
  207. ).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 3, total: 90, gold: 90 });
  208. expect(
  209. await callTool(client, "shop.buy", { shopId: "general-store", itemId: "carrot-seed", qty: 1 }),
  210. ).toEqual({ shopId: "general-store", itemId: "carrot-seed", qty: 1, total: 25, gold: 65 });
  211. expect(await callTool(client, "placement.place", { buildingId: "coop", x: 10, y: 4 })).toMatchObject({
  212. id: "building:coop:10:4",
  213. type: "building",
  214. buildingId: "coop",
  215. x: 10,
  216. y: 4,
  217. w: 3,
  218. h: 3,
  219. blocksMovement: true,
  220. });
  221. expect(await callTool(client, "placement.place", { buildingId: "fence", x: 9, y: 8 })).toMatchObject({
  222. id: "building:fence:9:8",
  223. });
  224. expect(await callTool(client, "animal.spawn", { animalId: "chicken", x: 9, y: 3 })).toMatchObject({
  225. id: "animal:chicken:1",
  226. type: "animal",
  227. animalId: "chicken",
  228. stageIndex: 0,
  229. });
  230. expect(await callTool(client, "animal.stage", { id: "animal:chicken:1", stageIndex: 2 })).toMatchObject({
  231. stageIndex: 2,
  232. stage: "laying",
  233. });
  234. expect(await callTool(client, "animal.feed", { id: "animal:chicken:1" })).toEqual({
  235. id: "animal:chicken:1",
  236. animalId: "chicken",
  237. fed: true,
  238. });
  239. await callTool(client, "time.tick", { ticks: 480 });
  240. expect((await callTool(client, "item.query_inventory", {})).inventory.egg).toBe(1);
  241. expect(engine.world.get("mod:sample-stats")).toMatchObject({ greetings: 6 });
  242. expect(await callTool(client, "npc.get", { npcId: "npc:mayor" })).toMatchObject({
  243. id: "npc:mayor",
  244. type: "npc",
  245. name: "Mayor",
  246. x: 6,
  247. y: 6,
  248. });
  249. const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  250. expect(started.nodeId).toBe("greeting");
  251. expect(started.choices.map((choice: { text: string }) => choice.text)).toEqual([
  252. "Any farming tips?",
  253. "Tell me about chickens.",
  254. "Got anything for me?",
  255. "I am just passing through.",
  256. ]);
  257. const rewarded = await callTool(client, "dialogue.choose", { index: 2 });
  258. expect(rewarded.nodeId).toBe("quest-reward");
  259. expect(inv.count("egg")).toBe(3);
  260. const farewell = await callTool(client, "dialogue.choose", { index: 0 });
  261. expect(farewell.nodeId).toBe("farewell");
  262. expect(farewell.choices).toEqual([]);
  263. } finally {
  264. await close();
  265. }
  266. });
  267. });
  268. describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills, fishing minigame", () => {
  269. it("chains the quests: starter-quest complete → carrot-quest active → harvest carrots → complete + reward", async () => {
  270. const { engine, client, close } = await setup();
  271. try {
  272. const inv = engine.systems.get<InventorySystem>("inventory");
  273. if (!inv) throw new Error("inventory system not booted");
  274. const player = () => engine.world.get("player");
  275. await callTool(client, "time.advance", { ticks: 480 }); // day 2: starter gift + starter-quest active
  276. await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
  277. expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
  278. await callTool(client, "time.tick", { ticks: 480 }); // next day-start activates carrot-quest
  279. expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
  280. machineId: "carrot-quest",
  281. state: "active",
  282. started: true,
  283. });
  284. const carrotSeedBefore = inv.count("carrot-seed");
  285. await growAndHarvest(engine, client, [[6, 4]], "carrot-seed", 2, 3);
  286. expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete" });
  287. expect(inv.count("carrot")).toBe(1);
  288. expect(inv.count("carrot-seed")).toBe(carrotSeedBefore + 1); // -1 sown, +2 quest reward
  289. expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
  290. machineId: "carrot-quest",
  291. state: "complete",
  292. started: true,
  293. });
  294. } finally {
  295. await close();
  296. }
  297. });
  298. it("completes mayor-talk-quest by finishing the mayor dialogue and gates a new dialogue branch", async () => {
  299. const { engine, client, close } = await setup();
  300. try {
  301. const inv = engine.systems.get<InventorySystem>("inventory");
  302. if (!inv) throw new Error("inventory system not booted");
  303. const player = () => engine.world.get("player");
  304. await callTool(client, "time.advance", { ticks: 480 });
  305. await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); // starter-quest complete
  306. await callTool(client, "time.tick", { ticks: 480 }); // carrot-quest active
  307. await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3); // carrot-quest complete
  308. await callTool(client, "time.tick", { ticks: 480 }); // mayor-talk-quest active
  309. expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
  310. machineId: "mayor-talk-quest",
  311. state: "active",
  312. started: true,
  313. });
  314. const eggBefore = inv.count("egg");
  315. const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  316. expect(started.choices.map((choice: { text: string }) => choice.text)).not.toContain("Tell me about fishing.");
  317. await callTool(client, "dialogue.choose", { index: started.choices.length - 1 }); // farewell → dialogue-end
  318. expect(player()?.questProgress).toEqual({
  319. "starter-quest": "complete",
  320. "carrot-quest": "complete",
  321. "mayor-talk-quest": "complete",
  322. });
  323. expect(inv.count("egg")).toBe(eggBefore + 1);
  324. expect(player()?.gold).toBe(100);
  325. expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
  326. machineId: "mayor-talk-quest",
  327. state: "complete",
  328. started: true,
  329. });
  330. const again = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  331. expect(again.choices.map((choice: { text: string }) => choice.text)).toContain("Tell me about fishing.");
  332. } finally {
  333. await close();
  334. }
  335. });
  336. it("fishes at the pond via IntentSystem: fish gained, stamina drained, skill incremented, mod event + treat reward", async () => {
  337. const { engine, client, close } = await setup();
  338. try {
  339. const inv = engine.systems.get<InventorySystem>("inventory");
  340. const intent = engine.systems.get<IntentSystem>("intent");
  341. if (!inv || !intent) throw new Error("systems not booted");
  342. const player = () => engine.world.get("player");
  343. const caught: unknown[] = [];
  344. engine.events.on("mod:fish-caught", (payload) => caught.push(payload));
  345. const tired: unknown[] = [];
  346. engine.events.on("mod:fish-too-tired", (payload) => tired.push(payload));
  347. expect(player()).toMatchObject({ stamina: 100, skills: {} });
  348. await callTool(client, "time.advance", { ticks: 480 }); // day 2: daily-stamina machine restores to ready
  349. expect(player()?.stamina).toBe(100);
  350. expect(intent.handle("move", { tile: { x: 3, y: 3 } })).toMatchObject({ ok: true });
  351. for (let i = 0; i < 3; i++) {
  352. expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({
  353. action: "interact",
  354. tile: { x: 3, y: 2 },
  355. ok: true,
  356. });
  357. }
  358. expect(inv.count("fish")).toBe(3);
  359. expect(player()).toMatchObject({ stamina: 85, skills: { fishing: 3 } });
  360. expect(caught).toHaveLength(3);
  361. expect(caught[0]).toMatchObject({ fish: "fish", size: 1, x: 3, y: 2, stamina: 95 });
  362. expect(caught[2]).toMatchObject({ fish: "fish", size: 3, catches: 3, stamina: 85 });
  363. expect(inv.count("egg")).toBe(1); // every third catch: fishing-treat machine matches mod:fish-caught
  364. expect(engine.world.get("plot:3:2")).toBeUndefined(); // the Mod normalizes the default till-on-water
  365. (player() as { stamina: number }).stamina = 3;
  366. expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ ok: true });
  367. expect(inv.count("fish")).toBe(3);
  368. expect(player()).toMatchObject({ stamina: 3, skills: { fishing: 3 } });
  369. expect(tired).toHaveLength(1);
  370. expect(tired[0]).toMatchObject({ x: 3, y: 2, stamina: 3 });
  371. expect(await callTool(client, "behavior.statemachine.status", { machineId: "daily-stamina" })).toEqual({
  372. machineId: "daily-stamina",
  373. state: "low",
  374. started: true,
  375. });
  376. } finally {
  377. await close();
  378. }
  379. });
  380. });
  381. async function ensureWatered(
  382. engine: ReturnType<typeof createEngine>,
  383. client: Client,
  384. x: number,
  385. y: number,
  386. ): Promise<void> {
  387. const plot = engine.world.get(`plot:${x}:${y}`) as { watered?: boolean } | undefined;
  388. if (!plot || plot.watered) return;
  389. await callTool(client, "crop.water", { x, y });
  390. }
  391. async function growAndHarvest(
  392. engine: ReturnType<typeof createEngine>,
  393. client: Client,
  394. plots: [number, number][],
  395. seedItemId: string,
  396. sproutDays: number,
  397. grownDays: number,
  398. ): Promise<void> {
  399. const planting = engine.systems.get<PlantingSystem>("planting");
  400. if (!planting) throw new Error("planting system not booted");
  401. for (const [x, y] of plots) {
  402. planting.till(x, y);
  403. planting.sow(x, y, seedItemId);
  404. await ensureWatered(engine, client, x, y);
  405. }
  406. await callTool(client, "time.tick", { ticks: 480 * sproutDays });
  407. for (const [x, y] of plots) {
  408. await ensureWatered(engine, client, x, y);
  409. }
  410. await callTool(client, "time.tick", { ticks: 480 * grownDays });
  411. for (const [x, y] of plots) {
  412. planting.harvest(x, y);
  413. }
  414. }