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