@mflrevan/ucp 0.3.0 → 0.3.2

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.3.0` of the Unity Control Protocol CLI.
3
+ Version `0.3.2` 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
 
@@ -49,7 +49,7 @@ Or add this to `Packages/manifest.json`:
49
49
  ```json
50
50
  {
51
51
  "dependencies": {
52
- "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.3.0"
52
+ "com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.3.2"
53
53
  }
54
54
  }
55
55
  ```
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-03-14
4
+
5
+ ### Added
6
+
7
+ - Added `editor/quit` so the CLI can request graceful Unity editor shutdown before falling back to OS-level close/terminate behavior.
8
+
9
+ ### Changed
10
+
11
+ - Bridge server registration now includes editor lifecycle RPC handlers alongside the existing play, compile, scene, asset, and build controllers.
12
+
13
+ ## [0.3.1] - 2026-03-14
14
+
15
+ ### Added
16
+
17
+ - Added `asset/write-batch` for multi-field serialized asset updates in one bridge call.
18
+
19
+ ### Changed
20
+
21
+ - Player settings now expose `defaultIsNativeResolution` so installer automation can reconcile live editor state as well as on-disk project settings.
22
+ - Object reference payloads now include asset `path` and `guid` when available.
23
+
24
+ ### Fixed
25
+
26
+ - Fixed buffered log searches by applying regex filtering before count truncation.
27
+ - Fixed buffered log list requests being capped to 10 returned entries regardless of requested `count`.
28
+ - Fixed serialized object reference writes silently accepting unresolved references in both object and asset controllers.
29
+
3
30
  ## [0.3.0] - 2026-03-13
4
31
 
5
32
  ### Added
@@ -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.3.0";
29
+ private const string ProtocolVersion = "0.3.2";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -108,6 +108,9 @@ namespace UCP.Bridge
108
108
  // Compilation
109
109
  CompilationController.Register(s_router);
110
110
 
111
+ // Editor lifecycle
112
+ EditorController.Register(s_router);
113
+
111
114
  // Scenes
112
115
  SceneController.Register(s_router);
113
116
 
@@ -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
  }
@@ -0,0 +1,18 @@
1
+ using UnityEditor;
2
+
3
+ namespace UCP.Bridge
4
+ {
5
+ public static class EditorController
6
+ {
7
+ public static void Register(CommandRouter router)
8
+ {
9
+ router.Register("editor/quit", HandleQuit);
10
+ }
11
+
12
+ private static object HandleQuit(string paramsJson)
13
+ {
14
+ EditorApplication.delayCall += () => EditorApplication.Exit(0);
15
+ return new { status = "ok", message = "Unity editor shutdown requested" };
16
+ }
17
+ }
18
+ }
@@ -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
+ }
@@ -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
 
@@ -13,6 +13,7 @@ namespace UCP.Bridge.Tests
13
13
  public class ControllerSmokeTests
14
14
  {
15
15
  private const string TempAssetPath = "Assets/UcpControllerSmoke.asset";
16
+ private const string TempReferenceAssetPath = "Assets/UcpControllerReference.asset";
16
17
  private const string TempPrefabPath = "Assets/UcpControllerSmoke.prefab";
17
18
  private const string TempMaterialPath = "Assets/UcpControllerSmoke.mat";
18
19
  private const string TempTextPath = "Assets/UcpControllerSmoke.txt";
@@ -35,6 +36,7 @@ namespace UCP.Bridge.Tests
35
36
  EditorSettingsController.Register(_router);
36
37
  EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
37
38
  DeleteTempAsset();
39
+ DeleteTempReferenceAsset();
38
40
  DeleteTempPrefab();
39
41
  DeleteTempMaterial();
40
42
  DeleteTempTextFile();
@@ -45,6 +47,7 @@ namespace UCP.Bridge.Tests
45
47
  public void TearDown()
46
48
  {
47
49
  DeleteTempAsset();
50
+ DeleteTempReferenceAsset();
48
51
  DeleteTempPrefab();
49
52
  DeleteTempMaterial();
50
53
  DeleteTempTextFile();
@@ -114,7 +117,7 @@ namespace UCP.Bridge.Tests
114
117
  }
115
118
 
116
119
  [Test]
117
- public void LogsTail_TruncatesBulkResultsToTenEntries()
120
+ public void LogsTail_ReturnsRequestedBufferedCount()
118
121
  {
119
122
  for (var index = 0; index < 12; index++)
120
123
  LogsController.RecordTestLog("info", $"log {index}");
@@ -125,8 +128,8 @@ namespace UCP.Bridge.Tests
125
128
 
126
129
  var result = (Dictionary<string, object>)response.result;
127
130
  Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(12));
128
- Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(10));
129
- 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);
130
133
 
131
134
  var logs = (List<object>)result["logs"];
132
135
  var first = (Dictionary<string, object>)logs[0];
@@ -134,6 +137,23 @@ namespace UCP.Bridge.Tests
134
137
  Assert.That(first.ContainsKey("messagePreview"), Is.True);
135
138
  }
136
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
+
137
157
  [Test]
138
158
  public void LogsSearch_UsesRegexAgainstBufferedHistory()
139
159
  {
@@ -246,6 +266,70 @@ namespace UCP.Bridge.Tests
246
266
  Assert.That(EditorUtility.InstanceIDToObject(instanceId), Is.Null);
247
267
  }
248
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
+
249
333
  [Test]
250
334
  public void FileController_WritePatchRead_AndRejectsPathTraversal()
251
335
  {
@@ -411,6 +495,15 @@ namespace UCP.Bridge.Tests
411
495
  }
412
496
  }
413
497
 
498
+ private static void DeleteTempReferenceAsset()
499
+ {
500
+ if (AssetDatabase.LoadMainAssetAtPath(TempReferenceAssetPath) != null)
501
+ {
502
+ AssetDatabase.DeleteAsset(TempReferenceAssetPath);
503
+ AssetDatabase.SaveAssets();
504
+ }
505
+ }
506
+
414
507
  private static void DeleteTempPrefab()
415
508
  {
416
509
  if (AssetDatabase.LoadMainAssetAtPath(TempPrefabPath) != null)
@@ -481,5 +574,17 @@ namespace UCP.Bridge.Tests
481
574
  private sealed class SearchNestedAsset : ScriptableObject
482
575
  {
483
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
+ }
484
589
  }
485
590
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Unity Control Protocol — CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {