@mmapp/player-core 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.d.mts +1436 -0
  2. package/dist/index.d.ts +1436 -0
  3. package/dist/index.js +4828 -0
  4. package/dist/index.mjs +4762 -0
  5. package/package.json +35 -0
  6. package/package.json.backup +35 -0
  7. package/src/__tests__/actions.test.ts +187 -0
  8. package/src/__tests__/blueprint-e2e.test.ts +706 -0
  9. package/src/__tests__/blueprint-test-runner.test.ts +680 -0
  10. package/src/__tests__/core-functions.test.ts +78 -0
  11. package/src/__tests__/dsl-compiler.test.ts +1382 -0
  12. package/src/__tests__/dsl-grammar.test.ts +1682 -0
  13. package/src/__tests__/events.test.ts +200 -0
  14. package/src/__tests__/expression.test.ts +296 -0
  15. package/src/__tests__/failure-policies.test.ts +110 -0
  16. package/src/__tests__/frontend-context.test.ts +182 -0
  17. package/src/__tests__/integration.test.ts +256 -0
  18. package/src/__tests__/security.test.ts +190 -0
  19. package/src/__tests__/state-machine.test.ts +450 -0
  20. package/src/__tests__/testing-engine.test.ts +671 -0
  21. package/src/actions/dispatcher.ts +80 -0
  22. package/src/actions/index.ts +7 -0
  23. package/src/actions/types.ts +25 -0
  24. package/src/dsl/compiler/component-mapper.ts +289 -0
  25. package/src/dsl/compiler/field-mapper.ts +187 -0
  26. package/src/dsl/compiler/index.ts +82 -0
  27. package/src/dsl/compiler/manifest-compiler.ts +76 -0
  28. package/src/dsl/compiler/symbol-table.ts +214 -0
  29. package/src/dsl/compiler/utils.ts +48 -0
  30. package/src/dsl/compiler/view-compiler.ts +286 -0
  31. package/src/dsl/compiler/workflow-compiler.ts +600 -0
  32. package/src/dsl/index.ts +66 -0
  33. package/src/dsl/ir-migration.ts +221 -0
  34. package/src/dsl/ir-types.ts +416 -0
  35. package/src/dsl/lexer.ts +579 -0
  36. package/src/dsl/parser.ts +115 -0
  37. package/src/dsl/types.ts +256 -0
  38. package/src/events/event-bus.ts +68 -0
  39. package/src/events/index.ts +9 -0
  40. package/src/events/pattern-matcher.ts +61 -0
  41. package/src/events/types.ts +27 -0
  42. package/src/expression/evaluator.ts +676 -0
  43. package/src/expression/functions.ts +214 -0
  44. package/src/expression/index.ts +13 -0
  45. package/src/expression/types.ts +64 -0
  46. package/src/index.ts +61 -0
  47. package/src/state-machine/index.ts +16 -0
  48. package/src/state-machine/interpreter.ts +319 -0
  49. package/src/state-machine/types.ts +89 -0
  50. package/src/testing/action-trace.ts +209 -0
  51. package/src/testing/blueprint-test-runner.ts +214 -0
  52. package/src/testing/graph-walker.ts +249 -0
  53. package/src/testing/index.ts +69 -0
  54. package/src/testing/nrt-comparator.ts +199 -0
  55. package/src/testing/nrt-types.ts +230 -0
  56. package/src/testing/test-actions.ts +645 -0
  57. package/src/testing/test-compiler.ts +278 -0
  58. package/src/testing/test-runner.ts +444 -0
  59. package/src/testing/types.ts +231 -0
  60. package/src/validation/definition-validator.ts +812 -0
  61. package/src/validation/index.ts +13 -0
  62. package/tsconfig.json +26 -0
  63. package/vitest.config.ts +8 -0
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Test Action Handlers — pluggable capabilities for test Blueprints.
3
+ *
4
+ * The test Blueprint uses __test_* action types. Which action handlers
5
+ * are registered determines HOW the test executes:
6
+ *
7
+ * - In-process: creates StateMachines in memory, sub-millisecond
8
+ * - API: makes HTTP calls to a running NestJS backend (MM Server Player)
9
+ * - Cross-platform (future): orchestrates across multiple Players
10
+ *
11
+ * Same Blueprint → different capabilities → same results.
12
+ *
13
+ * Architecture (API mode):
14
+ * Player (browser/iOS) executes Test Blueprint
15
+ * → __test_transition action handler
16
+ * → HTTP POST /workflow/instances/:id/transitions/:name
17
+ * → NestJS Backend (MM Server Player)
18
+ * → EventRouter handles cross-instance reactions
19
+ */
20
+
21
+ import { StateMachine } from '../state-machine/interpreter';
22
+ import { EventBus } from '../events/event-bus';
23
+ import { createEvaluator, WEB_FAILURE_POLICIES } from '../expression';
24
+ import type { Evaluator, ExpressionContext } from '../expression/types';
25
+ import type { PlayerWorkflowDefinition, ActionHandler, PlayerAction } from '../state-machine/types';
26
+ import type {
27
+ ApiTestAdapter,
28
+ StepResult,
29
+ AssertionResult,
30
+ TestAssertion,
31
+ } from './types';
32
+
33
+ // =============================================================================
34
+ // Shared result collector (used by both in-process and API handlers)
35
+ // =============================================================================
36
+
37
+ export interface TestActionState {
38
+ /** All step results collected during execution */
39
+ stepResults: StepResult[];
40
+ /** Whether any step has failed */
41
+ failed: boolean;
42
+ /** Current step tracking */
43
+ currentStep: {
44
+ name: string;
45
+ index: number;
46
+ startTime: number;
47
+ assertions: AssertionResult[];
48
+ error?: string;
49
+ } | null;
50
+ /** Set field on the orchestrator StateMachine (bound after creation) */
51
+ setOrchestratorField: (field: string, value: unknown) => void;
52
+ }
53
+
54
+ function createTestState(): TestActionState {
55
+ return {
56
+ stepResults: [],
57
+ failed: false,
58
+ currentStep: null,
59
+ setOrchestratorField: () => {},
60
+ };
61
+ }
62
+
63
+ // =============================================================================
64
+ // In-Process Action Handlers
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Create action handlers for in-process test execution.
69
+ *
70
+ * Test instances are StateMachines running in memory.
71
+ * Cross-instance reactions via shared EventBus.
72
+ * Sub-millisecond execution.
73
+ */
74
+ export function createInProcessTestActions(
75
+ definitions: Record<string, PlayerWorkflowDefinition>,
76
+ ): { handlers: Map<string, ActionHandler>; state: TestActionState } {
77
+ const testState = createTestState();
78
+ const handlers = new Map<string, ActionHandler>();
79
+
80
+ // Shared resources
81
+ const evaluator = createEvaluator({
82
+ functions: [],
83
+ failurePolicy: WEB_FAILURE_POLICIES.EVENT_REACTION,
84
+ });
85
+ const machines = new Map<string, StateMachine>();
86
+ let eventBus: EventBus | null = null;
87
+
88
+ // ---- Lifecycle actions ----
89
+
90
+ handlers.set('__test_init', (action: PlayerAction) => {
91
+ const config = action.config as { connectEventBuses?: boolean };
92
+ if (config.connectEventBuses) {
93
+ eventBus = new EventBus();
94
+ }
95
+ });
96
+
97
+ handlers.set('__test_create_instance', (action: PlayerAction) => {
98
+ const { testId, definitionSlug, initialData } = action.config as {
99
+ testId: string;
100
+ definitionSlug: string;
101
+ initialData: Record<string, unknown>;
102
+ };
103
+
104
+ const def = definitions[definitionSlug];
105
+ if (!def) {
106
+ throw new Error(`Definition not found: "${definitionSlug}"`);
107
+ }
108
+
109
+ const smHandlers = createInstanceActionHandlers(def, evaluator);
110
+ const sm = new StateMachine(def, initialData, { evaluator, actionHandlers: smHandlers });
111
+ machines.set(testId, sm);
112
+
113
+ // Wire EventBus for cross-instance reactions
114
+ if (eventBus) {
115
+ wireOnEventSubscriptions(sm, eventBus, evaluator);
116
+ }
117
+ });
118
+
119
+ // ---- Step tracking ----
120
+
121
+ handlers.set('__test_step_begin', (action: PlayerAction) => {
122
+ const { stepName, stepIndex } = action.config as { stepName: string; stepIndex: number };
123
+ testState.currentStep = {
124
+ name: stepName,
125
+ index: stepIndex,
126
+ startTime: performance.now(),
127
+ assertions: [],
128
+ };
129
+ });
130
+
131
+ handlers.set('__test_step_end', (_action: PlayerAction) => {
132
+ if (!testState.currentStep) return;
133
+
134
+ const step = testState.currentStep;
135
+ const passed = !step.error && step.assertions.every(a => a.passed);
136
+ if (!passed) testState.failed = true;
137
+
138
+ testState.stepResults.push({
139
+ stepName: step.name,
140
+ passed,
141
+ assertionResults: step.assertions,
142
+ durationMs: performance.now() - step.startTime,
143
+ error: step.error,
144
+ });
145
+
146
+ // Signal to the orchestrator
147
+ testState.setOrchestratorField('__test_step_failed', !passed);
148
+ testState.currentStep = null;
149
+ });
150
+
151
+ // ---- Test actions ----
152
+
153
+ handlers.set('__test_transition', async (action: PlayerAction) => {
154
+ const { testId, transitionName, data } = action.config as {
155
+ testId: string;
156
+ transitionName: string;
157
+ data?: Record<string, unknown>;
158
+ };
159
+
160
+ const sm = machines.get(testId);
161
+ if (!sm) {
162
+ if (testState.currentStep) {
163
+ testState.currentStep.error = `Instance not found: "${testId}"`;
164
+ }
165
+ return;
166
+ }
167
+
168
+ const result = await sm.transition(transitionName, data);
169
+ if (!result.success) {
170
+ if (testState.currentStep) {
171
+ testState.currentStep.error = `Transition failed: ${result.error}`;
172
+ }
173
+ }
174
+ });
175
+
176
+ handlers.set('__test_set_field', (action: PlayerAction) => {
177
+ const { testId, field, value } = action.config as {
178
+ testId: string;
179
+ field: string;
180
+ value: unknown;
181
+ };
182
+
183
+ const sm = machines.get(testId);
184
+ if (!sm) {
185
+ if (testState.currentStep) {
186
+ testState.currentStep.error = `Instance not found: "${testId}"`;
187
+ }
188
+ return;
189
+ }
190
+
191
+ sm.setField(field, value);
192
+ });
193
+
194
+ handlers.set('__test_publish_event', async (action: PlayerAction) => {
195
+ const { topic, payload } = action.config as {
196
+ topic: string;
197
+ payload: Record<string, unknown>;
198
+ };
199
+
200
+ if (eventBus) {
201
+ await eventBus.publish(topic, payload);
202
+ }
203
+ });
204
+
205
+ // ---- Settle (no-op for in-process, EventBus is synchronous) ----
206
+
207
+ handlers.set('__test_settle', () => {});
208
+
209
+ // ---- Assertions ----
210
+
211
+ handlers.set('__test_assert', (action: PlayerAction) => {
212
+ const { testId, target, path, operator, expected, label } = action.config as {
213
+ testId: string;
214
+ target: TestAssertion['target'];
215
+ path?: string;
216
+ operator: TestAssertion['operator'];
217
+ expected: unknown;
218
+ label?: string;
219
+ };
220
+
221
+ const sm = machines.get(testId);
222
+ if (!sm) {
223
+ testState.currentStep?.assertions.push({
224
+ passed: false,
225
+ assertion: { target, path, operator, expected, label },
226
+ actual: undefined,
227
+ error: `Instance not found: "${testId}"`,
228
+ });
229
+ return;
230
+ }
231
+
232
+ const actual = getInProcessTarget(target, path, sm);
233
+ const passed = compareValues(actual, operator, expected);
234
+
235
+ testState.currentStep?.assertions.push({
236
+ passed,
237
+ assertion: { target, path, operator, expected, label },
238
+ actual,
239
+ });
240
+ });
241
+
242
+ // ---- Report ----
243
+
244
+ handlers.set('__test_report', () => {});
245
+
246
+ // ---- Standard handlers (set_field on orchestrator) ----
247
+
248
+ handlers.set('set_field', (action: PlayerAction) => {
249
+ testState.setOrchestratorField(
250
+ action.config.field as string,
251
+ action.config.value,
252
+ );
253
+ });
254
+
255
+ return { handlers, state: testState };
256
+ }
257
+
258
+ // =============================================================================
259
+ // API Action Handlers
260
+ // =============================================================================
261
+
262
+ /**
263
+ * Create action handlers for API test execution.
264
+ *
265
+ * Test instances are real DB-backed workflow instances on the NestJS backend.
266
+ * The backend's EventRouter handles cross-instance reactions automatically.
267
+ * Settle delay allows async reactions to complete before assertions.
268
+ */
269
+ export function createApiTestActions(
270
+ adapter: ApiTestAdapter,
271
+ options?: { settleDelayMs?: number },
272
+ ): { handlers: Map<string, ActionHandler>; state: TestActionState } {
273
+ const testState = createTestState();
274
+ const handlers = new Map<string, ActionHandler>();
275
+ const settleDelayMs = options?.settleDelayMs ?? 200;
276
+
277
+ // Map test instance IDs → server instance IDs
278
+ const instanceMap = new Map<string, string>();
279
+ const createdIds: string[] = [];
280
+
281
+ // ---- Lifecycle ----
282
+
283
+ handlers.set('__test_init', () => {
284
+ // EventRouter on server handles cross-instance wiring automatically
285
+ });
286
+
287
+ handlers.set('__test_create_instance', async (action: PlayerAction) => {
288
+ const { testId, definitionSlug, initialData } = action.config as {
289
+ testId: string;
290
+ definitionSlug: string;
291
+ initialData: Record<string, unknown>;
292
+ };
293
+
294
+ const snapshot = await adapter.createInstance({
295
+ definitionSlug,
296
+ stateData: Object.keys(initialData).length > 0 ? initialData : undefined,
297
+ });
298
+ instanceMap.set(testId, snapshot.id);
299
+ createdIds.push(snapshot.id);
300
+
301
+ // Start the instance (PENDING → ACTIVE)
302
+ await adapter.startInstance(snapshot.id);
303
+ });
304
+
305
+ // ---- Step tracking (same as in-process) ----
306
+
307
+ handlers.set('__test_step_begin', (action: PlayerAction) => {
308
+ const { stepName, stepIndex } = action.config as { stepName: string; stepIndex: number };
309
+ testState.currentStep = {
310
+ name: stepName,
311
+ index: stepIndex,
312
+ startTime: performance.now(),
313
+ assertions: [],
314
+ };
315
+ });
316
+
317
+ handlers.set('__test_step_end', (_action: PlayerAction) => {
318
+ if (!testState.currentStep) return;
319
+
320
+ const step = testState.currentStep;
321
+ const passed = !step.error && step.assertions.every(a => a.passed);
322
+ if (!passed) testState.failed = true;
323
+
324
+ testState.stepResults.push({
325
+ stepName: step.name,
326
+ passed,
327
+ assertionResults: step.assertions,
328
+ durationMs: performance.now() - step.startTime,
329
+ error: step.error,
330
+ });
331
+
332
+ testState.setOrchestratorField('__test_step_failed', !passed);
333
+ testState.currentStep = null;
334
+ });
335
+
336
+ // ---- Test actions (via HTTP) ----
337
+
338
+ handlers.set('__test_transition', async (action: PlayerAction) => {
339
+ const { testId, transitionName, data } = action.config as {
340
+ testId: string;
341
+ transitionName: string;
342
+ data?: Record<string, unknown>;
343
+ };
344
+
345
+ const serverId = instanceMap.get(testId);
346
+ if (!serverId) {
347
+ if (testState.currentStep) {
348
+ testState.currentStep.error = `Instance not found: "${testId}"`;
349
+ }
350
+ return;
351
+ }
352
+
353
+ const result = await adapter.triggerTransition(serverId, transitionName, data);
354
+ if (!result.success) {
355
+ if (testState.currentStep) {
356
+ testState.currentStep.error = `Transition failed: ${result.error ?? 'unknown'}`;
357
+ }
358
+ }
359
+ });
360
+
361
+ handlers.set('__test_set_field', async (action: PlayerAction) => {
362
+ const { testId, field, value } = action.config as {
363
+ testId: string;
364
+ field: string;
365
+ value: unknown;
366
+ };
367
+
368
+ const serverId = instanceMap.get(testId);
369
+ if (!serverId) {
370
+ if (testState.currentStep) {
371
+ testState.currentStep.error = `Instance not found: "${testId}"`;
372
+ }
373
+ return;
374
+ }
375
+
376
+ await adapter.updateStateData(serverId, { [field]: value });
377
+ });
378
+
379
+ handlers.set('__test_publish_event', async (_action: PlayerAction) => {
380
+ // In API mode, events flow automatically via the backend's EventRouter.
381
+ // publish_event is effectively a no-op — use transition actions to trigger events.
382
+ if (testState.currentStep) {
383
+ testState.currentStep.error =
384
+ 'publish_event not supported in API mode. Events flow via EventRouter when transitions fire.';
385
+ }
386
+ });
387
+
388
+ // ---- Settle: wait for backend EventRouter to process reactions ----
389
+
390
+ handlers.set('__test_settle', async () => {
391
+ if (settleDelayMs > 0) {
392
+ await new Promise(resolve => setTimeout(resolve, settleDelayMs));
393
+ }
394
+ });
395
+
396
+ // ---- Assertions (via HTTP GET) ----
397
+
398
+ handlers.set('__test_assert', async (action: PlayerAction) => {
399
+ const { testId, target, path, operator, expected, label } = action.config as {
400
+ testId: string;
401
+ target: TestAssertion['target'];
402
+ path?: string;
403
+ operator: TestAssertion['operator'];
404
+ expected: unknown;
405
+ label?: string;
406
+ };
407
+
408
+ const serverId = instanceMap.get(testId);
409
+ if (!serverId) {
410
+ testState.currentStep?.assertions.push({
411
+ passed: false,
412
+ assertion: { target, path, operator, expected, label },
413
+ actual: undefined,
414
+ error: `Instance not found: "${testId}"`,
415
+ });
416
+ return;
417
+ }
418
+
419
+ const snapshot = await adapter.getInstance(serverId);
420
+ const actual = getApiTarget(target, path, snapshot);
421
+ const passed = compareValues(actual, operator, expected);
422
+
423
+ testState.currentStep?.assertions.push({
424
+ passed,
425
+ assertion: { target, path, operator, expected, label },
426
+ actual,
427
+ });
428
+ });
429
+
430
+ // ---- Report ----
431
+
432
+ handlers.set('__test_report', () => {});
433
+
434
+ // ---- Standard handlers ----
435
+
436
+ handlers.set('set_field', (action: PlayerAction) => {
437
+ testState.setOrchestratorField(
438
+ action.config.field as string,
439
+ action.config.value,
440
+ );
441
+ });
442
+
443
+ return {
444
+ handlers,
445
+ state: testState,
446
+ // Expose for cleanup
447
+ getCreatedInstanceIds: () => createdIds,
448
+ getInstanceMap: () => instanceMap,
449
+ } as { handlers: Map<string, ActionHandler>; state: TestActionState };
450
+ }
451
+
452
+ // =============================================================================
453
+ // Helpers shared between modes
454
+ // =============================================================================
455
+
456
+ function getInProcessTarget(
457
+ target: TestAssertion['target'],
458
+ path: string | undefined,
459
+ sm: StateMachine,
460
+ ): unknown {
461
+ switch (target) {
462
+ case 'state':
463
+ return sm.currentState;
464
+ case 'status':
465
+ return sm.status;
466
+ case 'state_data':
467
+ if (!path) return sm.stateData;
468
+ return getNestedValue(sm.stateData, path);
469
+ case 'available_transitions':
470
+ return sm.getAvailableTransitions().map(t => t.name);
471
+ default:
472
+ throw new Error(`Unknown assertion target: "${target}"`);
473
+ }
474
+ }
475
+
476
+ function getApiTarget(
477
+ target: TestAssertion['target'],
478
+ path: string | undefined,
479
+ snapshot: { currentState: string; status: string; stateData: Record<string, unknown>; availableTransitions: string[] },
480
+ ): unknown {
481
+ switch (target) {
482
+ case 'state':
483
+ return snapshot.currentState;
484
+ case 'status':
485
+ return snapshot.status;
486
+ case 'state_data':
487
+ if (!path) return snapshot.stateData;
488
+ return getNestedValue(snapshot.stateData, path);
489
+ case 'available_transitions':
490
+ return snapshot.availableTransitions;
491
+ default:
492
+ throw new Error(`Unknown assertion target: "${target}"`);
493
+ }
494
+ }
495
+
496
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
497
+ const parts = path.split('.');
498
+ let current: unknown = obj;
499
+ for (const part of parts) {
500
+ if (current == null || typeof current !== 'object') return undefined;
501
+ current = (current as Record<string, unknown>)[part];
502
+ }
503
+ return current;
504
+ }
505
+
506
+ function compareValues(
507
+ actual: unknown,
508
+ operator: TestAssertion['operator'],
509
+ expected: unknown,
510
+ ): boolean {
511
+ switch (operator) {
512
+ case 'eq':
513
+ return deepEqual(actual, expected);
514
+ case 'neq':
515
+ return !deepEqual(actual, expected);
516
+ case 'gt':
517
+ return typeof actual === 'number' && typeof expected === 'number' && actual > expected;
518
+ case 'gte':
519
+ return typeof actual === 'number' && typeof expected === 'number' && actual >= expected;
520
+ case 'lt':
521
+ return typeof actual === 'number' && typeof expected === 'number' && actual < expected;
522
+ case 'lte':
523
+ return typeof actual === 'number' && typeof expected === 'number' && actual <= expected;
524
+ case 'contains': {
525
+ if (Array.isArray(actual)) return actual.includes(expected);
526
+ if (typeof actual === 'string' && typeof expected === 'string')
527
+ return actual.includes(expected);
528
+ return false;
529
+ }
530
+ case 'truthy':
531
+ return Boolean(actual);
532
+ case 'falsy':
533
+ return !actual;
534
+ default:
535
+ return false;
536
+ }
537
+ }
538
+
539
+ function deepEqual(a: unknown, b: unknown): boolean {
540
+ if (a === b) return true;
541
+ if (a == null || b == null) return false;
542
+ if (typeof a !== typeof b) return false;
543
+
544
+ if (Array.isArray(a) && Array.isArray(b)) {
545
+ if (a.length !== b.length) return false;
546
+ return a.every((v, i) => deepEqual(v, b[i]));
547
+ }
548
+
549
+ if (typeof a === 'object' && typeof b === 'object') {
550
+ const keysA = Object.keys(a as Record<string, unknown>);
551
+ const keysB = Object.keys(b as Record<string, unknown>);
552
+ if (keysA.length !== keysB.length) return false;
553
+ return keysA.every(k =>
554
+ deepEqual(
555
+ (a as Record<string, unknown>)[k],
556
+ (b as Record<string, unknown>)[k],
557
+ ),
558
+ );
559
+ }
560
+
561
+ return false;
562
+ }
563
+
564
+ // =============================================================================
565
+ // Instance action handlers (for in-process test instances)
566
+ // =============================================================================
567
+
568
+ function createInstanceActionHandlers(
569
+ _def: PlayerWorkflowDefinition,
570
+ _evaluator: Evaluator,
571
+ ): Map<string, ActionHandler> {
572
+ const handlers = new Map<string, ActionHandler>();
573
+ let smRef: StateMachine | null = null;
574
+
575
+ handlers.set('set_field', (action: PlayerAction) => {
576
+ if (smRef && typeof action.config.field === 'string') {
577
+ smRef.setField(action.config.field, action.config.value);
578
+ }
579
+ });
580
+
581
+ handlers.set('set_memory', (action: PlayerAction) => {
582
+ if (smRef && typeof action.config.key === 'string') {
583
+ smRef.setMemory(action.config.key, action.config.value);
584
+ }
585
+ });
586
+
587
+ // The smRef will be set after StateMachine creation via closure
588
+ // This is a known pattern — see test-runner.ts
589
+ (handlers as any).__bindSm = (sm: StateMachine) => {
590
+ smRef = sm;
591
+ };
592
+
593
+ return handlers;
594
+ }
595
+
596
+ function wireOnEventSubscriptions(
597
+ sm: StateMachine,
598
+ eventBus: EventBus,
599
+ evaluator: Evaluator,
600
+ ): void {
601
+ const unsubs: Array<() => void> = [];
602
+
603
+ function subscribeCurrentState(): void {
604
+ for (const unsub of unsubs) unsub();
605
+ unsubs.length = 0;
606
+
607
+ const stateDef = sm.getCurrentStateDefinition();
608
+ if (!stateDef?.on_event?.length) return;
609
+
610
+ for (const sub of stateDef.on_event) {
611
+ const unsub = eventBus.subscribe(sub.match, async (event) => {
612
+ const ctx: ExpressionContext = {
613
+ ...sm.stateData,
614
+ state_data: sm.stateData,
615
+ event: event.payload,
616
+ current_state: sm.currentState,
617
+ status: sm.status,
618
+ };
619
+
620
+ if (sub.conditions?.length) {
621
+ for (const condition of sub.conditions) {
622
+ const result = evaluator.evaluate<boolean>(condition, ctx);
623
+ if (!result.value) return;
624
+ }
625
+ }
626
+
627
+ for (const action of sub.actions) {
628
+ if (action.type === 'set_field' && typeof action.config.field === 'string') {
629
+ sm.setField(action.config.field, action.config.value);
630
+ } else if (action.type === 'set_memory' && typeof action.config.key === 'string') {
631
+ sm.setMemory(action.config.key, action.config.value);
632
+ }
633
+ }
634
+ });
635
+ unsubs.push(unsub);
636
+ }
637
+ }
638
+
639
+ subscribeCurrentState();
640
+ sm.on((event) => {
641
+ if (event.type === 'state_enter') {
642
+ subscribeCurrentState();
643
+ }
644
+ });
645
+ }