@mflrevan/ucp 0.5.1 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  using System;
2
2
  using System.Collections.Generic;
3
+ using System.Linq;
3
4
  using UnityEngine;
4
5
  using UnityEngine.SceneManagement;
5
6
 
@@ -10,6 +11,8 @@ namespace UCP.Bridge
10
11
  public static void Register(CommandRouter router)
11
12
  {
12
13
  router.Register("snapshot", HandleSnapshot);
14
+ router.Register("scene/query", HandleSceneQuery);
15
+ router.Register("object/get-children", HandleGetChildren);
13
16
  router.Register("objects/list", HandleListObjects);
14
17
  router.Register("objects/components", HandleGetComponents);
15
18
  router.Register("objects/transform", HandleGetTransform);
@@ -120,20 +123,84 @@ namespace UCP.Bridge
120
123
  return new Dictionary<string, object> { ["objects"] = objects };
121
124
  }
122
125
 
123
- private static void ListObjectsRecursive(GameObject go, List<object> list, int depth, int maxDepth)
126
+ private static object HandleSceneQuery(string paramsJson)
124
127
  {
125
- list.Add(new Dictionary<string, object>
128
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
129
+ if (p == null || !p.TryGetValue("query", out var queryObj) || queryObj == null)
130
+ throw new ArgumentException("Missing 'query' parameter");
131
+
132
+ var query = ParseSceneQuery(queryObj.ToString());
133
+ var fields = ParseFields(p.TryGetValue("fields", out var fieldsObj) ? fieldsObj?.ToString() : null);
134
+ var maxDepth = 32;
135
+ if (p.TryGetValue("depth", out var depthObj) && depthObj != null)
136
+ maxDepth = Math.Max(0, Convert.ToInt32(depthObj));
137
+
138
+ var scene = SceneManager.GetActiveScene();
139
+ var results = new List<object>();
140
+ foreach (var root in scene.GetRootGameObjects())
141
+ QueryHierarchy(root, query, fields, results, 0, maxDepth);
142
+
143
+ return new Dictionary<string, object>
126
144
  {
127
- ["instanceId"] = go.GetInstanceID(),
145
+ ["scene"] = scene.path,
146
+ ["sceneName"] = scene.name,
147
+ ["query"] = query.Raw,
148
+ ["count"] = results.Count,
149
+ ["objects"] = results
150
+ };
151
+ }
152
+
153
+ private static object HandleGetChildren(string paramsJson)
154
+ {
155
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
156
+ if (p == null || !p.TryGetValue("instanceId", out var idObj))
157
+ throw new ArgumentException("Missing 'instanceId' parameter");
158
+
159
+ int instanceId = Convert.ToInt32(idObj);
160
+ int maxDepth = 1;
161
+ if (p.TryGetValue("depth", out var depthObj))
162
+ maxDepth = Math.Max(1, Convert.ToInt32(depthObj));
163
+
164
+ var go = FindByInstanceId(instanceId);
165
+ if (go == null)
166
+ throw new Exception($"GameObject not found: {instanceId}");
167
+
168
+ var children = new List<object>();
169
+ int objectCount = 0;
170
+ int componentCount = 0;
171
+
172
+ for (int i = 0; i < go.transform.childCount; i++)
173
+ {
174
+ children.Add(SerializeGameObjectTree(
175
+ go.transform.GetChild(i).gameObject,
176
+ 1,
177
+ maxDepth,
178
+ ref objectCount,
179
+ ref componentCount));
180
+ }
181
+
182
+ return new Dictionary<string, object>
183
+ {
184
+ ["instanceId"] = instanceId,
128
185
  ["name"] = go.name,
129
186
  ["active"] = go.activeSelf,
130
187
  ["tag"] = go.tag,
131
188
  ["layer"] = go.layer,
132
189
  ["layerName"] = LayerMask.LayerToName(go.layer),
133
190
  ["childCount"] = go.transform.childCount,
134
- ["components"] = GetComponentTypes(go),
135
- ["depth"] = depth
136
- });
191
+ ["requestedDepth"] = maxDepth,
192
+ ["children"] = children,
193
+ ["stats"] = new Dictionary<string, object>
194
+ {
195
+ ["objectCount"] = objectCount,
196
+ ["componentCount"] = componentCount
197
+ }
198
+ };
199
+ }
200
+
201
+ private static void ListObjectsRecursive(GameObject go, List<object> list, int depth, int maxDepth)
202
+ {
203
+ list.Add(CreateGameObjectEntry(go, depth));
137
204
 
138
205
  if (depth < maxDepth)
139
206
  {
@@ -142,6 +209,140 @@ namespace UCP.Bridge
142
209
  }
143
210
  }
