unreal-engine-mcp-server 0.3.1 → 0.4.3

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 (144) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +1 -1
  4. package/README.md +22 -7
  5. package/dist/index.js +137 -46
  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.d.ts +3 -2
  11. package/dist/resources/assets.js +117 -109
  12. package/dist/resources/levels.d.ts +21 -3
  13. package/dist/resources/levels.js +31 -56
  14. package/dist/tools/actors.d.ts +3 -14
  15. package/dist/tools/actors.js +246 -302
  16. package/dist/tools/animation.d.ts +57 -102
  17. package/dist/tools/animation.js +429 -450
  18. package/dist/tools/assets.d.ts +13 -2
  19. package/dist/tools/assets.js +58 -46
  20. package/dist/tools/audio.d.ts +22 -13
  21. package/dist/tools/audio.js +467 -121
  22. package/dist/tools/blueprint.d.ts +32 -13
  23. package/dist/tools/blueprint.js +699 -448
  24. package/dist/tools/build_environment_advanced.d.ts +0 -1
  25. package/dist/tools/build_environment_advanced.js +236 -87
  26. package/dist/tools/consolidated-tool-definitions.d.ts +232 -15
  27. package/dist/tools/consolidated-tool-definitions.js +124 -255
  28. package/dist/tools/consolidated-tool-handlers.js +749 -766
  29. package/dist/tools/debug.d.ts +72 -10
  30. package/dist/tools/debug.js +170 -36
  31. package/dist/tools/editor.d.ts +9 -2
  32. package/dist/tools/editor.js +30 -44
  33. package/dist/tools/foliage.d.ts +34 -15
  34. package/dist/tools/foliage.js +97 -107
  35. package/dist/tools/introspection.js +19 -21
  36. package/dist/tools/landscape.d.ts +1 -2
  37. package/dist/tools/landscape.js +311 -168
  38. package/dist/tools/level.d.ts +3 -28
  39. package/dist/tools/level.js +642 -192
  40. package/dist/tools/lighting.d.ts +14 -3
  41. package/dist/tools/lighting.js +236 -123
  42. package/dist/tools/materials.d.ts +25 -7
  43. package/dist/tools/materials.js +102 -79
  44. package/dist/tools/niagara.d.ts +10 -12
  45. package/dist/tools/niagara.js +74 -94
  46. package/dist/tools/performance.d.ts +12 -4
  47. package/dist/tools/performance.js +38 -79
  48. package/dist/tools/physics.d.ts +34 -10
  49. package/dist/tools/physics.js +364 -292
  50. package/dist/tools/rc.js +98 -24
  51. package/dist/tools/sequence.d.ts +1 -0
  52. package/dist/tools/sequence.js +146 -24
  53. package/dist/tools/ui.d.ts +31 -4
  54. package/dist/tools/ui.js +83 -66
  55. package/dist/tools/visual.d.ts +11 -0
  56. package/dist/tools/visual.js +245 -30
  57. package/dist/types/tool-types.d.ts +0 -6
  58. package/dist/types/tool-types.js +1 -8
  59. package/dist/unreal-bridge.d.ts +32 -2
  60. package/dist/unreal-bridge.js +621 -127
  61. package/dist/utils/elicitation.d.ts +57 -0
  62. package/dist/utils/elicitation.js +104 -0
  63. package/dist/utils/error-handler.d.ts +0 -33
  64. package/dist/utils/error-handler.js +4 -111
  65. package/dist/utils/http.d.ts +2 -22
  66. package/dist/utils/http.js +12 -75
  67. package/dist/utils/normalize.d.ts +4 -4
  68. package/dist/utils/normalize.js +15 -7
  69. package/dist/utils/python-output.d.ts +18 -0
  70. package/dist/utils/python-output.js +290 -0
  71. package/dist/utils/python.d.ts +2 -0
  72. package/dist/utils/python.js +4 -0
  73. package/dist/utils/response-validator.d.ts +6 -1
  74. package/dist/utils/response-validator.js +66 -13
  75. package/dist/utils/result-helpers.d.ts +27 -0
  76. package/dist/utils/result-helpers.js +147 -0
  77. package/dist/utils/safe-json.d.ts +0 -2
  78. package/dist/utils/safe-json.js +0 -43
  79. package/dist/utils/validation.d.ts +16 -0
  80. package/dist/utils/validation.js +70 -7
  81. package/mcp-config-example.json +2 -2
  82. package/package.json +11 -10
  83. package/server.json +37 -14
  84. package/src/index.ts +146 -50
  85. package/src/prompts/index.ts +211 -13
  86. package/src/resources/actors.ts +59 -44
  87. package/src/resources/assets.ts +123 -102
  88. package/src/resources/levels.ts +37 -47
  89. package/src/tools/actors.ts +269 -313
  90. package/src/tools/animation.ts +556 -539
  91. package/src/tools/assets.ts +59 -45
  92. package/src/tools/audio.ts +507 -113
  93. package/src/tools/blueprint.ts +778 -462
  94. package/src/tools/build_environment_advanced.ts +312 -106
  95. package/src/tools/consolidated-tool-definitions.ts +136 -267
  96. package/src/tools/consolidated-tool-handlers.ts +871 -795
  97. package/src/tools/debug.ts +179 -38
  98. package/src/tools/editor.ts +35 -37
  99. package/src/tools/foliage.ts +110 -104
  100. package/src/tools/introspection.ts +24 -22
  101. package/src/tools/landscape.ts +334 -181
  102. package/src/tools/level.ts +683 -182
  103. package/src/tools/lighting.ts +244 -123
  104. package/src/tools/materials.ts +114 -83
  105. package/src/tools/niagara.ts +87 -81
  106. package/src/tools/performance.ts +49 -88
  107. package/src/tools/physics.ts +393 -299
  108. package/src/tools/rc.ts +103 -25
  109. package/src/tools/sequence.ts +157 -30
  110. package/src/tools/ui.ts +101 -70
  111. package/src/tools/visual.ts +250 -29
  112. package/src/types/tool-types.ts +0 -9
  113. package/src/unreal-bridge.ts +658 -140
  114. package/src/utils/elicitation.ts +129 -0
  115. package/src/utils/error-handler.ts +4 -159
  116. package/src/utils/http.ts +16 -115
  117. package/src/utils/normalize.ts +20 -10
  118. package/src/utils/python-output.ts +351 -0
  119. package/src/utils/python.ts +3 -0
  120. package/src/utils/response-validator.ts +68 -17
  121. package/src/utils/result-helpers.ts +193 -0
  122. package/src/utils/safe-json.ts +0 -50
  123. package/src/utils/validation.ts +94 -7
  124. package/tests/run-unreal-tool-tests.mjs +720 -0
  125. package/tsconfig.json +2 -2
  126. package/dist/python-utils.d.ts +0 -29
  127. package/dist/python-utils.js +0 -54
  128. package/dist/tools/tool-definitions.d.ts +0 -4919
  129. package/dist/tools/tool-definitions.js +0 -1065
  130. package/dist/tools/tool-handlers.d.ts +0 -47
  131. package/dist/tools/tool-handlers.js +0 -863
  132. package/dist/types/index.d.ts +0 -323
  133. package/dist/types/index.js +0 -28
  134. package/dist/utils/cache-manager.d.ts +0 -64
  135. package/dist/utils/cache-manager.js +0 -176
  136. package/dist/utils/errors.d.ts +0 -133
  137. package/dist/utils/errors.js +0 -256
  138. package/src/python/editor_compat.py +0 -181
  139. package/src/python-utils.ts +0 -57
  140. package/src/tools/tool-definitions.ts +0 -1081
  141. package/src/tools/tool-handlers.ts +0 -973
  142. package/src/types/index.ts +0 -414
  143. package/src/utils/cache-manager.ts +0 -213
  144. 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
+ });