@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,676 @@
1
+ /**
2
+ * Expression Evaluator — createEvaluator factory and evaluation engine.
3
+ *
4
+ * Parses and evaluates expressions with a safe recursive descent parser.
5
+ * NO dynamic code compilation (no new Function(), no eval(), no with()).
6
+ *
7
+ * Supported syntax:
8
+ * - Path resolution: `state_data.title`, `$instance.fields.name`
9
+ * - Function calls: `add(1, 2)`, `if(eq(x, 1), 'yes', 'no')`
10
+ * - Template interpolation: `"Hello {{name}}, you have {{count}} items"`
11
+ * - Ternary expressions: `condition ? 'yes' : 'no'`
12
+ * - Operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `&&`, `||`, `!`
13
+ * - Literals: strings, numbers, booleans, null
14
+ *
15
+ * Grammar:
16
+ * expression → ternary
17
+ * ternary → logicalOr ('?' expression ':' expression)?
18
+ * logicalOr → logicalAnd ('||' logicalAnd)*
19
+ * logicalAnd → equality ('&&' equality)*
20
+ * equality → comparison (('=='|'!=') comparison)*
21
+ * comparison → unary (('>'|'<'|'>='|'<=') unary)*
22
+ * unary → ('!' unary) | call
23
+ * call → primary ('(' args? ')')? ('.' IDENT ('(' args? ')')?)*
24
+ * primary → NUMBER | STRING | BOOL | NULL | IDENT | '(' expression ')'
25
+ */
26
+
27
+ import type {
28
+ Evaluator,
29
+ EvaluatorConfig,
30
+ ExpressionContext,
31
+ ExpressionResult,
32
+ FailurePolicy,
33
+ ExpressionFunction,
34
+ } from './types';
35
+ import { CORE_FUNCTIONS, buildFunctionMap } from './functions';
36
+
37
+ // =============================================================================
38
+ // AST Node Types
39
+ // =============================================================================
40
+
41
+ type ASTNode =
42
+ | { type: 'number'; value: number }
43
+ | { type: 'string'; value: string }
44
+ | { type: 'boolean'; value: boolean }
45
+ | { type: 'null' }
46
+ | { type: 'identifier'; name: string }
47
+ | { type: 'path'; segments: string[] }
48
+ | { type: 'call'; name: string; args: ASTNode[] }
49
+ | { type: 'method_call'; object: ASTNode; method: string; args: ASTNode[] }
50
+ | { type: 'member'; object: ASTNode; property: string }
51
+ | { type: 'unary'; operator: '!'; operand: ASTNode }
52
+ | { type: 'binary'; operator: string; left: ASTNode; right: ASTNode }
53
+ | { type: 'ternary'; condition: ASTNode; consequent: ASTNode; alternate: ASTNode };
54
+
55
+ // =============================================================================
56
+ // Parser
57
+ // =============================================================================
58
+
59
+ const MAX_DEPTH = 50;
60
+
61
+ class Parser {
62
+ private pos = 0;
63
+ private depth = 0;
64
+ private readonly input: string;
65
+
66
+ constructor(input: string) {
67
+ this.input = input;
68
+ }
69
+
70
+ parse(): ASTNode {
71
+ this.skipWhitespace();
72
+ const node = this.parseExpression();
73
+ this.skipWhitespace();
74
+ if (this.pos < this.input.length) {
75
+ throw new Error(`Unexpected character at position ${this.pos}: '${this.input[this.pos]}'`);
76
+ }
77
+ return node;
78
+ }
79
+
80
+ private guardDepth(): void {
81
+ if (++this.depth > MAX_DEPTH) {
82
+ throw new Error('Expression too deeply nested');
83
+ }
84
+ }
85
+
86
+ private parseExpression(): ASTNode {
87
+ this.guardDepth();
88
+ try {
89
+ return this.parseTernary();
90
+ } finally {
91
+ this.depth--;
92
+ }
93
+ }
94
+
95
+ private parseTernary(): ASTNode {
96
+ let node = this.parseLogicalOr();
97
+ this.skipWhitespace();
98
+ if (this.peek() === '?') {
99
+ this.advance(); // consume '?'
100
+ const consequent = this.parseExpression();
101
+ this.skipWhitespace();
102
+ this.expect(':');
103
+ const alternate = this.parseExpression();
104
+ node = { type: 'ternary', condition: node, consequent, alternate };
105
+ }
106
+ return node;
107
+ }
108
+
109
+ private parseLogicalOr(): ASTNode {
110
+ let left = this.parseLogicalAnd();
111
+ this.skipWhitespace();
112
+ while (this.match('||')) {
113
+ const right = this.parseLogicalAnd();
114
+ left = { type: 'binary', operator: '||', left, right };
115
+ this.skipWhitespace();
116
+ }
117
+ return left;
118
+ }
119
+
120
+ private parseLogicalAnd(): ASTNode {
121
+ let left = this.parseEquality();
122
+ this.skipWhitespace();
123
+ while (this.match('&&')) {
124
+ const right = this.parseEquality();
125
+ left = { type: 'binary', operator: '&&', left, right };
126
+ this.skipWhitespace();
127
+ }
128
+ return left;
129
+ }
130
+
131
+ private parseEquality(): ASTNode {
132
+ let left = this.parseComparison();
133
+ this.skipWhitespace();
134
+ while (true) {
135
+ if (this.match('==')) {
136
+ const right = this.parseComparison();
137
+ left = { type: 'binary', operator: '==', left, right };
138
+ } else if (this.match('!=')) {
139
+ const right = this.parseComparison();
140
+ left = { type: 'binary', operator: '!=', left, right };
141
+ } else {
142
+ break;
143
+ }
144
+ this.skipWhitespace();
145
+ }
146
+ return left;
147
+ }
148
+
149
+ private parseComparison(): ASTNode {
150
+ let left = this.parseUnary();
151
+ this.skipWhitespace();
152
+ while (true) {
153
+ if (this.match('>=')) {
154
+ const right = this.parseUnary();
155
+ left = { type: 'binary', operator: '>=', left, right };
156
+ } else if (this.match('<=')) {
157
+ const right = this.parseUnary();
158
+ left = { type: 'binary', operator: '<=', left, right };
159
+ } else if (this.peek() === '>' && !this.lookAhead('>=')) {
160
+ this.advance();
161
+ const right = this.parseUnary();
162
+ left = { type: 'binary', operator: '>', left, right };
163
+ } else if (this.peek() === '<' && !this.lookAhead('<=')) {
164
+ this.advance();
165
+ const right = this.parseUnary();
166
+ left = { type: 'binary', operator: '<', left, right };
167
+ } else {
168
+ break;
169
+ }
170
+ this.skipWhitespace();
171
+ }
172
+ return left;
173
+ }
174
+
175
+ private parseUnary(): ASTNode {
176
+ this.skipWhitespace();
177
+ if (this.peek() === '!') {
178
+ this.advance();
179
+ const operand = this.parseUnary();
180
+ return { type: 'unary', operator: '!', operand };
181
+ }
182
+
183
+ // Handle unary minus for negative numbers
184
+ if (this.peek() === '-') {
185
+ const nextChar = this.input[this.pos + 1];
186
+ if (nextChar !== undefined && (nextChar >= '0' && nextChar <= '9' || nextChar === '.')) {
187
+ // Parse as negative number in primary
188
+ return this.parseCallChain();
189
+ }
190
+ }
191
+
192
+ return this.parseCallChain();
193
+ }
194
+
195
+ private parseCallChain(): ASTNode {
196
+ let node = this.parsePrimary();
197
+
198
+ // Handle call chains: ident(args), ident.prop, ident.method(args)
199
+ while (true) {
200
+ this.skipWhitespace();
201
+ if (this.peek() === '(') {
202
+ // Function call — only valid if node is identifier or path
203
+ this.advance(); // consume '('
204
+ const args = this.parseArgList();
205
+ this.expect(')');
206
+
207
+ if (node.type === 'identifier') {
208
+ node = { type: 'call', name: node.name, args };
209
+ } else if (node.type === 'path') {
210
+ // e.g. module.fn(args) — treat the last segment as the function name
211
+ const name = node.segments.join('.');
212
+ node = { type: 'call', name, args };
213
+ } else if (node.type === 'member') {
214
+ node = { type: 'method_call', object: node.object, method: node.property, args };
215
+ } else {
216
+ throw new Error('Cannot call non-function');
217
+ }
218
+ } else if (this.peek() === '.') {
219
+ this.advance(); // consume '.'
220
+ const prop = this.parseIdentifierName();
221
+ node = { type: 'member', object: node, property: prop };
222
+ } else {
223
+ break;
224
+ }
225
+ }
226
+
227
+ return node;
228
+ }
229
+
230
+ private parsePrimary(): ASTNode {
231
+ this.skipWhitespace();
232
+ const ch = this.peek();
233
+
234
+ // Parenthesized expression
235
+ if (ch === '(') {
236
+ this.advance();
237
+ const expr = this.parseExpression();
238
+ this.skipWhitespace();
239
+ this.expect(')');
240
+ return expr;
241
+ }
242
+
243
+ // String literal (single or double quoted)
244
+ if (ch === "'" || ch === '"') {
245
+ return this.parseString();
246
+ }
247
+
248
+ // Number literal (including negative)
249
+ if (ch === '-' || (ch >= '0' && ch <= '9')) {
250
+ return this.parseNumber();
251
+ }
252
+
253
+ // Identifier, boolean, null, or path
254
+ if (this.isIdentStart(ch)) {
255
+ return this.parseIdentifierOrPath();
256
+ }
257
+
258
+ throw new Error(
259
+ `Unexpected character at position ${this.pos}: '${ch || 'EOF'}'`,
260
+ );
261
+ }
262
+
263
+ private parseString(): ASTNode {
264
+ const quote = this.advance(); // consume opening quote
265
+ let value = '';
266
+ while (this.pos < this.input.length && this.peek() !== quote) {
267
+ if (this.peek() === '\\') {
268
+ this.advance(); // consume backslash
269
+ const esc = this.advance();
270
+ if (esc === 'n') value += '\n';
271
+ else if (esc === 't') value += '\t';
272
+ else if (esc === 'r') value += '\r';
273
+ else value += esc;
274
+ } else {
275
+ value += this.advance();
276
+ }
277
+ }
278
+ if (this.pos >= this.input.length) {
279
+ throw new Error('Unterminated string literal');
280
+ }
281
+ this.advance(); // consume closing quote
282
+ return { type: 'string', value };
283
+ }
284
+
285
+ private parseNumber(): ASTNode {
286
+ let numStr = '';
287
+ if (this.peek() === '-') {
288
+ numStr += this.advance();
289
+ }
290
+ while (this.pos < this.input.length && (this.input[this.pos] >= '0' && this.input[this.pos] <= '9')) {
291
+ numStr += this.advance();
292
+ }
293
+ if (this.peek() === '.' && this.pos + 1 < this.input.length && this.input[this.pos + 1] >= '0' && this.input[this.pos + 1] <= '9') {
294
+ numStr += this.advance(); // consume '.'
295
+ while (this.pos < this.input.length && (this.input[this.pos] >= '0' && this.input[this.pos] <= '9')) {
296
+ numStr += this.advance();
297
+ }
298
+ }
299
+ return { type: 'number', value: Number(numStr) };
300
+ }
301
+
302
+ private parseIdentifierOrPath(): ASTNode {
303
+ const name = this.parseIdentifierName();
304
+
305
+ // Check for keywords
306
+ if (name === 'true') return { type: 'boolean', value: true };
307
+ if (name === 'false') return { type: 'boolean', value: false };
308
+ if (name === 'null') return { type: 'null' };
309
+ if (name === 'undefined') return { type: 'null' };
310
+
311
+ return { type: 'identifier', name };
312
+ }
313
+
314
+ private parseIdentifierName(): string {
315
+ let name = '';
316
+ if (this.peek() === '$') name += this.advance();
317
+ while (this.pos < this.input.length && this.isIdentPart(this.input[this.pos])) {
318
+ name += this.advance();
319
+ }
320
+ if (!name) {
321
+ throw new Error(`Expected identifier at position ${this.pos}`);
322
+ }
323
+ return name;
324
+ }
325
+
326
+ private parseArgList(): ASTNode[] {
327
+ this.skipWhitespace();
328
+ if (this.peek() === ')') return [];
329
+
330
+ const args: ASTNode[] = [];
331
+ args.push(this.parseExpression());
332
+ this.skipWhitespace();
333
+ while (this.peek() === ',') {
334
+ this.advance(); // consume ','
335
+ args.push(this.parseExpression());
336
+ this.skipWhitespace();
337
+ }
338
+ return args;
339
+ }
340
+
341
+ // Character utilities
342
+
343
+ private peek(): string {
344
+ return this.input[this.pos] ?? '';
345
+ }
346
+
347
+ private advance(): string {
348
+ return this.input[this.pos++] ?? '';
349
+ }
350
+
351
+ private match(str: string): boolean {
352
+ if (this.input.startsWith(str, this.pos)) {
353
+ this.pos += str.length;
354
+ return true;
355
+ }
356
+ return false;
357
+ }
358
+
359
+ private lookAhead(str: string): boolean {
360
+ return this.input.startsWith(str, this.pos);
361
+ }
362
+
363
+ private expect(ch: string): void {
364
+ this.skipWhitespace();
365
+ if (this.peek() !== ch) {
366
+ throw new Error(`Expected '${ch}' at position ${this.pos}, got '${this.peek() || 'EOF'}'`);
367
+ }
368
+ this.advance();
369
+ }
370
+
371
+ private skipWhitespace(): void {
372
+ while (this.pos < this.input.length && ' \t\n\r'.includes(this.input[this.pos])) {
373
+ this.pos++;
374
+ }
375
+ }
376
+
377
+ private isIdentStart(ch: string): boolean {
378
+ return (
379
+ (ch >= 'a' && ch <= 'z') ||
380
+ (ch >= 'A' && ch <= 'Z') ||
381
+ ch === '_' ||
382
+ ch === '$'
383
+ );
384
+ }
385
+
386
+ private isIdentPart(ch: string): boolean {
387
+ return this.isIdentStart(ch) || (ch >= '0' && ch <= '9');
388
+ }
389
+ }
390
+
391
+ // =============================================================================
392
+ // AST Evaluator
393
+ // =============================================================================
394
+
395
+ function evaluateAST(
396
+ node: ASTNode,
397
+ context: ExpressionContext,
398
+ fnMap: Map<string, ExpressionFunction['fn']>,
399
+ ): unknown {
400
+ switch (node.type) {
401
+ case 'number':
402
+ return node.value;
403
+ case 'string':
404
+ return node.value;
405
+ case 'boolean':
406
+ return node.value;
407
+ case 'null':
408
+ return null;
409
+
410
+ case 'identifier':
411
+ return resolvePath(node.name, context);
412
+
413
+ case 'path':
414
+ return resolvePath(node.segments.join('.'), context);
415
+
416
+ case 'member': {
417
+ const obj = evaluateAST(node.object, context, fnMap);
418
+ if (obj == null || typeof obj !== 'object') return undefined;
419
+ return (obj as Record<string, unknown>)[node.property];
420
+ }
421
+
422
+ case 'call': {
423
+ const fn = fnMap.get(node.name);
424
+ if (!fn) return undefined;
425
+ const args = node.args.map(a => evaluateAST(a, context, fnMap));
426
+ return fn(...args);
427
+ }
428
+
429
+ case 'method_call': {
430
+ // Try to resolve as a dotted function name first (e.g., Math.abs)
431
+ const obj = evaluateAST(node.object, context, fnMap);
432
+ if (obj != null && typeof obj === 'object') {
433
+ const method = (obj as Record<string, unknown>)[node.method];
434
+ if (typeof method === 'function') {
435
+ const args = node.args.map(a => evaluateAST(a, context, fnMap));
436
+ return method.apply(obj, args);
437
+ }
438
+ }
439
+ return undefined;
440
+ }
441
+
442
+ case 'unary': {
443
+ const operand = evaluateAST(node.operand, context, fnMap);
444
+ return !operand;
445
+ }
446
+
447
+ case 'binary': {
448
+ // Short-circuit for logical operators
449
+ if (node.operator === '&&') {
450
+ const left = evaluateAST(node.left, context, fnMap);
451
+ if (!left) return left;
452
+ return evaluateAST(node.right, context, fnMap);
453
+ }
454
+ if (node.operator === '||') {
455
+ const left = evaluateAST(node.left, context, fnMap);
456
+ if (left) return left;
457
+ return evaluateAST(node.right, context, fnMap);
458
+ }
459
+
460
+ const left = evaluateAST(node.left, context, fnMap);
461
+ const right = evaluateAST(node.right, context, fnMap);
462
+
463
+ switch (node.operator) {
464
+ // eslint-disable-next-line eqeqeq
465
+ case '==': return left == right;
466
+ // eslint-disable-next-line eqeqeq
467
+ case '!=': return left != right;
468
+ case '>': return Number(left) > Number(right);
469
+ case '<': return Number(left) < Number(right);
470
+ case '>=': return Number(left) >= Number(right);
471
+ case '<=': return Number(left) <= Number(right);
472
+ default: return undefined;
473
+ }
474
+ }
475
+
476
+ case 'ternary': {
477
+ const condition = evaluateAST(node.condition, context, fnMap);
478
+ return condition
479
+ ? evaluateAST(node.consequent, context, fnMap)
480
+ : evaluateAST(node.alternate, context, fnMap);
481
+ }
482
+ }
483
+ }
484
+
485
+ // =============================================================================
486
+ // AST Cache
487
+ // =============================================================================
488
+
489
+ const MAX_CACHE = 500;
490
+
491
+ /** Parsed AST cache — shared across evaluator instances */
492
+ const astCache = new Map<string, ASTNode>();
493
+
494
+ function evictIfNeeded(): void {
495
+ if (astCache.size > MAX_CACHE) {
496
+ const keys = Array.from(astCache.keys());
497
+ const evictCount = Math.floor(MAX_CACHE * 0.25);
498
+ for (let i = 0; i < evictCount; i++) {
499
+ astCache.delete(keys[i]!);
500
+ }
501
+ }
502
+ }
503
+
504
+ function parseAndCache(expr: string): ASTNode {
505
+ const cached = astCache.get(expr);
506
+ if (cached) return cached;
507
+
508
+ const parser = new Parser(expr);
509
+ const ast = parser.parse();
510
+ astCache.set(expr, ast);
511
+ evictIfNeeded();
512
+ return ast;
513
+ }
514
+
515
+ // =============================================================================
516
+ // Public API (unchanged signatures)
517
+ // =============================================================================
518
+
519
+ /** Template regex: matches {{expression}} */
520
+ const TEMPLATE_RE = /\{\{(.+?)\}\}/g;
521
+
522
+ /**
523
+ * Resolve a dot-path from a context object.
524
+ * Handles: "state_data.title", "$instance.fields.name"
525
+ */
526
+ function resolvePath(path: string, context: ExpressionContext): unknown {
527
+ const parts = path.split('.');
528
+ let current: unknown = context;
529
+ for (const part of parts) {
530
+ if (current == null || typeof current !== 'object') return undefined;
531
+ current = (current as Record<string, unknown>)[part];
532
+ }
533
+ return current;
534
+ }
535
+
536
+ /**
537
+ * Evaluate a single expression string against a context.
538
+ */
539
+ function evaluateExpression(
540
+ expr: string,
541
+ context: ExpressionContext,
542
+ fnMap: Map<string, ExpressionFunction['fn']>,
543
+ ): unknown {
544
+ const trimmed = expr.trim();
545
+
546
+ // Fast paths for common patterns
547
+ if (trimmed === 'true') return true;
548
+ if (trimmed === 'false') return false;
549
+ if (trimmed === 'null') return null;
550
+ if (trimmed === 'undefined') return undefined;
551
+
552
+ // Numeric literal
553
+ const num = Number(trimmed);
554
+ if (!isNaN(num) && trimmed !== '') return num;
555
+
556
+ // String literal (single or double quoted)
557
+ if (
558
+ (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
559
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
560
+ ) {
561
+ return trimmed.slice(1, -1);
562
+ }
563
+
564
+ // Simple path (no operators, no function calls)
565
+ if (/^[a-zA-Z_$][\w$.]*$/.test(trimmed)) {
566
+ return resolvePath(trimmed, context);
567
+ }
568
+
569
+ // Parse expression to AST and evaluate
570
+ const ast = parseAndCache(trimmed);
571
+ return evaluateAST(ast, context, fnMap);
572
+ }
573
+
574
+ /**
575
+ * WEB_FAILURE_POLICIES — predefined failure policies for Web Player contexts.
576
+ */
577
+ export const WEB_FAILURE_POLICIES: Record<string, FailurePolicy> = {
578
+ VIEW_BINDING: {
579
+ on_error: 'return_fallback',
580
+ fallback_value: '',
581
+ log_level: 'warn',
582
+ },
583
+ EVENT_REACTION: {
584
+ on_error: 'log_and_skip',
585
+ fallback_value: undefined,
586
+ log_level: 'error',
587
+ },
588
+ DURING_ACTION: {
589
+ on_error: 'log_and_skip',
590
+ fallback_value: undefined,
591
+ log_level: 'error',
592
+ },
593
+ CONDITIONAL_VISIBILITY: {
594
+ on_error: 'return_fallback',
595
+ fallback_value: true, // Show by default if condition fails
596
+ log_level: 'warn',
597
+ },
598
+ };
599
+
600
+ /**
601
+ * createEvaluator — factory function that builds a configured evaluator.
602
+ *
603
+ * @param config - Function registry and failure policy
604
+ * @returns Evaluator instance with evaluate, evaluateTemplate, and validate methods
605
+ */
606
+ export function createEvaluator(config: EvaluatorConfig): Evaluator {
607
+ const allFunctions = [...CORE_FUNCTIONS, ...config.functions];
608
+ const fnMap = buildFunctionMap(allFunctions);
609
+ const policy = config.failurePolicy;
610
+
611
+ function handleError(expr: string, error: unknown): ExpressionResult {
612
+ const message = error instanceof Error ? error.message : String(error);
613
+
614
+ if (policy.log_level === 'error') {
615
+ console.error(`[player-core] Expression error: "${expr}" — ${message}`);
616
+ } else if (policy.log_level === 'warn') {
617
+ console.warn(`[player-core] Expression error: "${expr}" — ${message}`);
618
+ }
619
+
620
+ switch (policy.on_error) {
621
+ case 'throw':
622
+ throw error;
623
+ case 'return_fallback':
624
+ return { value: policy.fallback_value, status: 'fallback', error: message };
625
+ case 'log_and_skip':
626
+ default:
627
+ return { value: policy.fallback_value, status: 'error', error: message };
628
+ }
629
+ }
630
+
631
+ return {
632
+ evaluate<T = unknown>(expression: string, context: ExpressionContext): ExpressionResult<T> {
633
+ try {
634
+ const value = evaluateExpression(expression, context, fnMap);
635
+ return { value: value as T, status: 'ok' };
636
+ } catch (error) {
637
+ return handleError(expression, error) as ExpressionResult<T>;
638
+ }
639
+ },
640
+
641
+ evaluateTemplate(template: string, context: ExpressionContext): ExpressionResult<string> {
642
+ try {
643
+ // If no template markers, return as-is
644
+ if (!template.includes('{{')) {
645
+ return { value: template, status: 'ok' };
646
+ }
647
+
648
+ const result = template.replace(TEMPLATE_RE, (_match, expr: string) => {
649
+ const value = evaluateExpression(expr, context, fnMap);
650
+ return value != null ? String(value) : '';
651
+ });
652
+
653
+ return { value: result, status: 'ok' };
654
+ } catch (error) {
655
+ return handleError(template, error) as ExpressionResult<string>;
656
+ }
657
+ },
658
+
659
+ validate(expression: string): { valid: boolean; errors: string[] } {
660
+ const errors: string[] = [];
661
+
662
+ try {
663
+ parseAndCache(expression);
664
+ } catch (e) {
665
+ errors.push(e instanceof Error ? e.message : String(e));
666
+ }
667
+
668
+ return { valid: errors.length === 0, errors };
669
+ },
670
+ };
671
+ }
672
+
673
+ /** Clear parsed AST cache (for testing) */
674
+ export function clearExpressionCache(): void {
675
+ astCache.clear();
676
+ }