144
211
 
212
+ private static void QueryHierarchy(
213
+ GameObject go,
214
+ SceneQuery query,
215
+ HashSet<string> fields,
216
+ List<object> results,
217
+ int depth,
218
+ int maxDepth)
219
+ {
220
+ if (MatchesQuery(go, query))
221
+ results.Add(ProjectGameObject(go, fields, depth));
222
+
223
+ if (depth >= maxDepth)
224
+ return;
225
+
226
+ for (int i = 0; i < go.transform.childCount; i++)
227
+ QueryHierarchy(go.transform.GetChild(i).gameObject, query, fields, results, depth + 1, maxDepth);
228
+ }
229
+
230
+ private static Dictionary<string, object> ProjectGameObject(GameObject go, HashSet<string> fields, int depth)
231
+ {
232
+ var entry = new Dictionary<string, object>();
233
+ AddField(entry, fields, "instanceId", go.GetInstanceID());
234
+ AddField(entry, fields, "name", go.name);
235
+ AddField(entry, fields, "active", go.activeSelf);
236
+ AddField(entry, fields, "activeInHierarchy", go.activeInHierarchy);
237
+ AddField(entry, fields, "tag", go.tag);
238
+ AddField(entry, fields, "layer", go.layer);
239
+ AddField(entry, fields, "layerName", LayerMask.LayerToName(go.layer));
240
+ AddField(entry, fields, "depth", depth);
241
+ AddField(entry, fields, "childCount", go.transform.childCount);
242
+ AddField(entry, fields, "components", GetComponentTypes(go));
243
+ return entry;
244
+ }
245
+
246
+ private static void AddField(Dictionary<string, object> entry, HashSet<string> fields, string key, object value)
247
+ {
248
+ if (fields.Contains(key))
249
+ entry[key] = value;
250
+ }
251
+
252
+ private static HashSet<string> ParseFields(string raw)
253
+ {
254
+ var source = string.IsNullOrEmpty(raw) ? "instanceId,name,active,components" : raw;
255
+ return new HashSet<string>(
256
+ source.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
257
+ .Select(field => field.Trim()),
258
+ StringComparer.OrdinalIgnoreCase);
259
+ }
260
+
261
+ private static SceneQuery ParseSceneQuery(string raw)
262
+ {
263
+ var query = new SceneQuery { Raw = raw ?? string.Empty };
264
+ foreach (var token in SplitQuery(query.Raw))
265
+ {
266
+ var index = token.IndexOf('=');
267
+ if (index <= 0)
268
+ throw new ArgumentException($"Unsupported query token '{token}'. Use key=value.");
269
+
270
+ var key = token.Substring(0, index).Trim().ToLowerInvariant();
271
+ var value = token.Substring(index + 1).Trim().Trim('"');
272
+ switch (key)
273
+ {
274
+ case "name":
275
+ query.Name = value;
276
+ break;
277
+ case "component":
278
+ case "components":
279
+ query.Component = value;
280
+ break;
281
+ case "active":
282
+ query.Active = Convert.ToBoolean(value);
283
+ break;
284
+ case "tag":
285
+ query.Tag = value;
286
+ break;
287
+ case "layer":
288
+ query.Layer = value;
289
+ break;
290
+ default:
291
+ throw new ArgumentException($"Unsupported scene query key '{key}'");
292
+ }
293
+ }
294
+
295
+ return query;
296
+ }
297
+
298
+ private static IEnumerable<string> SplitQuery(string raw)
299
+ {
300
+ return (raw ?? string.Empty)
301
+ .Replace(" AND ", " and ")
302
+ .Split(new[] { " and ", "," }, StringSplitOptions.RemoveEmptyEntries)
303
+ .Select(token => token.Trim())
304
+ .Where(token => token.Length > 0);
305
+ }
306
+
307
+ private static bool MatchesQuery(GameObject go, SceneQuery query)
308
+ {
309
+ if (!string.IsNullOrEmpty(query.Name)
310
+ && !go.name.Contains(query.Name, StringComparison.OrdinalIgnoreCase))
311
+ return false;
312
+ if (!string.IsNullOrEmpty(query.Component) && !HasComponent(go, query.Component))
313
+ return false;
314
+ if (query.Active.HasValue && go.activeSelf != query.Active.Value)
315
+ return false;
316
+ if (!string.IsNullOrEmpty(query.Tag) && !string.Equals(go.tag, query.Tag, StringComparison.OrdinalIgnoreCase))
317
+ return false;
318
+ if (!string.IsNullOrEmpty(query.Layer) && !LayerMatches(go.layer, query.Layer))
319
+ return false;
320
+
321
+ return true;
322
+ }
323
+
324
+ private static bool HasComponent(GameObject go, string componentName)
325
+ {
326
+ foreach (var component in go.GetComponents<Component>())
327
+ {
328
+ if (component == null)
329
+ continue;
330
+ var type = component.GetType();
331
+ if (type.Name.Equals(componentName, StringComparison.OrdinalIgnoreCase)
332
+ || (type.FullName != null && type.FullName.Equals(componentName, StringComparison.OrdinalIgnoreCase)))
333
+ return true;
334
+ }
335
+
336
+ return false;
337
+ }
338
+
339
+ private static bool LayerMatches(int layer, string expected)
340
+ {
341
+ if (int.TryParse(expected, out var parsed))
342
+ return layer == parsed;
343
+ return string.Equals(LayerMask.LayerToName(layer), expected, StringComparison.OrdinalIgnoreCase);
344
+ }
345
+
145
346
  private static object HandleGetComponents(string paramsJson)
