trigger.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  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. it("play-bgm emits bgm-play with soundId and optional fadeMs (TR-12.1)", async () => {
  215. const { system: spy, seen } = eventSpy();
  216. const engine = engineSetup(spy);
  217. await engine.start();
  218. try {
  219. engine.registry.register("behavior", {
  220. id: "bgm-start",
  221. when: { op: "exists", path: "time.day" },
  222. fire: [{ action: "play-bgm", soundId: "bgm-x", fadeMs: 300 }],
  223. });
  224. const result = triggerOf(engine).fire("bgm-start");
  225. expect(result).toEqual({ triggerId: "bgm-start", fired: true });
  226. const bgmPlay = seen.find((s) => s.event === "bgm-play")?.payload as { soundId: string; fadeMs?: number };
  227. expect(bgmPlay).toEqual({ soundId: "bgm-x", fadeMs: 300 });
  228. } finally {
  229. await engine.stop();
  230. }
  231. });
  232. it("play-bgm without fadeMs emits bgm-play with only soundId", async () => {
  233. const { system: spy, seen } = eventSpy();
  234. const engine = engineSetup(spy);
  235. await engine.start();
  236. try {
  237. engine.registry.register("behavior", {
  238. id: "bgm-start-simple",
  239. when: { op: "exists", path: "time.day" },
  240. fire: [{ action: "play-bgm", soundId: "bgm-y" }],
  241. });
  242. const result = triggerOf(engine).fire("bgm-start-simple");
  243. expect(result).toEqual({ triggerId: "bgm-start-simple", fired: true });
  244. const bgmPlay = seen.find((s) => s.event === "bgm-play")?.payload as { soundId: string; fadeMs?: number };
  245. expect(bgmPlay).toEqual({ soundId: "bgm-y", fadeMs: undefined });
  246. } finally {
  247. await engine.stop();
  248. }
  249. });
  250. it("stop-bgm emits bgm-stop with optional fadeMs", async () => {
  251. const { system: spy, seen } = eventSpy();
  252. const engine = engineSetup(spy);
  253. await engine.start();
  254. try {
  255. engine.registry.register("behavior", {
  256. id: "bgm-end",
  257. when: { op: "exists", path: "time.day" },
  258. fire: [{ action: "stop-bgm", fadeMs: 500 }],
  259. });
  260. const result = triggerOf(engine).fire("bgm-end");
  261. expect(result).toEqual({ triggerId: "bgm-end", fired: true });
  262. const bgmStop = seen.find((s) => s.event === "bgm-stop")?.payload as { fadeMs?: number };
  263. expect(bgmStop).toEqual({ fadeMs: 500 });
  264. } finally {
  265. await engine.stop();
  266. }
  267. });
  268. it("stop-bgm without fadeMs emits bgm-stop with undefined fadeMs", async () => {
  269. const { system: spy, seen } = eventSpy();
  270. const engine = engineSetup(spy);
  271. await engine.start();
  272. try {
  273. engine.registry.register("behavior", {
  274. id: "bgm-end-simple",
  275. when: { op: "exists", path: "time.day" },
  276. fire: [{ action: "stop-bgm" }],
  277. });
  278. const result = triggerOf(engine).fire("bgm-end-simple");
  279. expect(result).toEqual({ triggerId: "bgm-end-simple", fired: true });
  280. const bgmStop = seen.find((s) => s.event === "bgm-stop")?.payload as { fadeMs?: number };
  281. expect(bgmStop).toEqual({ fadeMs: undefined });
  282. } finally {
  283. await engine.stop();
  284. }
  285. });
  286. });
  287. describe("TriggerSystem add action (D33)", () => {
  288. it("add accumulates across multiple fires on a numeric field", async () => {
  289. const engine = engineSetup();
  290. await engine.start();
  291. try {
  292. engine.registry.register("behavior", {
  293. id: "gain-gold",
  294. when: { op: "exists", path: "time.day" },
  295. fire: [{ action: "add", path: "player.gold", value: 5 }],
  296. });
  297. const player = engine.world.get("player");
  298. if (!player) throw new Error("player entity not seeded");
  299. player.gold = 10;
  300. expect(triggerOf(engine).fire("gain-gold").fired).toBe(true);
  301. expect(triggerOf(engine).fire("gain-gold").fired).toBe(true);
  302. expect(player.gold).toBe(20);
  303. } finally {
  304. await engine.stop();
  305. }
  306. });
  307. it("negative add decrements a numeric field", async () => {
  308. const engine = engineSetup();
  309. await engine.start();
  310. try {
  311. engine.registry.register("behavior", {
  312. id: "spend-gold",
  313. when: { op: "exists", path: "time.day" },
  314. fire: [{ action: "add", path: "player.gold", value: -3 }],
  315. });
  316. const player = engine.world.get("player");
  317. if (!player) throw new Error("player entity not seeded");
  318. player.gold = 10;
  319. triggerOf(engine).fire("spend-gold");
  320. expect(player.gold).toBe(7);
  321. } finally {
  322. await engine.stop();
  323. }
  324. });
  325. it("a missing field starts from 0 then adds", async () => {
  326. const engine = engineSetup();
  327. await engine.start();
  328. try {
  329. engine.registry.register("behavior", {
  330. id: "first-gold",
  331. when: { op: "exists", path: "time.day" },
  332. fire: [{ action: "add", path: "player.gold", value: 100 }],
  333. });
  334. const player = engine.world.get("player");
  335. if (!player) throw new Error("player entity not seeded");
  336. delete player.gold;
  337. triggerOf(engine).fire("first-gold");
  338. expect(player.gold).toBe(100);
  339. triggerOf(engine).fire("first-gold");
  340. expect(player.gold).toBe(200);
  341. } finally {
  342. await engine.stop();
  343. }
  344. });
  345. it("add on a non-numeric field throws", async () => {
  346. const engine = engineSetup();
  347. await engine.start();
  348. try {
  349. engine.registry.register("behavior", {
  350. id: "bad-add",
  351. when: { op: "exists", path: "time.day" },
  352. fire: [{ action: "add", path: "player.gold", value: 1 }],
  353. });
  354. const player = engine.world.get("player");
  355. if (!player) throw new Error("player entity not seeded");
  356. player.gold = "rich";
  357. expect(() => triggerOf(engine).fire("bad-add")).toThrow(/action add: field player\.gold is not a number/);
  358. } finally {
  359. await engine.stop();
  360. }
  361. });
  362. it("add on a missing entity throws like set-state", async () => {
  363. const engine = engineSetup();
  364. await engine.start();
  365. try {
  366. engine.registry.register("behavior", {
  367. id: "bad-add-entity",
  368. when: { op: "exists", path: "time.day" },
  369. fire: [{ action: "add", path: "world.ghost.score", value: 1 }],
  370. });
  371. expect(() => triggerOf(engine).fire("bad-add-entity")).toThrow(/no entity: ghost/);
  372. } finally {
  373. await engine.stop();
  374. }
  375. });
  376. it("add works in state machine onEnter actions", async () => {
  377. const engine = engineSetup();
  378. await engine.start();
  379. try {
  380. engine.registry.register("statemachine", {
  381. id: "gold-machine",
  382. initial: "start",
  383. states: [
  384. { id: "start" },
  385. { id: "done", onEnter: [{ action: "add", path: "player.gold", value: 100 }] },
  386. ],
  387. transitions: [{ from: "start", to: "done", when: { op: "exists", path: "time.day" } }],
  388. });
  389. triggerOf(engine).machineTransition("gold-machine", "done");
  390. expect(engine.world.get("player")?.gold).toBe(100);
  391. } finally {
  392. await engine.stop();
  393. }
  394. });
  395. it("set-state remains absolute after adds", async () => {
  396. const engine = engineSetup();
  397. await engine.start();
  398. try {
  399. engine.registry.register("behavior", {
  400. id: "reset-gold",
  401. when: { op: "exists", path: "time.day" },
  402. fire: [{ action: "set-state", path: "player.gold", value: 42 }],
  403. });
  404. const player = engine.world.get("player");
  405. if (!player) throw new Error("player entity not seeded");
  406. player.gold = 100;
  407. triggerOf(engine).fire("reset-gold");
  408. expect(player.gold).toBe(42);
  409. } finally {
  410. await engine.stop();
  411. }
  412. });
  413. });
  414. describe("TriggerSystem.match", () => {
  415. it("runs triggers whose condition matches the event payload (time.day style ctx)", async () => {
  416. const { system: spy, seen } = eventSpy();
  417. const engine = engineSetup(spy);
  418. await engine.start();
  419. try {
  420. engine.registry.register("behavior", {
  421. id: "gift-on-parsnip",
  422. when: { op: "eq", path: "payload.itemId", value: "parsnip" },
  423. fire: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }],
  424. });
  425. timeOf(engine).set(1, "spring");
  426. inventoryOf(engine).give("parsnip", 1);
  427. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  428. expect(seen.some((s) => s.event === "trigger-fire")).toBe(true);
  429. inventoryOf(engine).give("hoe", 1);
  430. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  431. } finally {
  432. await engine.stop();
  433. }
  434. });
  435. it("spring-gift fires on any bus event once time.day >= 5", async () => {
  436. const engine = engineSetup();
  437. await engine.start();
  438. try {
  439. timeOf(engine).set(1, "spring");
  440. inventoryOf(engine).give("parsnip", 1);
  441. expect(inventoryOf(engine).count("parsnip-seed")).toBe(0);
  442. timeOf(engine).set(5, "spring");
  443. inventoryOf(engine).give("parsnip", 1);
  444. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  445. inventoryOf(engine).give("parsnip", 1);
  446. expect(inventoryOf(engine).count("parsnip-seed")).toBe(2);
  447. } finally {
  448. await engine.stop();
  449. }
  450. });
  451. it("trigger-fire events are never re-matched (v1 loop guard)", async () => {
  452. const { system: spy, seen } = eventSpy();
  453. const engine = engineSetup(spy);
  454. await engine.start();
  455. try {
  456. engine.registry.register("behavior", {
  457. id: "echo",
  458. when: { op: "exists", path: "time.day" },
  459. fire: [{ action: "emit-event", event: "trigger-fire", payload: { triggerId: "echo" } }],
  460. });
  461. expect(triggerOf(engine).match("trigger-fire", {})).toEqual({ fired: 0 });
  462. timeOf(engine).set(1, "spring");
  463. const result = triggerOf(engine).match("behavior:seed", {});
  464. expect(result).toEqual({ fired: 1 });
  465. const triggerFireCount = seen.filter((s) => s.event === "trigger-fire").length;
  466. expect(triggerFireCount).toBe(2);
  467. } finally {
  468. await engine.stop();
  469. }
  470. });
  471. it("engine forwards bus events to the trigger system via on()", async () => {
  472. const { system: spy, seen } = eventSpy();
  473. const engine = engineSetup(spy);
  474. await engine.start();
  475. try {
  476. engine.registry.register("behavior", {
  477. id: "summer-day",
  478. when: {
  479. and: [
  480. { op: "eq", path: "time.day", value: 1 },
  481. { op: "eq", path: "time.season", value: "summer" },
  482. ],
  483. },
  484. fire: [{ action: "emit-event", event: "behavior:summer", payload: { day: 1 } }],
  485. });
  486. timeOf(engine).set(1, "spring");
  487. inventoryOf(engine).give("parsnip", 1);
  488. expect(seen.some((s) => s.event === "behavior:summer")).toBe(false);
  489. timeOf(engine).set(1, "summer");
  490. inventoryOf(engine).give("parsnip", 1);
  491. expect(seen.some((s) => s.event === "behavior:summer")).toBe(true);
  492. } finally {
  493. await engine.stop();
  494. }
  495. });
  496. });