unreal-engine-mcp-server 0.2.1

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 (155) hide show
  1. package/.dockerignore +57 -0
  2. package/.env.production +25 -0
  3. package/.eslintrc.json +54 -0
  4. package/.github/workflows/publish-mcp.yml +75 -0
  5. package/Dockerfile +54 -0
  6. package/LICENSE +21 -0
  7. package/Public/icon.png +0 -0
  8. package/README.md +209 -0
  9. package/claude_desktop_config_example.json +13 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.js +7 -0
  12. package/dist/index.d.ts +31 -0
  13. package/dist/index.js +484 -0
  14. package/dist/prompts/index.d.ts +14 -0
  15. package/dist/prompts/index.js +38 -0
  16. package/dist/python-utils.d.ts +29 -0
  17. package/dist/python-utils.js +54 -0
  18. package/dist/resources/actors.d.ts +13 -0
  19. package/dist/resources/actors.js +83 -0
  20. package/dist/resources/assets.d.ts +23 -0
  21. package/dist/resources/assets.js +245 -0
  22. package/dist/resources/levels.d.ts +17 -0
  23. package/dist/resources/levels.js +94 -0
  24. package/dist/tools/actors.d.ts +51 -0
  25. package/dist/tools/actors.js +459 -0
  26. package/dist/tools/animation.d.ts +196 -0
  27. package/dist/tools/animation.js +579 -0
  28. package/dist/tools/assets.d.ts +21 -0
  29. package/dist/tools/assets.js +304 -0
  30. package/dist/tools/audio.d.ts +170 -0
  31. package/dist/tools/audio.js +416 -0
  32. package/dist/tools/blueprint.d.ts +144 -0
  33. package/dist/tools/blueprint.js +652 -0
  34. package/dist/tools/build_environment_advanced.d.ts +66 -0
  35. package/dist/tools/build_environment_advanced.js +484 -0
  36. package/dist/tools/consolidated-tool-definitions.d.ts +2598 -0
  37. package/dist/tools/consolidated-tool-definitions.js +607 -0
  38. package/dist/tools/consolidated-tool-handlers.d.ts +2 -0
  39. package/dist/tools/consolidated-tool-handlers.js +1050 -0
  40. package/dist/tools/debug.d.ts +185 -0
  41. package/dist/tools/debug.js +265 -0
  42. package/dist/tools/editor.d.ts +88 -0
  43. package/dist/tools/editor.js +365 -0
  44. package/dist/tools/engine.d.ts +30 -0
  45. package/dist/tools/engine.js +36 -0
  46. package/dist/tools/foliage.d.ts +155 -0
  47. package/dist/tools/foliage.js +525 -0
  48. package/dist/tools/introspection.d.ts +98 -0
  49. package/dist/tools/introspection.js +683 -0
  50. package/dist/tools/landscape.d.ts +158 -0
  51. package/dist/tools/landscape.js +375 -0
  52. package/dist/tools/level.d.ts +110 -0
  53. package/dist/tools/level.js +362 -0
  54. package/dist/tools/lighting.d.ts +159 -0
  55. package/dist/tools/lighting.js +1179 -0
  56. package/dist/tools/materials.d.ts +34 -0
  57. package/dist/tools/materials.js +146 -0
  58. package/dist/tools/niagara.d.ts +145 -0
  59. package/dist/tools/niagara.js +289 -0
  60. package/dist/tools/performance.d.ts +163 -0
  61. package/dist/tools/performance.js +412 -0
  62. package/dist/tools/physics.d.ts +189 -0
  63. package/dist/tools/physics.js +784 -0
  64. package/dist/tools/rc.d.ts +110 -0
  65. package/dist/tools/rc.js +363 -0
  66. package/dist/tools/sequence.d.ts +112 -0
  67. package/dist/tools/sequence.js +675 -0
  68. package/dist/tools/tool-definitions.d.ts +4919 -0
  69. package/dist/tools/tool-definitions.js +891 -0
  70. package/dist/tools/tool-handlers.d.ts +47 -0
  71. package/dist/tools/tool-handlers.js +830 -0
  72. package/dist/tools/ui.d.ts +171 -0
  73. package/dist/tools/ui.js +337 -0
  74. package/dist/tools/visual.d.ts +29 -0
  75. package/dist/tools/visual.js +67 -0
  76. package/dist/types/env.d.ts +10 -0
  77. package/dist/types/env.js +18 -0
  78. package/dist/types/index.d.ts +323 -0
  79. package/dist/types/index.js +28 -0
  80. package/dist/types/tool-types.d.ts +274 -0
  81. package/dist/types/tool-types.js +13 -0
  82. package/dist/unreal-bridge.d.ts +126 -0
  83. package/dist/unreal-bridge.js +992 -0
  84. package/dist/utils/cache-manager.d.ts +64 -0
  85. package/dist/utils/cache-manager.js +176 -0
  86. package/dist/utils/error-handler.d.ts +66 -0
  87. package/dist/utils/error-handler.js +243 -0
  88. package/dist/utils/errors.d.ts +133 -0
  89. package/dist/utils/errors.js +256 -0
  90. package/dist/utils/http.d.ts +26 -0
  91. package/dist/utils/http.js +135 -0
  92. package/dist/utils/logger.d.ts +12 -0
  93. package/dist/utils/logger.js +32 -0
  94. package/dist/utils/normalize.d.ts +17 -0
  95. package/dist/utils/normalize.js +49 -0
  96. package/dist/utils/response-validator.d.ts +34 -0
  97. package/dist/utils/response-validator.js +121 -0
  98. package/dist/utils/safe-json.d.ts +4 -0
  99. package/dist/utils/safe-json.js +97 -0
  100. package/dist/utils/stdio-redirect.d.ts +2 -0
  101. package/dist/utils/stdio-redirect.js +20 -0
  102. package/dist/utils/validation.d.ts +50 -0
  103. package/dist/utils/validation.js +173 -0
  104. package/mcp-config-example.json +14 -0
  105. package/package.json +63 -0
  106. package/server.json +60 -0
  107. package/src/cli.ts +7 -0
  108. package/src/index.ts +543 -0
  109. package/src/prompts/index.ts +51 -0
  110. package/src/python/editor_compat.py +181 -0
  111. package/src/python-utils.ts +57 -0
  112. package/src/resources/actors.ts +92 -0
  113. package/src/resources/assets.ts +251 -0
  114. package/src/resources/levels.ts +83 -0
  115. package/src/tools/actors.ts +480 -0
  116. package/src/tools/animation.ts +713 -0
  117. package/src/tools/assets.ts +305 -0
  118. package/src/tools/audio.ts +548 -0
  119. package/src/tools/blueprint.ts +736 -0
  120. package/src/tools/build_environment_advanced.ts +526 -0
  121. package/src/tools/consolidated-tool-definitions.ts +619 -0
  122. package/src/tools/consolidated-tool-handlers.ts +1093 -0
  123. package/src/tools/debug.ts +368 -0
  124. package/src/tools/editor.ts +360 -0
  125. package/src/tools/engine.ts +32 -0
  126. package/src/tools/foliage.ts +652 -0
  127. package/src/tools/introspection.ts +778 -0
  128. package/src/tools/landscape.ts +523 -0
  129. package/src/tools/level.ts +410 -0
  130. package/src/tools/lighting.ts +1316 -0
  131. package/src/tools/materials.ts +148 -0
  132. package/src/tools/niagara.ts +312 -0
  133. package/src/tools/performance.ts +549 -0
  134. package/src/tools/physics.ts +924 -0
  135. package/src/tools/rc.ts +437 -0
  136. package/src/tools/sequence.ts +791 -0
  137. package/src/tools/tool-definitions.ts +907 -0
  138. package/src/tools/tool-handlers.ts +941 -0
  139. package/src/tools/ui.ts +499 -0
  140. package/src/tools/visual.ts +60 -0
  141. package/src/types/env.ts +27 -0
  142. package/src/types/index.ts +414 -0
  143. package/src/types/tool-types.ts +343 -0
  144. package/src/unreal-bridge.ts +1118 -0
  145. package/src/utils/cache-manager.ts +213 -0
  146. package/src/utils/error-handler.ts +320 -0
  147. package/src/utils/errors.ts +312 -0
  148. package/src/utils/http.ts +184 -0
  149. package/src/utils/logger.ts +30 -0
  150. package/src/utils/normalize.ts +54 -0
  151. package/src/utils/response-validator.ts +145 -0
  152. package/src/utils/safe-json.ts +112 -0
  153. package/src/utils/stdio-redirect.ts +18 -0
  154. package/src/utils/validation.ts +212 -0
  155. package/tsconfig.json +33 -0
