@sharpee/transcript-tester 0.9.61-beta

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 (137) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +367 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/condition-evaluator.d.ts +30 -0
  8. package/dist/condition-evaluator.d.ts.map +1 -0
  9. package/dist/condition-evaluator.js +314 -0
  10. package/dist/condition-evaluator.js.map +1 -0
  11. package/dist/fast-cli.d.ts +13 -0
  12. package/dist/fast-cli.d.ts.map +1 -0
  13. package/dist/fast-cli.js +363 -0
  14. package/dist/fast-cli.js.map +1 -0
  15. package/dist/index.d.ts +17 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +48 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/navigator.d.ts +27 -0
  20. package/dist/navigator.d.ts.map +1 -0
  21. package/dist/navigator.js +303 -0
  22. package/dist/navigator.js.map +1 -0
  23. package/dist/parser.d.ts +19 -0
  24. package/dist/parser.d.ts.map +1 -0
  25. package/dist/parser.js +453 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/reporter.d.ts +41 -0
  28. package/dist/reporter.d.ts.map +1 -0
  29. package/dist/reporter.js +386 -0
  30. package/dist/reporter.js.map +1 -0
  31. package/dist/runner.d.ts +44 -0
  32. package/dist/runner.d.ts.map +1 -0
  33. package/dist/runner.js +977 -0
  34. package/dist/runner.js.map +1 -0
  35. package/dist/story-loader.d.ts +31 -0
  36. package/dist/story-loader.d.ts.map +1 -0
  37. package/dist/story-loader.js +169 -0
  38. package/dist/story-loader.js.map +1 -0
  39. package/dist/types.d.ts +204 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +8 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist-esm/cli.d.ts +11 -0
  44. package/dist-esm/cli.d.ts.map +1 -0
  45. package/dist-esm/cli.js +332 -0
  46. package/dist-esm/cli.js.map +1 -0
  47. package/dist-esm/condition-evaluator.d.ts +30 -0
  48. package/dist-esm/condition-evaluator.d.ts.map +1 -0
  49. package/dist-esm/condition-evaluator.js +311 -0
  50. package/dist-esm/condition-evaluator.js.map +1 -0
  51. package/dist-esm/fast-cli.d.ts +13 -0
  52. package/dist-esm/fast-cli.d.ts.map +1 -0
  53. package/dist-esm/fast-cli.js +328 -0
  54. package/dist-esm/fast-cli.js.map +1 -0
  55. package/dist-esm/index.d.ts +17 -0
  56. package/dist-esm/index.d.ts.map +1 -0
  57. package/dist-esm/index.js +21 -0
  58. package/dist-esm/index.js.map +1 -0
  59. package/dist-esm/navigator.d.ts +27 -0
  60. package/dist-esm/navigator.d.ts.map +1 -0
  61. package/dist-esm/navigator.js +300 -0
  62. package/dist-esm/navigator.js.map +1 -0
  63. package/dist-esm/parser.d.ts +19 -0
  64. package/dist-esm/parser.d.ts.map +1 -0
  65. package/dist-esm/parser.js +415 -0
  66. package/dist-esm/parser.js.map +1 -0
  67. package/dist-esm/reporter.d.ts +41 -0
  68. package/dist-esm/reporter.d.ts.map +1 -0
  69. package/dist-esm/reporter.js +342 -0
  70. package/dist-esm/reporter.js.map +1 -0
  71. package/dist-esm/runner.d.ts +44 -0
  72. package/dist-esm/runner.d.ts.map +1 -0
  73. package/dist-esm/runner.js +941 -0
  74. package/dist-esm/runner.js.map +1 -0
  75. package/dist-esm/story-loader.d.ts +31 -0
  76. package/dist-esm/story-loader.d.ts.map +1 -0
  77. package/dist-esm/story-loader.js +131 -0
  78. package/dist-esm/story-loader.js.map +1 -0
  79. package/dist-esm/types.d.ts +204 -0
  80. package/dist-esm/types.d.ts.map +1 -0
  81. package/dist-esm/types.js +7 -0
  82. package/dist-esm/types.js.map +1 -0
  83. package/dist-npm/cli.d.ts +11 -0
  84. package/dist-npm/cli.d.ts.map +1 -0
  85. package/dist-npm/cli.js +367 -0
  86. package/dist-npm/cli.js.map +1 -0
  87. package/dist-npm/condition-evaluator.d.ts +30 -0
  88. package/dist-npm/condition-evaluator.d.ts.map +1 -0
  89. package/dist-npm/condition-evaluator.js +314 -0
  90. package/dist-npm/condition-evaluator.js.map +1 -0
  91. package/dist-npm/fast-cli.d.ts +13 -0
  92. package/dist-npm/fast-cli.d.ts.map +1 -0
  93. package/dist-npm/fast-cli.js +363 -0
  94. package/dist-npm/fast-cli.js.map +1 -0
  95. package/dist-npm/index.d.ts +17 -0
  96. package/dist-npm/index.d.ts.map +1 -0
  97. package/dist-npm/index.js +48 -0
  98. package/dist-npm/index.js.map +1 -0
  99. package/dist-npm/navigator.d.ts +27 -0
  100. package/dist-npm/navigator.d.ts.map +1 -0
  101. package/dist-npm/navigator.js +303 -0
  102. package/dist-npm/navigator.js.map +1 -0
  103. package/dist-npm/parser.d.ts +19 -0
  104. package/dist-npm/parser.d.ts.map +1 -0
  105. package/dist-npm/parser.js +453 -0
  106. package/dist-npm/parser.js.map +1 -0
  107. package/dist-npm/reporter.d.ts +41 -0
  108. package/dist-npm/reporter.d.ts.map +1 -0
  109. package/dist-npm/reporter.js +386 -0
  110. package/dist-npm/reporter.js.map +1 -0
  111. package/dist-npm/runner.d.ts +44 -0
  112. package/dist-npm/runner.d.ts.map +1 -0
  113. package/dist-npm/runner.js +977 -0
  114. package/dist-npm/runner.js.map +1 -0
  115. package/dist-npm/story-loader.d.ts +31 -0
  116. package/dist-npm/story-loader.d.ts.map +1 -0
  117. package/dist-npm/story-loader.js +169 -0
  118. package/dist-npm/story-loader.js.map +1 -0
  119. package/dist-npm/types.d.ts +204 -0
  120. package/dist-npm/types.d.ts.map +1 -0
  121. package/dist-npm/types.js +8 -0
  122. package/dist-npm/types.js.map +1 -0
  123. package/package.json +49 -0
  124. package/src/cli.ts +385 -0
  125. package/src/condition-evaluator.ts +382 -0
  126. package/src/fast-cli.ts +403 -0
  127. package/src/index.ts +26 -0
  128. package/src/navigator.ts +365 -0
  129. package/src/parser.ts +488 -0
  130. package/src/reporter.ts +409 -0
  131. package/src/runner.ts +1152 -0
  132. package/src/story-loader.ts +168 -0
  133. package/src/types.ts +244 -0
  134. package/tsconfig.esm.json +11 -0
  135. package/tsconfig.esm.tsbuildinfo +1 -0
  136. package/tsconfig.json +22 -0
  137. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,941 @@
