@mflrevan/ucp 0.2.3 → 0.3.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.2.3` of the Unity Control Protocol CLI.
3
+ Version `0.3.1` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -42,12 +42,14 @@ ucp build targets
42
42
  ucp install
43
43
  ```
44
44
 
45
+ Default `ucp install` writes a tracked git dependency to `Packages/manifest.json` pinned to the CLI version. It does not write a local `file:` dependency unless you explicitly choose a local embedded mode.
46
+
45
47
  Or add this to `Packages/manifest.json`:
46
48
 
47
49
  ```json
48
50
  {
49
51
  "dependencies": {
50
- "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.2.3"
52
+ "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.3.1"
51
53
  }
52
54
  }
53
55
  ```
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-03-14
4
+
5
+ ### Added
6
+
7
+ - Added `asset/write-batch` for multi-field serialized asset updates in one bridge call.
8
+
9
+ ### Changed
10
+
11
+ - Player settings now expose `defaultIsNativeResolution` so installer automation can reconcile live editor state as well as on-disk project settings.
12
+ - Object reference payloads now include asset `path` and `guid` when available.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed buffered log searches by applying regex filtering before count truncation.
17
+ - Fixed buffered log list requests being capped to 10 returned entries regardless of requested `count`.
18
+ - Fixed serialized object reference writes silently accepting unresolved references in both object and asset controllers.
19
+
20
+ ## [0.3.0] - 2026-03-13
21
+
22
+ ### Added
23
+
24
+ - Dirty-scene automation controls in bridge scene/play handlers so unattended CLI workflows can avoid save-confirmation modal interruptions.
25
+
26
+ ### Changed
27
+
28
+ - Scene load and play entry now auto-handle dirty scenes by default for non-interactive automation flows.
29
+
30
+ ### Fixed
31
+
32
+ - Fixed edit-mode test execution when called during Play Mode by deferring run start until Play Mode exits.
33
+ - Fixed workflow interruptions caused by Unity save-scene prompts during scene transitions and play mode entry.
34
+
3
35
  ## [0.2.3] - 2026-03-12
4
36
 
5
37
  ### Fixed
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.2.3";
29
+ private const string ProtocolVersion = "0.3.1";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -14,6 +14,7 @@ namespace UCP.Bridge
14
14
  router.Register("asset/info", HandleInfo);
15
15
  router.Register("asset/read", HandleReadScriptableObject);
16
16
  router.Register("asset/write", HandleWriteScriptableObject);
17
+ router.Register("asset/write-batch", HandleWriteScriptableObjectBatch);
17
18
  router.Register("asset/create-so", HandleCreateScriptableObject);
18
19
  }
19
20
 
@@ -207,15 +208,44 @@ namespace UCP.Bridge
207
208
 
208
209
  string fieldName = fieldObj.ToString();
209
210
  var so = new SerializedObject(asset);
210
- var prop = so.FindProperty(fieldName);
211
- if (prop == null)
211
+ Undo.RecordObject(asset, $"UCP Write {fieldName}");
212
+ WriteFieldValue(so, asset, fieldName, p["value"]);
213
+ so.ApplyModifiedProperties();
214
+ EditorUtility.SetDirty(asset);
215
+ AssetDatabase.SaveAssetIfDirty(asset);
216
+ so.Dispose();
217
+
218
+ return new Dictionary<string, object>
212
219
  {
213
- so.Dispose();
214
- throw new ArgumentException($"Field '{fieldName}' not found on {asset.GetType().Name}");
220
+ ["status"] = "ok",
221
+ ["path"] = assetPath,
222
+ ["field"] = fieldName
223
+ };
224
+ }
225
+
226
+ private static object HandleWriteScriptableObjectBatch(string paramsJson)
227
+ {
228
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
229
+ if (p == null || !p.TryGetValue("path", out var pathObj))
230
+ throw new ArgumentException("Missing 'path' parameter");
231
+ if (!p.TryGetValue("values", out var valuesObj) || !(valuesObj is Dictionary<string, object> values))
232
+ throw new ArgumentException("Missing 'values' parameter");
233
+
234
+ string assetPath = pathObj.ToString();
235
+ var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
236
+ if (asset == null)
237
+ throw new ArgumentException($"Asset not found: {assetPath}");
238
+
239
+ var so = new SerializedObject(asset);
240
+ Undo.RecordObject(asset, $"UCP Batch Write {asset.name}");
241
+
242
+ var fields = new List<object>();
243
+ foreach (var entry in values)
244
+ {
245
+ WriteFieldValue(so, asset, entry.Key, entry.Value);
246
+ fields.Add(entry.Key);
215
247
  }
216
248
 
217
- Undo.RecordObject(asset, $"UCP Write {fieldName}");
218
- WriteSerializedValue(prop, p["value"]);
219
249
  so.ApplyModifiedProperties();
220
250
  EditorUtility.SetDirty(asset);
221
251
  AssetDatabase.SaveAssetIfDirty(asset);
@@ -225,7 +255,7 @@ namespace UCP.Bridge
225
255
  {
226
256
  ["status"] = "ok",
227
257
  ["path"] = assetPath,
228
- ["field"] = fieldName
258
+ ["fields"] = fields
229
259
  };
230
260
  }
231
261
 
@@ -381,14 +411,7 @@ namespace UCP.Bridge
381
411
  var c = prop.colorValue;
382
412
  return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
383
413
  case SerializedPropertyType.ObjectReference:
384
- if (prop.objectReferenceValue != null)
385
- return new Dictionary<string, object>
386
- {
387
- ["instanceId"] = prop.objectReferenceValue.GetInstanceID(),
388
- ["name"] = prop.objectReferenceValue.name,
389
- ["type"] = prop.objectReferenceValue.GetType().Name
390
- };
391
- return null;
414
+ return ObjectReferenceResolver.Serialize(prop.objectReferenceValue);
392
415
  case SerializedPropertyType.Enum:
393
416
  return prop.enumValueIndex < prop.enumDisplayNames.Length
394
417
  ? prop.enumDisplayNames[prop.enumValueIndex]
