statemachine.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { resolve } from "node:path";
  2. import { describe, expect, it } from "vitest";
  3. import {
  4. createEngine,
  5. stateMachineSchema,
  6. type InventorySystem,
  7. type SaveSystem,
  8. type StateMachineDef,
  9. type System,
  10. type TimeSystem,
  11. type TriggerSystem,
  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 saveOf(engine: ReturnType<typeof createEngine>): SaveSystem {
  35. const save = engine.systems.get<SaveSystem>("save");
  36. if (!save) throw new Error("save system not booted");
  37. return save;
  38. }
  39. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  40. const seen: { event: string; payload: unknown }[] = [];
  41. const system: System = {
  42. id: "spy",
  43. init() {},
  44. on(event, payload) {
  45. seen.push({ event, payload });
  46. },
  47. };
  48. return { system, seen };
  49. }
  50. function stateOf(engine: ReturnType<typeof createEngine>, machineId: string): string | undefined {
  51. return engine.world.get(`sm:${machineId}`)?.state as string | undefined;
  52. }
  53. const SIMPLE_DEF: StateMachineDef = {
  54. id: "test-simple",
  55. initial: "sleeping",
  56. states: [
  57. { id: "sleeping" },
  58. { id: "working", onEnter: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  59. { id: "resting", onExit: [{ action: "emit-event", event: "test:left-resting", payload: { state: "resting" } }] },
  60. ],
  61. transitions: [{ from: "working", to: "resting", on: "item-acquire" }],
  62. onSchedule: [{ at: "day-start", to: "working" }],
  63. };
  64. describe("stateMachineSchema", () => {
  65. it("accepts a valid def and rejects unknown initial / undeclared states / duplicate ids / empty transitions", () => {
  66. expect(stateMachineSchema.safeParse(SIMPLE_DEF).success).toBe(true);
  67. const badInitial = stateMachineSchema.safeParse({ ...SIMPLE_DEF, initial: "nope" });
  68. expect(badInitial.success).toBe(false);
  69. const badTransitionTarget = stateMachineSchema.safeParse({
  70. ...SIMPLE_DEF,
  71. transitions: [{ from: "working", to: "ghost", on: "kick" }],
  72. });
  73. expect(badTransitionTarget.success).toBe(false);
  74. const badTransitionSource = stateMachineSchema.safeParse({
  75. ...SIMPLE_DEF,
  76. transitions: [{ from: "ghost", to: "working", on: "kick" }],
  77. });
  78. expect(badTransitionSource.success).toBe(false);
  79. const duplicateStates = stateMachineSchema.safeParse({
  80. ...SIMPLE_DEF,
  81. states: [{ id: "sleeping" }, { id: "sleeping" }],
  82. });
  83. expect(duplicateStates.success).toBe(false);
  84. const noTrigger = stateMachineSchema.safeParse({
  85. ...SIMPLE_DEF,
  86. transitions: [{ from: "sleeping", to: "working" }],
  87. });
  88. expect(noTrigger.success).toBe(false);
  89. const badScheduleTarget = stateMachineSchema.safeParse({
  90. ...SIMPLE_DEF,
  91. onSchedule: [{ at: "day-start", to: "ghost" }],
  92. });
  93. expect(badScheduleTarget.success).toBe(false);
  94. const emptyAt = stateMachineSchema.safeParse({
  95. ...SIMPLE_DEF,
  96. onSchedule: [{ at: {}, to: "working" }],
  97. });
  98. expect(emptyAt.success).toBe(false);
  99. });
  100. });
  101. describe("state machine driving (event + condition)", () => {
  102. it("creates a lazy instance on first transition, runs onEnter actions, and advances on events", async () => {
  103. const engine = engineSetup();
  104. await engine.start();
  105. try {
  106. engine.registry.register("statemachine", SIMPLE_DEF);
  107. const trigger = triggerOf(engine);
  108. expect(engine.world.get("sm:test-simple")).toBeUndefined();
  109. expect(trigger.machineStatus("test-simple")).toEqual({ machineId: "test-simple", state: "sleeping", started: false });
  110. trigger.match("day-start", { day: 2, season: "spring" });
  111. expect(stateOf(engine, "test-simple")).toBe("working");
  112. expect(engine.world.get("sm:test-simple")).toMatchObject({ type: "statemachine", machineId: "test-simple" });
  113. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  114. expect(trigger.machineStatus("test-simple")).toEqual({ machineId: "test-simple", state: "working", started: true });
  115. trigger.match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 });
  116. expect(stateOf(engine, "test-simple")).toBe("resting");
  117. } finally {
  118. await engine.stop();
  119. }
  120. });
  121. it("runs onExit actions when leaving a state", async () => {
  122. const { system: spy, seen } = eventSpy();
  123. const engine = engineSetup(spy);
  124. await engine.start();
  125. try {
  126. engine.registry.register("statemachine", SIMPLE_DEF);
  127. const trigger = triggerOf(engine);
  128. trigger.match("day-start", { day: 2, season: "spring" });
  129. trigger.match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 });
  130. expect(seen.some((s) => s.event === "test:left-resting")).toBe(false);
  131. trigger.match("day-start", { day: 3, season: "spring" });
  132. expect(stateOf(engine, "test-simple")).toBe("working");
  133. expect(seen.some((s) => s.event === "test:left-resting")).toBe(true);
  134. } finally {
  135. await engine.stop();
  136. }
  137. });
  138. it("a transition with when (no on) is evaluated on every bus event", async () => {
  139. const engine = engineSetup();
  140. await engine.start();
  141. try {
  142. engine.registry.register("statemachine", {
  143. id: "test-cond",
  144. initial: "idle",
  145. states: [{ id: "idle" }, { id: "done" }],
  146. transitions: [{ from: "idle", to: "done", when: { op: "gte", path: "time.day", value: 5 } }],
  147. });
  148. const trigger = triggerOf(engine);
  149. timeOf(engine).set(3, "spring");
  150. trigger.match("behavior:any", {});
  151. expect(stateOf(engine, "test-cond")).toBeUndefined();
  152. timeOf(engine).set(5, "spring");
  153. trigger.match("behavior:any", {});
  154. expect(stateOf(engine, "test-cond")).toBe("done");
  155. } finally {
  156. await engine.stop();
  157. }
  158. });
  159. it("a transition with on + when requires both to hold", async () => {
  160. const engine = engineSetup();
  161. await engine.start();
  162. try {
  163. engine.registry.register("statemachine", {
  164. id: "test-both",
  165. initial: "inactive",
  166. states: [{ id: "inactive" }, { id: "active" }],
  167. transitions: [
  168. { from: "inactive", to: "active", on: "day-start", when: { op: "gte", path: "time.day", value: 3 } },
  169. ],
  170. });
  171. const trigger = triggerOf(engine);
  172. trigger.match("day-start", { day: 2, season: "spring" });
  173. expect(stateOf(engine, "test-both")).toBeUndefined();
  174. timeOf(engine).set(3, "spring");
  175. trigger.match("day-start", { day: 3, season: "spring" });
  176. expect(stateOf(engine, "test-both")).toBe("active");
  177. } finally {
  178. await engine.stop();
  179. }
  180. });
  181. it("first matching transition in declaration order wins; at most one per event", async () => {
  182. const engine = engineSetup();
  183. await engine.start();
  184. try {
  185. engine.registry.register("statemachine", {
  186. id: "test-order",
  187. initial: "a",
  188. states: [{ id: "a" }, { id: "b" }, { id: "c" }],
  189. transitions: [
  190. { from: "a", to: "b", on: "kick" },
  191. { from: "a", to: "c", on: "kick" },
  192. ],
  193. });
  194. triggerOf(engine).match("kick", {});
  195. expect(stateOf(engine, "test-order")).toBe("b");
  196. } finally {
  197. await engine.stop();
  198. }
  199. });
  200. it("transitioning to the current state is a no-op (no state-change, no onEnter)", async () => {
  201. const { system: spy, seen } = eventSpy();
  202. const engine = engineSetup(spy);
  203. await engine.start();
  204. try {
  205. engine.registry.register("statemachine", {
  206. id: "test-noop",
  207. initial: "a",
  208. states: [{ id: "a", onEnter: [{ action: "emit-event", event: "test:entered-a" }] }],
  209. transitions: [{ from: "a", to: "a", on: "kick" }],
  210. });
  211. triggerOf(engine).match("kick", {});
  212. expect(stateOf(engine, "test-noop")).toBe("a");
  213. expect(seen.some((s) => s.event === "state-change")).toBe(false);
  214. expect(seen.some((s) => s.event === "test:entered-a")).toBe(false);
  215. } finally {
  216. await engine.stop();
  217. }
  218. });
  219. });
  220. describe("state machine driving (onSchedule time primitive)", () => {
  221. it("time.advance drives a schedule-driven machine through the engine wiring (day-start)", async () => {
  222. const engine = engineSetup();
  223. await engine.start();
  224. try {
  225. engine.registry.register("statemachine", {
  226. id: "test-day",
  227. initial: "sleeping",
  228. states: [{ id: "sleeping" }, { id: "awake" }],
  229. transitions: [],
  230. onSchedule: [{ at: "day-start", to: "awake" }],
  231. });
  232. expect(engine.world.get("sm:test-day")).toBeUndefined();
  233. timeOf(engine).advance(480);
  234. expect(stateOf(engine, "test-day")).toBe("awake");
  235. } finally {
  236. await engine.stop();
  237. }
  238. });
  239. it("schedule entries with a when condition gate the transition", async () => {
  240. const engine = engineSetup();
  241. await engine.start();
  242. try {
  243. engine.registry.register("statemachine", {
  244. id: "test-sched-when",
  245. initial: "idle",
  246. states: [{ id: "idle" }, { id: "resting" }],
  247. transitions: [],
  248. onSchedule: [
  249. { at: "day-start", to: "resting", when: { op: "gte", path: "payload.day", value: 28 } },
  250. ],
  251. });
  252. const trigger = triggerOf(engine);
  253. trigger.match("day-start", { day: 5, season: "spring" });
  254. expect(stateOf(engine, "test-sched-when")).toBeUndefined();
  255. trigger.match("day-start", { day: 28, season: "spring" });
  256. expect(stateOf(engine, "test-sched-when")).toBe("resting");
  257. } finally {
  258. await engine.stop();
  259. }
  260. });
  261. it("the object at form matches the day-start/season-change payload", async () => {
  262. const engine = engineSetup();
  263. await engine.start();
  264. try {
  265. engine.registry.register("statemachine", {
  266. id: "test-sched-object",
  267. initial: "idle",
  268. states: [{ id: "idle" }, { id: "summer" }],
  269. transitions: [],
  270. onSchedule: [{ at: { day: 1, season: "summer" }, to: "summer" }],
  271. });
  272. const trigger = triggerOf(engine);
  273. trigger.match("day-start", { day: 1, season: "spring" });
  274. expect(stateOf(engine, "test-sched-object")).toBeUndefined();
  275. trigger.match("day-start", { day: 1, season: "summer" });
  276. expect(stateOf(engine, "test-sched-object")).toBe("summer");
  277. } finally {
  278. await engine.stop();
  279. }
  280. });
  281. it("schedule entries do not fire on non-time events", async () => {
  282. const engine = engineSetup();
  283. await engine.start();
  284. try {
  285. engine.registry.register("statemachine", {
  286. id: "test-sched-event",
  287. initial: "idle",
  288. states: [{ id: "idle" }, { id: "awake" }],
  289. transitions: [],
  290. onSchedule: [{ at: "day-start", to: "awake" }],
  291. });
  292. triggerOf(engine).match("item-acquire", { itemId: "parsnip", qty: 1, total: 1 });
  293. expect(stateOf(engine, "test-sched-event")).toBeUndefined();
  294. } finally {
  295. await engine.stop();
  296. }
  297. });
  298. it("engine forwards bus events to machines (item-acquire drives a transition)", async () => {
  299. const engine = engineSetup();
  300. await engine.start();
  301. try {
  302. engine.registry.register("statemachine", {
  303. id: "test-fwd",
  304. initial: "idle",
  305. states: [{ id: "idle" }, { id: "busy" }],
  306. transitions: [{ from: "idle", to: "busy", on: "item-acquire" }],
  307. });
  308. inventoryOf(engine).give("parsnip", 1);
  309. expect(stateOf(engine, "test-fwd")).toBe("busy");
  310. } finally {
  311. await engine.stop();
  312. }
  313. });
  314. it("the real content machine daily-routine is driven by time advance and item-acquire", async () => {
  315. const engine = engineSetup();
  316. await engine.start();
  317. try {
  318. expect(engine.registry.get<StateMachineDef>("statemachine", "daily-routine")).toBeDefined();
  319. timeOf(engine).advance(480);
  320. expect(stateOf(engine, "daily-routine")).toBe("working");
  321. timeOf(engine).set(27, "spring");
  322. timeOf(engine).advance(480);
  323. expect(stateOf(engine, "daily-routine")).toBe("resting");
  324. timeOf(engine).set(2, "spring");
  325. timeOf(engine).advance(480);
  326. expect(stateOf(engine, "daily-routine")).toBe("working");
  327. inventoryOf(engine).give("parsnip", 1);
  328. expect(stateOf(engine, "daily-routine")).toBe("resting");
  329. } finally {
  330. await engine.stop();
  331. }
  332. });
  333. });
  334. describe("state machine lifecycle + hooks", () => {
  335. it("machine state persists on the entity and survives save snapshot/restore", async () => {
  336. const engine = engineSetup();
  337. await engine.start();
  338. try {
  339. engine.registry.register("statemachine", {
  340. id: "test-persist",
  341. initial: "idle",
  342. states: [{ id: "idle" }, { id: "active" }],
  343. transitions: [{ from: "idle", to: "active", on: "kick" }],
  344. });
  345. triggerOf(engine).match("kick", {});
  346. expect(stateOf(engine, "test-persist")).toBe("active");
  347. const snapshot = saveOf(engine).snapshot();
  348. expect(snapshot.world.some((e) => e.id === "sm:test-persist" && e.state === "active")).toBe(true);
  349. saveOf(engine).restore(snapshot);
  350. expect(stateOf(engine, "test-persist")).toBe("active");
  351. } finally {
  352. await engine.stop();
  353. }
  354. });
  355. it("state-change events are not re-matched in the same pass (re-entrancy guard)", async () => {
  356. const engine = engineSetup();
  357. await engine.start();
  358. try {
  359. engine.registry.register("statemachine", {
  360. id: "test-re-a",
  361. initial: "a",
  362. states: [{ id: "a" }, { id: "b" }],
  363. transitions: [{ from: "a", to: "b", on: "kick" }],
  364. });
  365. engine.registry.register("statemachine", {
  366. id: "test-re-b",
  367. initial: "x",
  368. states: [{ id: "x" }, { id: "y" }],
  369. transitions: [{ from: "x", to: "y", on: "state-change" }],
  370. });
  371. const trigger = triggerOf(engine);
  372. trigger.match("kick", {});
  373. expect(stateOf(engine, "test-re-a")).toBe("b");
  374. expect(engine.world.get("sm:test-re-b")).toBeUndefined();
  375. trigger.match("state-change", {});
  376. expect(stateOf(engine, "test-re-b")).toBe("y");
  377. } finally {
  378. await engine.stop();
  379. }
  380. });
  381. it("the onStateChange hook fires on transitions (reads machine state via ctx.world)", async () => {
  382. const engine = engineSetup();
  383. await engine.start();
  384. try {
  385. engine.registry.register("statemachine", {
  386. id: "test-hook",
  387. initial: "a",
  388. states: [{ id: "a" }, { id: "b" }],
  389. transitions: [{ from: "a", to: "b", on: "kick" }],
  390. });
  391. const fired: unknown[] = [];
  392. engine.hooks.on("onStateChange", (ctx) => {
  393. fired.push(ctx.world.get("sm:test-hook")?.state);
  394. });
  395. triggerOf(engine).match("kick", {});
  396. expect(fired).toEqual(["b"]);
  397. } finally {
  398. await engine.stop();
  399. }
  400. });
  401. it("machineTransition forces a transition regardless of declared conditions", async () => {
  402. const engine = engineSetup();
  403. await engine.start();
  404. try {
  405. engine.registry.register("statemachine", {
  406. id: "test-force",
  407. initial: "idle",
  408. states: [
  409. { id: "idle" },
  410. { id: "complete", onEnter: [{ action: "give-item", itemId: "parsnip-seed", qty: 1 }] },
  411. ],
  412. transitions: [],
  413. });
  414. const trigger = triggerOf(engine);
  415. expect(trigger.machineTransition("test-force", "complete")).toEqual({
  416. machineId: "test-force",
  417. from: "idle",
  418. to: "complete",
  419. transitioned: true,
  420. });
  421. expect(stateOf(engine, "test-force")).toBe("complete");
  422. expect(inventoryOf(engine).count("parsnip-seed")).toBe(1);
  423. expect(trigger.machineTransition("test-force", "complete")).toEqual({
  424. machineId: "test-force",
  425. from: "complete",
  426. to: "complete",
  427. transitioned: false,
  428. });
  429. } finally {
  430. await engine.stop();
  431. }
  432. });
  433. it("machineStatus and machineTransition throw on unknown machine or state", async () => {
  434. const engine = engineSetup();
  435. await engine.start();
  436. try {
  437. const trigger = triggerOf(engine);
  438. expect(() => trigger.machineStatus("nope")).toThrow(/unknown machine: nope/);
  439. expect(() => trigger.machineTransition("nope", "a")).toThrow(/unknown machine: nope/);
  440. engine.registry.register("statemachine", {
  441. id: "test-err",
  442. initial: "a",
  443. states: [{ id: "a" }],
  444. transitions: [],
  445. });
  446. expect(() => trigger.machineTransition("test-err", "ghost")).toThrow(/unknown state: ghost/);
  447. } finally {
  448. await engine.stop();
  449. }
  450. });
  451. });