146
347
  {
147
348
  var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
@@ -202,8 +403,92 @@ namespace UCP.Bridge
202
403
 
203
404
  private static GameObject FindByInstanceId(int id)
204
405
  {
205
- var obj = UnityObjectCompat.ResolveByInstanceId(id);
206
- return obj as GameObject;
406
+ var direct = UnityObjectCompat.ResolveByInstanceId<GameObject>(id);
407
+ if (direct != null)
408
+ return direct;
409
+
410
+ for (int i = 0; i < SceneManager.sceneCount; i++)
411
+ {
412
+ var scene = SceneManager.GetSceneAt(i);
413
+ if (!scene.isLoaded)
414
+ continue;
415
+
416
+ foreach (var root in scene.GetRootGameObjects())
417
+ {
418
+ var found = FindInHierarchy(root, id);
419
+ if (found != null)
420
+ return found;
421
+ }
422
+ }
423
+
424
+ return null;
425
+ }
426
+
427
+ private static GameObject FindInHierarchy(GameObject go, int instanceId)
428
+ {
429
+ if (go.GetInstanceID() == instanceId)
430
+ return go;
431
+
432
+ for (int i = 0; i < go.transform.childCount; i++)
433
+ {
434
+ var found = FindInHierarchy(go.transform.GetChild(i).gameObject, instanceId);
435
+ if (found != null)
436
+ return found;
437
+ }
438
+
439
+ return null;
440
+ }
441
+
442
+ private static Dictionary<string, object> SerializeGameObjectTree(
443
+ GameObject go,
444
+ int depth,
445
+ int maxDepth,
446
+ ref int objectCount,
447
+ ref int componentCount)
448
+ {
449
+ objectCount++;
450
+
451
+ var entry = CreateGameObjectEntry(go, depth, ref componentCount);
452
+ if (depth < maxDepth)
453
+ {
454
+ var children = new List<object>();
455
+ for (int i = 0; i < go.transform.childCount; i++)
456
+ {
457
+ children.Add(SerializeGameObjectTree(
458
+ go.transform.GetChild(i).gameObject,
459
+ depth + 1,
460
+ maxDepth,
461
+ ref objectCount,
462
+ ref componentCount));
463
+ }
464
+
465
+ if (children.Count > 0)
466
+ entry["children"] = children;
467
+ }
468
+
469
+ return entry;
470
+ }
471
+
472
+ private static Dictionary<string, object> CreateGameObjectEntry(GameObject go, int depth)
473
+ {
474
+ int ignored = 0;
475
+ return CreateGameObjectEntry(go, depth, ref ignored);
476
+ }
477
+
478
+ private static Dictionary<string, object> CreateGameObjectEntry(GameObject go, int depth, ref int componentCount)
479
+ {
480
+ return new Dictionary<string, object>
481
+ {
482
+ ["instanceId"] = go.GetInstanceID(),
483
+ ["name"] = go.name,
484
+ ["active"] = go.activeSelf,
485
+ ["tag"] = go.tag,
486
+ ["layer"] = go.layer,
487
+ ["layerName"] = LayerMask.LayerToName(go.layer),
488
+ ["childCount"] = go.transform.childCount,
489
+ ["components"] = GetComponentTypes(go, ref componentCount),
490
+ ["depth"] = depth
491
+ };
207
492
  }
208
493
 
209
494
  private static List<object> GetComponentTypes(GameObject go)
@@ -223,5 +508,15 @@ namespace UCP.Bridge
223
508
  }