@@ -483,10 +506,9 @@ namespace UCP.Bridge
483
506
  prop.quaternionValue = new Quaternion(Convert.ToSingle(qArr[0]), Convert.ToSingle(qArr[1]), Convert.ToSingle(qArr[2]), Convert.ToSingle(qArr[3]));
484
507
  break;
485
508
  case SerializedPropertyType.ObjectReference:
486
- if (value == null)
487
- prop.objectReferenceValue = null;
488
- else if (value is Dictionary<string, object> refDict && refDict.TryGetValue("instanceId", out var refId))
489
- prop.objectReferenceValue = EditorUtility.InstanceIDToObject(Convert.ToInt32(refId));
509
+ prop.objectReferenceValue = ObjectReferenceResolver.Resolve(value, prop.displayName);
510
+ if (value != null && prop.objectReferenceValue == null)
511
+ throw new ArgumentException($"Unable to assign object reference to '{prop.displayName}'");
490
512
  break;
491
513
  case SerializedPropertyType.LayerMask:
492
514
  prop.intValue = Convert.ToInt32(value);
@@ -495,5 +517,14 @@ namespace UCP.Bridge
495
517
  throw new ArgumentException($"Cannot write property of type {prop.propertyType}");
496
518
  }
497
519
  }
520
+
521
+ private static void WriteFieldValue(SerializedObject serializedObject, UnityEngine.Object asset, string fieldName, object value)
522
+ {
523
+ var prop = serializedObject.FindProperty(fieldName);
524
+ if (prop == null)
525
+ throw new ArgumentException($"Field '{fieldName}' not found on {asset.GetType().Name}");
526
+
527
+ WriteSerializedValue(prop, value);
528
+ }
498
529
  }
499
530
  }
@@ -30,7 +30,7 @@ namespace UCP.Bridge
30
30
  ["companyName"] = PlayerSettings.companyName,
31
31
  ["productName"] = PlayerSettings.productName,
32
32
  ["bundleVersion"] = PlayerSettings.bundleVersion,
33
- ["defaultIsFullScreen"] = PlayerSettings.defaultIsNativeResolution,
33
+ ["defaultIsNativeResolution"] = PlayerSettings.defaultIsNativeResolution,
34
34
  ["runInBackground"] = PlayerSettings.runInBackground,
35
35
  ["colorSpace"] = PlayerSettings.colorSpace.ToString(),
36
36
  ["graphicsApi"] = PlayerSettings.GetGraphicsAPIs(EditorUserBuildSettings.activeBuildTarget)?[0].ToString() ?? "Unknown",
@@ -67,6 +67,9 @@ namespace UCP.Bridge
67
67
  case "runInBackground":
68
68
  PlayerSettings.runInBackground = Convert.ToBoolean(value);
69
69
  break;
70
+ case "defaultIsNativeResolution":
71
+ PlayerSettings.defaultIsNativeResolution = Convert.ToBoolean(value);
72
+ break;
70
73
  case "defaultScreenWidth":
71
74
  PlayerSettings.defaultScreenWidth = Convert.ToInt32(value);
72
75
  break;
@@ -9,7 +9,6 @@ namespace UCP.Bridge
9
9
  public static class LogsController
10
10
  {
11
11
  private const int MaxHistoryEntries = 2000;
12
- private const int MaxBulkResults = 10;
13
12
  private const int DefaultSearchWindow = 200;
14
13
  private const int MaxPreviewLength = 200;
15
14
 
@@ -145,8 +144,6 @@ namespace UCP.Bridge
145
144
  if (!string.IsNullOrEmpty(query.Level))
146
145
  candidates = candidates.Where(entry => PassesLevel(entry.Level, query.Level));
147
146
 
148
- candidates = candidates.OrderByDescending(entry => entry.Id).Take(query.Count);
149
-
150
147
  if (query.Regex != null)
151
148
  {
152
149
  candidates = candidates.Where(entry =>
@@ -155,14 +152,14 @@ namespace UCP.Bridge
155
152
  );
156
153
  }
157
154
 
158
- var allMatches = candidates.ToList();
159
- var returned = allMatches.Take(MaxBulkResults).Select(SerializeSummary).ToList();
155
+ var allMatches = candidates.OrderByDescending(entry => entry.Id).ToList();
156
+ var returned = allMatches.Take(query.Count).Select(SerializeSummary).ToList();
160
157
 
161
158
  return new LogQueryResult
162
159
  {
163
160
  Total = allMatches.Count,
164
161
  Returned = returned,
165
- Truncated = allMatches.Count > MaxBulkResults
162
+ Truncated = allMatches.Count > returned.Count
166
163
  };
167
164
  }
168
165
  }
