unreal-engine-mcp-server 0.4.0 → 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 (135) 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 +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
@@ -1,79 +1,179 @@
1
+ import { coerceBoolean, coerceNumber, coerceString, interpretStandardResult } from '../utils/result-helpers.js';
1
2
  export class LevelTools {
2
3
  bridge;
3
4
  constructor(bridge) {
4
5
  this.bridge = bridge;
5
6
  }
6
- // Execute console command
7
- async _executeCommand(command) {
8
- return this.bridge.httpCall('/remote/object/call', 'PUT', {
9
- objectPath: '/Script/Engine.Default__KismetSystemLibrary',
10
- functionName: 'ExecuteConsoleCommand',
11
- parameters: {
12
- WorldContextObject: null,
13
- Command: command,
14
- SpecificPlayer: null
15
- },
16
- generateTransaction: false
17
- });
18
- }
19
7
  // Load level (using LevelEditorSubsystem to avoid crashes)
20
8
  async loadLevel(params) {
21
9
  if (params.streaming) {
22
- // Try to add as streaming level
23
- const py = `\nimport unreal\ntry:\n # Use UnrealEditorSubsystem to get editor world\n ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)\n world = ues.get_editor_world() if ues else None\n if world:\n unreal.EditorLevelUtils.add_level_to_world(world, r"${params.levelPath}", unreal.LevelStreamingKismet)\n print('RESULT:{\\'success\\': True}')\n else:\n print('RESULT:{\\'success\\': False, \\'error\\': \\'No editor world\\'}')\nexcept Exception as e:\n print('RESULT:{\\'success\\': False, \\'error\\': \\'%s\\'}' % str(e))\n`.trim();
10
+ const python = `
11
+ import unreal
12
+ import json
13
+
14
+ result = {
15
+ "success": False,
16
+ "message": "",
17
+ "error": "",
18
+ "details": [],
19
+ "warnings": []
20
+ }
21
+
22
+ try:
23
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
24
+ world = ues.get_editor_world() if ues else None
25
+ if world:
26
+ try:
27
+ unreal.EditorLevelUtils.add_level_to_world(world, r"${params.levelPath}", unreal.LevelStreamingKismet)
28
+ result["success"] = True
29
+ result["message"] = "Streaming level added"
30
+ result["details"].append("Streaming level added via EditorLevelUtils")
31
+ except Exception as add_error:
32
+ result["error"] = f"Failed to add streaming level: {add_error}"
33
+ else:
34
+ result["error"] = "No editor world available"
35
+ except Exception as outer_error:
36
+ result["error"] = f"Streaming level operation failed: {outer_error}"
37
+
38
+ if result["success"]:
39
+ if not result["message"]:
40
+ result["message"] = "Streaming level added"
41
+ else:
42
+ if not result["error"]:
43
+ result["error"] = result["message"] or "Failed to add streaming level"
44
+ if not result["message"]:
45
+ result["message"] = result["error"]
46
+
47
+ if not result["warnings"]:
48
+ result.pop("warnings")
49
+ if not result["details"]:
50
+ result.pop("details")
51
+ if result.get("error") is None:
52
+ result.pop("error")
53
+
54
+ print("RESULT:" + json.dumps(result))
55
+ `.trim();
24
56
  try {
25
- const resp = await this.bridge.executePython(py);
26
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
27
- const m = out.match(/RESULT:({.*})/);
28
- if (m) {
29
- try {
30
- const parsed = JSON.parse(m[1].replace(/'/g, '"'));
31
- if (parsed.success)
32
- return { success: true, message: 'Streaming level added' };
57
+ const response = await this.bridge.executePython(python);
58
+ const interpreted = interpretStandardResult(response, {
59
+ successMessage: 'Streaming level added',
60
+ failureMessage: 'Failed to add streaming level'
61
+ });
62
+ if (interpreted.success) {
63
+ const result = {
64
+ success: true,
65
+ message: interpreted.message
66
+ };
67
+ if (interpreted.warnings?.length) {
68
+ result.warnings = interpreted.warnings;
69
+ }
70
+ if (interpreted.details?.length) {
71
+ result.details = interpreted.details;
33
72
  }
34
- catch { }
73
+ return result;
35
74
  }
36
75
  }
37
76
  catch { }
38
- // Fallback to console
39
77
  return this.bridge.executeConsoleCommand(`LoadStreamLevel ${params.levelPath}`);
40
78
  }
41
79
  else {
42
- // Use LevelEditorSubsystem.load_level() to avoid crashes
43
- // This properly handles the WorldContext and avoids the assertion failure
44
80
  const python = `
45
81
  import unreal
82
+ import json
83
+
84
+ result = {
85
+ "success": False,
86
+ "message": "",
87
+ "error": "",
88
+ "warnings": [],
89
+ "details": [],
90
+ "level": r"${params.levelPath}"
91
+ }
92
+
46
93
  try:
47
- # Use LevelEditorSubsystem which properly handles WorldContext
94
+ level_path = r"${params.levelPath}"
95
+ asset_path = level_path
96
+ try:
97
+ tail = asset_path.rsplit('/', 1)[-1]
98
+ if '.' not in tail:
99
+ asset_path = f"{asset_path}.{tail}"
100
+ except Exception:
101
+ pass
102
+
103
+ asset_exists = False
104
+ try:
105
+ asset_exists = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
106
+ except Exception:
107
+ asset_exists = False
108
+
109
+ if not asset_exists:
110
+ result["error"] = f"Level not found: {asset_path}"
111
+ else:
48
112
  les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
49
113
  if les:
50
- # load_level closes the current level and opens a new one
51
- # This is the proper way to switch levels in the editor
52
- success = les.load_level(r"${params.levelPath}")
53
- if success:
54
- print('RESULT:{"success": true, "message": "Level loaded successfully"}')
55
- else:
56
- print('RESULT:{"success": false, "error": "Failed to load level"}')
114
+ success = les.load_level(level_path)
115
+ if success:
116
+ result["success"] = True
117
+ result["message"] = "Level loaded successfully"
118
+ result["details"].append("Level loaded via LevelEditorSubsystem")
119
+ else:
120
+ result["error"] = "Failed to load level"
57
121
  else:
58
- print('RESULT:{"success": false, "error": "LevelEditorSubsystem not available"}')
59
- except Exception as e:
60
- print('RESULT:{"success": false, "error": "' + str(e).replace('"','\\"'
122
+ result["error"] = "LevelEditorSubsystem not available"
123
+ except Exception as err:
124
+ result["error"] = f"Failed to load level: {err}"
125
+
126
+ if result["success"]:
127
+ if not result["message"]:
128
+ result["message"] = "Level loaded successfully"
129
+ else:
130
+ if not result["error"]:
131
+ result["error"] = "Failed to load level"
132
+ if not result["message"]:
133
+ result["message"] = result["error"]
134
+
135
+ if not result["warnings"]:
136
+ result.pop("warnings")
137
+ if not result["details"]:
138
+ result.pop("details")
139
+ if result.get("error") is None:
140
+ result.pop("error")
141
+
142
+ print("RESULT:" + json.dumps(result))
61
143
  `.trim();
62
144
  try {
63
- const resp = await this.bridge.executePython(python);
64
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
65
- const m = out.match(/RESULT:({.*})/);
66
- if (m) {
67
- try {
68
- const parsed = JSON.parse(m[1]);
69
- return parsed.success
70
- ? { success: true, message: parsed.message || `Level ${params.levelPath} loaded` }
71
- : { success: false, error: parsed.error || 'Failed to load level' };
145
+ const response = await this.bridge.executePython(python);
146
+ const interpreted = interpretStandardResult(response, {
147
+ successMessage: `Level ${params.levelPath} loaded`,
148
+ failureMessage: `Failed to load level ${params.levelPath}`
149
+ });
150
+ const payloadLevel = coerceString(interpreted.payload.level) ?? params.levelPath;
151
+ if (interpreted.success) {
152
+ const result = {
153
+ success: true,
154
+ message: interpreted.message,
155
+ level: payloadLevel
156
+ };
157
+ if (interpreted.warnings?.length) {
158
+ result.warnings = interpreted.warnings;
72
159
  }
73
- catch { }
160
+ if (interpreted.details?.length) {
161
+ result.details = interpreted.details;
162
+ }
163
+ return result;
164
+ }
165
+ const failure = {
166
+ success: false,
167
+ error: interpreted.error || interpreted.message,
168
+ level: payloadLevel
169
+ };
170
+ if (interpreted.warnings?.length) {
171
+ failure.warnings = interpreted.warnings;
74
172
  }
75
- // If we get here but no error was thrown, assume success
76
- return { success: true, message: `Level ${params.levelPath} loaded` };
173
+ if (interpreted.details?.length) {
174
+ failure.details = interpreted.details;
175
+ }
176
+ return failure;
77
177
  }
78
178
  catch (e) {
79
179
  return { success: false, error: `Failed to load level: ${e}` };
@@ -82,74 +182,189 @@ except Exception as e:
82
182
  }
83
183
  // Save current level
84
184
  async saveLevel(_params) {
85
- // Use Python EditorLevelLibrary.save_current_level for reliability
86
185
  const python = `
87
186
  import unreal
187
+ import json
188
+
189
+ result = {
190
+ "success": False,
191
+ "message": "",
192
+ "error": "",
193
+ "warnings": [],
194
+ "details": [],
195
+ "skipped": False,
196
+ "reason": ""
197
+ }
198
+
199
+ def print_result(payload):
200
+ data = dict(payload)
201
+ if data.get("skipped") and not data.get("message"):
202
+ data["message"] = data.get("reason") or "Level save skipped"
203
+ if data.get("success") and not data.get("message"):
204
+ data["message"] = "Level saved"
205
+ if not data.get("success"):
206
+ if not data.get("error"):
207
+ data["error"] = data.get("message") or "Failed to save level"
208
+ if not data.get("message"):
209
+ data["message"] = data.get("error") or "Failed to save level"
210
+ if data.get("success"):
211
+ data.pop("error", None)
212
+ if not data.get("warnings"):
213
+ data.pop("warnings", None)
214
+ if not data.get("details"):
215
+ data.pop("details", None)
216
+ if not data.get("skipped"):
217
+ data.pop("skipped", None)
218
+ data.pop("reason", None)
219
+ else:
220
+ if not data.get("reason"):
221
+ data.pop("reason", None)
222
+ print("RESULT:" + json.dumps(data))
223
+
88
224
  try:
89
- # Attempt to reduce source control prompts (best-effort, may be a no-op depending on UE version)
225
+ # Attempt to reduce source control prompts (best-effort, may be a no-op depending on UE version)
226
+ try:
227
+ prefs = unreal.SourceControlPreferences()
228
+ muted = False
90
229
  try:
91
- prefs = unreal.SourceControlPreferences()
92
- try:
93
- prefs.set_enable_source_control(False)
94
- except Exception:
95
- try:
96
- prefs.enable_source_control = False
97
- except Exception:
98
- pass
230
+ prefs.set_enable_source_control(False)
231
+ muted = True
99
232
  except Exception:
100
- pass
233
+ try:
234
+ prefs.enable_source_control = False
235
+ muted = True
236
+ except Exception:
237
+ muted = False
238
+ if muted:
239
+ result["details"].append("Source control prompts disabled")
240
+ except Exception:
241
+ pass
101
242
 
102
- # Determine if level is dirty and save via LevelEditorSubsystem when possible
243
+ # Determine if level is dirty and save via LevelEditorSubsystem when possible
244
+ world = None
245
+ try:
246
+ world = unreal.EditorSubsystemLibrary.get_editor_world()
247
+ except Exception:
103
248
  try:
104
- world = None
249
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
250
+ world = ues.get_editor_world() if ues else None
251
+ except Exception:
252
+ world = None
253
+
254
+ pkg_path = None
255
+ try:
256
+ if world is not None:
257
+ full = world.get_path_name()
258
+ pkg_path = full.split('.')[0] if '.' in full else full
259
+ if pkg_path:
260
+ result["details"].append(f"Detected level package: {pkg_path}")
261
+ except Exception:
262
+ pkg_path = None
263
+
264
+ skip_save = False
265
+ try:
266
+ is_dirty = None
267
+ if pkg_path:
268
+ editor_asset_lib = getattr(unreal, 'EditorAssetLibrary', None)
269
+ if editor_asset_lib and hasattr(editor_asset_lib, 'is_asset_dirty'):
105
270
  try:
106
- world = unreal.EditorSubsystemLibrary.get_editor_world()
107
- except Exception:
108
- try:
109
- world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
110
- except Exception:
111
- world = None
112
- pkg_path = None
271
+ is_dirty = editor_asset_lib.is_asset_dirty(pkg_path)
272
+ except Exception as check_error:
273
+ result["warnings"].append(f"EditorAssetLibrary.is_asset_dirty failed: {check_error}")
274
+ is_dirty = None
275
+ if is_dirty is None:
276
+ # Fallback: attempt to inspect the current level package
113
277
  try:
114
- if world is not None:
115
- full = world.get_path_name()
116
- pkg_path = full.split('.')[0] if '.' in full else full
117
- except Exception:
118
- pkg_path = None
119
- if pkg_path and not unreal.EditorAssetLibrary.is_asset_dirty(pkg_path):
120
- print('RESULT:{"success": true, "skipped": true, "reason": "Level not dirty"}')
121
- raise SystemExit(0)
122
- except Exception:
123
- pass
278
+ ell = getattr(unreal, 'EditorLevelLibrary', None)
279
+ level = ell.get_current_level() if ell and hasattr(ell, 'get_current_level') else None
280
+ package = level.get_outermost() if level and hasattr(level, 'get_outermost') else None
281
+ if package and hasattr(package, 'is_dirty'):
282
+ is_dirty = package.is_dirty()
283
+ except Exception as fallback_error:
284
+ result["warnings"].append(f"Fallback dirty check failed: {fallback_error}")
285
+ if is_dirty is False:
286
+ result["success"] = True
287
+ result["skipped"] = True
288
+ result["reason"] = "Level not dirty"
289
+ result["message"] = "Level save skipped"
290
+ skip_save = True
291
+ elif is_dirty is None and pkg_path:
292
+ result["warnings"].append("Unable to determine level dirty state; attempting save anyway")
293
+ except Exception as dirty_error:
294
+ result["warnings"].append(f"Failed to check level dirty state: {dirty_error}")
124
295
 
125
- # Save using LevelEditorSubsystem to avoid deprecation
296
+ if not skip_save:
126
297
  saved = False
127
298
  try:
128
- les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
129
- if les:
130
- les.save_current_level()
131
- saved = True
132
- except Exception:
133
- pass
299
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
300
+ if les:
301
+ les.save_current_level()
302
+ saved = True
303
+ result["details"].append("Level saved via LevelEditorSubsystem")
304
+ except Exception as save_error:
305
+ result["error"] = f"Level save failed: {save_error}"
306
+ saved = False
307
+
134
308
  if not saved:
135
- # No fallback available - LevelEditorSubsystem is required
136
- raise Exception('LevelEditorSubsystem not available')
137
- print('RESULT:{"success": true}')
138
- except Exception as e:
139
- print('RESULT:{"success": false, "error": "' + str(e).replace('"','\\"') + '"}')
309
+ raise Exception('LevelEditorSubsystem not available')
310
+
311
+ result["success"] = True
312
+ if not result["message"]:
313
+ result["message"] = "Level saved"
314
+ except Exception as err:
315
+ result["error"] = str(err)
316
+
317
+ print_result(result)
140
318
  `.trim();
141
319
  try {
142
- const resp = await this.bridge.executePython(python);
143
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
144
- const m = out.match(/RESULT:({.*})/);
145
- if (m) {
146
- try {
147
- const parsed = JSON.parse(m[1]);
148
- return parsed.success ? { success: true, message: 'Level saved' } : { success: false, error: parsed.error };
320
+ const response = await this.bridge.executePython(python);
321
+ const interpreted = interpretStandardResult(response, {
322
+ successMessage: 'Level saved',
323
+ failureMessage: 'Failed to save level'
324
+ });
325
+ if (interpreted.success) {
326
+ const result = {
327
+ success: true,
328
+ message: interpreted.message
329
+ };
330
+ const skipped = coerceBoolean(interpreted.payload.skipped);
331
+ if (typeof skipped === 'boolean') {
332
+ result.skipped = skipped;
333
+ }
334
+ const reason = coerceString(interpreted.payload.reason);
335
+ if (reason) {
336
+ result.reason = reason;
149
337
  }
150
- catch { }
338
+ if (interpreted.warnings?.length) {
339
+ result.warnings = interpreted.warnings;
340
+ }
341
+ if (interpreted.details?.length) {
342
+ result.details = interpreted.details;
343
+ }
344
+ return result;
345
+ }
346
+ const failure = {
347
+ success: false,
348
+ error: interpreted.error || interpreted.message
349
+ };
350
+ if (interpreted.message && interpreted.message !== failure.error) {
351
+ failure.message = interpreted.message;
352
+ }
353
+ const skippedFailure = coerceBoolean(interpreted.payload.skipped);
354
+ if (typeof skippedFailure === 'boolean') {
355
+ failure.skipped = skippedFailure;
356
+ }
357
+ const failureReason = coerceString(interpreted.payload.reason);
358
+ if (failureReason) {
359
+ failure.reason = failureReason;
360
+ }
361
+ if (interpreted.warnings?.length) {
362
+ failure.warnings = interpreted.warnings;
151
363
  }
152
- return { success: true, message: 'Level saved' };
364
+ if (interpreted.details?.length) {
365
+ failure.details = interpreted.details;
366
+ }
367
+ return failure;
153
368
  }
154
369
  catch (e) {
155
370
  return { success: false, error: `Failed to save level: ${e}` };
@@ -160,30 +375,86 @@ except Exception as e:
160
375
  const basePath = params.savePath || '/Game/Maps';
161
376
  const isPartitioned = true; // default to World Partition for UE5
162
377
  const fullPath = `${basePath}/${params.levelName}`;
163
- const py = `
378
+ const python = `
164
379
  import unreal
380
+ import json
381
+
382
+ result = {
383
+ "success": False,
384
+ "message": "",
385
+ "error": "",
386
+ "warnings": [],
387
+ "details": [],
388
+ "path": r"${fullPath}",
389
+ "partitioned": ${isPartitioned ? 'True' : 'False'}
390
+ }
391
+
165
392
  try:
166
- les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
167
- if les:
168
- les.new_level(r"${fullPath}", ${isPartitioned ? 'True' : 'False'})
169
- print('RESULT:{"success": True, "message": "Level created"}')
170
- else:
171
- print('RESULT:{"success": False, "error": "LevelEditorSubsystem not available"}')
172
- except Exception as e:
173
- print('RESULT:{"success": False, "error": "' + str(e) + '"}')
393
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
394
+ if les:
395
+ les.new_level(r"${fullPath}", ${isPartitioned ? 'True' : 'False'})
396
+ result["success"] = True
397
+ result["message"] = "Level created"
398
+ result["details"].append("Level created via LevelEditorSubsystem.new_level")
399
+ else:
400
+ result["error"] = "LevelEditorSubsystem not available"
401
+ except Exception as err:
402
+ result["error"] = f"Level creation failed: {err}"
403
+
404
+ if result["success"]:
405
+ if not result["message"]:
406
+ result["message"] = "Level created"
407
+ else:
408
+ if not result["error"]:
409
+ result["error"] = "Failed to create level"
410
+ if not result["message"]:
411
+ result["message"] = result["error"]
412
+
413
+ if not result["warnings"]:
414
+ result.pop("warnings")
415
+ if not result["details"]:
416
+ result.pop("details")
417
+ if result.get("error") is None:
418
+ result.pop("error")
419
+
420
+ print("RESULT:" + json.dumps(result))
174
421
  `.trim();
175
422
  try {
176
- const resp = await this.bridge.executePython(py);
177
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
178
- const m = out.match(/RESULT:({.*})/);
179
- if (m) {
180
- try {
181
- const parsed = JSON.parse(m[1].replace(/'/g, '"'));
182
- return parsed.success ? { success: true, message: parsed.message } : { success: false, error: parsed.error };
423
+ const response = await this.bridge.executePython(python);
424
+ const interpreted = interpretStandardResult(response, {
425
+ successMessage: 'Level created',
426
+ failureMessage: 'Failed to create level'
427
+ });
428
+ const path = coerceString(interpreted.payload.path) ?? fullPath;
429
+ const partitioned = coerceBoolean(interpreted.payload.partitioned, isPartitioned) ?? isPartitioned;
430
+ if (interpreted.success) {
431
+ const result = {
432
+ success: true,
433
+ message: interpreted.message,
434
+ path,
435
+ partitioned
436
+ };
437
+ if (interpreted.warnings?.length) {
438
+ result.warnings = interpreted.warnings;
439
+ }
440
+ if (interpreted.details?.length) {
441
+ result.details = interpreted.details;
183
442
  }
184
- catch { }
443
+ return result;
185
444
  }
186
- return { success: true, message: 'Level creation attempted' };
445
+ const failure = {
446
+ success: false,
447
+ error: interpreted.error || interpreted.message,
448
+ path,
449
+ partitioned
450
+ };
451
+ if (interpreted.warnings?.length) {
452
+ failure.warnings = interpreted.warnings;
453
+ }
454
+ if (interpreted.details?.length) {
455
+ failure.details = interpreted.details;
456
+ }
457
+ return failure;
187
458
  }
188
459
  catch (e) {
189
460
  return { success: false, error: `Failed to create level: ${e}` };
@@ -191,26 +462,154 @@ except Exception as e:
191
462
  }
192
463
  // Stream level (Python attempt with fallback)
193
464
  async streamLevel(params) {
194
- const py = `\nimport unreal\ntry:\n # Use UnrealEditorSubsystem to get editor world\n ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)\n world = ues.get_editor_world() if ues else None\n if world:\n # Find streaming level by name and set flags\n updated = False\n for sl in world.get_streaming_levels():\n try:\n name = sl.get_world_asset_package_name() if hasattr(sl, 'get_world_asset_package_name') else str(sl.get_editor_property('world_asset'))\n if name and name.endswith('/${params.levelName}'):\n try: sl.set_should_be_loaded(${params.shouldBeLoaded ? 'True' : 'False'})\n except Exception: pass\n try: sl.set_should_be_visible(${params.shouldBeVisible ? 'True' : 'False'})\n except Exception: pass\n updated = True\n break\n except Exception: pass\n print('RESULT:{\\'success\\': %s}' % ('True' if updated else 'False'))\n else:\n print('RESULT:{\\'success\\': False, \\'error\\': \\'No editor world\\'}')\nexcept Exception as e:\n print('RESULT:{\\'success\\': False, \\'error\\': \\'%s\\'}' % str(e))\n`.trim();
465
+ const python = `
466
+ import unreal
467
+ import json
468
+
469
+ result = {
470
+ "success": False,
471
+ "message": "",
472
+ "error": "",
473
+ "warnings": [],
474
+ "details": [],
475
+ "level": "${params.levelName}",
476
+ "loaded": ${params.shouldBeLoaded ? 'True' : 'False'},
477
+ "visible": ${params.shouldBeVisible ? 'True' : 'False'}
478
+ }
479
+
480
+ try:
481
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
482
+ world = ues.get_editor_world() if ues else None
483
+ if world:
484
+ updated = False
485
+ streaming_levels = []
486
+ try:
487
+ if hasattr(world, 'get_streaming_levels'):
488
+ streaming_levels = list(world.get_streaming_levels() or [])
489
+ except Exception as primary_error:
490
+ result["warnings"].append(f"get_streaming_levels unavailable: {primary_error}")
491
+
492
+ if not streaming_levels:
493
+ try:
494
+ if hasattr(world, 'get_level_streaming_levels'):
495
+ streaming_levels = list(world.get_level_streaming_levels() or [])
496
+ except Exception as alt_error:
497
+ result["warnings"].append(f"get_level_streaming_levels unavailable: {alt_error}")
498
+
499
+ if not streaming_levels:
500
+ try:
501
+ fallback_levels = getattr(world, 'streaming_levels', None)
502
+ if fallback_levels is not None:
503
+ streaming_levels = list(fallback_levels)
504
+ except Exception as attr_error:
505
+ result["warnings"].append(f"streaming_levels attribute unavailable: {attr_error}")
506
+
507
+ if not streaming_levels:
508
+ result["error"] = "Streaming levels unavailable"
509
+ else:
510
+ for streaming_level in streaming_levels:
511
+ try:
512
+ name = None
513
+ if hasattr(streaming_level, 'get_world_asset_package_name'):
514
+ name = streaming_level.get_world_asset_package_name()
515
+ if not name:
516
+ try:
517
+ name = str(streaming_level.get_editor_property('world_asset'))
518
+ except Exception:
519
+ name = None
520
+
521
+ if name and name.endswith('/${params.levelName}'):
522
+ try:
523
+ streaming_level.set_should_be_loaded(${params.shouldBeLoaded ? 'True' : 'False'})
524
+ except Exception as load_error:
525
+ result["warnings"].append(f"Failed to set loaded flag: {load_error}")
526
+ try:
527
+ streaming_level.set_should_be_visible(${params.shouldBeVisible ? 'True' : 'False'})
528
+ except Exception as visible_error:
529
+ result["warnings"].append(f"Failed to set visibility: {visible_error}")
530
+ updated = True
531
+ break
532
+ except Exception as iteration_error:
533
+ result["warnings"].append(f"Streaming level iteration error: {iteration_error}")
534
+
535
+ if updated:
536
+ result["success"] = True
537
+ result["message"] = "Streaming level updated"
538
+ result["details"].append("Streaming level flags updated for editor world")
539
+ else:
540
+ result["error"] = "Streaming level not found"
541
+ else:
542
+ result["error"] = "No editor world available"
543
+ except Exception as err:
544
+ result["error"] = f"Streaming level update failed: {err}"
545
+
546
+ if result["success"]:
547
+ if not result["message"]:
548
+ result["message"] = "Streaming level updated"
549
+ else:
550
+ if not result["error"]:
551
+ result["error"] = "Streaming level update failed"
552
+ if not result["message"]:
553
+ result["message"] = result["error"]
554
+
555
+ if not result["warnings"]:
556
+ result.pop("warnings")
557
+ if not result["details"]:
558
+ result.pop("details")
559
+ if result.get("error") is None:
560
+ result.pop("error")
561
+
562
+ print("RESULT:" + json.dumps(result))
563
+ `.trim();
195
564
  try {
196
- const resp = await this.bridge.executePython(py);
197
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
198
- const m = out.match(/RESULT:({.*})/);
199
- if (m) {
200
- try {
201
- const parsed = JSON.parse(m[1].replace(/'/g, '"'));
202
- if (parsed.success)
203
- return { success: true, message: 'Streaming level updated' };
565
+ const response = await this.bridge.executePython(python);
566
+ const interpreted = interpretStandardResult(response, {
567
+ successMessage: 'Streaming level updated',
568
+ failureMessage: 'Streaming level update failed'
569
+ });
570
+ const levelName = coerceString(interpreted.payload.level) ?? params.levelName;
571
+ const loaded = coerceBoolean(interpreted.payload.loaded, params.shouldBeLoaded) ?? params.shouldBeLoaded;
572
+ const visible = coerceBoolean(interpreted.payload.visible, params.shouldBeVisible) ?? params.shouldBeVisible;
573
+ if (interpreted.success) {
574
+ const result = {
575
+ success: true,
576
+ message: interpreted.message,
577
+ level: levelName,
578
+ loaded,
579
+ visible
580
+ };
581
+ if (interpreted.warnings?.length) {
582
+ result.warnings = interpreted.warnings;
583
+ }
584
+ if (interpreted.details?.length) {
585
+ result.details = interpreted.details;
204
586
  }
205
- catch { }
587
+ return result;
588
+ }
589
+ const failure = {
590
+ success: false,
591
+ error: interpreted.error || interpreted.message || 'Streaming level update failed',
592
+ level: levelName,
593
+ loaded,
594
+ visible
595
+ };
596
+ if (interpreted.message && interpreted.message !== failure.error) {
597
+ failure.message = interpreted.message;
598
+ }
599
+ if (interpreted.warnings?.length) {
600
+ failure.warnings = interpreted.warnings;
206
601
  }
602
+ if (interpreted.details?.length) {
603
+ failure.details = interpreted.details;
604
+ }
605
+ return failure;
606
+ }
607
+ catch {
608
+ const loadCmd = params.shouldBeLoaded ? 'Load' : 'Unload';
609
+ const visCmd = params.shouldBeVisible ? 'Show' : 'Hide';
610
+ const command = `StreamLevel ${params.levelName} ${loadCmd} ${visCmd}`;
611
+ return this.bridge.executeConsoleCommand(command);
207
612
  }
208
- catch { }
209
- // Fallback
210
- const loadCmd = params.shouldBeLoaded ? 'Load' : 'Unload';
211
- const visCmd = params.shouldBeVisible ? 'Show' : 'Hide';
212
- const command = `StreamLevel ${params.levelName} ${loadCmd} ${visCmd}`;
213
- return this.bridge.executeConsoleCommand(command);
214
613
  }
215
614
  // World composition
216
615
  async setupWorldComposition(params) {
@@ -227,9 +626,7 @@ except Exception as e:
227
626
  else {
228
627
  commands.push('DisableWorldComposition');
229
628
  }
230
- for (const cmd of commands) {
231
- await this.bridge.executeConsoleCommand(cmd);
232
- }
629
+ await this.bridge.executeConsoleCommands(commands);
233
630
  return { success: true, message: 'World composition configured' };
234
631
  }
235
632
  // Level blueprint
@@ -260,9 +657,7 @@ except Exception as e:
260
657
  if (params.killZ !== undefined) {
261
658
  commands.push(`SetKillZ ${params.killZ}`);
262
659
  }
263
- for (const cmd of commands) {
264
- await this.bridge.executeConsoleCommand(cmd);
265
- }
660
+ await this.bridge.executeConsoleCommands(commands);
266
661
  return { success: true, message: 'World settings updated' };
267
662
  }
268
663
  // Level bounds
@@ -272,66 +667,121 @@ except Exception as e:
272
667
  }
273
668
  // Navigation mesh
274
669
  async buildNavMesh(params) {
275
- // Use Python API for safer navigation mesh building to avoid crashes
276
- const py = `
670
+ const python = `
277
671
  import unreal
278
672
  import json
673
+
674
+ result = {
675
+ "success": False,
676
+ "message": "",
677
+ "error": "",
678
+ "warnings": [],
679
+ "details": [],
680
+ "rebuildAll": ${params.rebuildAll ? 'True' : 'False'},
681
+ "selectedOnly": ${params.selectedOnly ? 'True' : 'False'},
682
+ "selectionCount": 0
683
+ }
684
+
279
685
  try:
280
- # Check if navigation system exists first
281
- nav_system = unreal.EditorSubsystemLibrary.get_editor_subsystem(unreal.NavigationSystemV1)
282
- if not nav_system:
283
- # Try alternative method
284
- # Try to get world via UnrealEditorSubsystem
285
- ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
286
- world = ues.get_editor_world() if ues else None
287
- nav_system = unreal.NavigationSystemV1.get_navigation_system(world) if world else None
288
-
289
- if nav_system:
290
- # Use the safe Python API method instead of console commands
291
- if ${params.rebuildAll ? 'True' : 'False'}:
292
- # Rebuild all navigation
293
- nav_system.navigation_build_async()
294
- print('RESULT:' + json.dumps({'success': True, 'message': 'Navigation rebuild started'}))
295
- else:
296
- # Update navigation for selected actors only
297
- # Use EditorActorSubsystem to get selected actors
298
- actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
299
- selected_actors = actor_subsystem.get_selected_level_actors() if actor_subsystem else []
300
- if selected_actors:
301
- for actor in selected_actors:
302
- nav_system.update_nav_octree(actor)
303
- print('RESULT:' + json.dumps({'success': True, 'message': f'Navigation updated for {len(selected_actors)} actors'}))
304
- else:
305
- # If nothing selected, do a safe incremental update
306
- nav_system.update(0.0)
307
- print('RESULT:' + json.dumps({'success': True, 'message': 'Navigation incremental update performed'}))
686
+ nav_system = unreal.EditorSubsystemLibrary.get_editor_subsystem(unreal.NavigationSystemV1)
687
+ if not nav_system:
688
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
689
+ world = ues.get_editor_world() if ues else None
690
+ nav_system = unreal.NavigationSystemV1.get_navigation_system(world) if world else None
691
+
692
+ if nav_system:
693
+ if ${params.rebuildAll ? 'True' : 'False'}:
694
+ nav_system.navigation_build_async()
695
+ result["success"] = True
696
+ result["message"] = "Navigation rebuild started"
697
+ result["details"].append("Triggered full navigation rebuild")
308
698
  else:
309
- # Navigation system not available - likely no nav mesh in level
310
- print('RESULT:' + json.dumps({'success': False, 'error': 'Navigation system not available. Add a NavMeshBoundsVolume to the level first.'}))
311
- except AttributeError as e:
312
- # Some methods might not be available in all UE versions
313
- print('RESULT:' + json.dumps({'success': False, 'error': f'Navigation API not available: {str(e)}'}))
314
- except Exception as e:
315
- print('RESULT:' + json.dumps({'success': False, 'error': f'Navigation build failed: {str(e)}'}))
699
+ actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
700
+ selected_actors = actor_subsystem.get_selected_level_actors() if actor_subsystem else []
701
+ result["selectionCount"] = len(selected_actors) if selected_actors else 0
702
+
703
+ if ${params.selectedOnly ? 'True' : 'False'} and selected_actors:
704
+ for actor in selected_actors:
705
+ nav_system.update_nav_octree(actor)
706
+ result["success"] = True
707
+ result["message"] = f"Navigation updated for {len(selected_actors)} actors"
708
+ result["details"].append("Updated nav octree for selected actors")
709
+ elif selected_actors:
710
+ for actor in selected_actors:
711
+ nav_system.update_nav_octree(actor)
712
+ nav_system.update(0.0)
713
+ result["success"] = True
714
+ result["message"] = f"Navigation updated for {len(selected_actors)} actors"
715
+ result["details"].append("Updated nav octree and performed incremental update")
716
+ else:
717
+ nav_system.update(0.0)
718
+ result["success"] = True
719
+ result["message"] = "Navigation incremental update performed"
720
+ result["details"].append("No selected actors; performed incremental update")
721
+ else:
722
+ result["error"] = "Navigation system not available. Add a NavMeshBoundsVolume to the level first."
723
+ except AttributeError as attr_error:
724
+ result["error"] = f"Navigation API not available: {attr_error}"
725
+ except Exception as err:
726
+ result["error"] = f"Navigation build failed: {err}"
727
+
728
+ if result["success"]:
729
+ if not result["message"]:
730
+ result["message"] = "Navigation build started"
731
+ else:
732
+ if not result["error"]:
733
+ result["error"] = result["message"] or "Navigation build failed"
734
+ if not result["message"]:
735
+ result["message"] = result["error"]
736
+
737
+ if not result["warnings"]:
738
+ result.pop("warnings")
739
+ if not result["details"]:
740
+ result.pop("details")
741
+ if result.get("error") is None:
742
+ result.pop("error")
743
+
744
+ if not result.get("selectionCount"):
745
+ result.pop("selectionCount", None)
746
+
747
+ print("RESULT:" + json.dumps(result))
316
748
  `.trim();
317
749
  try {
318
- const resp = await this.bridge.executePython(py);
319
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
320
- const m = out.match(/RESULT:({.*})/);
321
- if (m) {
322
- try {
323
- const parsed = JSON.parse(m[1]);
324
- return parsed.success
325
- ? { success: true, message: parsed.message }
326
- : { success: false, error: parsed.error };
327
- }
328
- catch { }
750
+ const response = await this.bridge.executePython(python);
751
+ const interpreted = interpretStandardResult(response, {
752
+ successMessage: params.rebuildAll ? 'Navigation rebuild started' : 'Navigation update started',
753
+ failureMessage: 'Navigation build failed'
754
+ });
755
+ const result = interpreted.success
756
+ ? { success: true, message: interpreted.message }
757
+ : { success: false, error: interpreted.error || interpreted.message };
758
+ const rebuildAll = coerceBoolean(interpreted.payload.rebuildAll, params.rebuildAll);
759
+ const selectedOnly = coerceBoolean(interpreted.payload.selectedOnly, params.selectedOnly);
760
+ if (typeof rebuildAll === 'boolean') {
761
+ result.rebuildAll = rebuildAll;
762
+ }
763
+ else if (typeof params.rebuildAll === 'boolean') {
764
+ result.rebuildAll = params.rebuildAll;
765
+ }
766
+ if (typeof selectedOnly === 'boolean') {
767
+ result.selectedOnly = selectedOnly;
768
+ }
769
+ else if (typeof params.selectedOnly === 'boolean') {
770
+ result.selectedOnly = params.selectedOnly;
771
+ }
772
+ const selectionCount = coerceNumber(interpreted.payload.selectionCount);
773
+ if (typeof selectionCount === 'number') {
774
+ result.selectionCount = selectionCount;
775
+ }
776
+ if (interpreted.warnings?.length) {
777
+ result.warnings = interpreted.warnings;
778
+ }
779
+ if (interpreted.details?.length) {
780
+ result.details = interpreted.details;
329
781
  }
330
- // Fallback message if no clear result
331
- return { success: true, message: 'Navigation mesh build attempted' };
782
+ return result;
332
783
  }
333
784
  catch (e) {
334
- // If Python fails, return error instead of trying console command that crashes
335
785
  return {
336
786
  success: false,
337
787
  error: `Navigation build not available: ${e}. Please ensure a NavMeshBoundsVolume exists in the level.`