1
+ /**
2
+ * Transcript Runner
3
+ *
4
+ * Executes transcript commands against a loaded story and checks results.
5
+ */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { evaluateCondition } from './condition-evaluator';
9
+ import { executeNavigate } from './navigator';
10
+ // Constants for directive execution
11
+ const MAX_WHILE_ITERATIONS = 100;
12
+ const MAX_BLOCK_DEPTH = 10;
13
+ /**
14
+ * Run a single transcript against an engine
15
+ *
16
+ * If transcript has items (with directives), use the smart runner.
17
+ * Otherwise, fall back to legacy command-only execution.
18
+ */
19
+ export async function runTranscript(transcript, engine, options = {}) {
20
+ // Use smart runner if we have items with directives
21
+ if (transcript.items && transcript.items.length > 0) {
22
+ const hasDirectives = transcript.items.some(i => i.type === 'directive');
23
+ if (hasDirectives) {
24
+ return runSmartTranscript(transcript, engine, options);
25
+ }
26
+ }
27
+ // Legacy: command-only execution
28
+ const startTime = Date.now();
29
+ const results = [];
30
+ for (const command of transcript.commands) {
31
+ const result = await runCommand(command, engine, options);
32
+ results.push(result);
33
+ if (options.stopOnFailure && !result.passed && !result.expectedFailure && !result.skipped) {
34
+ break;
35
+ }
36
+ }
37
+ const passed = results.filter(r => r.passed && !r.skipped).length;
38
+ const failed = results.filter(r => !r.passed && !r.expectedFailure && !r.skipped).length;
39
+ const expectedFailures = results.filter(r => r.expectedFailure).length;
40
+ const skipped = results.filter(r => r.skipped).length;
41
+ return {
42
+ transcript,
43
+ commands: results,
44
+ passed,
45
+ failed,
46
+ expectedFailures,
47
+ skipped,
48
+ duration: Date.now() - startTime
49
+ };
50
+ }
51
+ /**
52
+ * Run a transcript with smart directives (IF/WHILE/NAVIGATE/GOAL)
53
+ */
54
+ async function runSmartTranscript(transcript, engine, options = {}) {
55
+ const startTime = Date.now();
56
+ const results = [];
57
+ const items = transcript.items;
58
+ // Get player ID for condition evaluation
59
+ const playerId = getPlayerId(engine);
60
+ // Block state stack for control flow
61
+ const blockStack = [];
62
+ // Main execution loop
63
+ let i = 0;
64
+ while (i < items.length) {
65
+ const item = items[i];
66
+ // Check if we should skip this item due to inactive block
67
+ if (blockStack.length > 0 && !blockStack[blockStack.length - 1].active) {
68
+ // Skip this item, but process END directives to close blocks
69
+ if (item.type === 'directive') {
70
+ const handled = await handleEndDirective(item.directive, blockStack, i, items, engine, playerId, options);
71
+ if (handled.consumed) {
72
+ i = handled.nextIndex;
73
+ continue;
74
+ }
75
+ }
76
+ i++;
77
+ continue;
78
+ }
79
+ if (item.type === 'command') {
80
+ // Execute command
81
+ const result = await runCommand(item.command, engine, options);
82
+ results.push(result);
83
+ // Update annotation context for ext-testing
84
+ if (options.testingExtension?.setCommandContext) {
85
+ options.testingExtension.setCommandContext(result.command.input, result.actualOutput);
86
+ }
87
+ if (options.stopOnFailure && !result.passed && !result.expectedFailure && !result.skipped) {
88
+ break;
89
+ }
90
+ i++;
91
+ }
92
+ else if (item.type === 'comment') {
93
+ // Handle comment annotation for ext-testing
94
+ if (options.testingExtension?.addAnnotation && item.comment) {
95
+ options.testingExtension.addAnnotation('comment', item.comment.text, engine.world);
96
+ }
97
+ i++;
98
+ }
99
+ else if (item.type === 'directive') {
100
+ const directive = item.directive;
101
+ const directiveResult = await handleDirective(directive, blockStack, i, items, engine, playerId, options);
102
+ if (directiveResult.error && options.stopOnFailure) {
103
+ // Create a synthetic failed result for the directive
104
+ results.push(createDirectiveFailResult(directive, directiveResult.error));
105
+ break;
106
+ }
107
+ // Add any command results from directive execution (e.g., NAVIGATE)
108
+ if (directiveResult.commandResults) {
109
+ results.push(...directiveResult.commandResults);
110
+ }
111
+ i = directiveResult.nextIndex;
112
+ }
113
+ else {
114
+ i++;
115
+ }
116
+ }
117
+ const passed = results.filter(r => r.passed && !r.skipped).length;
118
+ const failed = results.filter(r => !r.passed && !r.expectedFailure && !r.skipped).length;
119
+ const expectedFailures = results.filter(r => r.expectedFailure).length;
120
+ const skipped = results.filter(r => r.skipped).length;
121
+ return {
122
+ transcript,
123
+ commands: results,
124
+ passed,
125
+ failed,
126
+ expectedFailures,
127
+ skipped,
128
+ duration: Date.now() - startTime
129
+ };
130
+ }
131
+ /**
132
+ * Get player entity ID from engine
133
+ */
134
+ function getPlayerId(engine) {
135
+ if (engine.world?.getPlayer) {
136
+ const player = engine.world.getPlayer();
137
+ return player?.id || 'player';
138
+ }
139
+ return 'player';
140
+ }
141
+ /**
142
+ * Handle a directive (control flow, navigation, goals)
143
+ */
144
+ async function handleDirective(directive, blockStack, currentIndex, items, engine, playerId, options) {
145
+ const world = engine.world;
146
+ const verbose = options.verbose || false;
147
+ switch (directive.type) {
148
+ case 'goal': {
149
+ // Start a goal block
150
+ if (blockStack.length >= MAX_BLOCK_DEPTH) {
151
+ return { nextIndex: currentIndex + 1, error: 'Max block depth exceeded' };
152
+ }
153
+ // Find REQUIRES and ENSURES for this goal
154
+ const requires = [];
155
+ const ensures = [];
156
+ let j = currentIndex + 1;
157
+ while (j < items.length) {
158
+ const nextItem = items[j];
159
+ if (nextItem.type !== 'directive')
160
+ break;
161
+ const nextDir = nextItem.directive;
162
+ if (nextDir.type === 'requires' && nextDir.condition) {
163
+ requires.push(nextDir.condition);
164
+ j++;
165
+ }
166
+ else if (nextDir.type === 'ensures' && nextDir.condition) {
167
+ ensures.push(nextDir.condition);
168
+ j++;
169
+ }
170
+ else {
171
+ break;
172
+ }
173
+ }
174
+ // Check REQUIRES preconditions
175
+ let allRequiresMet = true;
176
+ if (world) {
177
+ for (const req of requires) {
178
+ const result = evaluateCondition(req, world, playerId);
179
+ if (verbose) {
180
+ console.log(` [GOAL "${directive.goalName}"] REQUIRES: ${req} -> ${result.met ? 'OK' : 'FAILED'}`);
181
+ }
182
+ if (!result.met) {
183
+ allRequiresMet = false;
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ if (!allRequiresMet) {
189
+ return {
190
+ nextIndex: currentIndex + 1,
191
+ error: `Goal "${directive.goalName}" preconditions not met`
192
+ };
193
+ }
194
+ if (verbose) {
195
+ console.log(`[GOAL: ${directive.goalName}]`);
196
+ }
197
+ blockStack.push({
198
+ type: 'goal',
199
+ startIndex: j, // Skip past REQUIRES/ENSURES
200
+ active: true,
201
+ iterations: 0,
202
+ goalName: directive.goalName,
203
+ ensures
204
+ });
205
+ return { nextIndex: j };
206
+ }
207
+ case 'end_goal': {
208
+ // End goal block and check ENSURES
209
+ const block = blockStack.pop();
210
+ if (!block || block.type !== 'goal') {
211
+ return { nextIndex: currentIndex + 1, error: 'END GOAL without matching GOAL' };
212
+ }
213
+ // Check ENSURES postconditions
214
+ if (world && block.ensures) {
215
+ for (const ens of block.ensures) {
216
+ const result = evaluateCondition(ens, world, playerId);
217
+ if (verbose) {
218
+ console.log(` [END GOAL "${block.goalName}"] ENSURES: ${ens} -> ${result.met ? 'OK' : 'FAILED'}`);
219
+ }
220
+ if (!result.met) {
221
+ return {
222
+ nextIndex: currentIndex + 1,
223
+ error: `Goal "${block.goalName}" postcondition failed: ${ens}`
224
+ };
225
+ }
226
+ }
227
+ }
228
+ if (verbose) {
229
+ console.log(`[END GOAL: ${block.goalName}] - Success`);
230
+ }
231
+ return { nextIndex: currentIndex + 1 };
232
+ }
233
+ case 'requires':
234
+ case 'ensures':
235
+ // These are handled as part of GOAL processing
236
+ return { nextIndex: currentIndex + 1 };
237
+ case 'if': {
238
+ if (blockStack.length >= MAX_BLOCK_DEPTH) {
239
+ return { nextIndex: currentIndex + 1, error: 'Max block depth exceeded' };
240
+ }
241
+ let conditionMet = true;
242
+ if (world && directive.condition) {
243
+ const result = evaluateCondition(directive.condition, world, playerId);
244
+ conditionMet = result.met;
245
+ if (verbose) {
246
+ console.log(` [IF: ${directive.condition}] -> ${conditionMet ? 'TRUE' : 'FALSE'} (${result.reason})`);
247
+ }
248
+ }
249
+ blockStack.push({
250
+ type: 'if',
251
+ condition: directive.condition,
252
+ startIndex: currentIndex,
253
+ active: conditionMet,
254
+ iterations: 0
255
+ });
256
+ return { nextIndex: currentIndex + 1 };
257
+ }
258
+ case 'end_if': {
259
+ const block = blockStack.pop();
260
+ if (!block || block.type !== 'if') {
261
+ return { nextIndex: currentIndex + 1, error: 'END IF without matching IF' };
262
+ }
263
+ return { nextIndex: currentIndex + 1 };
264
+ }
265
+ case 'while': {
266
+ if (blockStack.length >= MAX_BLOCK_DEPTH) {
267
+ return { nextIndex: currentIndex + 1, error: 'Max block depth exceeded' };
268
+ }
269
+ let conditionMet = true;
270
+ if (world && directive.condition) {
271
+ const result = evaluateCondition(directive.condition, world, playerId);
272
+ conditionMet = result.met;
273
+ if (verbose) {
274
+ console.log(` [WHILE: ${directive.condition}] -> ${conditionMet ? 'TRUE (entering loop)' : 'FALSE (skipping loop)'}`);
275
+ }
276
+ }
277
+ blockStack.push({
278
+ type: 'while',
279
+ condition: directive.condition,
280
+ startIndex: currentIndex,
281
+ active: conditionMet,
282
+ iterations: 0
283
+ });
284
+ return { nextIndex: currentIndex + 1 };
285
+ }
286
+ case 'end_while': {
287
+ const block = blockStack[blockStack.length - 1];
288
+ if (!block || block.type !== 'while') {
289
+ blockStack.pop();
290
+ return { nextIndex: currentIndex + 1, error: 'END WHILE without matching WHILE' };
291
+ }
292
+ block.iterations++;
293
+ if (block.iterations >= MAX_WHILE_ITERATIONS) {
294
+ blockStack.pop();
295
+ return { nextIndex: currentIndex + 1, error: `WHILE loop exceeded ${MAX_WHILE_ITERATIONS} iterations` };
296
+ }
297
+ // Re-evaluate condition
298
+ let conditionMet = false;
299
+ if (world && block.condition) {
300
+ const result = evaluateCondition(block.condition, world, playerId);
301
+ conditionMet = result.met;
302
+ if (verbose) {
303
+ console.log(` [END WHILE iteration ${block.iterations}] ${block.condition} -> ${conditionMet ? 'TRUE (continue)' : 'FALSE (exit loop)'}`);
304
+ }
305
+ }
306
+ if (conditionMet) {
307
+ // Loop back to after WHILE directive
308
+ return { nextIndex: block.startIndex + 1 };
309
+ }
310
+ else {
311
+ // Exit loop
312
+ blockStack.pop();
313
+ return { nextIndex: currentIndex + 1 };
314
+ }
315
+ }
316
+ case 'navigate': {
317
+ if (!world || !directive.target) {
318
+ return { nextIndex: currentIndex + 1, error: 'NAVIGATE requires world model and target' };
319
+ }
320
+ if (verbose) {
321
+ console.log(`[NAVIGATE TO: "${directive.target}"]`);
322
+ }
323
+ const navResult = await executeNavigate(directive.target, world, engine, playerId, verbose);
324
+ if (!navResult.success) {
325
+ return {
326
+ nextIndex: currentIndex + 1,
327
+ error: navResult.error || `Navigation to "${directive.target}" failed`
328
+ };
329
+ }
330
+ // Create synthetic command results for the navigation commands
331
+ const commandResults = navResult.commands.map((cmd, idx) => ({
332
+ command: {
333
+ lineNumber: directive.lineNumber,
334
+ input: cmd,
335
+ expectedOutput: [],
336
+ assertions: []
337
+ },
338
+ actualOutput: `(navigated: ${navResult.path[idx] || '?'} -> ${navResult.path[idx + 1] || directive.target})`,
339
+ actualEvents: [],
340
+ passed: true,
341
+ expectedFailure: false,
342
+ skipped: false,
343
+ assertionResults: []
344
+ }));
345
+ return { nextIndex: currentIndex + 1, commandResults };
346
+ }
347
+ case 'save': {
348
+ if (!world || !directive.saveName) {
349
+ return { nextIndex: currentIndex + 1, error: 'SAVE requires world model and save name' };
350
+ }
351
+ if (verbose) {
352
+ console.log(`[$save ${directive.saveName}]`);
353
+ }
354
+ try {
355
+ // Get save directory from options or use default
356
+ const savesDir = options.savesDirectory || './saves';
357
+ // Ensure directory exists
358
+ if (!fs.existsSync(savesDir)) {
359
+ fs.mkdirSync(savesDir, { recursive: true });
360
+ }
361
+ // Serialize world state
362
+ const worldState = world.toJSON();
363
+ // Write to file
364
+ const savePath = path.join(savesDir, `${directive.saveName}.json`);
365
+ fs.writeFileSync(savePath, worldState, 'utf-8');
366
+ if (verbose) {
367
+ console.log(` Saved to: ${savePath}`);
368
+ }
369
+ }
370
+ catch (e) {
371
+ return {
372
+ nextIndex: currentIndex + 1,
373
+ error: `Failed to save "${directive.saveName}": ${e instanceof Error ? e.message : String(e)}`
374
+ };
375
+ }
376
+ return { nextIndex: currentIndex + 1 };
377
+ }
378
+ case 'restore': {
379
+ if (!world || !directive.saveName) {
380
+ return { nextIndex: currentIndex + 1, error: 'RESTORE requires world model and save name' };
381
+ }
382
+ if (verbose) {
383
+ console.log(`[$restore ${directive.saveName}]`);
384
+ }
385
+ try {
386
+ // Get save directory from options or use default
387
+ const savesDir = options.savesDirectory || './saves';
388
+ const savePath = path.join(savesDir, `${directive.saveName}.json`);
389
+ // Check file exists
390
+ if (!fs.existsSync(savePath)) {
391
+ return {
392
+ nextIndex: currentIndex + 1,
393
+ error: `Save file not found: ${savePath}`
394
+ };
395
+ }
396
+ // Read and restore world state
397
+ const worldState = fs.readFileSync(savePath, 'utf-8');
398
+ world.loadJSON(worldState);
399
+ if (verbose) {
400
+ console.log(` Restored from: ${savePath}`);
401
+ }
402
+ }
403
+ catch (e) {
404
+ return {
405
+ nextIndex: currentIndex + 1,
406
+ error: `Failed to restore "${directive.saveName}": ${e instanceof Error ? e.message : String(e)}`
407
+ };
408
+ }
409
+ return { nextIndex: currentIndex + 1 };
410
+ }
411
+ case 'test-command': {
412
+ // Execute ext-testing command ($teleport, $take, $kill, etc.)
413
+ if (!directive.testCommand) {
414
+ return { nextIndex: currentIndex + 1, error: 'Test command missing' };
415
+ }
416
+ if (!options.testingExtension) {
417
+ // No testing extension available - warn and skip
418
+ if (verbose) {
419
+ console.log(` [${directive.testCommand}] - skipped (no testing extension)`);
420
+ }
421
+ return { nextIndex: currentIndex + 1 };
422
+ }
423
+ if (!world) {
424
+ return { nextIndex: currentIndex + 1, error: 'World model not available for test command' };
425
+ }
426
+ if (verbose) {
427
+ console.log(`[${directive.testCommand}]`);
428
+ }
429
+ try {
430
+ const result = options.testingExtension.executeTestCommand(directive.testCommand, world);
431
+ if (verbose) {
432
+ if (result.output.length > 0) {
433
+ for (const line of result.output) {
434
+ console.log(` ${line}`);
435
+ }
436
+ }
437
+ if (result.error) {
438
+ console.log(` ERROR: ${result.error}`);
439
+ }
440
+ }
441
+ if (!result.success) {
442
+ return {
443
+ nextIndex: currentIndex + 1,
444
+ error: result.error || `Test command failed: ${directive.testCommand}`
445
+ };
446
+ }
447
+ }
448
+ catch (e) {
449
+ return {
450
+ nextIndex: currentIndex + 1,
451
+ error: `Test command error: ${e instanceof Error ? e.message : String(e)}`
452
+ };
453
+ }
454
+ return { nextIndex: currentIndex + 1 };
455
+ }
456
+ default:
457
+ return { nextIndex: currentIndex + 1 };
458
+ }
459
+ }
460
+ /**
461
+ * Handle END directives when in inactive block (for proper nesting)
462
+ */
463
+ async function handleEndDirective(directive, blockStack, currentIndex, items, engine, playerId, options) {
464
+ switch (directive.type) {
465
+ case 'end_if':
466
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].type === 'if') {
467
+ blockStack.pop();
468
+ return { consumed: true, nextIndex: currentIndex + 1 };
469
+ }
470
+ break;
471
+ case 'end_while':
472
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].type === 'while') {
473
+ blockStack.pop();
474
+ return { consumed: true, nextIndex: currentIndex + 1 };
475
+ }
476
+ break;
477
+ case 'end_goal':
478
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].type === 'goal') {
479
+ blockStack.pop();
480
+ return { consumed: true, nextIndex: currentIndex + 1 };
481
+ }
482
+ break;
483
+ // Handle nested block starts within inactive blocks
484
+ case 'if':
485
+ case 'while':
486
+ case 'goal':
487
+ // Push inactive block to maintain proper nesting
488
+ blockStack.push({
489
+ type: directive.type === 'goal' ? 'goal' : directive.type,
490
+ startIndex: currentIndex,
491
+ active: false,
492
+ iterations: 0
493
+ });
494
+ return { consumed: true, nextIndex: currentIndex + 1 };
495
+ }
496
+ return { consumed: false, nextIndex: currentIndex + 1 };
497
+ }
498
+ /**
499
+ * Create a synthetic failed result for a directive error
500
+ */
501
+ function createDirectiveFailResult(directive, error) {
502
+ return {
503
+ command: {
504
+ lineNumber: directive.lineNumber,
505
+ input: `[${directive.type.toUpperCase()}${directive.condition ? ': ' + directive.condition : ''}${directive.target ? ': "' + directive.target + '"' : ''}]`,
506
+ expectedOutput: [],
507
+ assertions: []
508
+ },
509
+ actualOutput: '',
510
+ actualEvents: [],
511
+ passed: false,
512
+ expectedFailure: false,
513
+ skipped: false,
514
+ assertionResults: [],
515
+ error
516
+ };
517
+ }
518
+ /**
519
+ * Run a single command and check assertions
520
+ */
521
+ async function runCommand(command, engine, options) {
522
+ // Check for skip/todo first
523
+ const skipAssertion = command.assertions.find(a => a.type === 'skip' || a.type === 'todo');
524
+ if (skipAssertion) {
525
+ return {
526
+ command,
527
+ actualOutput: '',
528
+ actualEvents: [],
529
+ passed: true,
530
+ expectedFailure: false,
531
+ skipped: true,
532
+ assertionResults: [{
533
+ assertion: skipAssertion,
534
+ passed: true,
535
+ message: skipAssertion.reason || 'Skipped'
536
+ }]
537
+ };
538
+ }
539
+ // Execute the command
540
+ let actualOutput;
541
+ let actualEvents = [];
542
+ let error;
543
+ try {
544
+ const result = await engine.executeCommand(command.input);
545
+ actualOutput = typeof result === 'string' ? result : (engine.getOutput?.() || '');
546
+ // Capture events from the engine (filter out system.* debug events)
547
+ if (engine.lastEvents) {
548
+ actualEvents = engine.lastEvents
549
+ .filter(e => !e.type.startsWith('system.'))
550
+ .map(e => ({
551
+ type: e.type,
552
+ data: e.data || {}
553
+ }));
554
+ }
555
+ }
556
+ catch (e) {
557
+ actualOutput = '';
558
+ error = e instanceof Error ? e.message : String(e);
559
+ }
560
+ // Normalize output for comparison
561
+ const normalizedActual = normalizeOutput(actualOutput);
562
+ const normalizedExpected = normalizeOutput(command.expectedOutput.join('\n'));
563
+ // Check all assertions
564
+ const assertionResults = [];
565
+ let allPassed = true;
566
+ for (const assertion of command.assertions) {
567
+ const result = checkAssertion(assertion, normalizedActual, normalizedExpected, actualEvents, engine.world);
568
+ assertionResults.push(result);
569
+ if (!result.passed) {
570
+ allPassed = false;
571
+ }
572
+ }
573
+ // Check for expected failure
574
+ const failAssertion = command.assertions.find(a => a.type === 'fail');
575
+ const expectedFailure = failAssertion !== undefined;
576
+ // For [FAIL] assertions, invert the logic
577
+ if (expectedFailure) {
578
+ return {
579
+ command,
580
+ actualOutput,
581
+ actualEvents,
582
+ passed: !allPassed, // Pass if assertions failed (as expected)
583
+ expectedFailure: true,
584
+ skipped: false,
585
+ assertionResults,
586
+ error
587
+ };
588
+ }
589
+ return {
590
+ command,
591
+ actualOutput,
592
+ actualEvents,
593
+ passed: allPassed && !error,
594
+ expectedFailure: false,
595
+ skipped: false,
596
+ assertionResults,
597
+ error
598
+ };
599
+ }
600
+ /**
601
+ * Check a single assertion against actual output, events, and world state
602
+ */
603
+ function checkAssertion(assertion, actualOutput, expectedOutput, events, world) {
604
+ switch (assertion.type) {
605
+ case 'ok':
606
+ // Exact match (after normalization)
607
+ const matches = actualOutput === expectedOutput;
608
+ return {
609
+ assertion,
610
+ passed: matches,
611
+ message: matches ? undefined : `Output did not match expected`
612
+ };
613
+ case 'ok-contains':
614
+ const contains = actualOutput.toLowerCase().includes(assertion.value.toLowerCase());
615
+ return {
616
+ assertion,
617
+ passed: contains,
618
+ message: contains ? undefined : `Output does not contain "${assertion.value}"`
619
+ };
620
+ case 'ok-not-contains':
621
+ const notContains = !actualOutput.toLowerCase().includes(assertion.value.toLowerCase());
622
+ return {
623
+ assertion,
624
+ passed: notContains,
625
+ message: notContains ? undefined : `Output should not contain "${assertion.value}"`
626
+ };
627
+ case 'ok-matches':
628
+ const regexMatches = assertion.pattern.test(actualOutput);
629
+ return {
630
+ assertion,
631
+ passed: regexMatches,
632
+ message: regexMatches ? undefined : `Output does not match pattern ${assertion.pattern}`
633
+ };
634
+ case 'fail':
635
+ // This is handled at the command level
636
+ return {
637
+ assertion,
638
+ passed: false,
639
+ message: assertion.reason
640
+ };
641
+ case 'skip':
642
+ case 'todo':
643
+ return {
644
+ assertion,
645
+ passed: true,
646
+ message: assertion.reason
647
+ };
648
+ case 'event-count': {
649
+ const expected = assertion.eventCount || 0;
650
+ const actual = events.length;
651
+ const countMatches = actual === expected;
652
+ return {
653
+ assertion,
654
+ passed: countMatches,
655
+ message: countMatches ? undefined : `Expected ${expected} events, got ${actual}`
656
+ };
657
+ }
658
+ case 'event-assert': {
659
+ return checkEventAssertion(assertion, events);
660
+ }
661
+ case 'state-assert': {
662
+ return checkStateAssertion(assertion, world);
663
+ }
664
+ default:
665
+ return {
666
+ assertion,
667
+ passed: false,
668
+ message: `Unknown assertion type: ${assertion.type}`
669
+ };
670
+ }
671
+ }
672
+ /**
673
+ * Check an event assertion (assertTrue/assertFalse for event existence/properties)
674
+ */
675
+ function checkEventAssertion(assertion, events) {
676
+ const { assertTrue, eventPosition, eventType, eventData } = assertion;
677
+ // Helper to check if an event matches
678
+ const eventMatches = (event) => {
679
+ if (event.type !== eventType)
680
+ return false;
681
+ if (eventData) {
682
+ for (const [key, value] of Object.entries(eventData)) {
683
+ if (event.data[key] !== value)
684
+ return false;
685
+ }
686
+ }
687
+ return true;
688
+ };
689
+ let found = false;
690
+ let actualEvent;
691
+ if (eventPosition !== undefined) {
692
+ // Check specific position (1-based)
693
+ const index = eventPosition - 1;
694
+ if (index >= 0 && index < events.length) {
695
+ actualEvent = events[index];
696
+ found = eventMatches(actualEvent);
697
+ }
698
+ }
699
+ else {
700
+ // Check any position
701
+ actualEvent = events.find(eventMatches);
702
+ found = actualEvent !== undefined;
703
+ }
704
+ // Apply assertTrue/assertFalse logic
705
+ const passed = assertTrue ? found : !found;
706
+ // Build message
707
+ let message;
708
+ if (!passed) {
709
+ if (assertTrue) {
710
+ // Expected to find but didn't
711
+ if (eventPosition !== undefined) {
712
+ const actualAtPos = events[eventPosition - 1];
713
+ if (actualAtPos) {
714
+ message = `Event ${eventPosition}: expected ${eventType}, got ${actualAtPos.type}`;
715
+ if (eventData) {
716
+ message += `. Expected data: ${JSON.stringify(eventData)}, got: ${JSON.stringify(actualAtPos.data)}`;
717
+ }
718
+ }
719
+ else {
720
+ message = `Event ${eventPosition}: position out of range (${events.length} events total)`;
721
+ }
722
+ }
723
+ else {
724
+ message = `No event matching ${eventType}`;
725
+ if (eventData) {
726
+ message += ` with ${JSON.stringify(eventData)}`;
727
+ }
728
+ message += `. Events: ${events.map(e => e.type).join(', ')}`;
729
+ }
730
+ }
731
+ else {
732
+ // Expected NOT to find but did
733
+ message = `Event ${eventType} should not exist but was found`;
734
+ if (eventData) {
735
+ message += ` with matching data ${JSON.stringify(eventData)}`;
736
+ }
737
+ }
738
+ }
739
+ return { assertion, passed, message };
740
+ }
741
+ /**
742
+ * Check a state assertion against the world model
743
+ */
744
+ function checkStateAssertion(assertion, world) {
745
+ const { assertTrue, stateExpression } = assertion;
746
+ if (!world) {
747
+ return {
748
+ assertion,
749
+ passed: false,
750
+ message: 'World model not available for state assertions'
751
+ };
752
+ }
753
+ if (!stateExpression) {
754
+ return {
755
+ assertion,
756
+ passed: false,
757
+ message: 'No state expression provided'
758
+ };
759
+ }
760
+ try {
761
+ const result = evaluateStateExpression(stateExpression, world);
762
+ const passed = assertTrue ? result.matches : !result.matches;
763
+ let message;
764
+ if (!passed) {
765
+ if (assertTrue) {
766
+ message = `State assertion failed: ${stateExpression}. ${result.details || ''}`;
767
+ }
768
+ else {
769
+ message = `State assertion should be false but was true: ${stateExpression}`;
770
+ }
771
+ }
772
+ return { assertion, passed, message };
773
+ }
774
+ catch (e) {
775
+ return {
776
+ assertion,
777
+ passed: false,
778
+ message: `Error evaluating state expression: ${e instanceof Error ? e.message : String(e)}`
779
+ };
780
+ }
781
+ }
782
+ /**
783
+ * Evaluate a state expression against the world model
784
+ * Supports: entity.property = value, entity.property != value,
785
+ * collection contains item, collection not-contains item
786
+ */
787
+ function evaluateStateExpression(expression, world) {
788
+ // Parse "entity.property = value" or "entity.property != value"
789
+ const equalityMatch = expression.match(/^(\w+)\.(\w+)\s*(=|!=)\s*(.+)$/);
790
+ if (equalityMatch) {
791
+ const [, entityName, property, operator, expectedValue] = equalityMatch;
792
+ // Find entity by name
793
+ const entity = findEntity(entityName, world);
794
+ if (!entity) {
795
+ return { matches: false, details: `Entity "${entityName}" not found` };
796
+ }
797
+ // Get property value
798
+ const actualValue = getEntityProperty(entity, property, world);
799
+ const expectedResolved = resolveValue(expectedValue.trim(), world);
800
+ const isEqual = actualValue === expectedResolved ||
801
+ (actualValue?.id && actualValue.id === expectedResolved) ||
802
+ (typeof expectedResolved === 'string' && actualValue?.id === expectedResolved);
803
+ if (operator === '=') {
804
+ return {
805
+ matches: isEqual,
806
+ details: isEqual ? undefined : `${entityName}.${property} is "${actualValue?.id || actualValue}", expected "${expectedResolved}"`
807
+ };
808
+ }
809
+ else {
810
+ return {
811
+ matches: !isEqual,
812
+ details: !isEqual ? undefined : `${entityName}.${property} should not be "${expectedResolved}"`
813
+ };
814
+ }
815
+ }
816
+ // Parse "collection contains item" or "collection not-contains item"
817
+ const containsMatch = expression.match(/^(\w+)\.(\w+)\s+(contains|not-contains)\s+(.+)$/);
818
+ if (containsMatch) {
819
+ const [, entityName, property, operator, itemName] = containsMatch;
820
+ const entity = findEntity(entityName, world);
821
+ if (!entity) {
822
+ return { matches: false, details: `Entity "${entityName}" not found` };
823
+ }
824
+ const collection = getEntityProperty(entity, property, world);
825
+ if (!Array.isArray(collection)) {
826
+ return { matches: false, details: `${entityName}.${property} is not a collection` };
827
+ }
828
+ const item = findEntity(itemName.trim(), world);
829
+ const itemId = item?.id || itemName.trim();
830
+ const hasItem = collection.some((c) => c === itemId || c?.id === itemId);
831
+ if (operator === 'contains') {
832
+ return { matches: hasItem, details: hasItem ? undefined : `${entityName}.${property} does not contain "${itemName}"` };
833
+ }
834
+ else {
835
+ return { matches: !hasItem, details: !hasItem ? undefined : `${entityName}.${property} should not contain "${itemName}"` };
836
+ }
837
+ }
838
+ return { matches: false, details: `Could not parse expression: ${expression}` };
839
+ }
840
+ /**
841
+ * Find an entity by name in the world model
842
+ */
843
+ function findEntity(name, world) {
844
+ // Try findEntityByName first
845
+ if (world.findEntityByName) {
846
+ const entity = world.findEntityByName(name);
847
+ if (entity)
848
+ return entity;
849
+ }
850
+ // Try getEntity/getEntityById
851
+ if (world.getEntity) {
852
+ const entity = world.getEntity(name);
853
+ if (entity)
854
+ return entity;
855
+ }
856
+ if (world.getEntityById) {
857
+ const entity = world.getEntityById(name);
858
+ if (entity)
859
+ return entity;
860
+ }
861
+ // Search all entities by name/alias
862
+ if (world.getAllEntities) {
863
+ const entities = world.getAllEntities();
864
+ for (const entity of entities) {
865
+ if (entity.name === name || entity.id === name)
866
+ return entity;
867
+ // Check identity trait for aliases
868
+ const identity = entity.traits?.identity || entity.get?.('IdentityTrait');
869
+ if (identity) {
870
+ if (identity.name === name)
871
+ return entity;
872
+ if (identity.aliases?.includes(name))
873
+ return entity;
874
+ }
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+ /**
880
+ * Get a property from an entity (world needed for spatial queries)
881
+ */
882
+ function getEntityProperty(entity, property, world) {
883
+ // Special handling for location - use world.getLocation()
884
+ if (property === 'location') {
885
+ if (world?.getLocation) {
886
+ return world.getLocation(entity.id);
887
+ }
888
+ return entity.location || entity.containerId;
889
+ }
890
+ // Special handling for contents - use world.getContents()
891
+ if (property === 'contents' || property === 'inventory') {
892
+ if (world?.getContents) {
893
+ return world.getContents(entity.id);
894
+ }
895
+ return entity.contents || entity.inventory || [];
896
+ }
897
+ // Direct property access
898
+ if (property in entity) {
899
+ return entity[property];
900
+ }
901
+ // Check traits
902
+ if (entity.traits && property in entity.traits) {
903
+ return entity.traits[property];
904
+ }
905
+ return undefined;
906
+ }
907
+ /**
908
+ * Resolve a value (could be entity name, literal, etc.)
909
+ */
910
+ function resolveValue(value, world) {
911
+ // Check if it's an entity reference
912
+ const entity = findEntity(value, world);
913
+ if (entity) {
914
+ return entity.id;
915
+ }
916
+ // Check for special values
917
+ if (value === 'null' || value === 'undefined' || value === 'nowhere') {
918
+ return undefined;
919
+ }
920
+ if (value === 'true')
921
+ return true;
922
+ if (value === 'false')
923
+ return false;
924
+ // Return as string
925
+ return value;
926
+ }
927
+ /**
928
+ * Normalize output for comparison
929
+ * - Trim whitespace
930
+ * - Normalize line endings
931
+ * - Collapse multiple spaces
932
+ */
933
+ function normalizeOutput(output) {
934
+ return output
935
+ .replace(/\r\n/g, '\n')
936
+ .split('\n')
937
+ .map(line => line.trim())
938
+ .join('\n')
939
+ .trim();
940
+ }
941
+ //# sourceMappingURL=runner.js.map