@@ -0,0 +1,93 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using UnityEditor;
4
+ using UnityEngine;
5
+
6
+ namespace UCP.Bridge
7
+ {
8
+ internal static class ObjectReferenceResolver
9
+ {
10
+ public static Dictionary<string, object> Serialize(UnityEngine.Object obj)
11
+ {
12
+ if (obj == null)
13
+ return null;
14
+
15
+ var result = new Dictionary<string, object>
16
+ {
17
+ ["instanceId"] = obj.GetInstanceID(),
18
+ ["name"] = obj.name,
19
+ ["type"] = obj.GetType().Name
20
+ };
21
+
22
+ var assetPath = AssetDatabase.GetAssetPath(obj);
23
+ if (!string.IsNullOrEmpty(assetPath))
24
+ {
25
+ result["path"] = assetPath;
26
+ var guid = AssetDatabase.AssetPathToGUID(assetPath);
27
+ if (!string.IsNullOrEmpty(guid))
28
+ result["guid"] = guid;
29
+ }
30
+
31
+ return result;
32
+ }
33
+
34
+ public static UnityEngine.Object Resolve(object value, string propertyName)
35
+ {
36
+ if (value == null)
37
+ return null;
38
+
39
+ if (value is Dictionary<string, object> reference)
40
+ {
41
+ if (reference.TryGetValue("instanceId", out var instanceId) && instanceId != null)
42
+ return ResolveByInstanceId(Convert.ToInt32(instanceId), propertyName);
43
+
44
+ if (reference.TryGetValue("path", out var path) && path != null)
45
+ return ResolveByPath(path.ToString(), propertyName);
46
+
47
+ if (reference.TryGetValue("guid", out var guid) && guid != null)
48
+ return ResolveByGuid(guid.ToString(), propertyName);
49
+
50
+ throw new ArgumentException($"Object reference for '{propertyName}' must include instanceId, path, or guid");
51
+ }
52
+
53
+ if (value is string stringValue)
54
+ {
55
+ return stringValue.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
56
+ ? ResolveByPath(stringValue, propertyName)
57
+ : ResolveByGuid(stringValue, propertyName);
58
+ }
59
+
60
+ if (value is sbyte || value is byte || value is short || value is ushort || value is int || value is uint || value is long || value is ulong)
61
+ return ResolveByInstanceId(Convert.ToInt32(value), propertyName);
62
+
63
+ throw new ArgumentException($"Unsupported object reference value for '{propertyName}'");
64
+ }
65
+
66
+ private static UnityEngine.Object ResolveByInstanceId(int instanceId, string propertyName)
67
+ {
68
+ var resolved = EditorUtility.InstanceIDToObject(instanceId);
69
+ if (resolved == null)
70
+ throw new ArgumentException($"Object reference for '{propertyName}' could not resolve instance id {instanceId}");
71
+
72
+ return resolved;
73
+ }
74
+
75
+ private static UnityEngine.Object ResolveByPath(string assetPath, string propertyName)
76
+ {
77
+ var resolved = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
78
+ if (resolved == null)
79
+ throw new ArgumentException($"Object reference for '{propertyName}' could not load asset at {assetPath}");
80
+
81
+ return resolved;
82
+ }
83
+
84
+ private static UnityEngine.Object ResolveByGuid(string guid, string propertyName)
85
+ {
86
+ var assetPath = AssetDatabase.GUIDToAssetPath(guid);
87
+ if (string.IsNullOrEmpty(assetPath))
88
+ throw new ArgumentException($"Object reference for '{propertyName}' could not resolve guid {guid}");
89
+
90
+ return ResolveByPath(assetPath, propertyName);
91
+ }
92
+ }
93
+ }
@@ -1,4 +1,7 @@
1
1
  using UnityEditor;
2
+ using UnityEditor.SceneManagement;
3
+ using UnityEngine.SceneManagement;
4
+ using System.Collections.Generic;
2
5
 
3
6
  namespace UCP.Bridge
4
7
  {
@@ -16,10 +19,53 @@ namespace UCP.Bridge
16
19
  if (EditorApplication.isPlaying)
17
20
  return new { status = "already_playing" };
18
21
 
22
+ var saveDirtyScenes = GetBoolParam(paramsJson, "saveDirtyScenes", true);
23
+ var discardUntitled = GetBoolParam(paramsJson, "discardUntitled", true);
24
+ SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
25
+
19
26
  EditorApplication.isPlaying = true;
20
27
  return new { status = "ok" };
21
28
  }
22
29
 
30
+ private static bool GetBoolParam(string paramsJson, string key, bool defaultValue)
31
+ {
32
+ var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
33
+ if (parameters != null && parameters.TryGetValue(key, out var valueObj) && valueObj is bool value)
34
+ return value;
35
+
36
+ return defaultValue;
37
+ }
38
+
39
+ private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
40
+ {
41
+ if (!saveDirtyScenes)
42
+ return;
43
+
44
+ var requiresUntitledDiscard = false;
45
+
46
+ for (var index = 0; index < SceneManager.sceneCount; index++)
47
+ {
48
+ var scene = SceneManager.GetSceneAt(index);
49
+ if (!scene.isLoaded || !scene.isDirty)
50
+ continue;
51
+
52
+ if (string.IsNullOrEmpty(scene.path))
53
+ {
54
+ if (!discardUntitled)
55
+ throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
56
+
57
+ requiresUntitledDiscard = true;
58
+ continue;
59
+ }
60
+
61
+ if (!EditorSceneManager.SaveScene(scene))
62
+ throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
63
+ }
64
+
65
+ if (requiresUntitledDiscard)
66
+ EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
67
+ }
68
+
23
69
  private static object HandleStop(string paramsJson)
24
70
  {
25
71
  if (!EditorApplication.isPlaying)
@@ -218,14 +218,7 @@ namespace UCP.Bridge
218
218
  var c = prop.colorValue;
219
219
  return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
220
220
  case SerializedPropertyType.ObjectReference:
221
- if (prop.objectReferenceValue != null)
222
- return new Dictionary<string, object>
223
- {
224
- ["instanceId"] = prop.objectReferenceValue.GetInstanceID(),
225
- ["name"] = prop.objectReferenceValue.name,
226
- ["type"] = prop.objectReferenceValue.GetType().Name
227
- };
228
- return null;
221
+ return ObjectReferenceResolver.Serialize(prop.objectReferenceValue);
229
222
  case SerializedPropertyType.LayerMask:
230
223
  return prop.intValue;
231
224
  case SerializedPropertyType.Enum:
@@ -422,15 +415,9 @@ namespace UCP.Bridge
422
415
  }
423
416
  break;
424
417
  case SerializedPropertyType.ObjectReference:
425
- if (value == null)
426
- {
427
- prop.objectReferenceValue = null;
428
- }
429
- else if (value is Dictionary<string, object> refDict && refDict.TryGetValue("instanceId", out var refId))
430
- {
431
- var obj = EditorUtility.InstanceIDToObject(Convert.ToInt32(refId));
432
- prop.objectReferenceValue = obj;
433
- }
418
+ prop.objectReferenceValue = ObjectReferenceResolver.Resolve(value, prop.displayName);
419
+ if (value != null && prop.objectReferenceValue == null)
420
+ throw new ArgumentException($"Unable to assign object reference to '{prop.displayName}'");
434
421
  break;
435
422
  case SerializedPropertyType.LayerMask:
436
423
  prop.intValue = Convert.ToInt32(value);
@@ -457,12 +444,7 @@ namespace UCP.Bridge
457
444
  if (value is Color c)
458
445
  return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
459
446
  if (value is UnityEngine.Object uObj)
