sample-game.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  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 isWindows = process.platform === "win32";
  81. const absArg = isWindows ? "C:\\abs\\content" : "/abs/content";
  82. const dirs = resolveDirs({ argv: ["--content-dir", absArg], env: {}, cwd: repoRoot });
  83. expect(dirs.contentDir).toBe(absArg);
  84. });
  85. it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => {
  86. const viaCli = resolveDirs({ argv: ["--ws-port", "8787"], env: {}, cwd: repoRoot });
  87. expect(viaCli.wsPort).toBe(8787);
  88. const viaEnv = resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
  89. expect(viaEnv.wsPort).toBe(9000);
  90. const cliWins = resolveDirs({ argv: ["--ws-port", "8787"], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
  91. expect(cliWins.wsPort).toBe(8787);
  92. const unset = resolveDirs({ argv: [], env: {}, cwd: repoRoot });
  93. expect(unset.wsPort).toBeUndefined();
  94. expect(resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "" }, cwd: repoRoot }).wsPort).toBeUndefined();
  95. });
  96. it("rejects invalid ws port values", () => {
  97. for (const bad of ["abc", "0", "-1", "8787.5", "70000"]) {
  98. expect(() => resolveDirs({ argv: ["--ws-port", bad], env: {}, cwd: repoRoot })).toThrow(/invalid ws port/);
  99. }
  100. });
  101. });
  102. describe("examples/sample-game end-to-end through the parameterized entry", () => {
  103. it("loads every content kind and server.info reflects the sample game", async () => {
  104. const { client, close } = await setup();
  105. try {
  106. const info = await callTool(client, "server.info", {});
  107. expect(info.content).toEqual({
  108. animal: ["chicken", "cow"],
  109. animation: ["player"],
  110. behavior: ["starter-gift"],
  111. building: ["barn", "coop", "fence", "shop"],
  112. crop: ["carrot", "parsnip", "pumpkin", "turnip"],
  113. dialogue: ["mayor", "mia"],
  114. interact: ["default"],
  115. map: ["farm"],
  116. time: ["default"],
  117. item: [
  118. "baked-egg",
  119. "cake",
  120. "carrot-seed",
  121. "carrot-soup",
  122. "carrot",
  123. "cherry-jam",
  124. "egg",
  125. "festival-cherry-petal",
  126. "festival-firework-ticket",
  127. "festival-harvest-charm",
  128. "festival-snow-crystal",
  129. "fish-stew",
  130. "fish",
  131. "grand-feast",
  132. "harvest-pie",
  133. "hoe",
  134. "milk",
  135. "oat-milk",
  136. "parsnip-seed",
  137. "parsnip-soup",
  138. "parsnip",
  139. "pumpkin-pie",
  140. "pumpkin-seed",
  141. "pumpkin",
  142. "snow-cone",
  143. "summer-pudding",
  144. "turnip-seed",
  145. "turnip-soup",
  146. "turnip",
  147. "watering-can",
  148. ],
  149. particle: ["dust", "rain", "sparkle", "wind"],
  150. recipe: [
  151. "baked-egg",
  152. "cake",
  153. "carrot-soup",
  154. "cherry-jam",
  155. "fish-stew",
  156. "grand-feast",
  157. "harvest-pie",
  158. "oat-milk",
  159. "parsnip-soup",
  160. "pumpkin-pie",
  161. "snow-cone",
  162. "summer-pudding",
  163. "turnip-soup",
  164. ],
  165. shader: ["rain-tint", "sparkle-glow"],
  166. shop: ["general-store"],
  167. sound: ["bgm-autumn", "bgm-spring", "bgm-summer", "bgm-victory", "bgm-winter", "coin", "harvest", "till", "water"],
  168. sprite: ["barn", "carrot-grown", "carrot-mature", "carrot-sprout", "chicken-chick", "chicken-grown", "chicken-laying", "coop", "cow-calf", "cow-grown", "cow-milking", "fence", "parsnip-grown", "parsnip-mature", "parsnip-sprout", "player-walk", "player", "pumpkin-grown", "pumpkin-mature", "pumpkin-sprout", "shop", "turnip-grown", "turnip-mature", "turnip-sprout"],
  169. statemachine: ["carrot-quest", "chicken-egg", "cow-milk", "daily-stamina", "festival-autumn", "festival-spring", "festival-summer", "festival-winter", "fishing-treat", "mayor-routine", "mayor-talk-quest", "restoration-quest", "starter-quest"],
  170. weather: ["default"],
  171. });
  172. expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  173. const map = await callTool(client, "map.get", { mapId: "farm" });
  174. expect(map.id).toBe("farm");
  175. expect(map.width).toBe(20);
  176. expect(map.height).toBe(16);
  177. } finally {
  178. await close();
  179. }
  180. });
  181. it("loads the sample-game binary assets into the engine asset table (D31)", async () => {
  182. const { engine, client, close } = await setup();
  183. try {
  184. expect(engine.assets.size).toBe(21);
  185. expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
  186. expect(engine.assets.get("water-1")).toMatchObject({ kind: "image", mime: "image/png" });
  187. expect(engine.assets.get("tilled-1")).toMatchObject({ kind: "image", mime: "image/png" });
  188. expect(engine.assets.get("plant-sprout")).toMatchObject({ kind: "image" });
  189. expect(engine.assets.get("coop-1")).toMatchObject({ kind: "image" });
  190. expect(engine.assets.get("fence-1")).toMatchObject({ kind: "image" });
  191. for (const id of ["click-1", "confirm-1", "error-1", "drop-1", "pluck-1"]) {
  192. expect(engine.assets.get(id)).toMatchObject({ kind: "audio", mime: "audio/ogg" });
  193. }
  194. const info = await callTool(client, "server.info", {});
  195. // sound catalog includes core sfx plus seasonal/victory BGMs added by the sample festival template.
  196. expect(info.content.sound).toEqual(expect.arrayContaining(["coin", "harvest", "till", "water"]));
  197. expect((info.content.sound as string[]).filter((id) => id.startsWith("bgm-")).sort()).toEqual([
  198. "bgm-autumn",
  199. "bgm-spring",
  200. "bgm-summer",
  201. "bgm-victory",
  202. "bgm-winter",
  203. ]);
  204. } finally {
  205. await close();
  206. }
  207. });
  208. it("plays the full gameplay arc: gift, grow, harvest, sell, buy, build, chicken, egg, quest, dialogue", async () => {
  209. const { engine, client, close } = await setup();
  210. try {
  211. const inv = engine.systems.get<InventorySystem>("inventory");
  212. const planting = engine.systems.get<PlantingSystem>("planting");
  213. if (!inv || !planting) throw new Error("systems not booted");
  214. const player = () => engine.world.get("player");
  215. expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [] });
  216. expect(inv.items()).toEqual({});
  217. const day2 = await callTool(client, "time.advance", { ticks: 480 });
  218. expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  219. expect(inv.items()).toEqual({ "parsnip-seed": 3, "carrot-seed": 2 });
  220. expect(player()?.flag).toEqual({ "starter-gift": "given" });
  221. expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [{ id: "npc:mayor", x: 6, y: 5 }] });
  222. expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 });
  223. expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
  224. machineId: "starter-quest",
  225. state: "active",
  226. started: true,
  227. });
  228. const weather = await callTool(client, "weather.get", {});
  229. expect(["sunny", "rain", "wind", "storm"]).toContain(weather.weatherId);
  230. const plots: [number, number][] = [
  231. [6, 4],
  232. [7, 4],
  233. [8, 4],
  234. ];
  235. for (const [x, y] of plots) {
  236. planting.till(x, y);
  237. planting.sow(x, y, "parsnip-seed");
  238. await ensureWatered(engine, client, x, y);
  239. }
  240. await callTool(client, "time.tick", { ticks: 480 });
  241. await callTool(client, "time.tick", { ticks: 480 });
  242. for (const [x, y] of plots) {
  243. await ensureWatered(engine, client, x, y);
  244. }
  245. await callTool(client, "time.tick", { ticks: 480 });
  246. await callTool(client, "time.tick", { ticks: 480 });
  247. for (const [x, y] of plots) {
  248. expect(engine.world.get(`plot:${x}:${y}`)).toMatchObject({ stageIndex: 2 });
  249. }
  250. for (const [x, y] of plots) {
  251. planting.harvest(x, y);
  252. }
  253. expect(inv.count("parsnip")).toBe(3);
  254. expect(inv.count("parsnip-seed")).toBe(2);
  255. expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
  256. expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
  257. machineId: "starter-quest",
  258. state: "complete",
  259. started: true,
  260. });
  261. expect(
  262. await callTool(client, "shop.sell", { shopId: "general-store", itemId: "parsnip", qty: 3 }),
  263. ).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 3, total: 90, gold: 90 });
  264. expect(
  265. await callTool(client, "shop.buy", { shopId: "general-store", itemId: "carrot-seed", qty: 1 }),
  266. ).toEqual({ shopId: "general-store", itemId: "carrot-seed", qty: 1, total: 25, gold: 65 });
  267. expect(await callTool(client, "placement.place", { buildingId: "coop", x: 10, y: 4 })).toMatchObject({
  268. id: "building:coop:10:4",
  269. type: "building",
  270. buildingId: "coop",
  271. x: 10,
  272. y: 4,
  273. w: 3,
  274. h: 3,
  275. blocksMovement: true,
  276. });
  277. expect(await callTool(client, "placement.place", { buildingId: "fence", x: 9, y: 8 })).toMatchObject({
  278. id: "building:fence:9:8",
  279. });
  280. expect(await callTool(client, "animal.spawn", { animalId: "chicken", x: 9, y: 3 })).toMatchObject({
  281. id: "animal:chicken:1",
  282. type: "animal",
  283. animalId: "chicken",
  284. stageIndex: 0,
  285. });
  286. expect(await callTool(client, "animal.stage", { id: "animal:chicken:1", stageIndex: 2 })).toMatchObject({
  287. stageIndex: 2,
  288. stage: "laying",
  289. });
  290. expect(await callTool(client, "animal.feed", { id: "animal:chicken:1" })).toEqual({
  291. id: "animal:chicken:1",
  292. animalId: "chicken",
  293. fed: true,
  294. });
  295. await callTool(client, "time.tick", { ticks: 480 });
  296. expect((await callTool(client, "item.query_inventory", {})).inventory.egg).toBe(1);
  297. expect(engine.world.get("mod:sample-stats")).toMatchObject({ greetings: 6 });
  298. expect(await callTool(client, "npc.get", { npcId: "npc:mayor" })).toMatchObject({
  299. id: "npc:mayor",
  300. type: "npc",
  301. name: "Mayor",
  302. x: 6,
  303. y: 6,
  304. });
  305. const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  306. expect(started.nodeId).toBe("greeting");
  307. expect(started.choices.map((choice: { text: string }) => choice.text)).toEqual([
  308. "Any farming tips?",
  309. "Tell me about chickens.",
  310. "Got anything for me?",
  311. "I am just passing through.",
  312. ]);
  313. const rewarded = await callTool(client, "dialogue.choose", { index: 2 });
  314. expect(rewarded.nodeId).toBe("quest-reward");
  315. expect(inv.count("egg")).toBe(3);
  316. const farewell = await callTool(client, "dialogue.choose", { index: 0 });
  317. expect(farewell.nodeId).toBe("farewell");
  318. expect(farewell.choices).toEqual([]);
  319. } finally {
  320. await close();
  321. }
  322. });
  323. });
  324. describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills, fishing minigame", () => {
  325. it("chains the quests: starter-quest complete → carrot-quest active → harvest carrots → complete + reward", async () => {
  326. const { engine, client, close } = await setup();
  327. try {
  328. const inv = engine.systems.get<InventorySystem>("inventory");
  329. if (!inv) throw new Error("inventory system not booted");
  330. const player = () => engine.world.get("player");
  331. await callTool(client, "time.advance", { ticks: 480 }); // day 2: starter gift + starter-quest active
  332. await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
  333. expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
  334. await callTool(client, "time.tick", { ticks: 480 }); // next day-start activates carrot-quest
  335. expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
  336. machineId: "carrot-quest",
  337. state: "active",
  338. started: true,
  339. });
  340. const carrotSeedBefore = inv.count("carrot-seed");
  341. await growAndHarvest(engine, client, [[6, 4]], "carrot-seed", 2, 3);
  342. expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete" });
  343. expect(inv.count("carrot")).toBe(1);
  344. expect(inv.count("carrot-seed")).toBe(carrotSeedBefore + 1); // -1 sown, +2 quest reward
  345. expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
  346. machineId: "carrot-quest",
  347. state: "complete",
  348. started: true,
  349. });
  350. } finally {
  351. await close();
  352. }
  353. });
  354. it("completes mayor-talk-quest by finishing the mayor dialogue and gates a new dialogue branch", async () => {
  355. const { engine, client, close } = await setup();
  356. try {
  357. const inv = engine.systems.get<InventorySystem>("inventory");
  358. if (!inv) throw new Error("inventory system not booted");
  359. const player = () => engine.world.get("player");
  360. await callTool(client, "time.advance", { ticks: 480 });
  361. await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); // starter-quest complete
  362. await callTool(client, "time.tick", { ticks: 480 }); // carrot-quest active
  363. await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3); // carrot-quest complete
  364. await callTool(client, "time.tick", { ticks: 480 }); // mayor-talk-quest active
  365. expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
  366. machineId: "mayor-talk-quest",
  367. state: "active",
  368. started: true,
  369. });
  370. const eggBefore = inv.count("egg");
  371. const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  372. expect(started.choices.map((choice: { text: string }) => choice.text)).not.toContain("Tell me about fishing.");
  373. await callTool(client, "dialogue.choose", { index: started.choices.length - 1 }); // farewell → dialogue-end
  374. expect(player()?.questProgress).toEqual({
  375. "starter-quest": "complete",
  376. "carrot-quest": "complete",
  377. "mayor-talk-quest": "complete",
  378. });
  379. expect(inv.count("egg")).toBe(eggBefore + 1);
  380. expect(player()?.gold).toBe(100);
  381. expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
  382. machineId: "mayor-talk-quest",
  383. state: "complete",
  384. started: true,
  385. });
  386. const again = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  387. expect(again.choices.map((choice: { text: string }) => choice.text)).toContain("Tell me about fishing.");
  388. } finally {
  389. await close();
  390. }
  391. });
  392. it("fishes at the pond via IntentSystem: fish gained, stamina drained, skill incremented, mod event + treat reward", async () => {
  393. const { engine, client, close } = await setup();
  394. try {
  395. const inv = engine.systems.get<InventorySystem>("inventory");
  396. const intent = engine.systems.get<IntentSystem>("intent");
  397. if (!inv || !intent) throw new Error("systems not booted");
  398. const player = () => engine.world.get("player");
  399. const caught: unknown[] = [];
  400. engine.events.on("mod:fish-caught", (payload) => caught.push(payload));
  401. const tired: unknown[] = [];
  402. engine.events.on("mod:fish-too-tired", (payload) => tired.push(payload));
  403. expect(player()).toMatchObject({ stamina: 100, skills: {} });
  404. await callTool(client, "time.advance", { ticks: 480 }); // day 2: daily-stamina machine restores to ready
  405. expect(player()?.stamina).toBe(100);
  406. expect(intent.handle("move", { tile: { x: 3, y: 3 } })).toMatchObject({ ok: true });
  407. for (let i = 0; i < 3; i++) {
  408. expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({
  409. action: "interact",
  410. tile: { x: 3, y: 2 },
  411. ok: true,
  412. });
  413. }
  414. expect(inv.count("fish")).toBe(3);
  415. expect(player()).toMatchObject({ stamina: 85, skills: { fishing: 3 } });
  416. expect(caught).toHaveLength(3);
  417. expect(caught[0]).toMatchObject({ fish: "fish", size: 1, x: 3, y: 2, stamina: 95 });
  418. expect(caught[2]).toMatchObject({ fish: "fish", size: 3, catches: 3, stamina: 85 });
  419. expect(inv.count("egg")).toBe(1); // every third catch: fishing-treat machine matches mod:fish-caught
  420. expect(engine.world.get("plot:3:2")).toBeUndefined(); // the Mod normalizes the default till-on-water
  421. (player() as { stamina: number }).stamina = 3;
  422. expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ ok: true });
  423. expect(inv.count("fish")).toBe(3);
  424. expect(player()).toMatchObject({ stamina: 3, skills: { fishing: 3 } });
  425. expect(tired).toHaveLength(1);
  426. expect(tired[0]).toMatchObject({ x: 3, y: 2, stamina: 3 });
  427. expect(await callTool(client, "behavior.statemachine.status", { machineId: "daily-stamina" })).toEqual({
  428. machineId: "daily-stamina",
  429. state: "low",
  430. started: true,
  431. });
  432. } finally {
  433. await close();
  434. }
  435. });
  436. });
  437. describe("examples/sample-game Tier 4 content: restoration arc + victory", () => {
  438. it("unlocks Mia, completes restoration-quest at 500 gold, and gates the victory dialogues", async () => {
  439. const { engine, client, close } = await setup();
  440. try {
  441. const inv = engine.systems.get<InventorySystem>("inventory");
  442. if (!inv) throw new Error("inventory system not booted");
  443. const player = () => engine.world.get("player");
  444. // Starter arc -> starter-quest complete.
  445. await callTool(client, "time.advance", { ticks: 480 });
  446. await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
  447. expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
  448. // carrot-quest -> complete.
  449. await callTool(client, "time.tick", { ticks: 480 });
  450. await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3);
  451. expect(player()?.questProgress?.["carrot-quest"]).toBe("complete");
  452. // mayor-talk-quest: activate + finish the mayor dialogue (+100 gold, +1 egg).
  453. await callTool(client, "time.tick", { ticks: 480 });
  454. const mayor = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  455. await callTool(client, "dialogue.choose", { index: mayor.choices.length - 1 }); // farewell -> dialogue-end
  456. expect(player()?.questProgress?.["mayor-talk-quest"]).toBe("complete");
  457. expect(player()?.gold).toBe(100);
  458. // First day-start after mayor-talk-quest: restoration-quest activates and Mia spawns.
  459. await callTool(client, "time.tick", { ticks: 480 });
  460. expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
  461. machineId: "restoration-quest",
  462. state: "active",
  463. started: true,
  464. });
  465. const npcs = await callTool(client, "npc.list", {});
  466. expect(npcs.npcs.map((npc: { id: string }) => npc.id)).toContain("npc:mia");
  467. // Reach the 500-gold restoration goal by selling pumpkins (a new autumn crop).
  468. await callTool(client, "item.give", { itemId: "pumpkin", qty: 12 });
  469. const sold = await callTool(client, "shop.sell", { shopId: "general-store", itemId: "pumpkin", qty: 12 });
  470. expect(sold.gold).toBe(640); // 100 + 12 * 45
  471. // Next day-start: restoration-quest completes -> flag.victory + 200 gold bonus + 3 eggs.
  472. const eggBefore = inv.count("egg");
  473. await callTool(client, "time.tick", { ticks: 480 });
  474. expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
  475. machineId: "restoration-quest",
  476. state: "complete",
  477. started: true,
  478. });
  479. expect(player()?.flag?.victory).toBe(true);
  480. // Expected base: 100 (start) + 12 * 45 (pumpkin sale) + 200 (restoration-quest bonus) = 840.
  481. // On or after Spring day 15, festival-spring additionally awards +150 gold for a total of 990.
  482. expect([840, 990]).toContain(player()?.gold);
  483. expect(inv.count("egg")).toBe(eggBefore + 3);
  484. // The Mayor's and Mia's victory branches are now gated in.
  485. const mayorAgain = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
  486. expect(mayorAgain.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is restored!");
  487. const mia = await callTool(client, "dialogue.start", { dialogueId: "mia" });
  488. expect(mia.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is thriving now!");
  489. } finally {
  490. await close();
  491. }
  492. });
  493. });
  494. async function ensureWatered(
  495. engine: ReturnType<typeof createEngine>,
  496. client: Client,
  497. x: number,
  498. y: number,
  499. ): Promise<void> {
  500. const plot = engine.world.get(`plot:${x}:${y}`) as { watered?: boolean } | undefined;
  501. if (!plot || plot.watered) return;
  502. await callTool(client, "crop.water", { x, y });
  503. }
  504. async function growAndHarvest(
  505. engine: ReturnType<typeof createEngine>,
  506. client: Client,
  507. plots: [number, number][],
  508. seedItemId: string,
  509. sproutDays: number,
  510. grownDays: number,
  511. ): Promise<void> {
  512. const planting = engine.systems.get<PlantingSystem>("planting");
  513. if (!planting) throw new Error("planting system not booted");
  514. for (const [x, y] of plots) {
  515. planting.till(x, y);
  516. planting.sow(x, y, seedItemId);
  517. await ensureWatered(engine, client, x, y);
  518. }
  519. await callTool(client, "time.tick", { ticks: 480 * sproutDays });
  520. for (const [x, y] of plots) {
  521. await ensureWatered(engine, client, x, y);
  522. }
  523. await callTool(client, "time.tick", { ticks: 480 * grownDays });
  524. for (const [x, y] of plots) {
  525. planting.harvest(x, y);
  526. }
  527. }