unreal-engine-mcp-server 0.4.0 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.env.production +1 -1
  2. package/.github/copilot-instructions.md +45 -0
  3. package/.github/workflows/publish-mcp.yml +3 -2
  4. package/README.md +21 -5
  5. package/dist/index.js +124 -31
  6. package/dist/prompts/index.d.ts +10 -3
  7. package/dist/prompts/index.js +186 -7
  8. package/dist/resources/actors.d.ts +19 -1
  9. package/dist/resources/actors.js +55 -64
  10. package/dist/resources/assets.js +46 -62
  11. package/dist/resources/levels.d.ts +21 -3
  12. package/dist/resources/levels.js +29 -54
  13. package/dist/tools/actors.d.ts +3 -14
  14. package/dist/tools/actors.js +246 -302
  15. package/dist/tools/animation.d.ts +57 -102
  16. package/dist/tools/animation.js +429 -450
  17. package/dist/tools/assets.d.ts +13 -2
  18. package/dist/tools/assets.js +52 -44
  19. package/dist/tools/audio.d.ts +22 -13
  20. package/dist/tools/audio.js +467 -121
  21. package/dist/tools/blueprint.d.ts +32 -13
  22. package/dist/tools/blueprint.js +699 -448
  23. package/dist/tools/build_environment_advanced.d.ts +0 -1
  24. package/dist/tools/build_environment_advanced.js +190 -45
  25. package/dist/tools/consolidated-tool-definitions.js +78 -252
  26. package/dist/tools/consolidated-tool-handlers.js +506 -133
  27. package/dist/tools/debug.d.ts +72 -10
  28. package/dist/tools/debug.js +167 -31
  29. package/dist/tools/editor.d.ts +9 -2
  30. package/dist/tools/editor.js +30 -44
  31. package/dist/tools/foliage.d.ts +34 -15
  32. package/dist/tools/foliage.js +97 -107
  33. package/dist/tools/introspection.js +19 -21
  34. package/dist/tools/landscape.d.ts +1 -2
  35. package/dist/tools/landscape.js +311 -168
  36. package/dist/tools/level.d.ts +3 -28
  37. package/dist/tools/level.js +642 -192
  38. package/dist/tools/lighting.d.ts +14 -3
  39. package/dist/tools/lighting.js +236 -123
  40. package/dist/tools/materials.d.ts +25 -7
  41. package/dist/tools/materials.js +102 -79
  42. package/dist/tools/niagara.d.ts +10 -12
  43. package/dist/tools/niagara.js +74 -94
  44. package/dist/tools/performance.d.ts +12 -4
  45. package/dist/tools/performance.js +38 -79
  46. package/dist/tools/physics.d.ts +34 -10
  47. package/dist/tools/physics.js +364 -292
  48. package/dist/tools/rc.js +97 -23
  49. package/dist/tools/sequence.d.ts +1 -0
  50. package/dist/tools/sequence.js +125 -22
  51. package/dist/tools/ui.d.ts +31 -4
  52. package/dist/tools/ui.js +83 -66
  53. package/dist/tools/visual.d.ts +11 -0
  54. package/dist/tools/visual.js +245 -30
  55. package/dist/types/tool-types.d.ts +0 -6
  56. package/dist/types/tool-types.js +1 -8
  57. package/dist/unreal-bridge.d.ts +32 -2
  58. package/dist/unreal-bridge.js +621 -127
  59. package/dist/utils/elicitation.d.ts +57 -0
  60. package/dist/utils/elicitation.js +104 -0
  61. package/dist/utils/error-handler.d.ts +0 -33
  62. package/dist/utils/error-handler.js +4 -111
  63. package/dist/utils/http.d.ts +2 -22
  64. package/dist/utils/http.js +12 -75
  65. package/dist/utils/normalize.d.ts +4 -4
  66. package/dist/utils/normalize.js +15 -7
  67. package/dist/utils/python-output.d.ts +18 -0
  68. package/dist/utils/python-output.js +290 -0
  69. package/dist/utils/python.d.ts +2 -0
  70. package/dist/utils/python.js +4 -0
  71. package/dist/utils/response-validator.js +28 -2
  72. package/dist/utils/result-helpers.d.ts +27 -0
  73. package/dist/utils/result-helpers.js +147 -0
  74. package/dist/utils/safe-json.d.ts +0 -2
  75. package/dist/utils/safe-json.js +0 -43
  76. package/dist/utils/validation.d.ts +16 -0
  77. package/dist/utils/validation.js +70 -7
  78. package/mcp-config-example.json +2 -2
  79. package/package.json +10 -9
  80. package/server.json +37 -14
  81. package/src/index.ts +130 -33
  82. package/src/prompts/index.ts +211 -13
  83. package/src/resources/actors.ts +59 -44
  84. package/src/resources/assets.ts +48 -51
  85. package/src/resources/levels.ts +35 -45
  86. package/src/tools/actors.ts +269 -313
  87. package/src/tools/animation.ts +556 -539
  88. package/src/tools/assets.ts +53 -43
  89. package/src/tools/audio.ts +507 -113
  90. package/src/tools/blueprint.ts +778 -462
  91. package/src/tools/build_environment_advanced.ts +266 -64
  92. package/src/tools/consolidated-tool-definitions.ts +90 -264
  93. package/src/tools/consolidated-tool-handlers.ts +630 -121
  94. package/src/tools/debug.ts +176 -33
  95. package/src/tools/editor.ts +35 -37
  96. package/src/tools/foliage.ts +110 -104
  97. package/src/tools/introspection.ts +24 -22
  98. package/src/tools/landscape.ts +334 -181
  99. package/src/tools/level.ts +683 -182
  100. package/src/tools/lighting.ts +244 -123
  101. package/src/tools/materials.ts +114 -83
  102. package/src/tools/niagara.ts +87 -81
  103. package/src/tools/performance.ts +49 -88
  104. package/src/tools/physics.ts +393 -299
  105. package/src/tools/rc.ts +102 -24
  106. package/src/tools/sequence.ts +136 -28
  107. package/src/tools/ui.ts +101 -70
  108. package/src/tools/visual.ts +250 -29
  109. package/src/types/tool-types.ts +0 -9
  110. package/src/unreal-bridge.ts +658 -140
  111. package/src/utils/elicitation.ts +129 -0
  112. package/src/utils/error-handler.ts +4 -159
  113. package/src/utils/http.ts +16 -115
  114. package/src/utils/normalize.ts +20 -10
  115. package/src/utils/python-output.ts +351 -0
  116. package/src/utils/python.ts +3 -0
  117. package/src/utils/response-validator.ts +25 -2
  118. package/src/utils/result-helpers.ts +193 -0
  119. package/src/utils/safe-json.ts +0 -50
  120. package/src/utils/validation.ts +94 -7
  121. package/tests/run-unreal-tool-tests.mjs +720 -0
  122. package/tsconfig.json +2 -2
  123. package/dist/python-utils.d.ts +0 -29
  124. package/dist/python-utils.js +0 -54
  125. package/dist/types/index.d.ts +0 -323
  126. package/dist/types/index.js +0 -28
  127. package/dist/utils/cache-manager.d.ts +0 -64
  128. package/dist/utils/cache-manager.js +0 -176
  129. package/dist/utils/errors.d.ts +0 -133
  130. package/dist/utils/errors.js +0 -256
  131. package/src/python/editor_compat.py +0 -181
  132. package/src/python-utils.ts +0 -57
  133. package/src/types/index.ts +0 -414
  134. package/src/utils/cache-manager.ts +0 -213
  135. package/src/utils/errors.ts +0 -312
