intent.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. createEngine,
  7. type BuildingDef,
  8. type CropDef,
  9. type DialogueDef,
  10. type GrowthSystem,
  11. type InteractAction,
  12. type IntentSystem,
  13. type InventorySystem,
  14. type ItemDef,
  15. type PlacementSystem,
  16. type PlayerIntentEvent,
  17. type ShopDef,
  18. type System,
  19. } from "../src/index.js";
  20. const TIME_YAML = [
  21. "id: default",
  22. "dayLengthTicks: 480",
  23. "seasons:",
  24. " - { name: spring, dayCount: 28 }",
  25. "start: { day: 1, season: spring }",
  26. "",
  27. ].join("\n");
  28. const FARM_YAML = [
  29. "id: farm",
  30. "width: 5",
  31. "height: 5",
  32. 'tilesetRef: "tiles"',
  33. "spawn: { x: 0, y: 0 }",
  34. "layers:",
  35. " - name: ground",
  36. " data:",
  37. " - [grass, grass, water, water, water]",
  38. " - [grass, grass, water, water, water]",
  39. " - [grass, grass, grass, grass, grass]",
  40. " - [grass, grass, grass, grass, grass]",
  41. " - [grass, grass, grass, grass, grass]",
  42. "",
  43. ].join("\n");
  44. const PARSNIP_SEED: ItemDef = { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true };
  45. const CARROT_SEED: ItemDef = { id: "carrot-seed", name: "Carrot Seed", kind: "seed", stackable: true };
  46. const HOE: ItemDef = { id: "hoe", name: "Hoe", kind: "tool", stackable: false };
  47. const PARSNIP: ItemDef = { id: "parsnip", name: "Parsnip", kind: "material", stackable: true };
  48. const PARSNIP_CROP: CropDef = {
  49. id: "parsnip",
  50. seedItem: "parsnip-seed",
  51. stages: [
  52. { name: "sprout", days: 1, waterRequired: true },
  53. { name: "mature", days: 1, waterRequired: false },
  54. ],
  55. seasons: ["spring"],
  56. drops: [{ itemId: "parsnip", qty: 1 }],
  57. };
  58. const CARROT_CROP: CropDef = {
  59. id: "carrot",
  60. seedItem: "carrot-seed",
  61. stages: [
  62. { name: "sprout", days: 1, waterRequired: true },
  63. { name: "mature", days: 1, waterRequired: false },
  64. ],
  65. seasons: ["spring"],
  66. drops: [{ itemId: "carrot", qty: 1 }],
  67. };
  68. const SHED: BuildingDef = {
  69. id: "shed",
  70. footprint: { w: 2, h: 2 },
  71. buildable: "map",
  72. blocksMovement: true,
  73. };
  74. const GENERAL_STORE: ShopDef = {
  75. id: "general-store",
  76. buys: [{ itemId: "parsnip", price: 30 }],
  77. sells: [
  78. { itemId: "hoe", price: 500 },
  79. { itemId: "parsnip-seed", price: 20, stock: 99 },
  80. ],
  81. };
  82. const MAYOR_DIALOGUE: DialogueDef = {
  83. id: "mayor",
  84. startNode: "greeting",
  85. nodes: [
  86. { id: "greeting", text: "Hello!", choices: [{ text: "Bye", to: "farewell" }] },
  87. { id: "farewell", text: "See ya" },
  88. ],
  89. };
  90. interface Setup {
  91. engine: ReturnType<typeof createEngine>;
  92. close: () => Promise<void>;
  93. }
  94. async function setup(spy?: System): Promise<Setup> {
  95. const tmp = await mkdtemp(join(tmpdir(), "farm-intent-"));
  96. await mkdir(join(tmp, "maps"), { recursive: true });
  97. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  98. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  99. const systems: System[] = [];
  100. if (spy) systems.push(spy);
  101. const engine = createEngine({ contentDir: tmp, systems });
  102. await engine.start();
  103. for (const item of [PARSNIP_SEED, CARROT_SEED, HOE, PARSNIP]) {
  104. engine.registry.register("item", item);
  105. }
  106. engine.registry.register("crop", PARSNIP_CROP);
  107. engine.registry.register("crop", CARROT_CROP);
  108. engine.registry.register("building", SHED);
  109. engine.registry.register("shop", GENERAL_STORE);
  110. engine.registry.register("dialogue", MAYOR_DIALOGUE);
  111. return {
  112. engine,
  113. close: async () => {
  114. await engine.stop();
  115. await rm(tmp, { recursive: true, force: true });
  116. },
  117. };
  118. }
  119. function intentOf(engine: ReturnType<typeof createEngine>): IntentSystem {
  120. const intent = engine.systems.get<IntentSystem>("intent");
  121. if (!intent) throw new Error("intent system not booted");
  122. return intent;
  123. }
  124. function spawnNpc(engine: ReturnType<typeof createEngine>, id: string, x: number, y: number, dialogue?: string): void {
  125. engine.world.create({ id, type: "npc", x, y, ...(dialogue !== undefined ? { dialogue } : {}) });
  126. }
  127. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  128. const seen: { event: string; payload: unknown }[] = [];
  129. const system: System = {
  130. id: "spy",
  131. init() {},
  132. on(event, payload) {
  133. seen.push({ event, payload });
  134. },
  135. };
  136. return { system, seen };
  137. }
  138. function intentEvents(seen: { event: string; payload: unknown }[]): PlayerIntentEvent[] {
  139. return seen.filter((entry) => entry.event === "player-intent").map((entry) => entry.payload as PlayerIntentEvent);
  140. }
  141. describe("IntentSystem.handle move", () => {
  142. it("moves the player entity to a walkable tile and emits player-intent", async () => {
  143. const { system: spy, seen } = eventSpy();
  144. const { engine, close } = await setup(spy);
  145. try {
  146. const result = intentOf(engine).handle("move", { tile: { x: 2, y: 2 } });
  147. expect(result).toMatchObject({ action: "move", tile: { x: 2, y: 2 }, ok: true, detail: "moved to (2,2)" });
  148. expect(engine.world.get("player")).toMatchObject({ x: 2, y: 2 });
  149. expect(intentEvents(seen)).toEqual([
  150. { action: "move", tile: { x: 2, y: 2 }, ok: true, detail: "moved to (2,2)" },
  151. ]);
  152. } finally {
  153. await close();
  154. }
  155. });
  156. it("rejects water tiles, out-of-bounds targets, and building-blocked cells", async () => {
  157. const { engine, close } = await setup();
  158. try {
  159. const water = intentOf(engine).handle("move", { tile: { x: 2, y: 0 } });
  160. expect(water).toMatchObject({ ok: false, error: "intent.move: tile (2,0) is not walkable" });
  161. const bounds = intentOf(engine).handle("move", { tile: { x: 5, y: 0 } });
  162. expect(bounds).toMatchObject({ ok: false, error: "intent.move: target out of bounds: (5,0)" });
  163. const placement = engine.systems.get<PlacementSystem>("placement");
  164. if (!placement) throw new Error("placement system not booted");
  165. placement.place("shed", 0, 0);
  166. const blocked = intentOf(engine).handle("move", { tile: { x: 0, y: 0 } });
  167. expect(blocked).toMatchObject({ ok: false, error: "intent.move: blocked by building building:shed:0:0 at (0,0)" });
  168. expect(engine.world.get("player")).toMatchObject({ x: 0, y: 0 });
  169. } finally {
  170. await close();
  171. }
  172. });
  173. it("emits player-intent for failed moves too", async () => {
  174. const { system: spy, seen } = eventSpy();
  175. const { engine, close } = await setup(spy);
  176. try {
  177. intentOf(engine).handle("move", { tile: { x: 2, y: 0 } });
  178. const events = intentEvents(seen);
  179. expect(events).toHaveLength(1);
  180. expect(events[0]).toMatchObject({ action: "move", tile: { x: 2, y: 0 }, ok: false });
  181. expect(events[0].error).toMatch(/not walkable/);
  182. } finally {
  183. await close();
  184. }
  185. });
  186. });
  187. describe("IntentSystem.handle interact", () => {
  188. it("tills an un-tilled tile (creating the plot entity)", async () => {
  189. const { engine, close } = await setup();
  190. try {
  191. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  192. expect(result).toMatchObject({ action: "interact", tile: { x: 3, y: 3 }, ok: true, detail: "tilled plot" });
  193. expect(engine.world.get("plot:3:3")).toMatchObject({ type: "plot", x: 3, y: 3, tilled: true });
  194. } finally {
  195. await close();
  196. }
  197. });
  198. it("tills a pre-tilled tile that has no plot entity yet", async () => {
  199. const { engine, close } = await setup();
  200. try {
  201. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  202. expect(result.ok).toBe(true);
  203. expect(result.detail).toBe("tilled plot");
  204. } finally {
  205. await close();
  206. }
  207. });
  208. it("sows the first seed-kind item in the player inventory on a tilled empty plot", async () => {
  209. const { engine, close } = await setup();
  210. try {
  211. const inv = engine.systems.get<InventorySystem>("inventory");
  212. if (!inv) throw new Error("inventory system not booted");
  213. inv.give("parsnip-seed", 3);
  214. inv.give("carrot-seed", 2);
  215. inv.give("hoe", 1);
  216. const planting = engine.systems.get("planting");
  217. if (!planting) throw new Error("planting system not booted");
  218. planting.till(3, 3);
  219. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  220. expect(result).toMatchObject({ action: "interact", ok: true, detail: "sowed Parsnip Seed" });
  221. expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip", stageIndex: 0 });
  222. expect(inv.count("parsnip-seed")).toBe(2);
  223. expect(inv.count("carrot-seed")).toBe(2);
  224. } finally {
  225. await close();
  226. }
  227. });
  228. it("fails with 'no seed in inventory' when the player has no seeds", async () => {
  229. const { engine, close } = await setup();
  230. try {
  231. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  232. expect(result.ok).toBe(true);
  233. expect(result.detail).toBe("tilled plot");
  234. const again = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  235. expect(again).toMatchObject({ ok: false, error: "no seed in inventory" });
  236. } finally {
  237. await close();
  238. }
  239. });
  240. it("waters a planted plot that is not watered", async () => {
  241. const { engine, close } = await setup();
  242. try {
  243. const inv = engine.systems.get<InventorySystem>("inventory");
  244. if (!inv) throw new Error("inventory system not booted");
  245. inv.give("parsnip-seed", 1);
  246. const planting = engine.systems.get("planting");
  247. if (!planting) throw new Error("planting system not booted");
  248. planting.till(2, 2);
  249. planting.sow(2, 2, "parsnip-seed");
  250. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  251. expect(result).toMatchObject({ action: "interact", ok: true, detail: "watered plot" });
  252. expect(engine.world.get("plot:2:2")).toMatchObject({ watered: true });
  253. } finally {
  254. await close();
  255. }
  256. });
  257. it("errors when a planted watered crop is not mature", async () => {
  258. const { engine, close } = await setup();
  259. try {
  260. const inv = engine.systems.get<InventorySystem>("inventory");
  261. if (!inv) throw new Error("inventory system not booted");
  262. inv.give("parsnip-seed", 1);
  263. const planting = engine.systems.get("planting");
  264. if (!planting) throw new Error("planting system not booted");
  265. planting.till(2, 2);
  266. planting.sow(2, 2, "parsnip-seed");
  267. planting.water(2, 2);
  268. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  269. expect(result).toMatchObject({ ok: false, error: "crop parsnip is not ready to harvest" });
  270. } finally {
  271. await close();
  272. }
  273. });
  274. it("harvests a mature crop and grants the drop", async () => {
  275. const { engine, close } = await setup();
  276. try {
  277. const inv = engine.systems.get<InventorySystem>("inventory");
  278. if (!inv) throw new Error("inventory system not booted");
  279. inv.give("parsnip-seed", 1);
  280. const planting = engine.systems.get("planting");
  281. const growth = engine.systems.get<GrowthSystem>("growth");
  282. if (!planting || !growth) throw new Error("systems not booted");
  283. planting.till(2, 2);
  284. planting.sow(2, 2, "parsnip-seed");
  285. growth.forceStage("plot:2:2", 1);
  286. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  287. expect(result).toMatchObject({ action: "interact", ok: true, detail: "harvested parsnip" });
  288. expect(inv.count("parsnip")).toBe(1);
  289. expect(engine.world.get("plot:2:2")).not.toMatchObject({ cropItemId: "parsnip" });
  290. } finally {
  291. await close();
  292. }
  293. });
  294. });
  295. describe("IntentSystem.handle interact order (D33 content-ized)", () => {
  296. it("custom order [water, harvest, ...] waters a mature unwatered crop (default would harvest)", async () => {
  297. const { engine, close } = await setup();
  298. try {
  299. engine.registry.register("interact", { id: "default", order: ["water", "harvest", "sow", "till"] });
  300. const inv = engine.systems.get<InventorySystem>("inventory");
  301. if (!inv) throw new Error("inventory system not booted");
  302. inv.give("parsnip-seed", 1);
  303. const planting = engine.systems.get("planting");
  304. const growth = engine.systems.get<GrowthSystem>("growth");
  305. if (!planting || !growth) throw new Error("systems not booted");
  306. planting.till(2, 2);
  307. planting.sow(2, 2, "parsnip-seed");
  308. growth.forceStage("plot:2:2", 1);
  309. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  310. expect(result).toMatchObject({ action: "interact", ok: true, detail: "watered plot" });
  311. expect(engine.world.get("plot:2:2")).toMatchObject({ watered: true, cropItemId: "parsnip" });
  312. } finally {
  313. await close();
  314. }
  315. });
  316. it("an interact def with a non-default id is picked as the first def of the kind", async () => {
  317. const { engine, close } = await setup();
  318. try {
  319. engine.registry.register("interact", { id: "custom-order", order: ["water", "harvest", "sow", "till"] });
  320. const inv = engine.systems.get<InventorySystem>("inventory");
  321. if (!inv) throw new Error("inventory system not booted");
  322. inv.give("parsnip-seed", 1);
  323. const planting = engine.systems.get("planting");
  324. const growth = engine.systems.get<GrowthSystem>("growth");
  325. if (!planting || !growth) throw new Error("systems not booted");
  326. planting.till(2, 2);
  327. planting.sow(2, 2, "parsnip-seed");
  328. growth.forceStage("plot:2:2", 1);
  329. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  330. expect(result).toMatchObject({ ok: true, detail: "watered plot" });
  331. } finally {
  332. await close();
  333. }
  334. });
  335. it("order [sow, till, ...] on an untilled tile without seed falls through to till", async () => {
  336. const { engine, close } = await setup();
  337. try {
  338. engine.registry.register("interact", { id: "default", order: ["sow", "till", "harvest", "water"] });
  339. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  340. expect(result).toMatchObject({ action: "interact", ok: true, detail: "tilled plot" });
  341. expect(engine.world.get("plot:3:3")).toMatchObject({ type: "plot", x: 3, y: 3, tilled: true });
  342. } finally {
  343. await close();
  344. }
  345. });
  346. it("order [harvest, sow, till, ...] picks sow over till on a tilled-empty plot with seed", async () => {
  347. const { engine, close } = await setup();
  348. try {
  349. engine.registry.register("interact", { id: "default", order: ["harvest", "sow", "till", "water"] });
  350. const inv = engine.systems.get<InventorySystem>("inventory");
  351. if (!inv) throw new Error("inventory system not booted");
  352. inv.give("parsnip-seed", 1);
  353. const planting = engine.systems.get("planting");
  354. if (!planting) throw new Error("planting system not booted");
  355. planting.till(3, 3);
  356. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  357. expect(result).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" });
  358. expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip" });
  359. } finally {
  360. await close();
  361. }
  362. });
  363. it("the default-id def wins over other interact defs", async () => {
  364. const { engine, close } = await setup();
  365. try {
  366. engine.registry.register("interact", { id: "custom-order", order: ["harvest", "water", "sow", "till"] });
  367. engine.registry.register("interact", { id: "default", order: ["water", "harvest", "sow", "till"] });
  368. const inv = engine.systems.get<InventorySystem>("inventory");
  369. if (!inv) throw new Error("inventory system not booted");
  370. inv.give("parsnip-seed", 1);
  371. const planting = engine.systems.get("planting");
  372. const growth = engine.systems.get<GrowthSystem>("growth");
  373. if (!planting || !growth) throw new Error("systems not booted");
  374. planting.till(2, 2);
  375. planting.sow(2, 2, "parsnip-seed");
  376. growth.forceStage("plot:2:2", 1);
  377. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  378. expect(result).toMatchObject({ ok: true, detail: "watered plot" });
  379. } finally {
  380. await close();
  381. }
  382. });
  383. it("without any interact def the built-in default order applies (harvest beats water)", async () => {
  384. const { engine, close } = await setup();
  385. try {
  386. const inv = engine.systems.get<InventorySystem>("inventory");
  387. if (!inv) throw new Error("inventory system not booted");
  388. inv.give("parsnip-seed", 1);
  389. const planting = engine.systems.get("planting");
  390. const growth = engine.systems.get<GrowthSystem>("growth");
  391. if (!planting || !growth) throw new Error("systems not booted");
  392. planting.till(2, 2);
  393. planting.sow(2, 2, "parsnip-seed");
  394. growth.forceStage("plot:2:2", 1);
  395. const result = intentOf(engine).handle("interact", { tile: { x: 2, y: 2 } });
  396. expect(result).toMatchObject({ ok: true, detail: "harvested parsnip" });
  397. } finally {
  398. await close();
  399. }
  400. });
  401. });
  402. describe("IntentSystem.handle validation", () => {
  403. it("rejects a malformed tile", async () => {
  404. const { system: spy, seen } = eventSpy();
  405. const { engine, close } = await setup(spy);
  406. try {
  407. const result = intentOf(engine).handle("move", { tile: "nope" });
  408. expect(result).toMatchObject({ ok: false, error: "intent: tile must be {x, y} non-negative integers" });
  409. expect(result.tile).toEqual({ x: 0, y: 0 });
  410. expect(intentEvents(seen)).toHaveLength(1);
  411. } finally {
  412. await close();
  413. }
  414. });
  415. it("rejects an unknown action and still emits player-intent", async () => {
  416. const { system: spy, seen } = eventSpy();
  417. const { engine, close } = await setup(spy);
  418. try {
  419. const result = intentOf(engine).handle("dance", { tile: { x: 1, y: 1 } });
  420. expect(result).toMatchObject({ ok: false, error: "intent: unknown action: dance" });
  421. expect(intentEvents(seen)).toHaveLength(1);
  422. } finally {
  423. await close();
  424. }
  425. });
  426. });
  427. describe("IntentSystem.handle buy/sell", () => {
  428. it("buy: purchases an item with gold, emits player-intent, and reports the transaction detail", async () => {
  429. const { system: spy, seen } = eventSpy();
  430. const { engine, close } = await setup(spy);
  431. try {
  432. const player = engine.world.get("player");
  433. if (!player) throw new Error("player entity not seeded");
  434. player.gold = 100;
  435. const result = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 2 });
  436. expect(result).toMatchObject({ action: "buy", ok: true });
  437. expect(result.detail).toEqual({ shopId: "general-store", itemId: "parsnip-seed", qty: 2, total: 40, gold: 60 });
  438. const inv = engine.systems.get<InventorySystem>("inventory");
  439. if (!inv) throw new Error("inventory system not booted");
  440. expect(inv.count("parsnip-seed")).toBe(2);
  441. const events = intentEvents(seen);
  442. expect(events).toHaveLength(1);
  443. expect(events[0]).toMatchObject({ action: "buy", ok: true });
  444. } finally {
  445. await close();
  446. }
  447. });
  448. it("buy: defaults qty to 1 and rejects missing itemId, missing shopId, and non-positive qty", async () => {
  449. const { engine, close } = await setup();
  450. try {
  451. const player = engine.world.get("player");
  452. if (!player) throw new Error("player entity not seeded");
  453. player.gold = 100;
  454. const single = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed" });
  455. expect(single.ok).toBe(true);
  456. expect(single.detail).toMatchObject({ qty: 1, total: 20, gold: 80 });
  457. const missingItem = intentOf(engine).handle("buy", { shopId: "general-store" });
  458. expect(missingItem).toMatchObject({ ok: false, error: "player.intent: buy requires itemId" });
  459. const missingShop = intentOf(engine).handle("buy", { itemId: "parsnip-seed" });
  460. expect(missingShop).toMatchObject({ ok: false, error: "player.intent: buy requires shopId" });
  461. const badQty = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 0 });
  462. expect(badQty).toMatchObject({ ok: false, error: "player.intent: buy qty must be a positive integer" });
  463. const floatQty = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip-seed", qty: 1.5 });
  464. expect(floatQty.ok).toBe(false);
  465. } finally {
  466. await close();
  467. }
  468. });
  469. it("buy: passthrough economy errors — unknown shop, not sold, insufficient gold", async () => {
  470. const { engine, close } = await setup();
  471. try {
  472. const player = engine.world.get("player");
  473. if (!player) throw new Error("player entity not seeded");
  474. player.gold = 10;
  475. const unknownShop = intentOf(engine).handle("buy", { shopId: "nope", itemId: "parsnip-seed" });
  476. expect(unknownShop).toMatchObject({ ok: false, error: "economy: unknown shop: nope" });
  477. const notSold = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "parsnip" });
  478. expect(notSold).toMatchObject({ ok: false, error: "economy.buy: general-store does not sell parsnip" });
  479. const broke = intentOf(engine).handle("buy", { shopId: "general-store", itemId: "hoe", qty: 1 });
  480. expect(broke).toMatchObject({ ok: false, error: "economy.buy: insufficient gold (need 500, have 10)" });
  481. } finally {
  482. await close();
  483. }
  484. });
  485. it("sell: sells an owned item, reports the transaction, and emits player-intent", async () => {
  486. const { system: spy, seen } = eventSpy();
  487. const { engine, close } = await setup(spy);
  488. try {
  489. const inv = engine.systems.get<InventorySystem>("inventory");
  490. if (!inv) throw new Error("inventory system not booted");
  491. inv.give("parsnip", 3);
  492. const result = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "parsnip", qty: 2 });
  493. expect(result.ok).toBe(true);
  494. expect(result.detail).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 2, total: 60, gold: 60 });
  495. expect(inv.count("parsnip")).toBe(1);
  496. expect(intentEvents(seen)).toHaveLength(1);
  497. } finally {
  498. await close();
  499. }
  500. });
  501. it("sell: passthrough economy errors — not bought by the shop, insufficient inventory, missing itemId", async () => {
  502. const { engine, close } = await setup();
  503. try {
  504. const missingItem = intentOf(engine).handle("sell", { shopId: "general-store" });
  505. expect(missingItem).toMatchObject({ ok: false, error: "player.intent: sell requires itemId" });
  506. const notBought = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "hoe" });
  507. expect(notBought).toMatchObject({ ok: false, error: "economy.sell: general-store does not buy hoe" });
  508. const inv = engine.systems.get<InventorySystem>("inventory");
  509. if (!inv) throw new Error("inventory system not booted");
  510. inv.give("parsnip", 1);
  511. const short = intentOf(engine).handle("sell", { shopId: "general-store", itemId: "parsnip", qty: 9 });
  512. expect(short).toMatchObject({
  513. ok: false,
  514. error: "economy.sell: insufficient parsnip in inventory (have 1, need 9)",
  515. });
  516. } finally {
  517. await close();
  518. }
  519. });
  520. });
  521. describe("IntentSystem.handle talk/choose", () => {
  522. it("talk: starts an npc's dialogue by npcId and reports the DialogueState", async () => {
  523. const { system: spy, seen } = eventSpy();
  524. const { engine, close } = await setup(spy);
  525. try {
  526. spawnNpc(engine, "npc:mayor", 6, 3, "mayor");
  527. const result = intentOf(engine).handle("talk", { npcId: "npc:mayor" });
  528. expect(result.ok).toBe(true);
  529. expect(result.detail).toEqual({
  530. dialogueId: "mayor",
  531. nodeId: "greeting",
  532. text: "Hello!",
  533. choices: [{ text: "Bye", to: "farewell" }],
  534. });
  535. expect(engine.world.get("dialogue:active")).toMatchObject({ dialogueId: "mayor", nodeId: "greeting" });
  536. const events = intentEvents(seen);
  537. expect(events[events.length - 1]).toMatchObject({ action: "talk", ok: true });
  538. } finally {
  539. await close();
  540. }
  541. });
  542. it("talk: resolves the npc from a tile when npcId is absent", async () => {
  543. const { engine, close } = await setup();
  544. try {
  545. spawnNpc(engine, "npc:mayor", 6, 3, "mayor");
  546. const result = intentOf(engine).handle("talk", { tile: { x: 6, y: 3 } });
  547. expect(result.ok).toBe(true);
  548. expect(result.detail).toMatchObject({ dialogueId: "mayor" });
  549. } finally {
  550. await close();
  551. }
  552. });
  553. it("talk: errors — no npcId and no tile, unknown npc, no npc at tile, npc without dialogue", async () => {
  554. const { engine, close } = await setup();
  555. try {
  556. const noTarget = intentOf(engine).handle("talk", {});
  557. expect(noTarget).toMatchObject({ ok: false, error: "player.intent: talk requires npcId or tile" });
  558. const unknown = intentOf(engine).handle("talk", { npcId: "ghost" });
  559. expect(unknown).toMatchObject({ ok: false, error: "player.intent: unknown npc: ghost" });
  560. const noNpcAtTile = intentOf(engine).handle("talk", { tile: { x: 4, y: 4 } });
  561. expect(noNpcAtTile).toMatchObject({ ok: false, error: "player.intent: no npc at tile" });
  562. spawnNpc(engine, "npc:mute", 6, 3);
  563. const noDialogue = intentOf(engine).handle("talk", { npcId: "npc:mute" });
  564. expect(noDialogue).toMatchObject({ ok: false, error: "player.intent: npc npc:mute has no dialogue" });
  565. } finally {
  566. await close();
  567. }
  568. });
  569. it("choose: advances the dialogue and errors on no-active / non-integer / out-of-range", async () => {
  570. const { engine, close } = await setup();
  571. try {
  572. const noActive = intentOf(engine).handle("choose", { index: 0 });
  573. expect(noActive).toMatchObject({ ok: false, error: "player.intent: no active dialogue" });
  574. const badIndex = intentOf(engine).handle("choose", { index: "x" });
  575. expect(badIndex).toMatchObject({ ok: false, error: "player.intent: choose requires a non-negative integer index" });
  576. spawnNpc(engine, "npc:mayor", 6, 3, "mayor");
  577. intentOf(engine).handle("talk", { npcId: "npc:mayor" });
  578. const outOfRange = intentOf(engine).handle("choose", { index: 5 });
  579. expect(outOfRange).toMatchObject({ ok: false, error: "dialogue: choice index out of range: 5" });
  580. const chosen = intentOf(engine).handle("choose", { index: 0 });
  581. expect(chosen.ok).toBe(true);
  582. expect(chosen.detail).toEqual({ dialogueId: "mayor", nodeId: "farewell", text: "See ya", choices: [] });
  583. expect(engine.world.get("dialogue:active")).toBeUndefined();
  584. } finally {
  585. await close();
  586. }
  587. });
  588. });
  589. describe("IntentSystem.handle select-seed and the selectedSeed sow fix", () => {
  590. it("select-seed: sets player.selectedSeed for a seed item and clears when absent", async () => {
  591. const { engine, close } = await setup();
  592. try {
  593. const set = intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" });
  594. expect(set).toMatchObject({ action: "select-seed", ok: true, detail: { selectedSeed: "parsnip-seed" } });
  595. expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" });
  596. const cleared = intentOf(engine).handle("select-seed", {});
  597. expect(cleared).toMatchObject({ ok: true, detail: { selectedSeed: null } });
  598. expect((engine.world.get("player") as { selectedSeed?: string }).selectedSeed).toBeUndefined();
  599. } finally {
  600. await close();
  601. }
  602. });
  603. it("select-seed: rejects non-seed and unknown items", async () => {
  604. const { engine, close } = await setup();
  605. try {
  606. const notASeed = intentOf(engine).handle("select-seed", { seedItemId: "hoe" });
  607. expect(notASeed).toMatchObject({ ok: false, error: "player.intent: hoe is not a seed item" });
  608. const unknown = intentOf(engine).handle("select-seed", { seedItemId: "ghost" });
  609. expect(unknown).toMatchObject({ ok: false, error: "player.intent: ghost is not a seed item" });
  610. } finally {
  611. await close();
  612. }
  613. });
  614. it("sow uses the selected seed over the first inventory seed and keeps the selection", async () => {
  615. const { engine, close } = await setup();
  616. try {
  617. const inv = engine.systems.get<InventorySystem>("inventory");
  618. if (!inv) throw new Error("inventory system not booted");
  619. inv.give("carrot-seed", 2);
  620. inv.give("parsnip-seed", 3);
  621. intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" });
  622. const planting = engine.systems.get("planting");
  623. if (!planting) throw new Error("planting system not booted");
  624. planting.till(3, 3);
  625. const result = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  626. expect(result).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" });
  627. expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "parsnip" });
  628. expect(inv.count("parsnip-seed")).toBe(2);
  629. expect(inv.count("carrot-seed")).toBe(2);
  630. expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" });
  631. } finally {
  632. await close();
  633. }
  634. });
  635. it("sow falls back to the first inventory seed when the selected seed is not owned or not a seed", async () => {
  636. const { engine, close } = await setup();
  637. try {
  638. const inv = engine.systems.get<InventorySystem>("inventory");
  639. if (!inv) throw new Error("inventory system not booted");
  640. const planting = engine.systems.get("planting");
  641. if (!planting) throw new Error("planting system not booted");
  642. inv.give("carrot-seed", 2);
  643. intentOf(engine).handle("select-seed", { seedItemId: "parsnip-seed" });
  644. planting.till(3, 3);
  645. const notOwned = intentOf(engine).handle("interact", { tile: { x: 3, y: 3 } });
  646. expect(notOwned).toMatchObject({ ok: true, detail: "sowed Carrot Seed" });
  647. expect(engine.world.get("plot:3:3")).toMatchObject({ cropItemId: "carrot" });
  648. const player = engine.world.get("player");
  649. if (!player) throw new Error("player entity not seeded");
  650. player.selectedSeed = "hoe";
  651. inv.take("carrot-seed", 1);
  652. inv.give("hoe", 1);
  653. inv.give("parsnip-seed", 1);
  654. planting.till(4, 4);
  655. const invalid = intentOf(engine).handle("interact", { tile: { x: 4, y: 4 } });
  656. expect(invalid).toMatchObject({ ok: true, detail: "sowed Parsnip Seed" });
  657. } finally {
  658. await close();
  659. }
  660. });
  661. });