460
- return new Dictionary<string, object>
461
- {
462
- ["instanceId"] = uObj.GetInstanceID(),
463
- ["name"] = uObj.name,
464
- ["type"] = uObj.GetType().Name
465
- };
447
+ return ObjectReferenceResolver.Serialize(uObj);
466
448
  return value.ToString();
467
449
  }
468
450
 
@@ -40,6 +40,8 @@ namespace UCP.Bridge
40
40
  throw new System.ArgumentException("Missing 'path' parameter");
41
41
 
42
42
  var path = pathObj.ToString();
43
+ var saveDirtyScenes = GetBoolParam(p, "saveDirtyScenes", true);
44
+ var discardUntitled = GetBoolParam(p, "discardUntitled", true);
43
45
 
44
46
  if (EditorApplication.isPlaying)
45
47
  {
@@ -47,12 +49,51 @@ namespace UCP.Bridge
47
49
  }
48
50
  else
49
51
  {
50
- EditorSceneManager.OpenScene(path);
52
+ SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
53
+ EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
51
54
  }
52
55
 
53
56
  return new { status = "ok", loaded = path };
54
57
  }
55
58
 
59
+ private static bool GetBoolParam(Dictionary<string, object> parameters, string key, bool defaultValue)
60
+ {
61
+ if (parameters != null && parameters.TryGetValue(key, out var valueObj) && valueObj is bool value)
62
+ return value;
63
+
64
+ return defaultValue;
65
+ }
66
+
67
+ private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
68
+ {
69
+ if (!saveDirtyScenes)
70
+ return;
71
+
72
+ var requiresUntitledDiscard = false;
73
+
74
+ for (var index = 0; index < SceneManager.sceneCount; index++)
75
+ {
76
+ var scene = SceneManager.GetSceneAt(index);
77
+ if (!scene.isLoaded || !scene.isDirty)
78
+ continue;
79
+
80
+ if (string.IsNullOrEmpty(scene.path))
81
+ {
82
+ if (!discardUntitled)
83
+ throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
84
+
85
+ requiresUntitledDiscard = true;
86
+ continue;
87
+ }
88
+
89
+ if (!EditorSceneManager.SaveScene(scene))
90
+ throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
91
+ }
92
+
93
+ if (requiresUntitledDiscard)
94
+ EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
95
+ }
96
+
56
97
  private static object HandleActive(string paramsJson)
57
98
  {
58
99
  var scene = SceneManager.GetActiveScene();
@@ -55,7 +55,21 @@ namespace UCP.Bridge
55
55
  executionSettings.filters[0].testNames = new[] { filter };
56
56
  }
57
57
 
58
- s_api.Execute(executionSettings);
58
+ var shouldWaitForPlayModeExit =
59
+ testMode == TestMode.EditMode &&
60
+ (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode);
61
+
62
+ if (shouldWaitForPlayModeExit)
63
+ {
64
+ if (EditorApplication.isPlaying)
65
+ EditorApplication.isPlaying = false;
66
+
67
+ ExecuteWhenReady(executionSettings, testMode, EditorApplication.timeSinceStartup + 30.0);
68
+ }
69
+ else
70
+ {
71
+ s_api.Execute(executionSettings);
72
+ }
59
73
 
60
74
  // Tests run asynchronously in Unity. We return immediately with a pending status.
61
75
  // Results will be sent as notifications when complete.
@@ -63,7 +77,53 @@ namespace UCP.Bridge
63
77
  {
64
78
  ["status"] = "started",
65
79
  ["mode"] = mode,
66
- ["message"] = "Tests started. Results will arrive as notifications."
80
+ ["message"] = shouldWaitForPlayModeExit
81
+ ? "Edit-mode tests queued. Waiting for Unity to exit play mode before starting."
82
+ : "Tests started. Results will arrive as notifications."
83
+ };
84
+ }
85
+
86
+ private static void ExecuteWhenReady(ExecutionSettings settings, TestMode mode, double deadline)
87
+ {
88
+ EditorApplication.delayCall += () =>
89
+ {
90
+ var stillInPlayMode = EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode;
91
+ if (mode == TestMode.EditMode && stillInPlayMode)
92
+ {
93
+ if (EditorApplication.timeSinceStartup > deadline)
94
+ {
95
+ BridgeServer.BroadcastNotification("tests/result", new Dictionary<string, object>
96
+ {
97
+ ["summary"] = new Dictionary<string, object>
98
+ {
99
+ ["total"] = 1,
100
+ ["passed"] = 0,
101
+ ["failed"] = 1,
102
+ ["skipped"] = 0,
103
+ ["duration"] = 0.0
104
+ },
105
+ ["tests"] = new List<object>
106
+ {
107
+ new Dictionary<string, object>
108
+ {
109
+ ["name"] = "UCP.Bridge.Tests.PlayModeExitGuard",
110
+ ["status"] = "failed",
111
+ ["duration"] = 0.0,
112
+ ["message"] = "Timed out waiting for Unity to exit play mode before running edit-mode tests."
113
+ }
114
+ }
115
+ });
116
+
117
+ if (s_api != null && s_collector != null)
118
+ s_api.UnregisterCallbacks(s_collector);
119
+ return;
120
+ }
121
+
122
+ ExecuteWhenReady(settings, mode, deadline);
123
+ return;
124
+ }
125
+
126
+ s_api.Execute(settings);
67
127
  };
68
128
  }
69
129
 
@@ -1,15 +1,22 @@
1
1
  using System;
2
+ using System.Collections;
2
3
  using System.Collections.Generic;
4
+ using System.Text.RegularExpressions;
3
5
  using NUnit.Framework;
4
6
  using UnityEditor;
5
7
  using UnityEditor.SceneManagement;
6
8
  using UnityEngine;
9
+ using UnityEngine.TestTools;
7
10
 
8
11
  namespace UCP.Bridge.Tests