@@ -0,0 +1,1118 @@
1
+ import WebSocket from 'ws';
2
+ import { createHttpClient } from './utils/http.js';
3
+ import { Logger } from './utils/logger.js';
4
+ import { loadEnv } from './types/env.js';
5
+
6
+ // RcMessage interface reserved for future WebSocket message handling
7
+ // interface RcMessage {
8
+ // MessageName: string;
9
+ // Parameters?: any;
10
+ // }
11
+
12
+ interface RcCallBody {
13
+ objectPath: string; // e.g. "/Script/UnrealEd.Default__EditorAssetLibrary"
14
+ functionName: string; // e.g. "ListAssets"
15
+ parameters?: Record<string, any>;
16
+ generateTransaction?: boolean;
17
+ }
18
+
19
+ interface CommandQueueItem {
20
+ command: () => Promise<any>;
21
+ resolve: (value: any) => void;
22
+ reject: (reason?: any) => void;
23
+ priority: number;
24
+ retryCount?: number;
25
+ }
26
+
27
+ interface PythonScriptTemplate {
28
+ name: string;
29
+ script: string;
30
+ params?: Record<string, any>;
31
+ }
32
+
33
+ export class UnrealBridge {
34
+ private ws?: WebSocket;
35
+ private http = createHttpClient('');
36
+ private env = loadEnv();
37
+ private log = new Logger('UnrealBridge');
38
+ private connected = false;
39
+ private reconnectTimer?: NodeJS.Timeout;
40
+ private reconnectAttempts = 0;
41
+ private readonly MAX_RECONNECT_ATTEMPTS = 5;
42
+ private readonly BASE_RECONNECT_DELAY = 1000;
43
+
44
+ // Command queue for throttling
45
+ private commandQueue: CommandQueueItem[] = [];
46
+ private isProcessing = false;
47
+ private readonly MIN_COMMAND_DELAY = 100; // Increased to prevent console spam
48
+ private readonly MAX_COMMAND_DELAY = 500; // Maximum delay for heavy operations
49
+ private readonly STAT_COMMAND_DELAY = 300; // Special delay for stat commands to avoid warnings
50
+ private lastCommandTime = 0;
51
+ private lastStatCommandTime = 0; // Track stat commands separately
52
+
53
+ // Safe viewmodes that won't cause crashes (per docs and testing)
54
+ private readonly _SAFE_VIEWMODES = [
55
+ 'Lit', 'Unlit', 'Wireframe', 'DetailLighting',
56
+ 'LightingOnly', 'ReflectionOverride', 'ShaderComplexity'
57
+ ];
58
+
59
+ // Unsafe viewmodes that can cause crashes or instability via visualizeBuffer
60
+ private readonly UNSAFE_VIEWMODES = [
61
+ 'BaseColor', 'WorldNormal', 'Metallic', 'Specular',
62
+ 'Roughness', 'SubsurfaceColor', 'Opacity',
63
+ 'LightComplexity', 'LightmapDensity',
64
+ 'StationaryLightOverlap', 'CollisionPawn', 'CollisionVisibility'
65
+ ];
66
+
67
+ // Python script templates for EditorLevelLibrary access
68
+ private readonly PYTHON_TEMPLATES: Record<string, PythonScriptTemplate> = {
69
+ GET_ALL_ACTORS: {
70
+ name: 'get_all_actors',
71
+ script: `
72
+ import unreal
73
+ # Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
74
+ subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
75
+ actors = subsys.get_all_level_actors()
76
+ result = [{'name': a.get_name(), 'class': a.get_class().get_name(), 'path': a.get_path_name()} for a in actors]
77
+ print(f"RESULT:{result}")
78
+ `.trim()
79
+ },
80
+ SPAWN_ACTOR_AT_LOCATION: {
81
+ name: 'spawn_actor',
82
+ script: `
83
+ import unreal
84
+ location = unreal.Vector({x}, {y}, {z})
85
+ rotation = unreal.Rotator({pitch}, {yaw}, {roll})
86
+ actor_class = unreal.EditorAssetLibrary.load_asset("{class_path}")
87
+ if actor_class:
88
+ # Use EditorActorSubsystem instead of deprecated EditorLevelLibrary
89
+ subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
90
+ spawned = subsys.spawn_actor_from_object(actor_class, location, rotation)
91
+ print(f"RESULT:{{'success': True, 'actor': spawned.get_name()}}")
92
+ else:
93
+ print(f"RESULT:{{'success': False, 'error': 'Failed to load actor class'}}")
94
+ `.trim()
95
+ },
96
+ DELETE_ACTOR: {
97
+ name: 'delete_actor',
98
+ script: `
99
+ import unreal
100
+ import json
101
+ subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
102
+ actors = subsys.get_all_level_actors()
103
+ found = False
104
+ for actor in actors:
105
+ if not actor:
106
+ continue
107
+ label = actor.get_actor_label()
108
+ name = actor.get_name()
109
+ if label == "{actor_name}" or name == "{actor_name}" or label.lower().startswith("{actor_name}".lower()+"_"):
110
+ subsys.destroy_actor(actor)
111
+ print("RESULT:" + json.dumps({'success': True, 'deleted': label}))
112
+ found = True
113
+ break
114
+ if not found:
115
+ print("RESULT:" + json.dumps({'success': False, 'error': 'Actor not found'}))
116
+ `.trim()
117
+ },
118
+ CREATE_ASSET: {
119
+ name: 'create_asset',
120
+ script: `
121
+ import unreal
122
+ asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
123
+ factory = unreal.{factory_class}()
124
+ asset = asset_tools.create_asset("{asset_name}", "{package_path}", {asset_class}, factory)
125
+ if asset:
126
+ unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
127
+ print(f"RESULT:{{'success': True, 'path': asset.get_path_name()}}")
128
+ else:
129
+ print(f"RESULT:{{'success': False, 'error': 'Failed to create asset'}}")
130
+ `.trim()
131
+ },
132
+ SET_VIEWPORT_CAMERA: {
133
+ name: 'set_viewport_camera',
134
+ script: `
135
+ import unreal
136
+ location = unreal.Vector({x}, {y}, {z})
137
+ rotation = unreal.Rotator({pitch}, {yaw}, {roll})
138
+ # Use UnrealEditorSubsystem for viewport operations (UE5.1+)
139
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
140
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
141
+ if ues:
142
+ ues.set_level_viewport_camera_info(location, rotation)
143
+ try:
144
+ if les:
145
+ les.editor_invalidate_viewports()
146
+ except Exception:
147
+ pass
148
+ print(f"RESULT:{{'success': True, 'location': [{x}, {y}, {z}], 'rotation': [{pitch}, {yaw}, {roll}]}}")
149
+ else:
150
+ print(f"RESULT:{{'success': False, 'error': 'UnrealEditorSubsystem not available'}}")
151
+ `.trim()
152
+ },
153
+ BUILD_LIGHTING: {
154
+ name: 'build_lighting',
155
+ script: `
156
+ import unreal
157
+ try:
158
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
159
+ if les:
160
+ q = unreal.LightingBuildQuality.{quality}
161
+ les.build_light_maps(q, True)
162
+ print(f"RESULT:{{'success': True, 'quality': '{quality}', 'method': 'LevelEditorSubsystem'}}")
163
+ else:
164
+ print(f"RESULT:{{'success': False, 'error': 'LevelEditorSubsystem not available'}}")
165
+ except Exception as e:
166
+ print(f"RESULT:{{'success': False, 'error': str(e)}}")
167
+ `.trim()
168
+ },
169
+ SAVE_ALL_DIRTY_PACKAGES: {
170
+ name: 'save_dirty_packages',
171
+ script: `
172
+ import unreal
173
+ saved = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
174
+ print(f"RESULT:{{'success': {saved}, 'message': 'All dirty packages saved'}}")
175
+ `.trim()
176
+ }
177
+ };
178
+
179
+ get isConnected() { return this.connected; }
180
+
181
+ /**
182
+ * Attempt to connect with retries
183
+ * @param maxAttempts Maximum number of connection attempts
184
+ * @param timeoutMs Timeout for each connection attempt in milliseconds
185
+ * @param retryDelayMs Delay between retry attempts in milliseconds
186
+ * @returns Promise that resolves when connected or rejects after all attempts fail
187
+ */
188
+ async tryConnect(maxAttempts: number = 3, timeoutMs: number = 5000, retryDelayMs: number = 2000): Promise<boolean> {
189
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
190
+ try {
191
+ this.log.info(`Connection attempt ${attempt}/${maxAttempts}`);
192
+ await this.connect(timeoutMs);
193
+ return true; // Successfully connected
194
+ } catch (err) {
195
+ this.log.warn(`Connection attempt ${attempt} failed:`, err);
196
+
197
+ if (attempt < maxAttempts) {
198
+ this.log.info(`Retrying in ${retryDelayMs}ms...`);
199
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
200
+ } else {
201
+ this.log.error(`All ${maxAttempts} connection attempts failed`);
202
+ return false; // All attempts failed
203
+ }
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+
209
+ async connect(timeoutMs: number = 5000): Promise<void> {
210
+ const wsUrl = `ws://${this.env.UE_HOST}:${this.env.UE_RC_WS_PORT}`;
211
+ const httpBase = `http://${this.env.UE_HOST}:${this.env.UE_RC_HTTP_PORT}`;
212
+ this.http = createHttpClient(httpBase);
213
+
214
+ this.log.info(`Connecting to UE Remote Control: ${wsUrl}`);
215
+ this.ws = new WebSocket(wsUrl);
216
+
217
+ await new Promise<void>((resolve, reject) => {
218
+ if (!this.ws) return reject(new Error('WS not created'));
219
+
220
+ // Setup timeout
221
+ const timeout = setTimeout(() => {
222
+ this.log.warn(`Connection timeout after ${timeoutMs}ms`);
223
+ if (this.ws) {
224
+ this.ws.removeAllListeners();
225
+ // Only close if the websocket is in CONNECTING state
226
+ if (this.ws.readyState === WebSocket.CONNECTING) {
227
+ try {
228
+ this.ws.terminate(); // Use terminate instead of close for immediate cleanup
229
+ } catch (e) {
230
+ // Ignore close errors
231
+ }
232
+ }
233
+ this.ws = undefined;
234
+ }
235
+ reject(new Error('Connection timeout: Unreal Engine may not be running or Remote Control is not enabled'));
236
+ }, timeoutMs);
237
+
238
+ // Success handler
239
+ const onOpen = () => {
240
+ clearTimeout(timeout);
241
+ this.connected = true;
242
+ this.log.info('Connected to Unreal Remote Control');
243
+ this.startCommandProcessor(); // Start command processor on connect
244
+ resolve();
245
+ };
246
+
247
+ // Error handler
248
+ const onError = (err: Error) => {
249
+ clearTimeout(timeout);
250
+ this.log.error('WebSocket error', err);
251
+ if (this.ws) {
252
+ this.ws.removeAllListeners();
253
+ try {
254
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
255
+ this.ws.terminate();
256
+ }
257
+ } catch (e) {
258
+ // Ignore close errors
259
+ }
260
+ this.ws = undefined;
261
+ }
262
+ reject(new Error(`Failed to connect: ${err.message}`));
263
+ };
264
+
265
+ // Close handler (if closed before open)
266
+ const onClose = () => {
267
+ if (!this.connected) {
268
+ clearTimeout(timeout);
269
+ reject(new Error('Connection closed before establishing'));
270
+ } else {
271
+ // Normal close after connection was established
272
+ this.connected = false;
273
+ this.log.warn('WebSocket closed');
274
+ this.scheduleReconnect();
275
+ }
276
+ };
277
+
278
+ // Message handler (currently best-effort logging)
279
+ const onMessage = (raw: WebSocket.RawData) => {
280
+ try {
281
+ const msg = JSON.parse(String(raw));
282
+ this.log.debug('WS message', msg);
283
+ } catch (e) {
284
+ this.log.error('Failed parsing WS message', e);
285
+ }
286
+ };
287
+
288
+ // Attach listeners
289
+ this.ws.once('open', onOpen);
290
+ this.ws.once('error', onError);
291
+ this.ws.on('close', onClose);
292
+ this.ws.on('message', onMessage);
293
+ });
294
+ }
295
+
296
+
297
+ async httpCall<T = any>(path: string, method: 'GET' | 'POST' | 'PUT' = 'POST', body?: any): Promise<T> {
298
+ const url = path.startsWith('/') ? path : `/${path}`;
299
+ const started = Date.now();
300
+
301
+ // Fix Content-Length header issue - ensure body is properly handled
302
+ if (body === undefined || body === null) {
303
+ body = method === 'GET' ? undefined : {};
304
+ }
305
+
306
+ // Add timeout wrapper to prevent hanging
307
+ const CALL_TIMEOUT = 10000; // 10 seconds timeout
308
+
309
+ // CRITICAL: Intercept and block dangerous console commands at HTTP level
310
+ if (url === '/remote/object/call' && body?.functionName === 'ExecuteConsoleCommand') {
311
+ const command = body?.parameters?.Command;
312
+ if (command && typeof command === 'string') {
313
+ const cmdLower = command.trim().toLowerCase();
314
+
315
+ // List of commands that cause crashes
316
+ const crashCommands = [
317
+ 'buildpaths', // Causes access violation 0x0000000000000060
318
+ 'rebuildnavigation', // Can crash without nav system
319
+ 'buildhierarchicallod', // Can crash without proper setup
320
+ 'buildlandscapeinfo', // Can crash without landscape
321
+ 'rebuildselectednavigation' // Nav-related crash
322
+ ];
323
+
324
+ // Check if this is a crash-inducing command
325
+ if (crashCommands.some(dangerous => cmdLower === dangerous || cmdLower.startsWith(dangerous + ' '))) {
326
+ this.log.warn(`BLOCKED dangerous command that causes crashes: ${command}`);
327
+ // Return a safe error response instead of executing
328
+ return {
329
+ success: false,
330
+ error: `Command '${command}' blocked: This command can cause Unreal Engine to crash. Use the Python API alternatives instead.`
331
+ } as any;
332
+ }
333
+
334
+ // Also block other dangerous commands
335
+ const dangerousPatterns = [
336
+ 'quit', 'exit', 'r.gpucrash', 'debug crash',
337
+ 'viewmode visualizebuffer' // These can crash in certain states
338
+ ];
339
+
340
+ if (dangerousPatterns.some(pattern => cmdLower.includes(pattern))) {
341
+ this.log.warn(`BLOCKED potentially dangerous command: ${command}`);
342
+ return {
343
+ success: false,
344
+ error: `Command '${command}' blocked for safety.`
345
+ } as any;
346
+ }
347
+ }
348
+ }
349
+
350
+ // Retry logic with exponential backoff and timeout
351
+ let lastError: any;
352
+ for (let attempt = 0; attempt < 3; attempt++) {
353
+ try {
354
+ // For GET requests, send payload as query parameters (not in body)
355
+ const config: any = { url, method, timeout: CALL_TIMEOUT };
356
+ if (method === 'GET' && body && typeof body === 'object') {
357
+ config.params = body;
358
+ } else if (body !== undefined) {
359
+ config.data = body;
360
+ }
361
+
362
+ // Wrap with timeout promise to ensure we don't hang
363
+ const requestPromise = this.http.request<T>(config);
364
+ const timeoutPromise = new Promise<never>((_, reject) => {
365
+ setTimeout(() => {
366
+ reject(new Error(`Request timeout after ${CALL_TIMEOUT}ms`));
367
+ }, CALL_TIMEOUT);
368
+ });
369
+
370
+ const resp = await Promise.race([requestPromise, timeoutPromise]);
371
+ const ms = Date.now() - started;
372
+ this.log.debug(`[HTTP ${method}] ${url} -> ${ms}ms`);
373
+ return resp.data;
374
+ } catch (error: any) {
375
+ lastError = error;
376
+ const delay = Math.min(1000 * Math.pow(2, attempt), 5000); // Exponential backoff with 5s max
377
+
378
+ // Log timeout errors specifically
379
+ if (error.message?.includes('timeout')) {
380
+ this.log.warn(`HTTP request timed out (attempt ${attempt + 1}/3): ${url}`);
381
+ }
382
+
383
+ if (attempt < 2) {
384
+ this.log.warn(`HTTP request failed (attempt ${attempt + 1}/3), retrying in ${delay}ms...`);
385
+ await new Promise(resolve => setTimeout(resolve, delay));
386
+
387
+ // If connection error, try to reconnect
388
+ if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message?.includes('timeout')) {
389
+ this.scheduleReconnect();
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ throw lastError;
396
+ }
397
+
398
+ // Generic function call via Remote Control HTTP API
399
+ async call(body: RcCallBody): Promise<any> {
400
+ // Using HTTP endpoint /remote/object/call
401
+ const result = await this.httpCall<any>('/remote/object/call', 'PUT', {
402
+ generateTransaction: false,
403
+ ...body
404
+ });
405
+ return result;
406
+ }
407
+
408
+ async getExposed(): Promise<any> {
409
+ return this.httpCall('/remote/preset', 'GET');
410
+ }
411
+
412
+ // Execute a console command safely with validation and throttling
413
+ async executeConsoleCommand(command: string): Promise<any> {
414
+ // Validate command is not empty
415
+ if (!command || typeof command !== 'string') {
416
+ throw new Error('Invalid command: must be a non-empty string');
417
+ }
418
+
419
+ const cmdTrimmed = command.trim();
420
+ if (cmdTrimmed.length === 0) {
421
+ // Return success for empty commands to match UE behavior
422
+ return { success: true, message: 'Empty command ignored' };
423
+ }
424
+
425
+ // Check for dangerous commands
426
+ const dangerousCommands = [
427
+ 'quit', 'exit', 'delete', 'destroy', 'kill', 'crash',
428
+ 'viewmode visualizebuffer basecolor',
429
+ 'viewmode visualizebuffer worldnormal',
430
+ 'r.gpucrash',
431
+ 'buildpaths', // Can cause access violation if nav system not initialized
432
+ 'rebuildnavigation' // Can also crash without proper nav setup
433
+ ];
434
+
435
+ const cmdLower = cmdTrimmed.toLowerCase();
436
+ if (dangerousCommands.some(dangerous => cmdLower.includes(dangerous))) {
437
+ throw new Error(`Dangerous command blocked: ${command}`);
438
+ }
439
+
440
+ // Determine priority based on command type
441
+ let priority = 7; // Default priority
442
+
443
+ if (command.includes('BuildLighting') || command.includes('BuildPaths')) {
444
+ priority = 1; // Heavy operation
445
+ } else if (command.includes('summon') || command.includes('spawn')) {
446
+ priority = 5; // Medium operation
447
+ } else if (command.startsWith('stat') || command.startsWith('show')) {
448
+ priority = 9; // Light operation
449
+ }
450
+
451
+ // Known invalid command patterns
452
+ const invalidPatterns = [
453
+ /^\d+$/, // Just numbers
454
+ /^invalid_command/i,
455
+ /^this_is_not_a_valid/i,
456
+ ];
457
+
458
+ const isLikelyInvalid = invalidPatterns.some(pattern => pattern.test(cmdTrimmed));
459
+ if (isLikelyInvalid) {
460
+ this.log.warn(`Command appears invalid: ${cmdTrimmed}`);
461
+ }
462
+
463
+ try {
464
+ const result = await this.executeThrottledCommand(
465
+ () => this.httpCall('/remote/object/call', 'PUT', {
466
+ objectPath: '/Script/Engine.Default__KismetSystemLibrary',
467
+ functionName: 'ExecuteConsoleCommand',
468
+ parameters: {
469
+ WorldContextObject: null,
470
+ Command: cmdTrimmed,
471
+ SpecificPlayer: null
472
+ },
473
+ generateTransaction: false
474
+ }),
475
+ priority
476
+ );
477
+
478
+ return result;
479
+ } catch (error) {
480
+ this.log.error(`Console command failed: ${cmdTrimmed}`, error);
481
+ throw error;
482
+ }
483
+ }
484
+
485
+ // Try to execute a Python command via the PythonScriptPlugin, fallback to `py` console command.
486
+ async executePython(command: string): Promise<any> {
487
+ const isMultiLine = /[\r\n]/.test(command) || command.includes(';');
488
+ try {
489
+ // Use ExecutePythonCommandEx with appropriate mode based on content
490
+ return await this.httpCall('/remote/object/call', 'PUT', {
491
+ objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
492
+ functionName: 'ExecutePythonCommandEx',
493
+ parameters: {
494
+ PythonCommand: command,
495
+ ExecutionMode: isMultiLine ? 'ExecuteFile' : 'ExecuteStatement',
496
+ FileExecutionScope: 'Private'
497
+ },
498
+ generateTransaction: false
499
+ });
500
+ } catch {
501
+ try {
502
+ // Fallback to ExecutePythonCommand (more tolerant for multi-line)
503
+ return await this.httpCall('/remote/object/call', 'PUT', {
504
+ objectPath: '/Script/PythonScriptPlugin.Default__PythonScriptLibrary',
505
+ functionName: 'ExecutePythonCommand',
506
+ parameters: {
507
+ Command: command
508
+ },
509
+ generateTransaction: false
510
+ });
511
+ } catch {
512
+ // Final fallback: execute via console py command
513
+ this.log.warn('PythonScriptLibrary not available or failed, falling back to console `py` command');
514
+
515
+ // For simple single-line commands
516
+ if (!isMultiLine) {
517
+ return await this.executeConsoleCommand(`py ${command}`);
518
+ }
519
+
520
+ // For multi-line scripts, try to execute as a block
521
+ try {
522
+ // Try executing as a single exec block
523
+ // Properly escape the script for Python exec
524
+ const escapedScript = command
525
+ .replace(/\\/g, '\\\\')
526
+ .replace(/"/g, '\\"')
527
+ .replace(/\n/g, '\\n')
528
+ .replace(/\r/g, '');
529
+ return await this.executeConsoleCommand(`py exec("${escapedScript}")`);
530
+ } catch {
531
+ // If that fails, break into smaller chunks
532
+ try {
533
+ // First ensure unreal is imported
534
+ await this.executeConsoleCommand('py import unreal');
535
+
536
+ // For complex multi-line scripts, execute in logical chunks
537
+ const commandWithoutImport = command.replace(/^\s*import\s+unreal\s*;?\s*/m, '');
538
+
539
+ // Split by semicolons first, then by newlines
540
+ const statements = commandWithoutImport
541
+ .split(/[;\n]/)
542
+ .map(s => s.trim())
543
+ .filter(s => s.length > 0 && !s.startsWith('#'));
544
+
545
+ let result = null;
546
+ for (const stmt of statements) {
547
+ // Skip if statement is too long for console
548
+ if (stmt.length > 200) {
549
+ // Try to execute as a single exec block
550
+ const miniScript = `exec("""${stmt.replace(/"/g, '\\"')}""")`;
551
+ result = await this.executeConsoleCommand(`py ${miniScript}`);
552
+ } else {
553
+ result = await this.executeConsoleCommand(`py ${stmt}`);
554
+ }
555
+ // Small delay between commands
556
+ await new Promise(resolve => setTimeout(resolve, 30));
557
+ }
558
+
559
+ return result;
560
+ } catch {
561
+ // Final fallback: execute line by line
562
+ const lines = command.split('\n').filter(line => line.trim().length > 0);
563
+ let result = null;
564
+
565
+ for (const line of lines) {
566
+ // Skip comments
567
+ if (line.trim().startsWith('#')) {
568
+ continue;
569
+ }
570
+ result = await this.executeConsoleCommand(`py ${line.trim()}`);
571
+ // Small delay between commands to ensure execution order
572
+ await new Promise(resolve => setTimeout(resolve, 50));
573
+ }
574
+
575
+ return result;
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ // Connection recovery
583
+ private scheduleReconnect(): void {
584
+ if (this.reconnectTimer || this.connected) {
585
+ return;
586
+ }
587
+
588
+ if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
589
+ this.log.error('Max reconnection attempts reached. Please check Unreal Engine.');
590
+ return;
591
+ }
592
+
593
+ // Exponential backoff with jitter
594
+ const delay = Math.min(
595
+ this.BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
596
+ 30000 // Max 30 seconds
597
+ );
598
+
599
+ this.log.info(`Scheduling reconnection attempt ${this.reconnectAttempts + 1}/${this.MAX_RECONNECT_ATTEMPTS} in ${Math.round(delay)}ms`);
600
+
601
+ this.reconnectTimer = setTimeout(async () => {
602
+ this.reconnectTimer = undefined;
603
+ this.reconnectAttempts++;
604
+
605
+ try {
606
+ await this.connect();
607
+ this.reconnectAttempts = 0;
608
+ this.log.info('Successfully reconnected to Unreal Engine');
609
+ } catch (err) {
610
+ this.log.error('Reconnection attempt failed:', err);
611
+ this.scheduleReconnect();
612
+ }
613
+ }, delay);
614
+ }
615
+
616
+ // Graceful shutdown
617
+ async disconnect(): Promise<void> {
618
+ if (this.reconnectTimer) {
619
+ clearTimeout(this.reconnectTimer);
620
+ this.reconnectTimer = undefined;
621
+ }
622
+
623
+ if (this.ws) {
624
+ this.ws.close();
625
+ this.ws = undefined;
626
+ }
627
+
628
+ this.connected = false;
629
+ }
630
+
631
+ /**
632
+ * Enhanced Editor Function Access
633
+ * Use Python scripting as a bridge to access modern Editor Subsystem functions
634
+ */
635
+ async executeEditorFunction(functionName: string, params?: Record<string, any>): Promise<any> {
636
+ const template = this.PYTHON_TEMPLATES[functionName];
637
+ if (!template) {
638
+ throw new Error(`Unknown editor function: ${functionName}`);
639
+ }
640
+
641
+ let script = template.script;
642
+
643
+ // Replace parameters in the script
644
+ if (params) {
645
+ for (const [key, value] of Object.entries(params)) {
646
+ const placeholder = `{${key}}`;
647
+ script = script.replace(new RegExp(placeholder, 'g'), String(value));
648
+ }
649
+ }
650
+
651
+ try {
652
+ // Execute Python script with result parsing
653
+ const result = await this.executePythonWithResult(script);
654
+ return result;
655
+ } catch (error) {
656
+ this.log.error(`Failed to execute editor function ${functionName}:`, error);
657
+
658
+ // Fallback to console command if Python fails
659
+ return this.executeFallbackCommand(functionName, params);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Execute Python script and parse the result
665
+ */
666
+ private async executePythonWithResult(script: string): Promise<any> {
667
+ try {
668
+ // Wrap script to capture output so we can parse RESULT: lines reliably
669
+ const wrappedScript = `
670
+ import sys
671
+ import io
672
+ old_stdout = sys.stdout
673
+ sys.stdout = buffer = io.StringIO()
674
+ try:
675
+ ${script.split('\n').join('\n ')}
676
+ finally:
677
+ output = buffer.getvalue()
678
+ sys.stdout = old_stdout
679
+ if output:
680
+ print(output)
681
+ `.trim()
682
+ .replace(/\r?\n/g, '\n');
683
+
684
+ const response = await this.executePython(wrappedScript);
685
+
686
+ // Extract textual output from various response shapes
687
+ let out = '';
688
+ try {
689
+ if (response && typeof response === 'string') {
690
+ out = response;
691
+ } else if (response && typeof response === 'object') {
692
+ // Common RC Python response contains LogOutput array entries with .Output strings
693
+ if (Array.isArray((response as any).LogOutput)) {
694
+ out = (response as any).LogOutput.map((l: any) => l.Output || '').join('');
695
+ } else if (typeof (response as any).Output === 'string') {
696
+ out = (response as any).Output;
697
+ } else if (typeof (response as any).result === 'string') {
698
+ out = (response as any).result;
699
+ } else {
700
+ // Fallback to stringifying object (may still include RESULT in nested fields)
701
+ out = JSON.stringify(response);
702
+ }
703
+ }
704
+ } catch {
705
+ out = String(response || '');
706
+ }
707
+
708
+ // Find the last RESULT: JSON block in the output for robustness
709
+ const matches = Array.from(out.matchAll(/RESULT:({[\s\S]*?})/g));
710
+ if (matches.length > 0) {
711
+ const last = matches[matches.length - 1][1];
712
+ try {
713
+ // Accept single quotes and True/False from Python repr if present
714
+ const normalized = last
715
+ .replace(/'/g, '"')
716
+ .replace(/\bTrue\b/g, 'true')
717
+ .replace(/\bFalse\b/g, 'false');
718
+ return JSON.parse(normalized);
719
+ } catch {
720
+ return { raw: last };
721
+ }
722
+ }
723
+
724
+ // If no RESULT: marker, return the best-effort textual output or original response
725
+ return typeof response !== 'undefined' ? response : out;
726
+ } catch {
727
+ this.log.warn('Python execution failed, trying direct execution');
728
+ return this.executePython(script);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Get the Unreal Engine version via Python and parse major/minor/patch.
734
+ */
735
+ async getEngineVersion(): Promise<{ version: string; major: number; minor: number; patch: number; isUE56OrAbove: boolean; }> {
736
+ try {
737
+ const script = `
738
+ import unreal, json, re
739
+ ver = str(unreal.SystemLibrary.get_engine_version())
740
+ m = re.match(r'^(\\d+)\\.(\\d+)\\.(\\d+)', ver)
741
+ major = int(m.group(1)) if m else 0
742
+ minor = int(m.group(2)) if m else 0
743
+ patch = int(m.group(3)) if m else 0
744
+ print('RESULT:' + json.dumps({'version': ver, 'major': major, 'minor': minor, 'patch': patch}))
745
+ `.trim();
746
+ const result = await this.executePythonWithResult(script);
747
+ const version = String(result?.version ?? 'unknown');
748
+ const major = Number(result?.major ?? 0) || 0;
749
+ const minor = Number(result?.minor ?? 0) || 0;
750
+ const patch = Number(result?.patch ?? 0) || 0;
751
+ const isUE56OrAbove = major > 5 || (major === 5 && minor >= 6);
752
+ return { version, major, minor, patch, isUE56OrAbove };
753
+ } catch (error) {
754
+ this.log.warn('Failed to get engine version via Python', error);
755
+ return { version: 'unknown', major: 0, minor: 0, patch: 0, isUE56OrAbove: false };
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Query feature flags (Python availability, editor subsystems) via Python.
761
+ */
762
+ async getFeatureFlags(): Promise<{ pythonEnabled: boolean; subsystems: { unrealEditor: boolean; levelEditor: boolean; editorActor: boolean; } }> {
763
+ try {
764
+ const script = `
765
+ import unreal, json
766
+ flags = {}
767
+ # Python plugin availability (class exists)
768
+ try:
769
+ _ = unreal.PythonScriptLibrary
770
+ flags['pythonEnabled'] = True
771
+ except Exception:
772
+ flags['pythonEnabled'] = False
773
+ # Editor subsystems
774
+ try:
775
+ flags['unrealEditor'] = bool(unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem))
776
+ except Exception:
777
+ flags['unrealEditor'] = False
778
+ try:
779
+ flags['levelEditor'] = bool(unreal.get_editor_subsystem(unreal.LevelEditorSubsystem))
780
+ except Exception:
781
+ flags['levelEditor'] = False
782
+ try:
783
+ flags['editorActor'] = bool(unreal.get_editor_subsystem(unreal.EditorActorSubsystem))
784
+ except Exception:
785
+ flags['editorActor'] = False
786
+ print('RESULT:' + json.dumps(flags))
787
+ `.trim();
788
+ const res = await this.executePythonWithResult(script);
789
+ return {
790
+ pythonEnabled: Boolean(res?.pythonEnabled),
791
+ subsystems: {
792
+ unrealEditor: Boolean(res?.unrealEditor),
793
+ levelEditor: Boolean(res?.levelEditor),
794
+ editorActor: Boolean(res?.editorActor)
795
+ }
796
+ };
797
+ } catch (e) {
798
+ this.log.warn('Failed to get feature flags via Python', e);
799
+ return { pythonEnabled: false, subsystems: { unrealEditor: false, levelEditor: false, editorActor: false } };
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Fallback commands when Python is not available
805
+ */
806
+ private async executeFallbackCommand(functionName: string, params?: Record<string, any>): Promise<any> {
807
+ switch (functionName) {
808
+ case 'SPAWN_ACTOR_AT_LOCATION':
809
+ return this.executeConsoleCommand(
810
+ `summon ${params?.class_path || 'StaticMeshActor'} ${params?.x || 0} ${params?.y || 0} ${params?.z || 0}`
811
+ );
812
+
813
+ case 'DELETE_ACTOR':
814
+ // Use Python-based deletion to avoid unsafe console command and improve reliability
815
+ return this.executePythonWithResult(this.PYTHON_TEMPLATES.DELETE_ACTOR.script.replace('{actor_name}', String(params?.actor_name || '')));
816
+
817
+ case 'BUILD_LIGHTING':
818
+ return this.executeConsoleCommand('BuildLighting');
819
+
820
+ default:
821
+ throw new Error(`No fallback available for ${functionName}`);
822
+ }
823
+ }
824
+
825
+ /**
826
+ * SOLUTION 2: Safe ViewMode Switching
827
+ * Prevent crashes by validating and safely switching viewmodes
828
+ */
829
+ async setSafeViewMode(mode: string): Promise<any> {
830
+ const normalizedMode = mode.charAt(0).toUpperCase() + mode.slice(1).toLowerCase();
831
+
832
+ // Check if the viewmode is known to be unsafe
833
+ if (this.UNSAFE_VIEWMODES.includes(normalizedMode)) {
834
+ this.log.warn(`Viewmode '${normalizedMode}' is known to cause crashes. Using safe alternative.`);
835
+
836
+ // For visualizeBuffer modes, we need special handling
837
+ if (normalizedMode === 'BaseColor' || normalizedMode === 'WorldNormal') {
838
+ // First ensure we're in a safe state
839
+ await this.executeConsoleCommand('viewmode Lit');
840
+ await this.delay(100);
841
+
842
+ // Try to use a safer alternative or skip
843
+ this.log.info(`Skipping unsafe visualizeBuffer mode: ${normalizedMode}`);
844
+ return {
845
+ success: false,
846
+ message: `Viewmode ${normalizedMode} skipped for safety`,
847
+ alternative: 'Lit'
848
+ };
849
+ }
850
+
851
+ // For other unsafe modes, switch to safe alternatives
852
+ const safeAlternative = this.getSafeAlternative(normalizedMode);
853
+ return this.executeConsoleCommand(`viewmode ${safeAlternative}`);
854
+ }
855
+
856
+ // Safe mode - execute with delay
857
+ return this.executeThrottledCommand(() =>
858
+ this.httpCall('/remote/object/call', 'PUT', {
859
+ objectPath: '/Script/Engine.Default__KismetSystemLibrary',
860
+ functionName: 'ExecuteConsoleCommand',
861
+ parameters: {
862
+ WorldContextObject: null,
863
+ Command: `viewmode ${normalizedMode}`,
864
+ SpecificPlayer: null
865
+ },
866
+ generateTransaction: false
867
+ })
868
+ );
869
+ }
870
+
871
+ /**
872
+ * Get safe alternative for unsafe viewmodes
873
+ */
874
+ private getSafeAlternative(unsafeMode: string): string {
875
+ const alternatives: Record<string, string> = {
876
+ 'BaseColor': 'Unlit',
877
+ 'WorldNormal': 'Lit',
878
+ 'Metallic': 'Lit',
879
+ 'Specular': 'Lit',
880
+ 'Roughness': 'Lit',
881
+ 'LightComplexity': 'LightingOnly',
882
+ 'ShaderComplexity': 'Wireframe',
883
+ 'CollisionPawn': 'Wireframe',
884
+ 'CollisionVisibility': 'Wireframe'
885
+ };
886
+
887
+ return alternatives[unsafeMode] || 'Lit';
888
+ }
889
+
890
+ /**
891
+ * SOLUTION 3: Command Throttling and Queueing
892
+ * Prevent rapid command execution that can overwhelm the engine
893
+ */
894
+ private async executeThrottledCommand<T>(
895
+ command: () => Promise<T>,
896
+ priority: number = 5
897
+ ): Promise<T> {
898
+ return new Promise((resolve, reject) => {
899
+ this.commandQueue.push({
900
+ command,
901
+ resolve,
902
+ reject,
903
+ priority
904
+ });
905
+
906
+ // Sort by priority (lower number = higher priority)
907
+ this.commandQueue.sort((a, b) => a.priority - b.priority);
908
+
909
+ // Process queue if not already processing
910
+ if (!this.isProcessing) {
911
+ this.processCommandQueue();
912
+ }
913
+ });
914
+ }
915
+
916
+ /**
917
+ * Process command queue with appropriate delays
918
+ */
919
+ private async processCommandQueue(): Promise<void> {
920
+ if (this.isProcessing || this.commandQueue.length === 0) {
921
+ return;
922
+ }
923
+
924
+ this.isProcessing = true;
925
+
926
+ while (this.commandQueue.length > 0) {
927
+ const item = this.commandQueue.shift();
928
+ if (!item) continue; // Skip if undefined
929
+
930
+ // Calculate delay based on time since last command
931
+ const timeSinceLastCommand = Date.now() - this.lastCommandTime;
932
+ const requiredDelay = this.calculateDelay(item.priority);
933
+
934
+ if (timeSinceLastCommand < requiredDelay) {
935
+ await this.delay(requiredDelay - timeSinceLastCommand);
936
+ }
937
+
938
+ try {
939
+ const result = await item.command();
940
+ item.resolve(result);
941
+ } catch (error) {
942
+ // Retry logic for transient failures
943
+ if (item.retryCount === undefined) {
944
+ item.retryCount = 0;
945
+ }
946
+
947
+ if (item.retryCount < 3) {
948
+ item.retryCount++;
949
+ this.log.warn(`Command failed, retrying (${item.retryCount}/3)`);
950
+
951
+ // Re-add to queue with increased priority
952
+ this.commandQueue.unshift({
953
+ command: item.command,
954
+ resolve: item.resolve,
955
+ reject: item.reject,
956
+ priority: Math.max(1, item.priority - 1),
957
+ retryCount: item.retryCount
958
+ });
959
+
960
+ // Add extra delay before retry
961
+ await this.delay(500);
962
+ } else {
963
+ item.reject(error);
964
+ }
965
+ }
966
+
967
+ this.lastCommandTime = Date.now();
968
+ }
969
+
970
+ this.isProcessing = false;
971
+ }
972
+
973
+ /**
974
+ * Calculate appropriate delay based on command priority and type
975
+ */
976
+ private calculateDelay(priority: number): number {
977
+ // Priority 1-3: Heavy operations (asset creation, lighting build)
978
+ if (priority <= 3) {
979
+ return this.MAX_COMMAND_DELAY;
980
+ }
981
+ // Priority 4-6: Medium operations (actor spawning, material changes)
982
+ else if (priority <= 6) {
983
+ return 200;
984
+ }
985
+ // Priority 8: Stat commands - need special handling
986
+ else if (priority === 8) {
987
+ // Check time since last stat command to avoid FindConsoleObject warnings
988
+ const timeSinceLastStat = Date.now() - this.lastStatCommandTime;
989
+ if (timeSinceLastStat < this.STAT_COMMAND_DELAY) {
990
+ return this.STAT_COMMAND_DELAY;
991
+ }
992
+ this.lastStatCommandTime = Date.now();
993
+ return 150;
994
+ }
995
+ // Priority 7,9-10: Light operations (console commands, queries)
996
+ else {
997
+ return this.MIN_COMMAND_DELAY;
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * SOLUTION 4: Enhanced Asset Creation
1003
+ * Use Python scripting for complex asset creation that requires editor scripting
1004
+ */
1005
+ async createComplexAsset(assetType: string, params: Record<string, any>): Promise<any> {
1006
+ const assetCreators: Record<string, string> = {
1007
+ 'Material': 'MaterialFactoryNew',
1008
+ 'MaterialInstance': 'MaterialInstanceConstantFactoryNew',
1009
+ 'Blueprint': 'BlueprintFactory',
1010
+ 'AnimationBlueprint': 'AnimBlueprintFactory',
1011
+ 'ControlRig': 'ControlRigBlueprintFactory',
1012
+ 'NiagaraSystem': 'NiagaraSystemFactoryNew',
1013
+ 'NiagaraEmitter': 'NiagaraEmitterFactoryNew',
1014
+ 'LandscapeGrassType': 'LandscapeGrassTypeFactory',
1015
+ 'PhysicsAsset': 'PhysicsAssetFactory'
1016
+ };
1017
+
1018
+ const factoryClass = assetCreators[assetType];
1019
+ if (!factoryClass) {
1020
+ throw new Error(`Unknown asset type: ${assetType}`);
1021
+ }
1022
+
1023
+ const createParams = {
1024
+ factory_class: factoryClass,
1025
+ asset_class: `unreal.${assetType}`,
1026
+ asset_name: params.name || `New${assetType}`,
1027
+ package_path: params.path || '/Game/CreatedAssets',
1028
+ ...params
1029
+ };
1030
+
1031
+ return this.executeEditorFunction('CREATE_ASSET', createParams);
1032
+ }
1033
+
1034
+ /**
1035
+ * Start the command processor
1036
+ */
1037
+ private startCommandProcessor(): void {
1038
+ // Periodic queue processing to handle any stuck commands
1039
+ setInterval(() => {
1040
+ if (!this.isProcessing && this.commandQueue.length > 0) {
1041
+ this.processCommandQueue();
1042
+ }
1043
+ }, 1000);
1044
+ }
1045
+
1046
+ /**
1047
+ * Helper delay function
1048
+ */
1049
+ private delay(ms: number): Promise<void> {
1050
+ return new Promise(resolve => setTimeout(resolve, ms));
1051
+ }
1052
+
1053
+ /**
1054
+ * Batch command execution with proper delays
1055
+ */
1056
+ async executeBatch(commands: Array<{ command: string; priority?: number }>): Promise<any[]> {
1057
+ const results: any[] = [];
1058
+
1059
+ for (const cmd of commands) {
1060
+ const result = await this.executeConsoleCommand(cmd.command);
1061
+ results.push(result);
1062
+ }
1063
+
1064
+ return results;
1065
+ }
1066
+
1067
+ /**
1068
+ * Get safe console commands for common operations
1069
+ */
1070
+ getSafeCommands(): Record<string, string> {
1071
+ return {
1072
+ // Health check (safe, no side effects)
1073
+ 'HealthCheck': 'echo MCP Server Health Check',
1074
+
1075
+ // Performance monitoring (safe)
1076
+ 'ShowFPS': 'stat unit', // Use 'stat unit' instead of 'stat fps'
1077
+ 'ShowMemory': 'stat memory',
1078
+ 'ShowGame': 'stat game',
1079
+ 'ShowRendering': 'stat scenerendering',
1080
+ 'ClearStats': 'stat none',
1081
+
1082
+ // Safe viewmodes
1083
+ 'ViewLit': 'viewmode lit',
1084
+ 'ViewUnlit': 'viewmode unlit',
1085
+ 'ViewWireframe': 'viewmode wireframe',
1086
+ 'ViewDetailLighting': 'viewmode detaillighting',
1087
+ 'ViewLightingOnly': 'viewmode lightingonly',
1088
+
1089
+ // Safe show flags
1090
+ 'ShowBounds': 'show bounds',
1091
+ 'ShowCollision': 'show collision',
1092
+ 'ShowNavigation': 'show navigation',
1093
+ 'ShowFog': 'show fog',
1094
+ 'ShowGrid': 'show grid',
1095
+
1096
+ // PIE controls
1097
+ 'PlayInEditor': 'play',
1098
+ 'StopPlay': 'stop',
1099
+ 'PausePlay': 'pause',
1100
+
1101
+ // Time control
1102
+ 'SlowMotion': 'slomo 0.5',
1103
+ 'NormalSpeed': 'slomo 1',
1104
+ 'FastForward': 'slomo 2',
1105
+
1106
+ // Camera controls
1107
+ 'CameraSpeed1': 'camspeed 1',
1108
+ 'CameraSpeed4': 'camspeed 4',
1109
+ 'CameraSpeed8': 'camspeed 8',
1110
+
1111
+ // Rendering quality (safe)
1112
+ 'LowQuality': 'sg.ViewDistanceQuality 0',
1113
+ 'MediumQuality': 'sg.ViewDistanceQuality 1',
1114
+ 'HighQuality': 'sg.ViewDistanceQuality 2',
1115
+ 'EpicQuality': 'sg.ViewDistanceQuality 3'
1116
+ };
1117
+ }
1118
+ }