@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.
- package/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +367 -0
- package/dist/cli.js.map +1 -0
- package/dist/condition-evaluator.d.ts +30 -0
- package/dist/condition-evaluator.d.ts.map +1 -0
- package/dist/condition-evaluator.js +314 -0
- package/dist/condition-evaluator.js.map +1 -0
- package/dist/fast-cli.d.ts +13 -0
- package/dist/fast-cli.d.ts.map +1 -0
- package/dist/fast-cli.js +363 -0
- package/dist/fast-cli.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/navigator.d.ts +27 -0
- package/dist/navigator.d.ts.map +1 -0
- package/dist/navigator.js +303 -0
- package/dist/navigator.js.map +1 -0
- package/dist/parser.d.ts +19 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +453 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporter.d.ts +41 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +386 -0
- package/dist/reporter.js.map +1 -0
- package/dist/runner.d.ts +44 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +977 -0
- package/dist/runner.js.map +1 -0
- package/dist/story-loader.d.ts +31 -0
- package/dist/story-loader.d.ts.map +1 -0
- package/dist/story-loader.js +169 -0
- package/dist/story-loader.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist-esm/cli.d.ts +11 -0
- package/dist-esm/cli.d.ts.map +1 -0
- package/dist-esm/cli.js +332 -0
- package/dist-esm/cli.js.map +1 -0
- package/dist-esm/condition-evaluator.d.ts +30 -0
- package/dist-esm/condition-evaluator.d.ts.map +1 -0
- package/dist-esm/condition-evaluator.js +311 -0
- package/dist-esm/condition-evaluator.js.map +1 -0
- package/dist-esm/fast-cli.d.ts +13 -0
- package/dist-esm/fast-cli.d.ts.map +1 -0
- package/dist-esm/fast-cli.js +328 -0
- package/dist-esm/fast-cli.js.map +1 -0
- package/dist-esm/index.d.ts +17 -0
- package/dist-esm/index.d.ts.map +1 -0
- package/dist-esm/index.js +21 -0
- package/dist-esm/index.js.map +1 -0
- package/dist-esm/navigator.d.ts +27 -0
- package/dist-esm/navigator.d.ts.map +1 -0
- package/dist-esm/navigator.js +300 -0
- package/dist-esm/navigator.js.map +1 -0
- package/dist-esm/parser.d.ts +19 -0
- package/dist-esm/parser.d.ts.map +1 -0
- package/dist-esm/parser.js +415 -0
- package/dist-esm/parser.js.map +1 -0
- package/dist-esm/reporter.d.ts +41 -0
- package/dist-esm/reporter.d.ts.map +1 -0
- package/dist-esm/reporter.js +342 -0
- package/dist-esm/reporter.js.map +1 -0
- package/dist-esm/runner.d.ts +44 -0
- package/dist-esm/runner.d.ts.map +1 -0
- package/dist-esm/runner.js +941 -0
- package/dist-esm/runner.js.map +1 -0
- package/dist-esm/story-loader.d.ts +31 -0
- package/dist-esm/story-loader.d.ts.map +1 -0
- package/dist-esm/story-loader.js +131 -0
- package/dist-esm/story-loader.js.map +1 -0
- package/dist-esm/types.d.ts +204 -0
- package/dist-esm/types.d.ts.map +1 -0
- package/dist-esm/types.js +7 -0
- package/dist-esm/types.js.map +1 -0
- package/dist-npm/cli.d.ts +11 -0
- package/dist-npm/cli.d.ts.map +1 -0
- package/dist-npm/cli.js +367 -0
- package/dist-npm/cli.js.map +1 -0
- package/dist-npm/condition-evaluator.d.ts +30 -0
- package/dist-npm/condition-evaluator.d.ts.map +1 -0
- package/dist-npm/condition-evaluator.js +314 -0
- package/dist-npm/condition-evaluator.js.map +1 -0
- package/dist-npm/fast-cli.d.ts +13 -0
- package/dist-npm/fast-cli.d.ts.map +1 -0
- package/dist-npm/fast-cli.js +363 -0
- package/dist-npm/fast-cli.js.map +1 -0
- package/dist-npm/index.d.ts +17 -0
- package/dist-npm/index.d.ts.map +1 -0
- package/dist-npm/index.js +48 -0
- package/dist-npm/index.js.map +1 -0
- package/dist-npm/navigator.d.ts +27 -0
- package/dist-npm/navigator.d.ts.map +1 -0
- package/dist-npm/navigator.js +303 -0
- package/dist-npm/navigator.js.map +1 -0
- package/dist-npm/parser.d.ts +19 -0
- package/dist-npm/parser.d.ts.map +1 -0
- package/dist-npm/parser.js +453 -0
- package/dist-npm/parser.js.map +1 -0
- package/dist-npm/reporter.d.ts +41 -0
- package/dist-npm/reporter.d.ts.map +1 -0
- package/dist-npm/reporter.js +386 -0
- package/dist-npm/reporter.js.map +1 -0
- package/dist-npm/runner.d.ts +44 -0
- package/dist-npm/runner.d.ts.map +1 -0
- package/dist-npm/runner.js +977 -0
- package/dist-npm/runner.js.map +1 -0
- package/dist-npm/story-loader.d.ts +31 -0
- package/dist-npm/story-loader.d.ts.map +1 -0
- package/dist-npm/story-loader.js +169 -0
- package/dist-npm/story-loader.js.map +1 -0
- package/dist-npm/types.d.ts +204 -0
- package/dist-npm/types.d.ts.map +1 -0
- package/dist-npm/types.js +8 -0
- package/dist-npm/types.js.map +1 -0
- package/package.json +49 -0
- package/src/cli.ts +385 -0
- package/src/condition-evaluator.ts +382 -0
- package/src/fast-cli.ts +403 -0
- package/src/index.ts +26 -0
- package/src/navigator.ts +365 -0
- package/src/parser.ts +488 -0
- package/src/reporter.ts +409 -0
- package/src/runner.ts +1152 -0
- package/src/story-loader.ts +168 -0
- package/src/types.ts +244 -0
- package/tsconfig.esm.json +11 -0
- package/tsconfig.esm.tsbuildinfo +1 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|