9
12
  {
10
13
  public class ControllerSmokeTests
11
14
  {
12
15
  private const string TempAssetPath = "Assets/UcpControllerSmoke.asset";
16
+ private const string TempReferenceAssetPath = "Assets/UcpControllerReference.asset";
17
+ private const string TempPrefabPath = "Assets/UcpControllerSmoke.prefab";
18
+ private const string TempMaterialPath = "Assets/UcpControllerSmoke.mat";
19
+ private const string TempTextPath = "Assets/UcpControllerSmoke.txt";
13
20
 
14
21
  private CommandRouter _router;
15
22
 
@@ -20,8 +27,19 @@ namespace UCP.Bridge.Tests
20
27
  SnapshotController.Register(_router);
21
28
  AssetController.Register(_router);
22
29
  LogsController.Register(_router);
30
+ HierarchyController.Register(_router);
31
+ PropertyController.Register(_router);
32
+ FileController.Register(_router);
33
+ MaterialController.Register(_router);
34
+ PrefabController.Register(_router);
35
+ BuildController.Register(_router);
36
+ EditorSettingsController.Register(_router);
23
37
  EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
24
38
  DeleteTempAsset();
39
+ DeleteTempReferenceAsset();
40
+ DeleteTempPrefab();
41
+ DeleteTempMaterial();
42
+ DeleteTempTextFile();
25
43
  LogsController.ClearHistoryForTests();
26
44
  }
27
45
 
@@ -29,6 +47,10 @@ namespace UCP.Bridge.Tests
29
47
  public void TearDown()
30
48
  {
31
49
  DeleteTempAsset();
50
+ DeleteTempReferenceAsset();
51
+ DeleteTempPrefab();
52
+ DeleteTempMaterial();
53
+ DeleteTempTextFile();
32
54
  LogsController.ClearHistoryForTests();
33
55
  EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
34
56
  }
@@ -76,17 +98,18 @@ namespace UCP.Bridge.Tests
76
98
  var response = _router.Dispatch(
77
99
  "asset/search",
78
100
  1,
79
- "{\"type\":\"SearchNestedAsset\",\"name\":\"SmokeNested\",\"path\":\"Assets\",\"maxResults\":10}"
101
+ "{\"name\":\"SmokeNested\",\"path\":\"Assets\",\"maxResults\":10}"
80
102
  );
81
103
 
82
104
  Assert.That(response.error, Is.Null);
83
105
 
84
106
  var result = (Dictionary<string, object>)response.result;
85
- Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(1));
86
- Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(1));
107
+ Assert.That(Convert.ToInt32(result["total"]), Is.GreaterThanOrEqualTo(1));
108
+ Assert.That(Convert.ToInt32(result["returned"]), Is.GreaterThanOrEqualTo(1));
87
109
 
88
110
  var matches = (List<object>)result["results"];
89
- var match = (Dictionary<string, object>)matches[0];
111
+ var match = FindAssetMatch(matches, TempAssetPath, "SmokeNested");
112
+ Assert.That(match, Is.Not.Null);
90
113
  Assert.That(match["path"], Is.EqualTo(TempAssetPath));
91
114
  Assert.That(match["type"], Is.EqualTo("SearchNestedAsset"));
92
115
  Assert.That(match["name"], Is.EqualTo("SmokeNested"));
@@ -94,7 +117,7 @@ namespace UCP.Bridge.Tests
94
117
  }
95
118
 
96
119
  [Test]
97
- public void LogsTail_TruncatesBulkResultsToTenEntries()
120
+ public void LogsTail_ReturnsRequestedBufferedCount()
98
121
  {
99
122
  for (var index = 0; index < 12; index++)
100
123
  LogsController.RecordTestLog("info", $"log {index}");
@@ -105,8 +128,8 @@ namespace UCP.Bridge.Tests
105
128
 
106
129
  var result = (Dictionary<string, object>)response.result;
107
130
  Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(12));
108
- Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(10));
109
- Assert.That(Convert.ToBoolean(result["truncated"]), Is.True);
131
+ Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(12));
132
+ Assert.That(Convert.ToBoolean(result["truncated"]), Is.False);
110
133
 
111
134
  var logs = (List<object>)result["logs"];
112
135
  var first = (Dictionary<string, object>)logs[0];
@@ -114,6 +137,23 @@ namespace UCP.Bridge.Tests
114
137
  Assert.That(first.ContainsKey("messagePreview"), Is.True);
115
138
  }
116
139
 
140
+ [Test]
141
+ public void LogsSearch_FiltersBeforeApplyingCount()
142
+ {
143
+ LogsController.RecordTestLog("warning", "Target failed once");
144
+ for (var index = 0; index < 8; index++)
145
+ LogsController.RecordTestLog("info", $"Noise {index}");
146
+
147
+ var response = _router.Dispatch("logs/search", 1, "{\"pattern\":\"Target\",\"count\":1}");
148
+
149
+ Assert.That(response.error, Is.Null);
150
+
151
+ var result = (Dictionary<string, object>)response.result;
152
+ Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(1));
153
+ Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(1));
154
+ Assert.That(Convert.ToBoolean(result["truncated"]), Is.False);
155
+ }
156
+
117
157
  [Test]
118
158
  public void LogsSearch_UsesRegexAgainstBufferedHistory()
119
159
  {
@@ -174,6 +214,278 @@ namespace UCP.Bridge.Tests
174
214
  Assert.That(result["stackTrace"], Is.EqualTo("stack line 1\nstack line 2"));
175
215
  }
176
216
 