224
509
  return componentTypes;
225
510
  }
511
+
512
+ private sealed class SceneQuery
513
+ {
514
+ public string Raw;
515
+ public string Name;
516
+ public string Component;
517
+ public bool? Active;
518
+ public string Tag;
519
+ public string Layer;
520
+ }
226
521
  }
227
522
  }
@@ -435,6 +435,78 @@ namespace UCP.Bridge.Tests
435
435
  Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(1));
436
436
  }
437
437
 
438
+ [Test]
439
+ public void LogsStatus_BackfillsConsoleEntriesWhenHistoryStartsEmpty()
440
+ {
441
+ LogsController.SetConsoleBackfillProviderForTests(() => new List<LogsController.ConsoleBackfillEntry>
442
+ {
443
+ new LogsController.ConsoleBackfillEntry { Level = "warning", Message = "Compiler warning" },
444
+ new LogsController.ConsoleBackfillEntry { Level = "error", Message = "Compiler error" }
445
+ });
446
+
447
+ var response = _router.Dispatch("logs/status", 1, "{}");
448
+
449
+ Assert.That(response.error, Is.Null);
450
+
451
+ var result = (Dictionary<string, object>)response.result;
452
+ Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(2));
453
+
454
+ var byLevel = (Dictionary<string, object>)result["byLevel"];
455
+ Assert.That(Convert.ToInt32(byLevel["warning"]), Is.EqualTo(1));
456
+ Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(1));
457
+ }
458
+
459
+ [Test]
460
+ public void LogsTail_BackfillIgnoresUcpBridgeNoise()
461
+ {
462
+ LogsController.SetConsoleBackfillProviderForTests(() => new List<LogsController.ConsoleBackfillEntry>
463
+ {
464
+ new LogsController.ConsoleBackfillEntry { Level = "info", Message = "[UCP] Bridge server started on port 21342" },
465
+ new LogsController.ConsoleBackfillEntry { Level = "warning", Message = "User-facing warning" }
466
+ });
467
+
468
+ var response = _router.Dispatch("logs/tail", 1, "{\"count\":20}");
469
+
470
+ Assert.That(response.error, Is.Null);
471
+
472
+ var result = (Dictionary<string, object>)response.result;
473
+ Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(1));
474
+
475
+ var logs = (List<object>)result["logs"];
476
+ var only = (Dictionary<string, object>)logs[0];
477
+ Assert.That(only["messagePreview"], Is.EqualTo("User-facing warning"));
478
+ }
479
+
480
+ [Test]
481
+ public void LogsGet_CanReadBackfilledConsoleEntries()
482
+ {
483
+ LogsController.SetConsoleBackfillProviderForTests(() => new List<LogsController.ConsoleBackfillEntry>
484
+ {
485
+ new LogsController.ConsoleBackfillEntry
486
+ {
487
+ Level = "exception",
488
+ Message = "Backfilled exception",
489
+ StackTrace = "stack line 1\nstack line 2"
490
+ }
491
+ });
492
+
493
+ var tail = _router.Dispatch("logs/tail", 1, "{\"count\":20}");
494
+ Assert.That(tail.error, Is.Null);
495
+
496
+ var tailResult = (Dictionary<string, object>)tail.result;
497
+ var logs = (List<object>)tailResult["logs"];
498
+ var first = (Dictionary<string, object>)logs[0];
499
+ var id = Convert.ToInt64(first["id"]);
500
+
501
+ var get = _router.Dispatch("logs/get", 1, "{\"id\":" + id + "}");
502
+ Assert.That(get.error, Is.Null);
503
+
504
+ var getResult = (Dictionary<string, object>)get.result;
505
+ Assert.That(getResult["level"], Is.EqualTo("exception"));
506
+ Assert.That(getResult["message"], Is.EqualTo("Backfilled exception"));
507
+ Assert.That(getResult["stackTrace"], Is.EqualTo("stack line 1\nstack line 2"));
508
+ }
509
+
438
510
  [Test]
439
511
  public void ObjectLifecycle_CreateMutateAndDelete_WorksEndToEnd()
440
512
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.5.1",
3
+ "version": "0.5.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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Unity Control Protocol - CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {