unreal-engine-mcp-server 0.4.0 → 0.4.4

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 (135) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +3 -2
  4. package/README.md +21 -5
  5. package/dist/index.js +124 -31
  6. package/dist/prompts/index.d.ts +10 -3
  7. package/dist/prompts/index.js +186 -7
  8. package/dist/resources/actors.d.ts +19 -1
  9. package/dist/resources/actors.js +55 -64
  10. package/dist/resources/assets.js +46 -62
  11. package/dist/resources/levels.d.ts +21 -3
  12. package/dist/resources/levels.js +29 -54
  13. package/dist/tools/actors.d.ts +3 -14
  14. package/dist/tools/actors.js +246 -302
  15. package/dist/tools/animation.d.ts +57 -102
  16. package/dist/tools/animation.js +429 -450
  17. package/dist/tools/assets.d.ts +13 -2
  18. package/dist/tools/assets.js +52 -44
  19. package/dist/tools/audio.d.ts +22 -13
  20. package/dist/tools/audio.js +467 -121
  21. package/dist/tools/blueprint.d.ts +32 -13
  22. package/dist/tools/blueprint.js +699 -448
  23. package/dist/tools/build_environment_advanced.d.ts +0 -1
  24. package/dist/tools/build_environment_advanced.js +190 -45
  25. package/dist/tools/consolidated-tool-definitions.js +78 -252
  26. package/dist/tools/consolidated-tool-handlers.js +506 -133
  27. package/dist/tools/debug.d.ts +72 -10
  28. package/dist/tools/debug.js +167 -31
  29. package/dist/tools/editor.d.ts +9 -2
  30. package/dist/tools/editor.js +30 -44
  31. package/dist/tools/foliage.d.ts +34 -15
  32. package/dist/tools/foliage.js +97 -107
  33. package/dist/tools/introspection.js +19 -21
  34. package/dist/tools/landscape.d.ts +1 -2
  35. package/dist/tools/landscape.js +311 -168
  36. package/dist/tools/level.d.ts +3 -28
  37. package/dist/tools/level.js +642 -192
  38. package/dist/tools/lighting.d.ts +14 -3
  39. package/dist/tools/lighting.js +236 -123
  40. package/dist/tools/materials.d.ts +25 -7
  41. package/dist/tools/materials.js +102 -79
  42. package/dist/tools/niagara.d.ts +10 -12
  43. package/dist/tools/niagara.js +74 -94
  44. package/dist/tools/performance.d.ts +12 -4
  45. package/dist/tools/performance.js +38 -79
  46. package/dist/tools/physics.d.ts +34 -10
  47. package/dist/tools/physics.js +364 -292
  48. package/dist/tools/rc.js +97 -23
  49. package/dist/tools/sequence.d.ts +1 -0
  50. package/dist/tools/sequence.js +125 -22
  51. package/dist/tools/ui.d.ts +31 -4
  52. package/dist/tools/ui.js +83 -66
  53. package/dist/tools/visual.d.ts +11 -0
  54. package/dist/tools/visual.js +245 -30
  55. package/dist/types/tool-types.d.ts +0 -6
  56. package/dist/types/tool-types.js +1 -8
  57. package/dist/unreal-bridge.d.ts +32 -2
  58. package/dist/unreal-bridge.js +621 -127
  59. package/dist/utils/elicitation.d.ts +57 -0
  60. package/dist/utils/elicitation.js +104 -0
  61. package/dist/utils/error-handler.d.ts +0 -33
  62. package/dist/utils/error-handler.js +4 -111
  63. package/dist/utils/http.d.ts +2 -22
  64. package/dist/utils/http.js +12 -75
  65. package/dist/utils/normalize.d.ts +4 -4
  66. package/dist/utils/normalize.js +15 -7
  67. package/dist/utils/python-output.d.ts +18 -0
  68. package/dist/utils/python-output.js +290 -0
  69. package/dist/utils/python.d.ts +2 -0
  70. package/dist/utils/python.js +4 -0
  71. package/dist/utils/response-validator.js +28 -2
  72. package/dist/utils/result-helpers.d.ts +27 -0
  73. package/dist/utils/result-helpers.js +147 -0
  74. package/dist/utils/safe-json.d.ts +0 -2
  75. package/dist/utils/safe-json.js +0 -43
  76. package/dist/utils/validation.d.ts +16 -0
  77. package/dist/utils/validation.js +70 -7
  78. package/mcp-config-example.json +2 -2
  79. package/package.json +10 -9
  80. package/server.json +37 -14
  81. package/src/index.ts +130 -33
  82. package/src/prompts/index.ts +211 -13
  83. package/src/resources/actors.ts +59 -44
  84. package/src/resources/assets.ts +48 -51
  85. package/src/resources/levels.ts +35 -45
  86. package/src/tools/actors.ts +269 -313
  87. package/src/tools/animation.ts +556 -539
  88. package/src/tools/assets.ts +53 -43
  89. package/src/tools/audio.ts +507 -113
  90. package/src/tools/blueprint.ts +778 -462
  91. package/src/tools/build_environment_advanced.ts +266 -64
  92. package/src/tools/consolidated-tool-definitions.ts +90 -264
  93. package/src/tools/consolidated-tool-handlers.ts +630 -121
  94. package/src/tools/debug.ts +176 -33
  95. package/src/tools/editor.ts +35 -37
  96. package/src/tools/foliage.ts +110 -104
  97. package/src/tools/introspection.ts +24 -22
  98. package/src/tools/landscape.ts +334 -181
  99. package/src/tools/level.ts +683 -182
  100. package/src/tools/lighting.ts +244 -123
  101. package/src/tools/materials.ts +114 -83
  102. package/src/tools/niagara.ts +87 -81
  103. package/src/tools/performance.ts +49 -88
  104. package/src/tools/physics.ts +393 -299
  105. package/src/tools/rc.ts +102 -24
  106. package/src/tools/sequence.ts +136 -28
  107. package/src/tools/ui.ts +101 -70
  108. package/src/tools/visual.ts +250 -29
  109. package/src/types/tool-types.ts +0 -9
  110. package/src/unreal-bridge.ts +658 -140
  111. package/src/utils/elicitation.ts +129 -0
  112. package/src/utils/error-handler.ts +4 -159
  113. package/src/utils/http.ts +16 -115
  114. package/src/utils/normalize.ts +20 -10
  115. package/src/utils/python-output.ts +351 -0
  116. package/src/utils/python.ts +3 -0
  117. package/src/utils/response-validator.ts +25 -2
  118. package/src/utils/result-helpers.ts +193 -0
  119. package/src/utils/safe-json.ts +0 -50
  120. package/src/utils/validation.ts +94 -7
  121. package/tests/run-unreal-tool-tests.mjs +720 -0
  122. package/tsconfig.json +2 -2
  123. package/dist/python-utils.d.ts +0 -29
  124. package/dist/python-utils.js +0 -54
  125. package/dist/types/index.d.ts +0 -323
  126. package/dist/types/index.js +0 -28
  127. package/dist/utils/cache-manager.d.ts +0 -64
  128. package/dist/utils/cache-manager.js +0 -176
  129. package/dist/utils/errors.d.ts +0 -133
  130. package/dist/utils/errors.js +0 -256
  131. package/src/python/editor_compat.py +0 -181
  132. package/src/python-utils.ts +0 -57
  133. package/src/types/index.ts +0 -414
  134. package/src/utils/cache-manager.ts +0 -213
  135. package/src/utils/errors.ts +0 -312
@@ -0,0 +1,720 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { performance } from 'node:perf_hooks';
8
+
9
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
11
+
12
+ const failureKeywords = [
13
+ 'error',
14
+ 'fail',
15
+ 'invalid',
16
+ 'missing',
17
+ 'not found',
18
+ 'reject',
19
+ 'warning'
20
+ ];
21
+
22
+ const successKeywords = [
23
+ 'success',
24
+ 'spawn',
25
+ 'visible',
26
+ 'applied',
27
+ 'returns',
28
+ 'plays',
29
+ 'updates',
30
+ 'created',
31
+ 'saved'
32
+ ];
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const repoRoot = path.resolve(__dirname, '..');
36
+ const defaultDocPath = path.resolve(repoRoot, 'docs', 'unreal-tool-test-cases.md');
37
+ const docPath = path.resolve(repoRoot, process.env.UNREAL_MCP_TEST_DOC ?? defaultDocPath);
38
+ const reportsDir = path.resolve(repoRoot, 'tests', 'reports');
39
+ const resultsPath = path.join(reportsDir, `unreal-tool-test-results-${new Date().toISOString().replace(/[:]/g, '-')}.json`);
40
+ const defaultFbxDir = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_DIR ?? 'C:\\Users\\micro\\Downloads\\Compressed\\fbx');
41
+ const defaultFbxFile = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_FILE ?? path.join(defaultFbxDir, 'test_model.fbx'));
42
+
43
+ const cliOptions = parseCliOptions(process.argv.slice(2));
44
+ const serverCommand = process.env.UNREAL_MCP_SERVER_CMD ?? 'node';
45
+ const serverArgs = parseArgsList(process.env.UNREAL_MCP_SERVER_ARGS) ?? [path.join(repoRoot, 'dist', 'cli.js')];
46
+ const serverCwd = process.env.UNREAL_MCP_SERVER_CWD ?? repoRoot;
47
+
48
+ async function main() {
49
+ await ensureFbxDirectory();
50
+ const allCases = await loadTestCasesFromDoc(docPath);
51
+ if (allCases.length === 0) {
52
+ console.error(`No test cases detected in ${docPath}.`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+
57
+ const filteredCases = allCases.filter((testCase) => {
58
+ if (cliOptions.group && testCase.groupName !== cliOptions.group) return false;
59
+ if (cliOptions.caseId && testCase.caseId !== cliOptions.caseId) return false;
60
+ if (cliOptions.text && !testCase.scenario.toLowerCase().includes(cliOptions.text.toLowerCase())) {
61
+ return false;
62
+ }
63
+ return true;
64
+ });
65
+
66
+ if (filteredCases.length === 0) {
67
+ console.warn('No test cases matched the provided filters. Exiting.');
68
+ return;
69
+ }
70
+
71
+ let transport; let client;
72
+ const runResults = [];
73
+
74
+ if (!cliOptions.dryRun) {
75
+ try {
76
+ transport = new StdioClientTransport({
77
+ command: serverCommand,
78
+ args: serverArgs,
79
+ cwd: serverCwd,
80
+ stderr: 'inherit'
81
+ });
82
+
83
+ client = new Client({
84
+ name: 'unreal-mcp-tool-test-runner',
85
+ version: '0.1.0'
86
+ });
87
+
88
+ await client.connect(transport);
89
+ await client.listTools({});
90
+ } catch (err) {
91
+ console.error('Failed to start or initialize MCP server:', err);
92
+ if (transport) {
93
+ try { await transport.close(); } catch { /* ignore */ }
94
+ }
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ }
99
+
100
+ for (const testCase of filteredCases) {
101
+ if (testCase.skipReason) {
102
+ runResults.push({
103
+ ...testCase,
104
+ status: 'skipped',
105
+ detail: testCase.skipReason
106
+ });
107
+ console.log(formatResultLine(testCase, 'skipped', testCase.skipReason));
108
+ continue;
109
+ }
110
+
111
+ if (cliOptions.dryRun) {
112
+ runResults.push({
113
+ ...testCase,
114
+ status: 'skipped',
115
+ detail: 'Dry run'
116
+ });
117
+ console.log(formatResultLine(testCase, 'skipped', 'Dry run'));
118
+ continue;
119
+ }
120
+
121
+ const started = performance.now();
122
+ try {
123
+ const response = await client.callTool({
124
+ name: testCase.toolName,
125
+ arguments: testCase.arguments
126
+ });
127
+ const duration = performance.now() - started;
128
+ const evaluation = evaluateExpectation(testCase, response);
129
+ runResults.push({
130
+ ...testCase,
131
+ status: evaluation.passed ? 'passed' : 'failed',
132
+ durationMs: duration,
133
+ detail: evaluation.reason,
134
+ response
135
+ });
136
+ console.log(formatResultLine(testCase, evaluation.passed ? 'passed' : 'failed', evaluation.reason, duration));
137
+ } catch (err) {
138
+ const duration = performance.now() - started;
139
+ runResults.push({
140
+ ...testCase,
141
+ status: 'failed',
142
+ durationMs: duration,
143
+ detail: err instanceof Error ? err.message : String(err)
144
+ });
145
+ console.log(formatResultLine(testCase, 'failed', err instanceof Error ? err.message : String(err), duration));
146
+ }
147
+ }
148
+
149
+ if (!cliOptions.dryRun) {
150
+ try {
151
+ await client.close();
152
+ } catch {
153
+ // ignore
154
+ }
155
+ try {
156
+ await transport.close();
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+
162
+ await persistResults(runResults);
163
+ summarize(runResults);
164
+
165
+ if (runResults.some((result) => result.status === 'failed')) {
166
+ process.exitCode = 1;
167
+ }
168
+ }
169
+
170
+ function parseCliOptions(args) {
171
+ const options = {
172
+ dryRun: false,
173
+ group: undefined,
174
+ caseId: undefined,
175
+ text: undefined
176
+ };
177
+
178
+ for (const arg of args) {
179
+ if (arg === '--dry-run') {
180
+ options.dryRun = true;
181
+ } else if (arg.startsWith('--group=')) {
182
+ options.group = arg.slice('--group='.length);
183
+ } else if (arg.startsWith('--case=')) {
184
+ options.caseId = arg.slice('--case='.length);
185
+ } else if (arg.startsWith('--text=')) {
186
+ options.text = arg.slice('--text='.length);
187
+ }
188
+ }
189
+
190
+ return options;
191
+ }
192
+
193
+ function parseArgsList(value) {
194
+ if (!value) return undefined;
195
+ const trimmed = value.trim();
196
+ if (!trimmed) return undefined;
197
+ if (trimmed.startsWith('[')) {
198
+ try {
199
+ const parsed = JSON.parse(trimmed);
200
+ if (Array.isArray(parsed)) return parsed.map(String);
201
+ } catch (_) {
202
+ // fall through
203
+ }
204
+ }
205
+ return trimmed.split(/\s+/).filter(Boolean);
206
+ }
207
+
208
+ async function loadTestCasesFromDoc(filePath) {
209
+ const raw = await fs.readFile(filePath, 'utf8');
210
+ const lines = raw.split(/\r?\n/);
211
+ const cases = [];
212
+ let currentGroup = undefined;
213
+ let inLegacySection = false;
214
+
215
+ for (const line of lines) {
216
+ if (line.startsWith('## ')) {
217
+ const headerTitle = line.replace(/^##\s+/, '').trim();
218
+ if (headerTitle.toLowerCase().includes('legacy comprehensive matrix')) {
219
+ inLegacySection = true;
220
+ currentGroup = undefined;
221
+ continue;
222
+ }
223
+ if (inLegacySection) {
224
+ currentGroup = undefined;
225
+ continue;
226
+ }
227
+ currentGroup = headerTitle;
228
+ continue;
229
+ }
230
+ if (!currentGroup) continue;
231
+ if (!line.startsWith('|') || /^\|\s*-+/.test(line) || /^\|\s*#\s*\|/.test(line)) {
232
+ continue;
233
+ }
234
+
235
+ const columns = line.split('|').map((part) => part.trim());
236
+ if (columns.length < 5) continue;
237
+ const index = columns[1];
238
+ const scenario = columns[2];
239
+ const example = columns[3];
240
+ const expected = columns[4];
241
+ const payload = extractPayload(example);
242
+ const simplifiedGroup = simplifyGroupName(currentGroup);
243
+
244
+ const enriched = enrichTestCase({
245
+ group: currentGroup,
246
+ groupName: simplifiedGroup,
247
+ index,
248
+ scenario,
249
+ example,
250
+ expected,
251
+ payload
252
+ });
253
+
254
+ cases.push(enriched);
255
+ }
256
+ return cases;
257
+ }
258
+
259
+ function simplifyGroupName(groupTitle) {
260
+ return groupTitle
261
+ .replace(/`[^`]+`/g, '')
262
+ .replace(/\([^)]*\)/g, '')
263
+ .replace('Tools', 'Tools')
264
+ .trim();
265
+ }
266
+
267
+ function extractPayload(exampleColumn) {
268
+ if (!exampleColumn) return undefined;
269
+ const codeMatches = [...exampleColumn.matchAll(/`([^`]+)`/g)];
270
+ for (const match of codeMatches) {
271
+ const snippet = match[1].trim();
272
+ if (!snippet) continue;
273
+ const jsonCandidate = normalizeJsonCandidate(snippet);
274
+ if (!jsonCandidate) continue;
275
+ try {
276
+ return {
277
+ raw: snippet,
278
+ value: JSON.parse(jsonCandidate)
279
+ };
280
+ } catch (_) {
281
+ continue;
282
+ }
283
+ }
284
+ return undefined;
285
+ }
286
+
287
+ function normalizeJsonCandidate(snippet) {
288
+ const trimmed = snippet.trim();
289
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
290
+ return trimmed;
291
+ }
292
+ return undefined;
293
+ }
294
+
295
+ function enrichTestCase(rawCase) {
296
+ const caseIdBase = `${rawCase.groupName.toLowerCase().replace(/\s+/g, '-')}-${rawCase.index}`;
297
+ const base = {
298
+ group: rawCase.group,
299
+ groupName: rawCase.groupName,
300
+ index: rawCase.index,
301
+ scenario: rawCase.scenario,
302
+ expected: rawCase.expected,
303
+ example: rawCase.example,
304
+ caseId: caseIdBase,
305
+ payloadSnippet: rawCase.payload?.raw,
306
+ arguments: undefined,
307
+ toolName: undefined,
308
+ skipReason: undefined
309
+ };
310
+
311
+ const payloadValue = rawCase.payload?.value
312
+ ? hydratePlaceholders(rawCase.payload.value)
313
+ : undefined;
314
+ const scenarioLower = rawCase.scenario.toLowerCase();
315
+
316
+ switch (rawCase.groupName) {
317
+ case 'Lighting Tools': {
318
+ if (!payloadValue) {
319
+ return { ...base, skipReason: 'No JSON payload provided' };
320
+ }
321
+ if (/lightmass|ensure/i.test(rawCase.scenario)) {
322
+ return { ...base, skipReason: 'Scenario requires manual steps not exposed via consolidated tool' };
323
+ }
324
+ let lightType;
325
+ if (scenarioLower.includes('directional')) lightType = 'Directional';
326
+ else if (scenarioLower.includes('point')) lightType = 'Point';
327
+ else if (scenarioLower.includes('spot')) lightType = 'Spot';
328
+ else if (scenarioLower.includes('rect')) lightType = 'Rect';
329
+ else if (scenarioLower.includes('sky')) lightType = 'Sky';
330
+ else if (scenarioLower.includes('build lighting')) {
331
+ return {
332
+ ...base,
333
+ skipReason: 'Skipping build lighting scenarios to avoid long editor runs'
334
+ };
335
+ } else {
336
+ return { ...base, skipReason: 'Unrecognized light type or scenario' };
337
+ }
338
+
339
+ const args = {
340
+ action: 'create_light',
341
+ lightType,
342
+ name: payloadValue.name ?? `${lightType}Light_${rawCase.index}`
343
+ };
344
+
345
+ if (typeof payloadValue.intensity === 'number') {
346
+ args.intensity = payloadValue.intensity;
347
+ }
348
+ if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) {
349
+ args.location = {
350
+ x: payloadValue.location[0],
351
+ y: payloadValue.location[1],
352
+ z: payloadValue.location[2]
353
+ };
354
+ }
355
+ if (Array.isArray(payloadValue.rotation) && payloadValue.rotation.length === 3) {
356
+ args.rotation = {
357
+ pitch: payloadValue.rotation[0],
358
+ yaw: payloadValue.rotation[1],
359
+ roll: payloadValue.rotation[2]
360
+ };
361
+ }
362
+ if (Array.isArray(payloadValue.color) && payloadValue.color.length === 3) {
363
+ args.color = payloadValue.color;
364
+ }
365
+ if (payloadValue.radius !== undefined) {
366
+ args.radius = payloadValue.radius;
367
+ }
368
+ if (payloadValue.innerCone !== undefined) {
369
+ args.innerCone = payloadValue.innerCone;
370
+ }
371
+ if (payloadValue.outerCone !== undefined) {
372
+ args.outerCone = payloadValue.outerCone;
373
+ }
374
+ if (payloadValue.width !== undefined) {
375
+ args.width = payloadValue.width;
376
+ }
377
+ if (payloadValue.height !== undefined) {
378
+ args.height = payloadValue.height;
379
+ }
380
+ if (payloadValue.falloffExponent !== undefined) {
381
+ args.falloffExponent = payloadValue.falloffExponent;
382
+ }
383
+ if (typeof payloadValue.castShadows === 'boolean') {
384
+ args.castShadows = payloadValue.castShadows;
385
+ }
386
+ if (payloadValue.temperature !== undefined) {
387
+ args.temperature = payloadValue.temperature;
388
+ }
389
+ if (typeof payloadValue.sourceType === 'string') {
390
+ args.sourceType = payloadValue.sourceType;
391
+ }
392
+ if (typeof payloadValue.cubemapPath === 'string') {
393
+ args.cubemapPath = payloadValue.cubemapPath;
394
+ }
395
+ if (typeof payloadValue.recapture === 'boolean') {
396
+ args.recapture = payloadValue.recapture;
397
+ }
398
+
399
+ return {
400
+ ...base,
401
+ toolName: 'manage_level',
402
+ arguments: args
403
+ };
404
+ }
405
+ case 'Actor Tools': {
406
+ if (!payloadValue) {
407
+ return { ...base, skipReason: 'No JSON payload provided' };
408
+ }
409
+ const args = { ...payloadValue };
410
+ if (Array.isArray(args.location) && args.location.length === 3) {
411
+ args.location = { x: args.location[0], y: args.location[1], z: args.location[2] };
412
+ }
413
+ if (Array.isArray(args.rotation) && args.rotation.length === 3) {
414
+ args.rotation = { pitch: args.rotation[0], yaw: args.rotation[1], roll: args.rotation[2] };
415
+ }
416
+ return {
417
+ ...base,
418
+ toolName: 'control_actor',
419
+ arguments: args
420
+ };
421
+ }
422
+ case 'Asset Tools': {
423
+ if (!payloadValue) {
424
+ return { ...base, skipReason: 'Non-JSON payload requires manual execution' };
425
+ }
426
+ if (!payloadValue.action) {
427
+ return { ...base, skipReason: 'Action missing; unable to route to consolidated tool' };
428
+ }
429
+ if (!['list', 'import', 'create_material'].includes(payloadValue.action)) {
430
+ return { ...base, skipReason: `Action '${payloadValue.action}' not supported by automated runner` };
431
+ }
432
+ return {
433
+ ...base,
434
+ toolName: 'manage_asset',
435
+ arguments: payloadValue
436
+ };
437
+ }
438
+ case 'Animation Tools': {
439
+ if (!payloadValue) {
440
+ return { ...base, skipReason: 'No JSON payload provided' };
441
+ }
442
+ const args = { ...payloadValue };
443
+ if (scenarioLower.includes('animation blueprint')) {
444
+ args.action = 'create_animation_bp';
445
+ } else if (scenarioLower.includes('montage') || scenarioLower.includes('animation asset once')) {
446
+ args.action = 'play_montage';
447
+ } else if (scenarioLower.includes('ragdoll')) {
448
+ args.action = 'setup_ragdoll';
449
+ }
450
+ if (!args.action) {
451
+ return { ...base, skipReason: 'Scenario not supported by automated runner' };
452
+ }
453
+ return {
454
+ ...base,
455
+ toolName: 'animation_physics',
456
+ arguments: args
457
+ };
458
+ }
459
+ case 'Blueprint Tools': {
460
+ if (!payloadValue) {
461
+ return { ...base, skipReason: 'No JSON payload provided' };
462
+ }
463
+ const args = { ...payloadValue };
464
+ if (!args.action) {
465
+ args.action = scenarioLower.includes('component') ? 'add_component' : 'create';
466
+ }
467
+ return {
468
+ ...base,
469
+ toolName: 'manage_blueprint',
470
+ arguments: args
471
+ };
472
+ }
473
+ case 'Material Tools': {
474
+ if (!payloadValue) {
475
+ return { ...base, skipReason: 'No JSON payload provided' };
476
+ }
477
+ const args = { ...payloadValue };
478
+ if (!args.action && typeof args.name === 'string' && typeof args.path === 'string') {
479
+ args.action = 'create_material';
480
+ }
481
+ if (args.action === 'create_material') {
482
+ return {
483
+ ...base,
484
+ toolName: 'manage_asset',
485
+ arguments: {
486
+ action: 'create_material',
487
+ name: typeof args.name === 'string' ? args.name : `M_Test_${rawCase.index}`,
488
+ path: args.path
489
+ }
490
+ };
491
+ }
492
+ return { ...base, skipReason: 'Material scenario not supported by automated runner' };
493
+ }
494
+ case 'Niagara Tools': {
495
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
496
+ if (!payloadValue.action) {
497
+ return { ...base, skipReason: 'Missing action for Niagara scenario' };
498
+ }
499
+ if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) {
500
+ payloadValue.location = { x: payloadValue.location[0], y: payloadValue.location[1], z: payloadValue.location[2] };
501
+ }
502
+ return {
503
+ ...base,
504
+ toolName: 'create_effect',
505
+ arguments: payloadValue
506
+ };
507
+ }
508
+ case 'Level Tools': {
509
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
510
+ if (!payloadValue.action) {
511
+ return { ...base, skipReason: 'Missing action for level scenario' };
512
+ }
513
+ return {
514
+ ...base,
515
+ toolName: 'manage_level',
516
+ arguments: payloadValue
517
+ };
518
+ }
519
+ case 'Sequence Tools': {
520
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
521
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
522
+ return {
523
+ ...base,
524
+ toolName: 'manage_sequence',
525
+ arguments: payloadValue
526
+ };
527
+ }
528
+ case 'UI Tools': {
529
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
530
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
531
+ return {
532
+ ...base,
533
+ toolName: 'system_control',
534
+ arguments: payloadValue
535
+ };
536
+ }
537
+ case 'Physics Tools': {
538
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
539
+ if (payloadValue.action === 'apply_force') {
540
+ return {
541
+ ...base,
542
+ toolName: 'control_actor',
543
+ arguments: payloadValue
544
+ };
545
+ }
546
+ return { ...base, skipReason: 'Physics scenario not mapped to consolidated tools' };
547
+ }
548
+ case 'Landscape Tools': {
549
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
550
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
551
+ return {
552
+ ...base,
553
+ toolName: 'build_environment',
554
+ arguments: payloadValue
555
+ };
556
+ }
557
+ case 'Build Environment Tools': {
558
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
559
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
560
+ return {
561
+ ...base,
562
+ toolName: 'build_environment',
563
+ arguments: payloadValue
564
+ };
565
+ }
566
+ case 'Performance Tools': {
567
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
568
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
569
+ if (scenarioLower.includes('engine quit')) {
570
+ return {
571
+ ...base,
572
+ skipReason: 'Skipping engine quit to keep Unreal session alive during test run'
573
+ };
574
+ }
575
+ return {
576
+ ...base,
577
+ toolName: 'system_control',
578
+ arguments: payloadValue
579
+ };
580
+ }
581
+ case 'System Control Tools': {
582
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
583
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
584
+ if (payloadValue.action === 'engine_quit' || payloadValue.action === 'engine_start') {
585
+ return {
586
+ ...base,
587
+ skipReason: 'Skipping engine process management during automated run'
588
+ };
589
+ }
590
+ return {
591
+ ...base,
592
+ toolName: 'system_control',
593
+ arguments: payloadValue
594
+ };
595
+ }
596
+ case 'Debug Tools': {
597
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
598
+ if (!payloadValue.action) {
599
+ payloadValue.action = 'debug_shape';
600
+ }
601
+ return {
602
+ ...base,
603
+ toolName: 'create_effect',
604
+ arguments: payloadValue
605
+ };
606
+ }
607
+ case 'Remote Control Preset Tools': {
608
+ if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' };
609
+ if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' };
610
+ return {
611
+ ...base,
612
+ toolName: 'manage_rc',
613
+ arguments: payloadValue
614
+ };
615
+ }
616
+ default:
617
+ return { ...base, skipReason: `Unknown tool group '${rawCase.groupName}'` };
618
+ }
619
+ }
620
+
621
+ function evaluateExpectation(testCase, response) {
622
+ const lowerExpected = testCase.expected.toLowerCase();
623
+ const containsFailure = failureKeywords.some((word) => lowerExpected.includes(word));
624
+ const containsSuccess = successKeywords.some((word) => lowerExpected.includes(word));
625
+
626
+ const structuredSuccess = typeof response.structuredContent?.success === 'boolean'
627
+ ? response.structuredContent.success
628
+ : undefined;
629
+ const actualSuccess = structuredSuccess ?? !response.isError;
630
+
631
+ const expectedFailure = containsFailure && !containsSuccess;
632
+ const passed = expectedFailure ? !actualSuccess : !!actualSuccess;
633
+ let reason;
634
+ if (response.isError) {
635
+ reason = response.content?.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n');
636
+ } else if (response.structuredContent) {
637
+ reason = JSON.stringify(response.structuredContent);
638
+ } else if (response.content?.length) {
639
+ reason = response.content.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n');
640
+ } else {
641
+ reason = 'No structured response returned';
642
+ }
643
+
644
+ return { passed, reason };
645
+ }
646
+
647
+ function formatResultLine(testCase, status, detail, durationMs) {
648
+ const durationText = typeof durationMs === 'number' ? ` (${durationMs.toFixed(1)} ms)` : '';
649
+ return `[${status.toUpperCase()}] ${testCase.groupName} #${testCase.index} – ${testCase.scenario}${durationText}${detail ? ` => ${detail}` : ''}`;
650
+ }
651
+
652
+ async function persistResults(results) {
653
+ await fs.mkdir(reportsDir, { recursive: true });
654
+ const serializable = results.map((result) => ({
655
+ group: result.groupName,
656
+ caseId: result.caseId,
657
+ index: result.index,
658
+ scenario: result.scenario,
659
+ toolName: result.toolName,
660
+ arguments: result.arguments,
661
+ status: result.status,
662
+ durationMs: result.durationMs,
663
+ detail: result.detail
664
+ }));
665
+ await fs.writeFile(resultsPath, JSON.stringify({
666
+ generatedAt: new Date().toISOString(),
667
+ docPath,
668
+ results: serializable
669
+ }, null, 2));
670
+ }
671
+
672
+ function summarize(results) {
673
+ const totals = results.reduce((acc, result) => {
674
+ acc.total += 1;
675
+ acc[result.status] = (acc[result.status] ?? 0) + 1;
676
+ return acc;
677
+ }, { total: 0, passed: 0, failed: 0, skipped: 0 });
678
+
679
+ console.log('\nSummary');
680
+ console.log('=======');
681
+ console.log(`Total cases processed: ${totals.total}`);
682
+ console.log(`Passed: ${totals.passed}`);
683
+ console.log(`Failed: ${totals.failed}`);
684
+ console.log(`Skipped: ${totals.skipped}`);
685
+ console.log(`Results written to: ${resultsPath}`);
686
+ }
687
+
688
+ function normalizeWindowsPath(value) {
689
+ if (typeof value !== 'string') return value;
690
+ return value.replace(/\\+/g, '\\').replace(/\/+/g, '\\');
691
+ }
692
+
693
+ function hydratePlaceholders(value) {
694
+ if (typeof value === 'string') {
695
+ return value
696
+ .replaceAll('{{FBX_DIR}}', defaultFbxDir)
697
+ .replaceAll('{{FBX_TEST_MODEL}}', defaultFbxFile);
698
+ }
699
+ if (Array.isArray(value)) {
700
+ return value.map((entry) => hydratePlaceholders(entry));
701
+ }
702
+ if (value && typeof value === 'object') {
703
+ return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, hydratePlaceholders(val)]));
704
+ }
705
+ return value;
706
+ }
707
+
708
+ async function ensureFbxDirectory() {
709
+ if (!defaultFbxDir) return;
710
+ try {
711
+ await fs.mkdir(defaultFbxDir, { recursive: true });
712
+ } catch (err) {
713
+ console.warn(`Unable to ensure FBX directory '${defaultFbxDir}':`, err);
714
+ }
715
+ }
716
+
717
+ main().catch((err) => {
718
+ console.error('Unexpected error during test execution:', err);
719
+ process.exitCode = 1;
720
+ });