217
+ [Test]
218
+ public void ObjectLifecycle_CreateMutateAndDelete_WorksEndToEnd()
219
+ {
220
+ var create = _router.Dispatch("object/create", 1, "{\"name\":\"SmokeObject\"}");
221
+ Assert.That(create.error, Is.Null);
222
+
223
+ var createResult = (Dictionary<string, object>)create.result;
224
+ var instanceId = Convert.ToInt32(createResult["instanceId"]);
225
+
226
+ var rename = _router.Dispatch("object/set-name", 1, "{\"instanceId\":" + instanceId + ",\"name\":\"RenamedSmoke\"}");
227
+ Assert.That(rename.error, Is.Null);
228
+
229
+ var deactivate = _router.Dispatch("object/set-active", 1, "{\"instanceId\":" + instanceId + ",\"active\":false}");
230
+ Assert.That(deactivate.error, Is.Null);
231
+ var deactivateResult = (Dictionary<string, object>)deactivate.result;
232
+ Assert.That(Convert.ToBoolean(deactivateResult["active"]), Is.False);
233
+
234
+ var activate = _router.Dispatch("object/set-active", 1, "{\"instanceId\":" + instanceId + ",\"active\":true}");
235
+ Assert.That(activate.error, Is.Null);
236
+
237
+ var addComponent = _router.Dispatch("object/add-component", 1, "{\"instanceId\":" + instanceId + ",\"type\":\"BoxCollider\"}");
238
+ Assert.That(addComponent.error, Is.Null);
239
+
240
+ var setPosition = _router.Dispatch(
241
+ "object/set-property",
242
+ 1,
243
+ "{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\",\"value\":[1,2,3]}"
244
+ );
245
+ Assert.That(setPosition.error, Is.Null);
246
+
247
+ var getPosition = _router.Dispatch(
248
+ "object/get-property",
249
+ 1,
250
+ "{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\"}"
251
+ );
252
+ Assert.That(getPosition.error, Is.Null);
253
+
254
+ var updated = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
255
+ Assert.That(updated, Is.Not.Null);
256
+ var localPosition = updated.transform.localPosition;
257
+ Assert.That(localPosition.x, Is.EqualTo(1f).Within(0.001f));
258
+ Assert.That(localPosition.y, Is.EqualTo(2f).Within(0.001f));
259
+ Assert.That(localPosition.z, Is.EqualTo(3f).Within(0.001f));
260
+
261
+ var removeComponent = _router.Dispatch("object/remove-component", 1, "{\"instanceId\":" + instanceId + ",\"type\":\"BoxCollider\"}");
262
+ Assert.That(removeComponent.error, Is.Null);
263
+
264
+ var delete = _router.Dispatch("object/delete", 1, "{\"instanceId\":" + instanceId + "}");
265
+ Assert.That(delete.error, Is.Null);
266
+ Assert.That(EditorUtility.InstanceIDToObject(instanceId), Is.Null);
267
+ }
268
+
269
+ [Test]
270
+ public void ObjectSetProperty_AssignsObjectReferenceByAssetPath()
271
+ {
272
+ var referencedAsset = ScriptableObject.CreateInstance<SearchRootAsset>();
273
+ referencedAsset.name = "ReferenceAsset";
274
+ AssetDatabase.CreateAsset(referencedAsset, TempReferenceAssetPath);
275
+ AssetDatabase.SaveAssets();
276
+
277
+ var go = new GameObject("ReferenceCarrier");
278
+ var component = go.AddComponent<ReferenceComponent>();
279
+
280
+ var response = _router.Dispatch(
281
+ "object/set-property",
282
+ 1,
283
+ "{\"instanceId\":" + go.GetInstanceID() + ",\"component\":\"ReferenceComponent\",\"property\":\"referenceAsset\",\"value\":{\"path\":\"" + TempReferenceAssetPath + "\"}}"
284
+ );
285
+
286
+ Assert.That(response.error, Is.Null);
287
+ Assert.That(component.referenceAsset, Is.Not.Null);
288
+ Assert.That(AssetDatabase.GetAssetPath(component.referenceAsset), Is.EqualTo(TempReferenceAssetPath));
289
+ }
290
+
291
+ [Test]
292
+ public void ObjectSetProperty_RejectsUnknownObjectReference()
293
+ {
294
+ var go = new GameObject("ReferenceCarrier");
295
+ go.AddComponent<ReferenceComponent>();
296
+
297
+ var response = _router.Dispatch(
298
+ "object/set-property",
299
+ 1,
300
+ "{\"instanceId\":" + go.GetInstanceID() + ",\"component\":\"ReferenceComponent\",\"property\":\"referenceAsset\",\"value\":{\"path\":\"Assets/Missing.asset\"}}"
301
+ );
302
+
303
+ Assert.That(response.error, Is.Not.Null);
304
+ }
305
+
306
+ [Test]
307
+ public void AssetWriteBatch_UpdatesMultipleFieldsIncludingObjectReference()
308
+ {
309
+ var reference = ScriptableObject.CreateInstance<SearchRootAsset>();
310
+ reference.name = "ReferenceAsset";
311
+ AssetDatabase.CreateAsset(reference, TempReferenceAssetPath);
312
+
313
+ var asset = ScriptableObject.CreateInstance<BatchWritableAsset>();
314
+ asset.maxPlayers = 2;
315
+ asset.spawnDelay = 5f;
316
+ AssetDatabase.CreateAsset(asset, TempAssetPath);
317
+ AssetDatabase.SaveAssets();
318
+
319
+ var response = _router.Dispatch(
320
+ "asset/write-batch",
321
+ 1,
322
+ "{\"path\":\"" + TempAssetPath + "\",\"values\":{\"maxPlayers\":8,\"spawnDelay\":1.5,\"referenceAsset\":{\"path\":\"" + TempReferenceAssetPath + "\"}}}"
323
+ );
324
+
325
+ Assert.That(response.error, Is.Null);
326
+
327
+ var reloaded = AssetDatabase.LoadAssetAtPath<BatchWritableAsset>(TempAssetPath);
328
+ Assert.That(reloaded.maxPlayers, Is.EqualTo(8));
329
+ Assert.That(reloaded.spawnDelay, Is.EqualTo(1.5f).Within(0.001f));
330
+ Assert.That(AssetDatabase.GetAssetPath(reloaded.referenceAsset), Is.EqualTo(TempReferenceAssetPath));
331
+ }
332
+
333
+ [Test]
334
+ public void FileController_WritePatchRead_AndRejectsPathTraversal()
335
+ {
336
+ var write = _router.Dispatch("file/write", 1, "{\"path\":\"Assets/UcpControllerSmoke.txt\",\"content\":\"hello smoke\"}");
337
+ Assert.That(write.error, Is.Null);
338
+
339
+ var patch = _router.Dispatch(
340
+ "file/patch",
341
+ 1,
342
+ "{\"path\":\"Assets/UcpControllerSmoke.txt\",\"patch\":{\"find\":\"smoke\",\"replace\":\"patched\"}}"
343
+ );
344
+ Assert.That(patch.error, Is.Null);
345
+
346
+ var read = _router.Dispatch("file/read", 1, "{\"path\":\"Assets/UcpControllerSmoke.txt\"}");
347
+ Assert.That(read.error, Is.Null);
348
+ var readResult = (Dictionary<string, object>)read.result;
349
+ Assert.That(readResult["content"].ToString(), Is.EqualTo("hello patched"));
350
+
351
+ LogAssert.Expect(LogType.Error, new Regex("\\[UCP\\] Error handling 'file/read':"));
352
+ var traversal = _router.Dispatch("file/read", 1, "{\"path\":\"../outside.txt\"}");
353
+ Assert.That(traversal.error, Is.Not.Null);
354
+ }
355
+
356
+ [Test]
357
+ public void MaterialController_SetAndGetFloatProperty_RoundTrips()
358
+ {
359
+ var shader = Shader.Find("Standard") ?? Shader.Find("Universal Render Pipeline/Lit");
360
+ Assert.That(shader, Is.Not.Null);
361
+
362
+ var propertyName = FindFirstFloatOrRangeProperty(shader);
363
+ Assert.That(propertyName, Is.Not.Null.And.Not.Empty);
364
+
365
+ var material = new Material(shader) { name = "UcpControllerSmokeMat" };
366
+ AssetDatabase.CreateAsset(material, TempMaterialPath);
367
+ AssetDatabase.SaveAssets();
368
+
369
+ var set = _router.Dispatch(
370
+ "material/set-property",
371
+ 1,
372
+ "{\"path\":\"Assets/UcpControllerSmoke.mat\",\"property\":\"" + propertyName + "\",\"value\":0.42}"
373
+ );
374
+ Assert.That(set.error, Is.Null);
375
+
376
+ var get = _router.Dispatch(
377
+ "material/get-property",
378
+ 1,
379
+ "{\"path\":\"Assets/UcpControllerSmoke.mat\",\"property\":\"" + propertyName + "\"}"
380
+ );
381
+ Assert.That(get.error, Is.Null);
382
+
383
+ var getResult = (Dictionary<string, object>)get.result;
384
+ var value = Convert.ToSingle(getResult["value"]);
385
+ Assert.That(value, Is.EqualTo(0.42f).Within(0.001f));
386
+ }
387
+
388
+ [Test]
389
+ public void PrefabController_CreateStatusOverridesAndUnpack_Works()
390
+ {
391
+ var create = _router.Dispatch("object/create", 1, "{\"name\":\"PrefabSource\"}");
392
+ Assert.That(create.error, Is.Null);
393
+ var sourceId = Convert.ToInt32(((Dictionary<string, object>)create.result)["instanceId"]);
394
+
395
+ var createPrefab = _router.Dispatch(
396
+ "prefab/create",
397
+ 1,
398
+ "{\"instanceId\":" + sourceId + ",\"path\":\"Assets/UcpControllerSmoke.prefab\"}"
399
+ );
400
+ Assert.That(createPrefab.error, Is.Null);
401
+
402
+ var instantiate = _router.Dispatch(
403
+ "object/instantiate",
404
+ 1,
405
+ "{\"prefab\":\"Assets/UcpControllerSmoke.prefab\",\"name\":\"PrefabInstance\"}"
406
+ );
407
+ Assert.That(instantiate.error, Is.Null);
408
+ var instanceId = Convert.ToInt32(((Dictionary<string, object>)instantiate.result)["instanceId"]);
409
+
410
+ var status = _router.Dispatch("prefab/status", 1, "{\"instanceId\":" + instanceId + "}");
411
+ Assert.That(status.error, Is.Null);
412
+ var statusResult = (Dictionary<string, object>)status.result;
413
+ Assert.That(Convert.ToBoolean(statusResult["isInstance"]), Is.True);
414
+
415
+ var mutate = _router.Dispatch(
416
+ "object/set-property",
417
+ 1,
418
+ "{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\",\"value\":[2,0,0]}"
419
+ );
420
+ Assert.That(mutate.error, Is.Null);
421
+
422
+ var overrides = _router.Dispatch("prefab/overrides", 1, "{\"instanceId\":" + instanceId + "}");
423
+ Assert.That(overrides.error, Is.Null);
424
+ var overridesResult = (Dictionary<string, object>)overrides.result;
425
+ var modifications = (List<object>)overridesResult["propertyModifications"];
426
+ Assert.That(modifications.Count, Is.GreaterThanOrEqualTo(1));
427
+
428
+ var unpack = _router.Dispatch("prefab/unpack", 1, "{\"instanceId\":" + instanceId + "}");
429
+ Assert.That(unpack.error, Is.Null);
430
+
431
+ var unpackedStatus = _router.Dispatch("prefab/status", 1, "{\"instanceId\":" + instanceId + "}");
432
+ Assert.That(unpackedStatus.error, Is.Null);
433
+ var unpackedStatusResult = (Dictionary<string, object>)unpackedStatus.result;
434
+ Assert.That(Convert.ToBoolean(unpackedStatusResult["isInstance"]), Is.False);
435
+ }
436
+
437
+ [Test]
438
+ public void SettingsAndBuildControllers_RoundTripWithoutSideEffects()
439
+ {
440
+ var playerSettings = _router.Dispatch("settings/player", 1, "{}");
441
+ Assert.That(playerSettings.error, Is.Null);
442
+ var settingsResult = (Dictionary<string, object>)playerSettings.result;
443
+ var originalProductName = settingsResult["productName"].ToString();
444
+
445
+ var setPlayer = _router.Dispatch(
446
+ "settings/player-set",
447
+ 1,
448
+ "{\"key\":\"productName\",\"value\":\"UcpQaProduct\"}"
449
+ );
450
+ Assert.That(setPlayer.error, Is.Null);
451
+
452
+ var verifyPlayer = _router.Dispatch("settings/player", 1, "{}");
453
+ Assert.That(verifyPlayer.error, Is.Null);
454
+ var verifyResult = (Dictionary<string, object>)verifyPlayer.result;
455
+ Assert.That(verifyResult["productName"].ToString(), Is.EqualTo("UcpQaProduct"));
456
+
457
+ var restorePlayer = _router.Dispatch(
458
+ "settings/player-set",
459
+ 1,
460
+ "{\"key\":\"productName\",\"value\":\"" + originalProductName + "\"}"
461
+ );
462
+ Assert.That(restorePlayer.error, Is.Null);
463
+
464
+ var scenes = _router.Dispatch("build/scenes", 1, "{}");
465
+ Assert.That(scenes.error, Is.Null);
466
+ var scenesResult = (Dictionary<string, object>)scenes.result;
467
+ var currentScenes = (List<object>)scenesResult["scenes"];
468
+ Assert.That(currentScenes.Count, Is.GreaterThanOrEqualTo(1));
469
+
470
+ var firstScene = (Dictionary<string, object>)currentScenes[0];
471
+ var firstScenePath = firstScene["path"].ToString();
472
+
473
+ var setScenes = _router.Dispatch(
474
+ "build/set-scenes",
475
+ 1,
476
+ "{\"scenes\":[\"" + EscapeForJson(firstScenePath) + "\"]}"
477
+ );
478
+ Assert.That(setScenes.error, Is.Null);
479
+
480
+ var defines = _router.Dispatch("build/defines", 1, "{}");
481
+ Assert.That(defines.error, Is.Null);
482
+ var definesResult = (Dictionary<string, object>)defines.result;
483
+ var originalDefines = definesResult["defines"].ToString();
484
+
485
+ var setDefines = _router.Dispatch("build/set-defines", 1, "{\"defines\":\"" + EscapeForJson(originalDefines) + "\"}");
486
+ Assert.That(setDefines.error, Is.Null);
487
+ }
488
+
177
489
  private static void DeleteTempAsset()