@@ -1,23 +1,15 @@
1
1
  // Level management tools for Unreal Engine
2
2
  import { UnrealBridge } from '../unreal-bridge.js';
3
+ import {
4
+ coerceBoolean,
5
+ coerceNumber,
6
+ coerceString,
7
+ interpretStandardResult
8
+ } from '../utils/result-helpers.js';
3
9
 
4
10
  export class LevelTools {
5
11
  constructor(private bridge: UnrealBridge) {}
6
12
 
7
- // Execute console command
8
- private async _executeCommand(command: string) {
9
- return this.bridge.httpCall('/remote/object/call', 'PUT', {
10
- objectPath: '/Script/Engine.Default__KismetSystemLibrary',
11
- functionName: 'ExecuteConsoleCommand',
12
- parameters: {
13
- WorldContextObject: null,
14
- Command: command,
15
- SpecificPlayer: null
16
- },
17
- generateTransaction: false
18
- });
19
- }
20
-
21
13
  // Load level (using LevelEditorSubsystem to avoid crashes)
22
14
  async loadLevel(params: {
23
15
  levelPath: string;
@@ -25,52 +17,177 @@ export class LevelTools {
25
17
  position?: [number, number, number];
26
18
  }) {
27
19
  if (params.streaming) {
28
- // Try to add as streaming level
29
- 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();
20
+ const python = `
21
+ import unreal
22
+ import json
23
+
24
+ result = {
25
+ "success": False,
26
+ "message": "",
27
+ "error": "",
28
+ "details": [],
29
+ "warnings": []
30
+ }
31
+
32
+ try:
33
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
34
+ world = ues.get_editor_world() if ues else None
35
+ if world:
36
+ try:
37
+ unreal.EditorLevelUtils.add_level_to_world(world, r"${params.levelPath}", unreal.LevelStreamingKismet)
38
+ result["success"] = True
39
+ result["message"] = "Streaming level added"
40
+ result["details"].append("Streaming level added via EditorLevelUtils")
41
+ except Exception as add_error:
42
+ result["error"] = f"Failed to add streaming level: {add_error}"
43
+ else:
44
+ result["error"] = "No editor world available"
45
+ except Exception as outer_error:
46
+ result["error"] = f"Streaming level operation failed: {outer_error}"
47
+
48
+ if result["success"]:
49
+ if not result["message"]:
50
+ result["message"] = "Streaming level added"
51
+ else:
52
+ if not result["error"]:
53
+ result["error"] = result["message"] or "Failed to add streaming level"
54
+ if not result["message"]:
55
+ result["message"] = result["error"]
56
+
57
+ if not result["warnings"]:
58
+ result.pop("warnings")
59
+ if not result["details"]:
60
+ result.pop("details")
61
+ if result.get("error") is None:
62
+ result.pop("error")
63
+
64
+ print("RESULT:" + json.dumps(result))
65
+ `.trim();
66
+
30
67
  try {
31
- const resp = await this.bridge.executePython(py);
32
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
33
- const m = out.match(/RESULT:({.*})/);
34
- if (m) { try { const parsed = JSON.parse(m[1].replace(/'/g, '"')); if (parsed.success) return { success: true, message: 'Streaming level added' }; } catch {} }
68
+ const response = await this.bridge.executePython(python);
69
+ const interpreted = interpretStandardResult(response, {
70
+ successMessage: 'Streaming level added',
71
+ failureMessage: 'Failed to add streaming level'
72
+ });
73
+
74
+ if (interpreted.success) {
75
+ const result: Record<string, unknown> = {
76
+ success: true,
77
+ message: interpreted.message
78
+ };
79
+ if (interpreted.warnings?.length) {
80
+ result.warnings = interpreted.warnings;
81
+ }
82
+ if (interpreted.details?.length) {
83
+ result.details = interpreted.details;
84
+ }
85
+ return result;
86
+ }
35
87
  } catch {}
36
- // Fallback to console
88
+
37
89
  return this.bridge.executeConsoleCommand(`LoadStreamLevel ${params.levelPath}`);
38
90
  } else {
39
- // Use LevelEditorSubsystem.load_level() to avoid crashes
40
- // This properly handles the WorldContext and avoids the assertion failure
41
91
  const python = `
42
92
  import unreal
93
+ import json
94
+
95
+ result = {
96
+ "success": False,
97
+ "message": "",
98
+ "error": "",
99
+ "warnings": [],
100
+ "details": [],
101
+ "level": r"${params.levelPath}"
102
+ }
103
+
43
104
  try:
44
- # Use LevelEditorSubsystem which properly handles WorldContext
105
+ level_path = r"${params.levelPath}"
106
+ asset_path = level_path
107
+ try:
108
+ tail = asset_path.rsplit('/', 1)[-1]
109
+ if '.' not in tail:
110
+ asset_path = f"{asset_path}.{tail}"
111
+ except Exception:
112
+ pass
113
+
114
+ asset_exists = False
115
+ try:
116
+ asset_exists = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
117
+ except Exception:
118
+ asset_exists = False
119
+
120
+ if not asset_exists:
121
+ result["error"] = f"Level not found: {asset_path}"
122
+ else:
45
123
  les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
46
124
  if les:
47
- # load_level closes the current level and opens a new one
48
- # This is the proper way to switch levels in the editor
49
- success = les.load_level(r"${params.levelPath}")
50
- if success:
51
- print('RESULT:{"success": true, "message": "Level loaded successfully"}')
52
- else:
53
- print('RESULT:{"success": false, "error": "Failed to load level"}')
125
+ success = les.load_level(level_path)
126
+ if success:
127
+ result["success"] = True
128
+ result["message"] = "Level loaded successfully"
129
+ result["details"].append("Level loaded via LevelEditorSubsystem")
130
+ else:
131
+ result["error"] = "Failed to load level"
54
132
  else:
55
- print('RESULT:{"success": false, "error": "LevelEditorSubsystem not available"}')
56
- except Exception as e:
57
- print('RESULT:{"success": false, "error": "' + str(e).replace('"','\\"'
133
+ result["error"] = "LevelEditorSubsystem not available"
134
+ except Exception as err:
135
+ result["error"] = f"Failed to load level: {err}"
136
+
137
+ if result["success"]:
138
+ if not result["message"]:
139
+ result["message"] = "Level loaded successfully"
140
+ else:
141
+ if not result["error"]:
142
+ result["error"] = "Failed to load level"
143
+ if not result["message"]:
144
+ result["message"] = result["error"]
145
+
146
+ if not result["warnings"]:
147
+ result.pop("warnings")
148
+ if not result["details"]:
149
+ result.pop("details")
150
+ if result.get("error") is None:
151
+ result.pop("error")
152
+
153
+ print("RESULT:" + json.dumps(result))
58
154
  `.trim();
59
-
155
+
60
156
  try {
61
- const resp = await this.bridge.executePython(python);
62
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
63
- const m = out.match(/RESULT:({.*})/);
64
- if (m) {
65
- try {
66
- const parsed = JSON.parse(m[1]);
67
- return parsed.success
68
- ? { success: true, message: parsed.message || `Level ${params.levelPath} loaded` }
69
- : { success: false, error: parsed.error || 'Failed to load level' };
70
- } catch {}
157
+ const response = await this.bridge.executePython(python);
158
+ const interpreted = interpretStandardResult(response, {
159
+ successMessage: `Level ${params.levelPath} loaded`,
160
+ failureMessage: `Failed to load level ${params.levelPath}`
161
+ });
162
+ const payloadLevel = coerceString(interpreted.payload.level) ?? params.levelPath;
163
+
164
+ if (interpreted.success) {
165
+ const result: Record<string, unknown> = {
166
+ success: true,
167
+ message: interpreted.message,
168
+ level: payloadLevel
169
+ };
170
+ if (interpreted.warnings?.length) {
171
+ result.warnings = interpreted.warnings;
172
+ }
173
+ if (interpreted.details?.length) {
174
+ result.details = interpreted.details;
175
+ }
176
+ return result;
177
+ }
178
+
179
+ const failure: Record<string, unknown> = {
180
+ success: false,
181
+ error: interpreted.error || interpreted.message,
182
+ level: payloadLevel
183
+ };
184
+ if (interpreted.warnings?.length) {
185
+ failure.warnings = interpreted.warnings;
71
186
  }
72
- // If we get here but no error was thrown, assume success
73
- return { success: true, message: `Level ${params.levelPath} loaded` };
187
+ if (interpreted.details?.length) {
188
+ failure.details = interpreted.details;
189
+ }
190
+ return failure;
74
191
  } catch (e) {
75
192
  return { success: false, error: `Failed to load level: ${e}` };
76
193
  }
@@ -82,70 +199,193 @@ except Exception as e:
82
199
  levelName?: string;
83
200
  savePath?: string;
84
201
  }) {
85
- // Use Python EditorLevelLibrary.save_current_level for reliability
86
202
  const python = `
87
203
  import unreal
204
+ import json
205
+
206
+ result = {
207
+ "success": False,
208
+ "message": "",
209
+ "error": "",
210
+ "warnings": [],
211
+ "details": [],
212
+ "skipped": False,
213
+ "reason": ""
214
+ }
215
+
216
+ def print_result(payload):
217
+ data = dict(payload)
218
+ if data.get("skipped") and not data.get("message"):
219
+ data["message"] = data.get("reason") or "Level save skipped"
220
+ if data.get("success") and not data.get("message"):
221
+ data["message"] = "Level saved"
222
+ if not data.get("success"):
223
+ if not data.get("error"):
224
+ data["error"] = data.get("message") or "Failed to save level"
225
+ if not data.get("message"):
226
+ data["message"] = data.get("error") or "Failed to save level"
227
+ if data.get("success"):
228
+ data.pop("error", None)
229
+ if not data.get("warnings"):
230
+ data.pop("warnings", None)
231
+ if not data.get("details"):
232
+ data.pop("details", None)
233
+ if not data.get("skipped"):
234
+ data.pop("skipped", None)
235
+ data.pop("reason", None)
236
+ else:
237
+ if not data.get("reason"):
238
+ data.pop("reason", None)
239
+ print("RESULT:" + json.dumps(data))
240
+
88
241
  try:
89
- # Attempt to reduce source control prompts (best-effort, may be a no-op depending on UE version)
242
+ # Attempt to reduce source control prompts (best-effort, may be a no-op depending on UE version)
243
+ try:
244
+ prefs = unreal.SourceControlPreferences()
245
+ muted = False
90
246
  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
247
+ prefs.set_enable_source_control(False)
248
+ muted = True
99
249
  except Exception:
100
- pass
250
+ try:
251
+ prefs.enable_source_control = False
252
+ muted = True
253
+ except Exception:
254
+ muted = False
255
+ if muted:
256
+ result["details"].append("Source control prompts disabled")
257
+ except Exception:
258
+ pass
101
259
 
102
- # Determine if level is dirty and save via LevelEditorSubsystem when possible
260
+ # Determine if level is dirty and save via LevelEditorSubsystem when possible
261
+ world = None
262
+ try:
263
+ world = unreal.EditorSubsystemLibrary.get_editor_world()
264
+ except Exception:
103
265
  try:
104
- world = None
266
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
267
+ world = ues.get_editor_world() if ues else None
268
+ except Exception:
269
+ world = None
270
+
271
+ pkg_path = None
272
+ try:
273
+ if world is not None:
274
+ full = world.get_path_name()
275
+ pkg_path = full.split('.')[0] if '.' in full else full
276
+ if pkg_path:
277
+ result["details"].append(f"Detected level package: {pkg_path}")
278
+ except Exception:
279
+ pkg_path = None
280
+
281
+ skip_save = False
282
+ try:
283
+ is_dirty = None
284
+ if pkg_path:
285
+ editor_asset_lib = getattr(unreal, 'EditorAssetLibrary', None)
286
+ if editor_asset_lib and hasattr(editor_asset_lib, 'is_asset_dirty'):
105
287
  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
288
+ is_dirty = editor_asset_lib.is_asset_dirty(pkg_path)
289
+ except Exception as check_error:
290
+ result["warnings"].append(f"EditorAssetLibrary.is_asset_dirty failed: {check_error}")
291
+ is_dirty = None
292
+ if is_dirty is None:
293
+ # Fallback: attempt to inspect the current level package
113
294
  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
295
+ ell = getattr(unreal, 'EditorLevelLibrary', None)
296
+ level = ell.get_current_level() if ell and hasattr(ell, 'get_current_level') else None
297
+ package = level.get_outermost() if level and hasattr(level, 'get_outermost') else None
298
+ if package and hasattr(package, 'is_dirty'):
299
+ is_dirty = package.is_dirty()
300
+ except Exception as fallback_error:
301
+ result["warnings"].append(f"Fallback dirty check failed: {fallback_error}")
302
+ if is_dirty is False:
303
+ result["success"] = True
304
+ result["skipped"] = True
305
+ result["reason"] = "Level not dirty"
306
+ result["message"] = "Level save skipped"
307
+ skip_save = True
308
+ elif is_dirty is None and pkg_path:
309
+ result["warnings"].append("Unable to determine level dirty state; attempting save anyway")
310
+ except Exception as dirty_error:
311
+ result["warnings"].append(f"Failed to check level dirty state: {dirty_error}")
124
312
 
125
- # Save using LevelEditorSubsystem to avoid deprecation
313
+ if not skip_save:
126
314
  saved = False
127
315
  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
316
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
317
+ if les:
318
+ les.save_current_level()
319
+ saved = True
320
+ result["details"].append("Level saved via LevelEditorSubsystem")
321
+ except Exception as save_error:
322
+ result["error"] = f"Level save failed: {save_error}"
323
+ saved = False
324
+
134
325
  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('"','\\"') + '"}')
326
+ raise Exception('LevelEditorSubsystem not available')
327
+
328
+ result["success"] = True
329
+ if not result["message"]:
330
+ result["message"] = "Level saved"
331
+ except Exception as err:
332
+ result["error"] = str(err)
333
+
334
+ print_result(result)
140
335
  `.trim();
336
+
141
337
  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 { const parsed = JSON.parse(m[1]); return parsed.success ? { success: true, message: 'Level saved' } : { success: false, error: parsed.error }; } catch {}
338
+ const response = await this.bridge.executePython(python);
339
+ const interpreted = interpretStandardResult(response, {
340
+ successMessage: 'Level saved',
341
+ failureMessage: 'Failed to save level'
342
+ });
343
+
344
+ if (interpreted.success) {
345
+ const result: Record<string, unknown> = {
346
+ success: true,
347
+ message: interpreted.message
348
+ };
349
+ const skipped = coerceBoolean(interpreted.payload.skipped);
350
+ if (typeof skipped === 'boolean') {
351
+ result.skipped = skipped;
352
+ }
353
+ const reason = coerceString(interpreted.payload.reason);
354
+ if (reason) {
355
+ result.reason = reason;
356
+ }
357
+ if (interpreted.warnings?.length) {
358
+ result.warnings = interpreted.warnings;
359
+ }
360
+ if (interpreted.details?.length) {
361
+ result.details = interpreted.details;
362
+ }
363
+ return result;
147
364
  }
148
- return { success: true, message: 'Level saved' };
365
+
366
+ const failure: Record<string, unknown> = {
367
+ success: false,
368
+ error: interpreted.error || interpreted.message
369
+ };
370
+ if (interpreted.message && interpreted.message !== failure.error) {
371
+ failure.message = interpreted.message;
372
+ }
373
+ const skippedFailure = coerceBoolean(interpreted.payload.skipped);
374
+ if (typeof skippedFailure === 'boolean') {
375
+ failure.skipped = skippedFailure;
376
+ }
377
+ const failureReason = coerceString(interpreted.payload.reason);
378
+ if (failureReason) {
379
+ failure.reason = failureReason;
380
+ }
381
+ if (interpreted.warnings?.length) {
382
+ failure.warnings = interpreted.warnings;
383
+ }
384
+ if (interpreted.details?.length) {
385
+ failure.details = interpreted.details;
386
+ }
387
+
388
+ return failure;
149
389
  } catch (e) {
150
390
  return { success: false, error: `Failed to save level: ${e}` };
151
391
  }
@@ -160,24 +400,91 @@ except Exception as e:
160
400
  const basePath = params.savePath || '/Game/Maps';
161
401
  const isPartitioned = true; // default to World Partition for UE5
162
402
  const fullPath = `${basePath}/${params.levelName}`;
163
- const py = `
403
+ const python = `
164
404
  import unreal
405
+ import json
406
+
407
+ result = {
408
+ "success": False,
409
+ "message": "",
410
+ "error": "",
411
+ "warnings": [],
412
+ "details": [],
413
+ "path": r"${fullPath}",
414
+ "partitioned": ${isPartitioned ? 'True' : 'False'}
415
+ }
416
+
165
417
  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) + '"}')
418
+ les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
419
+ if les:
420
+ les.new_level(r"${fullPath}", ${isPartitioned ? 'True' : 'False'})
421
+ result["success"] = True
422
+ result["message"] = "Level created"
423
+ result["details"].append("Level created via LevelEditorSubsystem.new_level")
424
+ else:
425
+ result["error"] = "LevelEditorSubsystem not available"
426
+ except Exception as err:
427
+ result["error"] = f"Level creation failed: {err}"
428
+
429
+ if result["success"]:
430
+ if not result["message"]:
431
+ result["message"] = "Level created"
432
+ else:
433
+ if not result["error"]:
434
+ result["error"] = "Failed to create level"
435
+ if not result["message"]:
436
+ result["message"] = result["error"]
437
+
438
+ if not result["warnings"]:
439
+ result.pop("warnings")
440
+ if not result["details"]:
441
+ result.pop("details")
442
+ if result.get("error") is None:
443
+ result.pop("error")
444
+
445
+ print("RESULT:" + json.dumps(result))
174
446
  `.trim();
447
+
175
448
  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) { try { const parsed = JSON.parse(m[1].replace(/'/g, '"')); return parsed.success ? { success: true, message: parsed.message } : { success: false, error: parsed.error }; } catch {} }
180
- return { success: true, message: 'Level creation attempted' };
449
+ const response = await this.bridge.executePython(python);
450
+ const interpreted = interpretStandardResult(response, {
451
+ successMessage: 'Level created',
452
+ failureMessage: 'Failed to create level'
453
+ });
454
+
455
+ const path = coerceString(interpreted.payload.path) ?? fullPath;
456
+ const partitioned = coerceBoolean(interpreted.payload.partitioned, isPartitioned) ?? isPartitioned;
457
+
458
+ if (interpreted.success) {
459
+ const result: Record<string, unknown> = {
460
+ success: true,
461
+ message: interpreted.message,
462
+ path,
463
+ partitioned
464
+ };
465
+ if (interpreted.warnings?.length) {
466
+ result.warnings = interpreted.warnings;
467
+ }
468
+ if (interpreted.details?.length) {
469
+ result.details = interpreted.details;
470
+ }
471
+ return result;
472
+ }
473
+
474
+ const failure: Record<string, unknown> = {
475
+ success: false,
476
+ error: interpreted.error || interpreted.message,
477
+ path,
478
+ partitioned
479
+ };
480
+ if (interpreted.warnings?.length) {
481
+ failure.warnings = interpreted.warnings;
482
+ }
483
+ if (interpreted.details?.length) {
484
+ failure.details = interpreted.details;
485
+ }
486
+
487
+ return failure;
181
488
  } catch (e) {
182
489
  return { success: false, error: `Failed to create level: ${e}` };
183
490
  }
@@ -190,18 +497,157 @@ except Exception as e:
190
497
  shouldBeVisible: boolean;
191
498
  position?: [number, number, number];
192
499
  }) {
193
- 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();
500
+ const python = `
501
+ import unreal
502
+ import json
503
+
504
+ result = {
505
+ "success": False,
506
+ "message": "",
507
+ "error": "",
508
+ "warnings": [],
509
+ "details": [],
510
+ "level": "${params.levelName}",
511
+ "loaded": ${params.shouldBeLoaded ? 'True' : 'False'},
512
+ "visible": ${params.shouldBeVisible ? 'True' : 'False'}
513
+ }
514
+
515
+ try:
516
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
517
+ world = ues.get_editor_world() if ues else None
518
+ if world:
519
+ updated = False
520
+ streaming_levels = []
521
+ try:
522
+ if hasattr(world, 'get_streaming_levels'):
523
+ streaming_levels = list(world.get_streaming_levels() or [])
524
+ except Exception as primary_error:
525
+ result["warnings"].append(f"get_streaming_levels unavailable: {primary_error}")
526
+
527
+ if not streaming_levels:
528
+ try:
529
+ if hasattr(world, 'get_level_streaming_levels'):
530
+ streaming_levels = list(world.get_level_streaming_levels() or [])
531
+ except Exception as alt_error:
532
+ result["warnings"].append(f"get_level_streaming_levels unavailable: {alt_error}")
533
+
534
+ if not streaming_levels:
535
+ try:
536
+ fallback_levels = getattr(world, 'streaming_levels', None)
537
+ if fallback_levels is not None:
538
+ streaming_levels = list(fallback_levels)
539
+ except Exception as attr_error:
540
+ result["warnings"].append(f"streaming_levels attribute unavailable: {attr_error}")
541
+
542
+ if not streaming_levels:
543
+ result["error"] = "Streaming levels unavailable"
544
+ else:
545
+ for streaming_level in streaming_levels:
546
+ try:
547
+ name = None
548
+ if hasattr(streaming_level, 'get_world_asset_package_name'):
549
+ name = streaming_level.get_world_asset_package_name()
550
+ if not name:
551
+ try:
552
+ name = str(streaming_level.get_editor_property('world_asset'))
553
+ except Exception:
554
+ name = None
555
+
556
+ if name and name.endswith('/${params.levelName}'):
557
+ try:
558
+ streaming_level.set_should_be_loaded(${params.shouldBeLoaded ? 'True' : 'False'})
559
+ except Exception as load_error:
560
+ result["warnings"].append(f"Failed to set loaded flag: {load_error}")
561
+ try:
562
+ streaming_level.set_should_be_visible(${params.shouldBeVisible ? 'True' : 'False'})
563
+ except Exception as visible_error:
564
+ result["warnings"].append(f"Failed to set visibility: {visible_error}")
565
+ updated = True
566
+ break
567
+ except Exception as iteration_error:
568
+ result["warnings"].append(f"Streaming level iteration error: {iteration_error}")
569
+
570
+ if updated:
571
+ result["success"] = True
572
+ result["message"] = "Streaming level updated"
573
+ result["details"].append("Streaming level flags updated for editor world")
574
+ else:
575
+ result["error"] = "Streaming level not found"
576
+ else:
577
+ result["error"] = "No editor world available"
578
+ except Exception as err:
579
+ result["error"] = f"Streaming level update failed: {err}"
580
+
581
+ if result["success"]:
582
+ if not result["message"]:
583
+ result["message"] = "Streaming level updated"
584
+ else:
585
+ if not result["error"]:
586
+ result["error"] = "Streaming level update failed"
587
+ if not result["message"]:
588
+ result["message"] = result["error"]
589
+
590
+ if not result["warnings"]:
591
+ result.pop("warnings")
592
+ if not result["details"]:
593
+ result.pop("details")
594
+ if result.get("error") is None:
595
+ result.pop("error")
596
+
597
+ print("RESULT:" + json.dumps(result))
598
+ `.trim();
599
+
194
600
  try {
195
- const resp = await this.bridge.executePython(py);
196
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
197
- const m = out.match(/RESULT:({.*})/);
198
- if (m) { try { const parsed = JSON.parse(m[1].replace(/'/g, '"')); if (parsed.success) return { success: true, message: 'Streaming level updated' }; } catch {} }
199
- } catch {}
200
- // Fallback
201
- const loadCmd = params.shouldBeLoaded ? 'Load' : 'Unload';
202
- const visCmd = params.shouldBeVisible ? 'Show' : 'Hide';
203
- const command = `StreamLevel ${params.levelName} ${loadCmd} ${visCmd}`;
204
- return this.bridge.executeConsoleCommand(command);
601
+ const response = await this.bridge.executePython(python);
602
+ const interpreted = interpretStandardResult(response, {
603
+ successMessage: 'Streaming level updated',
604
+ failureMessage: 'Streaming level update failed'
605
+ });
606
+
607
+ const levelName = coerceString(interpreted.payload.level) ?? params.levelName;
608
+ const loaded = coerceBoolean(interpreted.payload.loaded, params.shouldBeLoaded) ?? params.shouldBeLoaded;
609
+ const visible = coerceBoolean(interpreted.payload.visible, params.shouldBeVisible) ?? params.shouldBeVisible;
610
+
611
+ if (interpreted.success) {
612
+ const result: Record<string, unknown> = {
613
+ success: true,
614
+ message: interpreted.message,
615
+ level: levelName,
616
+ loaded,
617
+ visible
618
+ };
619
+ if (interpreted.warnings?.length) {
620
+ result.warnings = interpreted.warnings;
621
+ }
622
+ if (interpreted.details?.length) {
623
+ result.details = interpreted.details;
624
+ }
625
+ return result;
626
+ }
627
+
628
+ const failure: Record<string, unknown> = {
629
+ success: false,
630
+ error: interpreted.error || interpreted.message || 'Streaming level update failed',
631
+ level: levelName,
632
+ loaded,
633
+ visible
634
+ };
635
+ if (interpreted.message && interpreted.message !== failure.error) {
636
+ failure.message = interpreted.message;
637
+ }
638
+ if (interpreted.warnings?.length) {
639
+ failure.warnings = interpreted.warnings;
640
+ }
641
+ if (interpreted.details?.length) {
642
+ failure.details = interpreted.details;
643
+ }
644
+ return failure;
645
+ } catch {
646
+ const loadCmd = params.shouldBeLoaded ? 'Load' : 'Unload';
647
+ const visCmd = params.shouldBeVisible ? 'Show' : 'Hide';
648
+ const command = `StreamLevel ${params.levelName} ${loadCmd} ${visCmd}`;
649
+ return this.bridge.executeConsoleCommand(command);
650
+ }
205
651
  }
206
652
 
207
653
  // World composition
@@ -211,7 +657,7 @@ except Exception as e:
211
657
  distanceStreaming?: boolean;
212
658
  streamingDistance?: number;
213
659
  }) {
214
- const commands = [];
660
+ const commands: string[] = [];
215
661
 
216
662
  if (params.enableComposition) {
217
663
  commands.push('EnableWorldComposition');
@@ -225,9 +671,7 @@ except Exception as e:
225
671
  commands.push('DisableWorldComposition');
226
672
  }
227
673
 
228
- for (const cmd of commands) {
229
- await this.bridge.executeConsoleCommand(cmd);
230
- }
674
+ await this.bridge.executeConsoleCommands(commands);
231
675
 
232
676
  return { success: true, message: 'World composition configured' };
233
677
  }
@@ -264,7 +708,7 @@ except Exception as e:
264
708
  defaultPawn?: string;
265
709
  killZ?: number;
266
710
  }) {
267
- const commands = [];
711
+ const commands: string[] = [];
268
712
 
269
713
  if (params.gravity !== undefined) {
270
714
  commands.push(`SetWorldGravity ${params.gravity}`);
@@ -282,9 +726,7 @@ except Exception as e:
282
726
  commands.push(`SetKillZ ${params.killZ}`);
283
727
  }
284
728
 
285
- for (const cmd of commands) {
286
- await this.bridge.executeConsoleCommand(cmd);
287
- }
729
+ await this.bridge.executeConsoleCommands(commands);
288
730
 
289
731
  return { success: true, message: 'World settings updated' };
290
732
  }
@@ -303,68 +745,126 @@ except Exception as e:
303
745
  rebuildAll?: boolean;
304
746
  selectedOnly?: boolean;
305
747
  }) {
306
- // Use Python API for safer navigation mesh building to avoid crashes
307
- const py = `
748
+ const python = `
308
749
  import unreal
309
750
  import json
751
+
752
+ result = {
753
+ "success": False,
754
+ "message": "",
755
+ "error": "",
756
+ "warnings": [],
757
+ "details": [],
758
+ "rebuildAll": ${params.rebuildAll ? 'True' : 'False'},
759
+ "selectedOnly": ${params.selectedOnly ? 'True' : 'False'},
760
+ "selectionCount": 0
761
+ }
762
+
310
763
  try:
311
- # Check if navigation system exists first
312
- nav_system = unreal.EditorSubsystemLibrary.get_editor_subsystem(unreal.NavigationSystemV1)
313
- if not nav_system:
314
- # Try alternative method
315
- # Try to get world via UnrealEditorSubsystem
316
- ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
317
- world = ues.get_editor_world() if ues else None
318
- nav_system = unreal.NavigationSystemV1.get_navigation_system(world) if world else None
319
-
320
- if nav_system:
321
- # Use the safe Python API method instead of console commands
322
- if ${params.rebuildAll ? 'True' : 'False'}:
323
- # Rebuild all navigation
324
- nav_system.navigation_build_async()
325
- print('RESULT:' + json.dumps({'success': True, 'message': 'Navigation rebuild started'}))
326
- else:
327
- # Update navigation for selected actors only
328
- # Use EditorActorSubsystem to get selected actors
329
- actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
330
- selected_actors = actor_subsystem.get_selected_level_actors() if actor_subsystem else []
331
- if selected_actors:
332
- for actor in selected_actors:
333
- nav_system.update_nav_octree(actor)
334
- print('RESULT:' + json.dumps({'success': True, 'message': f'Navigation updated for {len(selected_actors)} actors'}))
335
- else:
336
- # If nothing selected, do a safe incremental update
337
- nav_system.update(0.0)
338
- print('RESULT:' + json.dumps({'success': True, 'message': 'Navigation incremental update performed'}))
764
+ nav_system = unreal.EditorSubsystemLibrary.get_editor_subsystem(unreal.NavigationSystemV1)
765
+ if not nav_system:
766
+ ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
767
+ world = ues.get_editor_world() if ues else None
768
+ nav_system = unreal.NavigationSystemV1.get_navigation_system(world) if world else None
769
+
770
+ if nav_system:
771
+ if ${params.rebuildAll ? 'True' : 'False'}:
772
+ nav_system.navigation_build_async()
773
+ result["success"] = True
774
+ result["message"] = "Navigation rebuild started"
775
+ result["details"].append("Triggered full navigation rebuild")
339
776
  else:
340
- # Navigation system not available - likely no nav mesh in level
341
- print('RESULT:' + json.dumps({'success': False, 'error': 'Navigation system not available. Add a NavMeshBoundsVolume to the level first.'}))
342
- except AttributeError as e:
343
- # Some methods might not be available in all UE versions
344
- print('RESULT:' + json.dumps({'success': False, 'error': f'Navigation API not available: {str(e)}'}))
345
- except Exception as e:
346
- print('RESULT:' + json.dumps({'success': False, 'error': f'Navigation build failed: {str(e)}'}))
777
+ actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
778
+ selected_actors = actor_subsystem.get_selected_level_actors() if actor_subsystem else []
779
+ result["selectionCount"] = len(selected_actors) if selected_actors else 0
780
+
781
+ if ${params.selectedOnly ? 'True' : 'False'} and selected_actors:
782
+ for actor in selected_actors:
783
+ nav_system.update_nav_octree(actor)
784
+ result["success"] = True
785
+ result["message"] = f"Navigation updated for {len(selected_actors)} actors"
786
+ result["details"].append("Updated nav octree for selected actors")
787
+ elif selected_actors:
788
+ for actor in selected_actors:
789
+ nav_system.update_nav_octree(actor)
790
+ nav_system.update(0.0)
791
+ result["success"] = True
792
+ result["message"] = f"Navigation updated for {len(selected_actors)} actors"
793
+ result["details"].append("Updated nav octree and performed incremental update")
794
+ else:
795
+ nav_system.update(0.0)
796
+ result["success"] = True
797
+ result["message"] = "Navigation incremental update performed"
798
+ result["details"].append("No selected actors; performed incremental update")
799
+ else:
800
+ result["error"] = "Navigation system not available. Add a NavMeshBoundsVolume to the level first."
801
+ except AttributeError as attr_error:
802
+ result["error"] = f"Navigation API not available: {attr_error}"
803
+ except Exception as err:
804
+ result["error"] = f"Navigation build failed: {err}"
805
+
806
+ if result["success"]:
807
+ if not result["message"]:
808
+ result["message"] = "Navigation build started"
809
+ else:
810
+ if not result["error"]:
811
+ result["error"] = result["message"] or "Navigation build failed"
812
+ if not result["message"]:
813
+ result["message"] = result["error"]
814
+
815
+ if not result["warnings"]:
816
+ result.pop("warnings")
817
+ if not result["details"]:
818
+ result.pop("details")
819
+ if result.get("error") is None:
820
+ result.pop("error")
821
+
822
+ if not result.get("selectionCount"):
823
+ result.pop("selectionCount", None)
824
+
825
+ print("RESULT:" + json.dumps(result))
347
826
  `.trim();
348
-
827
+
349
828
  try {
350
- const resp = await this.bridge.executePython(py);
351
- const out = typeof resp === 'string' ? resp : JSON.stringify(resp);
352
- const m = out.match(/RESULT:({.*})/);
353
- if (m) {
354
- try {
355
- const parsed = JSON.parse(m[1]);
356
- return parsed.success
357
- ? { success: true, message: parsed.message }
358
- : { success: false, error: parsed.error };
359
- } catch {}
829
+ const response = await this.bridge.executePython(python);
830
+ const interpreted = interpretStandardResult(response, {
831
+ successMessage: params.rebuildAll ? 'Navigation rebuild started' : 'Navigation update started',
832
+ failureMessage: 'Navigation build failed'
833
+ });
834
+
835
+ const result: Record<string, unknown> = interpreted.success
836
+ ? { success: true, message: interpreted.message }
837
+ : { success: false, error: interpreted.error || interpreted.message };
838
+
839
+ const rebuildAll = coerceBoolean(interpreted.payload.rebuildAll, params.rebuildAll);
840
+ const selectedOnly = coerceBoolean(interpreted.payload.selectedOnly, params.selectedOnly);
841
+ if (typeof rebuildAll === 'boolean') {
842
+ result.rebuildAll = rebuildAll;
843
+ } else if (typeof params.rebuildAll === 'boolean') {
844
+ result.rebuildAll = params.rebuildAll;
845
+ }
846
+ if (typeof selectedOnly === 'boolean') {
847
+ result.selectedOnly = selectedOnly;
848
+ } else if (typeof params.selectedOnly === 'boolean') {
849
+ result.selectedOnly = params.selectedOnly;
360
850
  }
361
-
362
- // Fallback message if no clear result
363
- return { success: true, message: 'Navigation mesh build attempted' };
851
+
852
+ const selectionCount = coerceNumber(interpreted.payload.selectionCount);
853
+ if (typeof selectionCount === 'number') {
854
+ result.selectionCount = selectionCount;
855
+ }
856
+
857
+ if (interpreted.warnings?.length) {
858
+ result.warnings = interpreted.warnings;
859
+ }
860
+ if (interpreted.details?.length) {
861
+ result.details = interpreted.details;
862
+ }
863
+
864
+ return result;
364
865
  } catch (e) {
365
- // If Python fails, return error instead of trying console command that crashes
366
- return {
367
- success: false,
866
+ return {
867
+ success: false,
368
868
  error: `Navigation build not available: ${e}. Please ensure a NavMeshBoundsVolume exists in the level.`
369
869
  };
370
870
  }
@@ -407,4 +907,5 @@ except Exception as e:
407
907
  const command = `SetLevelLOD ${params.levelName} ${params.lodLevel} ${params.distance}`;
408
908
  return this.bridge.executeConsoleCommand(command);
409
909
  }
910
+
410
911
  }