trigger.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. createEngine,
  5. TriggerSystem,
  6. type ConditionExpr,
  7. type InventorySystem,
  8. type System,
  9. type TimeSystem,
  10. type TriggerDef,
  11. type TriggerEvalCtx,
  12. } from "../src/index.js";
  13. const repoRoot = resolve(process.cwd(), "../..");
  14. const contentDir = resolve(repoRoot, "content");
  15. const modsDir = resolve(repoRoot, "mods");
  16. function engineSetup(spy?: System) {
  17. return createEngine({ contentDir, modsDir, systems: spy ? [spy] : [] });
  18. }
  19. function triggerOf(engine: ReturnType<typeof createEngine>): TriggerSystem {
  20. const trigger = engine.systems.get<TriggerSystem>("trigger");
  21. if (!trigger) throw new Error("trigger system not booted");
  22. return trigger;
  23. }
  24. function timeOf(engine: ReturnType<typeof createEngine>): TimeSystem {
  25. const time = engine.systems.get<TimeSystem>("time");
  26. if (!time) throw new Error("time system not booted");
  27. return time;
  28. }
  29. function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
  30. const inv = engine.systems.get<InventorySystem>("inventory");
  31. if (!inv) throw new Error("inventory system not booted");
  32. return inv;
  33. }
  34. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  35. const seen: { event: string; payload: unknown }[] = [];
  36. const system: System = {
  37. id: "spy",
  38. init() {},
  39. on(event, payload) {
  40. seen.push({ event, payload });
  41. },
  42. };
  43. return { system, seen };
  44. }
  45. function ctxOf(values: Record<string, unknown>): TriggerEvalCtx {
  46. return { get: (path) => values[path] };
  47. }
  48. describe("TriggerSystem.evaluate", () => {
  49. it("evaluates eq/gte/lte/exists comparisons", () => {
  50. const system = new TriggerSystem();
  51. const ctx = ctxOf({ "time.day": 5, "time.season": "summer", "player.flag": undefined });
  52. const eq: ConditionExpr = { op: "eq", path: "time.season", value: "summer" };
  53. expect(system.evaluate(eq, ctx)).toBe(true);
  54. expect(system.evaluate({ ...eq, value: "winter" }, ctx)).toBe(false);
  55. const gte: ConditionExpr = { op: "gte", path: "time.day", value: 5 };
  56. expect(system.evaluate(gte, ctx)).toBe(true);
  57. expect(system.evaluate({ op: "gte", path: "time.day", value: 6 }, ctx)).toBe(false);
  58. const lte: ConditionExpr = { op: "lte", path: "time.day", value: 5 };
  59. expect(system.evaluate(lte, ctx)).toBe(true);
  60. expect(system.evaluate({ op: "lte", path: "time.day", value: 4 }, ctx)).toBe(false);
  61. const exists: ConditionExpr = { op: "exists", path: "player.flag" };
  62. expect(system.evaluate(exists, ctx)).toBe(false);
  63. expect(system.evaluate({ op: "exists", path: "time.day" }, ctx)).toBe(true);
  64. });
  65. it("gte/lte are false when either operand is not a number", () => {
  66. const system = new TriggerSystem();
  67. expect(system.evaluate({ op: "gte", path: "time.season", value: "summer" }, ctxOf({ "time.season": "summer" }))).toBe(false);
  68. expect(system.evaluate({ op: "gte", path: "time.day", value: "5" }, ctxOf({ "time.day": 5 }))).toBe(false);
  69. expect(system.evaluate({ op: "lte", path: "time.day", value: 5 }, ctxOf({ "time.day": undefined }))).toBe(false);
  70. });
  71. it("evaluates and/or/not composition", () => {
  72. const system = new TriggerSystem();
  73. const ctx = ctxOf({ "time.day": 1, "time.season": "summer", "player.flag": "welcomed" });
  74. const andTrue: ConditionExpr = {
  75. and: [
  76. { op: "eq", path: "time.day", value: 1 },
  77. { op: "eq", path: "time.season", value: "summer" },
  78. ],
  79. };
  80. expect(system.evaluate(andTrue, ctx)).toBe(true);
  81. expect(system.evaluate({ and: [...andTrue.and, { op: "eq", path: "time.day", value: 2 }] }, ctx)).toBe(false);
  82. const orTrue: ConditionExpr = {
  83. or: [
  84. { op: "eq", path: "time.day", value: 99 },
  85. { op: "eq", path: "time.season", value: "summer" },
  86. ],
  87. };
  88. expect(system.evaluate(orTrue, ctx)).toBe(true);
  89. expect(system.evaluate({ or: [{ op: "eq", path: "time.day", value: 99 }, { op: "eq", path: "time.season", value: "winter" }] }, ctx)).toBe(false);
  90. const notTrue: ConditionExpr = { not: { op: "eq", path: "time.season", value: "winter" } };
  91. expect(system.evaluate(notTrue, ctx)).toBe(true);
  92. expect(system.evaluate({ not: { op: "eq", path: "time.season", value: "summer" } }, ctx)).toBe(false);
  93. });
  94. });
  95. describe("TriggerSystem.fire", () => {
  96. it("condition true runs actions, emits trigger-fire, and fires the onTriggerFire hook", async () => {
  97. const { system: spy, seen } = eventSpy();
  98. const engine = engineSetup(spy);
  99. await engine.start();
  100. try {
  101. const hookFired: unknown[] = [];
  102. engine.hooks.on("onTriggerFire", (ctx) => {
  103. hookFired.push({ world: ctx.world.get("player")?.id, time: ctx.time.get().day });
  104. });
  105. timeOf(engine).set(5, "spring");
  106. const result = triggerOf(engine).fire("spring-gift");
  107. expect(result).toEqual({ triggerId: "spring-gift", fired: true });
  108. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  109. expect(hookFired).toEqual([{ world: "player", time: 5 }]);
  110. expect(seen.some((s) => s.event === "trigger-fire")).toBe(true);
  111. const firedEvent = seen.find((s) => s.event === "trigger-fire")?.payload as { triggerId: string };
  112. expect(firedEvent).toEqual({ triggerId: "spring-gift" });
  113. } finally {
  114. await engine.stop();
  115. }
  116. });
  117. it("condition false runs no actions and emits no trigger-fire", async () => {
  118. const { system: spy, seen } = eventSpy();
  119. const engine = engineSetup(spy);
  120. await engine.start();
  121. try {
  122. timeOf(engine).set(1, "spring");
  123. const result = triggerOf(engine).fire("spring-gift");
  124. expect(result).toEqual({ triggerId: "spring-gift", fired: false });
  125. expect(inventoryOf(engine).count("parsnip-seed")).toBe(0);
  126. expect(seen.some((s) => s.event === "trigger-fire")).toBe(false);
  127. } finally {
  128. await engine.stop();
  129. }
  130. });
  131. it("unknown trigger id throws", async () => {
  132. const engine = engineSetup();
  133. await engine.start();
  134. try {
  135. expect(() => triggerOf(engine).fire("nope")).toThrow(/unknown trigger: nope/);
  136. } finally {
  137. await engine.stop();
  138. }
  139. });
  140. it("set-state action mutates an entity field", async () => {
  141. const engine = engineSetup();
  142. await engine.start();
  143. try {
  144. engine.registry.register("behavior", {
  145. id: "mark-player",
  146. when: { op: "exists", path: "time.day" },
  147. fire: [{ action: "set-state", path: "player.flag", value: "welcomed" }],
  148. });
  149. const player = engine.world.get("player");
  150. if (!player) throw new Error("player entity not seeded");
  151. expect(player.flag).toBeUndefined();
  152. const result = triggerOf(engine).fire("mark-player");
  153. expect(result).toEqual({ triggerId: "mark-player", fired: true });
  154. expect(player.flag).toBe("welcomed");
  155. } finally {
  156. await engine.stop();
  157. }
  158. });
  159. it("set-state on a missing entity throws", async () => {
  160. const engine = engineSetup();
  161. await engine.start();
  162. try {
  163. engine.registry.register("behavior", {
  164. id: "bad-state",
  165. when: { op: "exists", path: "time.day" },
  166. fire: [{ action: "set-state", path: "world.ghost.state", value: 1 }],
  167. });
  168. expect(() => triggerOf(engine).fire("bad-state")).toThrow(/no entity: ghost/);
  169. } finally {
  170. await engine.stop();
  171. }
  172. });
  173. it("emit-event action emits the event with payload", async () => {
  174. const { system: spy, seen } = eventSpy();
  175. const engine = engineSetup(spy);
  176. await engine.start();
  177. try {
  178. engine.registry.register("behavior", {
  179. id: "hello",
  180. when: { op: "exists", path: "time.day" },
  181. fire: [{ action: "emit-event", event: "behavior:hello", payload: { greeting: "hi" } }],
  182. });
  183. const result = triggerOf(engine).fire("hello");
  184. expect(result).toEqual({ triggerId: "hello", fired: true });
  185. const emitted = seen.find((s) => s.event === "behavior:hello")?.payload as { greeting: string };
  186. expect(emitted).toEqual({ greeting: "hi" });
  187. } finally {
  188. await engine.stop();
  189. }
  190. });
  191. it("play-sound emits a sound-play event and dialogue actions are accepted no-ops in headless v1", async () => {
  192. const { system: spy, seen } = eventSpy();
  193. const engine = engineSetup(spy);
  194. await engine.start();
  195. try {
  196. engine.registry.register("behavior", {
  197. id: "flavor",
  198. when: { op: "exists", path: "time.day" },
  199. fire: [
  200. { action: "play-sound", sound: "chime" },
  201. { action: "dialogue", text: "hello" },
  202. ],
  203. });
  204. const result = triggerOf(engine).fire("flavor");
  205. expect(result).toEqual({ triggerId: "flavor", fired: true });
  206. expect(seen).toEqual([
  207. { event: "sound-play", payload: { soundId: "chime" } },
  208. { event: "trigger-fire", payload: { triggerId: "flavor" } },
  209. ]);
  210. } finally {
  211. await engine.stop();
  212. }
  213. });
  214. });
  215. describe("TriggerSystem add action (D33)", () => {
  216. it("add accumulates across multiple fires on a numeric field", async () => {
  217. const engine = engineSetup();
  218. await engine.start();
  219. try {
  220. engine.registry.register("behavior", {
  221. id: "gain-gold",
  222. when: { op: "exists", path: "time.day" },
  223. fire: [{ action: "add", path: "player.gold", value: 5 }],
  224. });
  225. const player = engine.world.get("player");
  226. if (!player) throw new Error("player entity not seeded");
  227. player.gold = 10;
  228. expect(triggerOf(engine).fire("gain-gold").fired).toBe(true);
  229. expect(triggerOf(engine).fire("gain-gold").fired).toBe(true);
  230. expect(player.gold).toBe(20);
  231. } finally {
  232. await engine.stop();
  233. }
  234. });
  235. it("negative add decrements a numeric field", async () => {
  236. const engine = engineSetup();
  237. await engine.start();
  238. try {
  239. engine.registry.register("behavior", {
  240. id: "spend-gold",
  241. when: { op: "exists", path: "time.day" },
  242. fire: [{ action: "add", path: "player.gold", value: -3 }],
  243. });
  244. const player = engine.world.get("player");
  245. if (!player) throw new Error("player entity not seeded");
  246. player.gold = 10;
  247. triggerOf(engine).fire("spend-gold");
  248. expect(player.gold).toBe(7);
  249. } finally {
  250. await engine.stop();
  251. }
  252. });
  253. it("a missing field starts from 0 then adds", async () => {
  254. const engine = engineSetup();
  255. await engine.start();
  256. try {
  257. engine.registry.register("behavior", {
  258. id: "first-gold",
  259. when: { op: "exists", path: "time.day" },
  260. fire: [{ action: "add", path: "player.gold", value: 100 }],
  261. });
  262. const player = engine.world.get("player");
  263. if (!player) throw new Error("player entity not seeded");
  264. delete player.gold;
  265. triggerOf(engine).fire("first-gold");
  266. expect(player.gold).toBe(100);
  267. triggerOf(engine).fire("first-gold");
  268. expect(player.gold).toBe(200);
  269. } finally {
  270. await engine.stop();
  271. }
  272. });
  273. it("add on a non-numeric field throws", async () => {
  274. const engine = engineSetup();
  275. await engine.start();
  276. try {
  277. engine.registry.register("behavior", {
  278. id: "bad-add",
  279. when: { op: "exists", path: "time.day" },
  280. fire: [{ action: "add", path: "player.gold", value: 1 }],
  281. });
  282. const player = engine.world.get("player");
  283. if (!player) throw new Error("player entity not seeded");
  284. player.gold = "rich";
  285. expect(() => triggerOf(engine).fire("bad-add")).toThrow(/action add: field player\.gold is not a number/);
  286. } finally {
  287. await engine.stop();
  288. }
  289. });
  290. it("add on a missing entity throws like set-state", async () => {
  291. const engine = engineSetup();
  292. await engine.start();
  293. try {
  294. engine.registry.register("behavior", {
  295. id: "bad-add-entity",
  296. when: { op: "exists", path: "time.day" },
  297. fire: [{ action: "add", path: "world.ghost.score", value: 1 }],
  298. });
  299. expect(() => triggerOf(engine).fire("bad-add-entity")).toThrow(/no entity: ghost/);
  300. } finally {
  301. await engine.stop();
  302. }
  303. });
  304. it("add works in state machine onEnter actions", async () => {
  305. const engine = engineSetup();
  306. await engine.start();
  307. try {
  308. engine.registry.register("statemachine", {
  309. id: "gold-machine",
  310. initial: "start",
  311. states: [
  312. { id: "start" },
  313. { id: "done", onEnter: [{ action: "add", path: "player.gold", value: 100 }] },
  314. ],
  315. transitions: [{ from: "start", to: "done", when: { op: "exists", path: "time.day" } }],
  316. });
  317. triggerOf(engine).machineTransition("gold-machine", "done");
  318. expect(engine.world.get("player")?.gold).toBe(100);
  319. } finally {
  320. await engine.stop();
  321. }
  322. });
  323. it("set-state remains absolute after adds", async () => {
  324. const engine = engineSetup();
  325. await engine.start();
  326. try {
  327. engine.registry.register("behavior", {
  328. id: "reset-gold",
  329. when: { op: "exists", path: "time.day" },
  330. fire: [{ action: "set-state", path: "player.gold", value: 42 }],
  331. });
  332. const player = engine.world.get("player");
  333. if (!player) throw new Error("player entity not seeded");
  334. player.gold = 100;
  335. triggerOf(engine).fire("reset-gold");
  336. expect(player.gold).toBe(42);
  337. } finally {
  338. await engine.stop();
  339. }
  340. });
  341. });
  342. describe("TriggerSystem.match", () => {
  343. it("runs triggers whose condition matches the event payload (time.day style ctx)", async () => {
  344. const { system: spy, seen } = eventSpy();
  345. const engine = engineSetup(spy);
  346. await engine.start();
  347. try {
  348. engine.registry.register("behavior", {
  349. id: "gift-on-parsnip",
  350. when: { op: "eq", path: "payload.itemId", value: "parsnip" },
  351. fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
  352. });
  353. timeOf(engine).set(1, "spring");
  354. inventoryOf(engine).give("parsnip", 1);
  355. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  356. expect(seen.some((s) => s.event === "trigger-fire")).toBe(true);
  357. inventoryOf(engine).give("hoe", 1);
  358. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  359. } finally {
  360. await engine.stop();
  361. }
  362. });
  363. it("spring-gift fires on any bus event once time.day >= 5", async () => {
  364. const engine = engineSetup();
  365. await engine.start();
  366. try {
  367. timeOf(engine).set(1, "spring");
  368. inventoryOf(engine).give("parsnip", 1);
  369. expect(inventoryOf(engine).count("parsnip-seed")).toBe(0);
  370. timeOf(engine).set(5, "spring");
  371. inventoryOf(engine).give("parsnip", 1);
  372. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  373. inventoryOf(engine).give("parsnip", 1);
  374. expect(inventoryOf(engine).count("parsnip-seed")).toBe(2);
  375. } finally {
  376. await engine.stop();
  377. }
  378. });
  379. it("trigger-fire events are never re-matched (v1 loop guard)", async () => {
  380. const { system: spy, seen } = eventSpy();
  381. const engine = engineSetup(spy);
  382. await engine.start();
  383. try {
  384. engine.registry.register("behavior", {
  385. id: "echo",
  386. when: { op: "exists", path: "time.day" },
  387. fire: [{ action: "emit-event", event: "trigger-fire", payload: { triggerId: "echo" } }],
  388. });
  389. expect(triggerOf(engine).match("trigger-fire", {})).toEqual({ fired: 0 });
  390. timeOf(engine).set(1, "spring");
  391. const result = triggerOf(engine).match("behavior:seed", {});
  392. expect(result).toEqual({ fired: 1 });
  393. const triggerFireCount = seen.filter((s) => s.event === "trigger-fire").length;
  394. expect(triggerFireCount).toBe(2);
  395. } finally {
  396. await engine.stop();
  397. }
  398. });
  399. it("engine forwards bus events to the trigger system via on()", async () => {
  400. const { system: spy, seen } = eventSpy();
  401. const engine = engineSetup(spy);
  402. await engine.start();
  403. try {
  404. engine.registry.register("behavior", {
  405. id: "summer-day",
  406. when: {
  407. and: [
  408. { op: "eq", path: "time.day", value: 1 },
  409. { op: "eq", path: "time.season", value: "summer" },
  410. ],
  411. },
  412. fire: [{ action: "emit-event", event: "behavior:summer", payload: { day: 1 } }],
  413. });
  414. timeOf(engine).set(1, "spring");
  415. inventoryOf(engine).give("parsnip", 1);
  416. expect(seen.some((s) => s.event === "behavior:summer")).toBe(false);
  417. timeOf(engine).set(1, "summer");
  418. inventoryOf(engine).give("parsnip", 1);
  419. expect(seen.some((s) => s.event === "behavior:summer")).toBe(true);
  420. } finally {
  421. await engine.stop();
  422. }
  423. });
  424. });