178
490
  {
179
491
  if (AssetDatabase.LoadMainAssetAtPath(TempAssetPath) != null)
@@ -183,6 +495,78 @@ namespace UCP.Bridge.Tests
183
495
  }
184
496
  }
185
497
 
498
+ private static void DeleteTempReferenceAsset()
499
+ {
500
+ if (AssetDatabase.LoadMainAssetAtPath(TempReferenceAssetPath) != null)
501
+ {
502
+ AssetDatabase.DeleteAsset(TempReferenceAssetPath);
503
+ AssetDatabase.SaveAssets();
504
+ }
505
+ }
506
+
507
+ private static void DeleteTempPrefab()
508
+ {
509
+ if (AssetDatabase.LoadMainAssetAtPath(TempPrefabPath) != null)
510
+ {
511
+ AssetDatabase.DeleteAsset(TempPrefabPath);
512
+ AssetDatabase.SaveAssets();
513
+ }
514
+ }
515
+
516
+ private static void DeleteTempMaterial()
517
+ {
518
+ if (AssetDatabase.LoadMainAssetAtPath(TempMaterialPath) != null)
519
+ {
520
+ AssetDatabase.DeleteAsset(TempMaterialPath);
521
+ AssetDatabase.SaveAssets();
522
+ }
523
+ }
524
+
525
+ private static void DeleteTempTextFile()
526
+ {
527
+ if (AssetDatabase.LoadMainAssetAtPath(TempTextPath) != null)
528
+ {
529
+ AssetDatabase.DeleteAsset(TempTextPath);
530
+ AssetDatabase.SaveAssets();
531
+ }
532
+ }
533
+
534
+ private static string FindFirstFloatOrRangeProperty(Shader shader)
535
+ {
536
+ for (var index = 0; index < shader.GetPropertyCount(); index++)
537
+ {
538
+ var propertyType = shader.GetPropertyType(index);
539
+ if (propertyType == UnityEngine.Rendering.ShaderPropertyType.Float
540
+ || propertyType == UnityEngine.Rendering.ShaderPropertyType.Range)
541
+ {
542
+ return shader.GetPropertyName(index);
543
+ }
544
+ }
545
+
546
+ return null;
547
+ }
548
+
549
+ private static string EscapeForJson(string value)
550
+ {
551
+ return value
552
+ .Replace("\\", "\\\\")
553
+ .Replace("\"", "\\\"");
554
+ }
555
+
556
+ private static Dictionary<string, object> FindAssetMatch(List<object> matches, string expectedPath, string expectedName)
557
+ {
558
+ foreach (var entry in matches)
559
+ {
560
+ var match = (Dictionary<string, object>)entry;
561
+ var path = match.ContainsKey("path") ? match["path"].ToString() : string.Empty;
562
+ var name = match.ContainsKey("name") ? match["name"].ToString() : string.Empty;
563
+ if (path == expectedPath && name == expectedName)
564
+ return match;
565
+ }
566
+
567
+ return null;
568
+ }
569
+
186
570
  private sealed class SearchRootAsset : ScriptableObject
187
571
  {
188
572
  }
@@ -190,5 +574,17 @@ namespace UCP.Bridge.Tests
190
574
  private sealed class SearchNestedAsset : ScriptableObject
191
575
  {
192
576
  }
577
+
578
+ private sealed class BatchWritableAsset : ScriptableObject
579
+ {
580
+ public int maxPlayers;
581
+ public float spawnDelay;
582
+ public SearchRootAsset referenceAsset;
583
+ }
584
+
585
+ private sealed class ReferenceComponent : MonoBehaviour
586
+ {
587
+ public SearchRootAsset referenceAsset;
588
+ }
193
589
  }
194
590
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "displayName": "Unity Control Protocol Bridge",
5
5
  "description": "WebSocket bridge for programmatic Unity Editor control via CLI and AI agents.",
6
6
  "unity": "2021.3",
@@ -21,7 +21,7 @@
21
21
  "url": "https://github.com/mflRevan/unity-control-protocol.git"
22
22
  },
23
23
  "type": "tool",
24
- "documentationUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/PROJECT.md",
24
+ "documentationUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/README.md",
25
25
  "changelogUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/unity-package/com.ucp.bridge/CHANGELOG.md",
26
26
  "dependencies": {}
27
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Unity Control Protocol — CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {