#nullable disable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using Codely.Newtonsoft.Json; // Added for JsonSerializationException using Codely.Newtonsoft.Json.Linq; using UnityEditor; using UnityEditor.Compilation; // For CompilationPipeline using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; using UnityEngine.SceneManagement; using UnityTcp.Editor.Helpers; // For Response class using UnityTcp.Editor.Serialization; namespace UnityTcp.Editor.Tools { /// /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// public static partial class ManageGameObject { private const int MaxBatchOps = 10; // Shared JsonSerializer to avoid per-call allocation overhead private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings { Converters = new List { new Vector3Converter(), new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), new BoundsConverter(), new UnityEngineObjectConverter() } }); // --- Main Handler --- public static object HandleCommand(JObject @params) { if (@params == null) { return Response.Error("Parameters cannot be null."); } string action = @params["action"]?.ToString().ToLower(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } // Normalize public aliases to canonical server actions / params (keep parity with TS client) if (action == "set_component_properties") { action = "set_component_property"; @params["action"] = "set_component_property"; } // Back-compat alias: componentType (camelCase) -> component_type (snake_case) if (@params["component_type"] == null && @params["componentType"] != null) { @params["component_type"] = @params["componentType"]; } // Back-compat structured targetRef -> target + searchMethod if (@params["target"] == null && @params["targetRef"] is JObject targetRef) { if (targetRef["id"] != null) { @params["target"] = targetRef["id"]; @params["searchMethod"] = "by_id"; } else if (targetRef["hierarchy_path"] != null) { @params["target"] = targetRef["hierarchy_path"]; @params["searchMethod"] = "by_path"; } else if (targetRef["name"] != null) { @params["target"] = targetRef["name"]; @params["searchMethod"] = "by_name"; } } // --- Validate client_state_rev for write operations --- var writeActions = new[] { "create_batch", "edit_batch", "ensure_component", "ensure_renderer_material", "ensure_mesh_collider_mesh", "ensure_prefab_default_sprite", "create", "modify", "delete", "add_component", "remove_component", "set_component_property" }; if (Array.Exists(writeActions, a => a == action)) { var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); if (revConflict != null) return revConflict; } // Parameters used by various actions JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) string searchMethod = @params["searchMethod"]?.ToString().ToLower(); // Get common parameters (consolidated) string name = @params["name"]?.ToString(); string tag = @params["tag"]?.ToString(); string layer = @params["layer"]?.ToString(); JToken parentToken = @params["parent"]; // --- Add parameter for controlling non-public field inclusion --- bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true // --- End add parameter --- // --- Prefab Redirection Check --- string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; if ( !string.IsNullOrEmpty(targetPath) && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) if (action == "modify" || action == "set_component_property") { Debug.Log( $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." ); // Prepare params for ManageAsset.ModifyAsset JObject assetParams = new JObject(); assetParams["action"] = "modify"; // ManageAsset uses "modify" assetParams["path"] = targetPath; // Extract properties. // For 'set_component_property', combine componentName and componentProperties. // For 'modify', directly use componentProperties. JObject properties = null; if (action == "set_component_property") { string compName = @params["componentName"]?.ToString(); JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting if (string.IsNullOrEmpty(compName)) return Response.Error( "Missing 'componentName' for 'set_component_property' on prefab." ); if (compProps == null) return Response.Error( $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." ); properties = new JObject(); properties[compName] = compProps; } else // action == "modify" { properties = @params["componentProperties"] as JObject; if (properties == null) return Response.Error( "Missing 'componentProperties' for 'modify' action on prefab." ); } assetParams["properties"] = properties; // Call ManageAsset handler return ManageAsset.HandleCommand(assetParams); } else if ( action == "delete" || action == "add_component" || action == "remove_component" || action == "get_components" ) // Added get_components here too { // Explicitly block other modifications on the prefab asset itself via manage_gameobject return Response.Error( $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." ); } // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. // No specific handling needed here, the code below will run. } // --- End Prefab Redirection Check --- try { switch (action) { // Batch operation (strict) case "create_batch": return HandleCreateBatch(@params); case "edit_batch": return HandleEditBatch(@params); // Ensure operations (idempotent) case "ensure_component": return EnsureComponent(@params, targetToken, searchMethod); case "ensure_renderer_material": return EnsureRendererMaterial(@params, targetToken, searchMethod); case "ensure_mesh_collider_mesh": return EnsureMeshColliderMesh(@params, targetToken, searchMethod); case "ensure_prefab_default_sprite": return EnsurePrefabDefaultSprite(@params); // Regular operations case "create": return CreateGameObject(@params); case "modify": return ModifyGameObject(@params, targetToken, searchMethod); case "delete": return DeleteGameObject(targetToken, searchMethod); case "find": return FindGameObjects(@params, targetToken, searchMethod); case "list_children": return ListChildren(@params, targetToken, searchMethod); case "get_components": string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string if (getCompTarget == null) return Response.Error( "'target' parameter required for get_components." ); // Pass the includeNonPublicSerialized flag here return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); case "add_component": return AddComponentToTarget(@params, targetToken, searchMethod); case "remove_component": return RemoveComponentFromTarget(@params, targetToken, searchMethod); case "set_component_property": return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); case "select": return SelectGameObject(@params, targetToken, searchMethod); default: return Response.Error($"Unknown action: '{action}'. Valid actions include: create_batch, edit_batch, ensure_component, ensure_renderer_material, ensure_mesh_collider_mesh, ensure_prefab_default_sprite, create, modify, delete, find, list_children, get_components, add_component, remove_component, set_component_property, select."); } } catch (Exception e) { Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); return Response.Error($"Internal error processing action '{action}': {e.Message}"); } } // --- Action Implementations --- private static object CreateGameObject(JObject @params) { string name = @params["name"]?.ToString(); if (string.IsNullOrEmpty(name)) { return Response.Error("'name' parameter is required for 'create' action."); } // Get prefab creation parameters bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; string prefabPath = @params["prefabPath"]?.ToString(); string tag = @params["tag"]?.ToString(); // Get tag for creation string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check GameObject newGo = null; // Initialize as null // Guardrail: primitiveType creates MeshRenderer/MeshFilter primitives and conflicts with SpriteRenderer. // Keep parity with TS client validation. if (!string.IsNullOrEmpty(primitiveType) && @params["componentsToAdd"] is JArray cta) { foreach (var compToken in cta) { if (compToken?.Type == JTokenType.String && string.Equals(compToken.ToString(), "SpriteRenderer", StringComparison.OrdinalIgnoreCase)) { return Response.Error( "Cannot add 'SpriteRenderer' when creating a primitiveType GameObject. Create an empty GameObject (omit primitiveType) and add SpriteRenderer instead." ); } } } // --- Try Instantiating Prefab First --- string originalPrefabPath = prefabPath; // Keep original for messages if (!string.IsNullOrEmpty(prefabPath)) { // If no extension, search for the prefab by name if ( !prefabPath.Contains("/") && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) ) { string prefabNameOnly = prefabPath; Debug.Log( $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" ); string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); if (guids.Length == 0) { return Response.Error( $"Prefab named '{prefabNameOnly}' not found anywhere in the project." ); } else if (guids.Length > 1) { string foundPaths = string.Join( ", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) ); return Response.Error( $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." ); } else // Exactly one found { prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path Debug.Log( $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" ); } } else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. Debug.LogWarning( $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." ); prefabPath += ".prefab"; // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. } // The logic above now handles finding or assuming the .prefab extension. GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefabAsset != null) { try { // Instantiate the prefab, initially place it at the root // Parent will be set later if specified newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; if (newGo == null) { // This might happen if the asset exists but isn't a valid GameObject prefab somehow Debug.LogError( $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." ); return Response.Error( $"Failed to instantiate prefab at '{prefabPath}'." ); } // Name the instance based on the 'name' parameter, not the prefab's default name if (!string.IsNullOrEmpty(name)) { newGo.name = name; } // Register Undo for prefab instantiation Undo.RegisterCreatedObjectUndo( newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" ); Debug.Log( $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." ); } catch (Exception e) { return Response.Error( $"Error instantiating prefab '{prefabPath}': {e.Message}" ); } } else { // Only return error if prefabPath was specified but not found. // If prefabPath was empty/null, we proceed to create primitive/empty. Debug.LogWarning( $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." ); // Do not return error here, allow fallback to primitive/empty creation } } // --- Fallback: Create Primitive or Empty GameObject --- bool createdNewObject = false; // Flag to track if we created (not instantiated) if (newGo == null) // Only proceed if prefab instantiation didn't happen { if (!string.IsNullOrEmpty(primitiveType)) { try { PrimitiveType type = (PrimitiveType) Enum.Parse(typeof(PrimitiveType), primitiveType, true); newGo = GameObject.CreatePrimitive(type); // Set name *after* creation for primitives if (!string.IsNullOrEmpty(name)) { newGo.name = name; } else { UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak return Response.Error( "'name' parameter is required when creating a primitive." ); } createdNewObject = true; } catch (ArgumentException) { return Response.Error( $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" ); } catch (Exception e) { return Response.Error( $"Failed to create primitive '{primitiveType}': {e.Message}" ); } } else // Create empty GameObject { if (string.IsNullOrEmpty(name)) { return Response.Error( "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." ); } newGo = new GameObject(name); createdNewObject = true; } // Record creation for Undo *only* if we created a new object if (createdNewObject) { Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); } } // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- if (newGo == null) { // Should theoretically not happen if logic above is correct, but safety check. return Response.Error("Failed to create or instantiate the GameObject."); } // Record potential changes to the existing prefab instance or the new GO // Record transform separately in case parent changes affect it Undo.RecordObject(newGo.transform, "Set GameObject Transform"); Undo.RecordObject(newGo, "Set GameObject Properties"); // Set Parent JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding if (parentGo == null) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object return Response.Error($"Parent specified ('{parentToken}') but not found."); } newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true } // Set Transform Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue) newGo.transform.localPosition = position.Value; if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; if (scale.HasValue) newGo.transform.localScale = scale.Value; // Set Tag (added for create action) if (!string.IsNullOrEmpty(tag)) { // Similar logic as in ModifyGameObject for setting/creating tags string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { newGo.tag = tagToSet; } catch (UnityException ex) { if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." ); try { InternalEditorUtility.AddTag(tagToSet); newGo.tag = tagToSet; // Retry Debug.Log( $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." ); } } else { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return Response.Error( $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." ); } } } // Set Layer (new for create action) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId != -1) { newGo.layer = layerId; } else { Debug.LogWarning( $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." ); } } // Add Components if (@params["componentsToAdd"] is JArray componentsToAddArray) { foreach (var compToken in componentsToAddArray) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) { typeName = compToken.ToString(); } else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(newGo, typeName, properties); if (addResult != null) // Check if AddComponentInternal returned an error object { UnityEngine.Object.DestroyImmediate(newGo); // Clean up return addResult; // Return the error response } } else { Debug.LogWarning( $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" ); } } } // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true GameObject finalInstance = newGo; // Use this for selection and return data if (createdNewObject && saveAsPrefab) { string finalPrefabPath = prefabPath; // Use a separate variable for saving path // This check should now happen *before* attempting to save if (string.IsNullOrEmpty(finalPrefabPath)) { // Clean up the created object before returning error UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." ); } // Ensure the *saving* path ends with .prefab if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) { Debug.Log( $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" ); finalPrefabPath += ".prefab"; } try { // Ensure directory exists using the final saving path string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); if ( !string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath) ) { System.IO.Directory.CreateDirectory(directoryPath); AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder Debug.Log( $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" ); } // Use SaveAsPrefabAssetAndConnect with the final saving path finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( newGo, finalPrefabPath, InteractionMode.UserAction ); if (finalInstance == null) { // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) UnityEngine.Object.DestroyImmediate(newGo); return Response.Error( $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." ); } Debug.Log( $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." ); // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect } catch (Exception e) { // Clean up the instance if prefab saving fails UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); } } // Select the instance in the scene (either prefab instance or newly created/saved one) Selection.activeGameObject = finalInstance; // Determine appropriate success message using the potentially updated or original path string messagePrefabPath = finalInstance == null ? originalPrefabPath : AssetDatabase.GetAssetPath( PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance ); string successMessage; if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab { successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; } else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab { successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; } else // Created new primitive or empty GO, didn't save as prefab { successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; } // Use the new serializer helper //return Response.Success(successMessage, GetGameObjectData(finalInstance)); return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); } private static object ModifyGameObject( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } // Record state for Undo *before* modifications Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); Undo.RecordObject(targetGo, "Modify GameObject Properties"); bool modified = false; // Rename (using consolidated 'name' parameter) string name = @params["name"]?.ToString(); if (!string.IsNullOrEmpty(name) && targetGo.name != name) { targetGo.name = name; modified = true; } // Change Parent (using consolidated 'parent' parameter) JToken parentToken = @params["parent"]; if (parentToken != null) { GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Check for hierarchy loops if ( newParentGo == null && !( parentToken.Type == JTokenType.Null || ( parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()) ) ) ) { return Response.Error($"New parent ('{parentToken}') not found."); } if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) { return Response.Error( $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." ); } if (targetGo.transform.parent != (newParentGo?.transform)) { targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true modified = true; } } // Set Active State bool? setActive = @params["setActive"]?.ToObject(); if (setActive.HasValue && targetGo.activeSelf != setActive.Value) { targetGo.SetActive(setActive.Value); modified = true; } // Change Tag (using consolidated 'tag' parameter) string tag = @params["tag"]?.ToString(); // Only attempt to change tag if a non-null tag is provided and it's different from the current one. // Allow setting an empty string to remove the tag (Unity uses "Untagged"). if (tag != null && targetGo.tag != tag) { // Ensure the tag is not empty, if empty, it means "Untagged" implicitly string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; try { targetGo.tag = tagToSet; modified = true; } catch (UnityException ex) { // Check if the error is specifically because the tag doesn't exist if (ex.Message.Contains("is not defined")) { Debug.LogWarning( $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." ); try { // Attempt to create the tag using internal utility InternalEditorUtility.AddTag(tagToSet); // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. // yield return null; // Cannot yield here, editor script limitation // Retry setting the tag immediately after creation targetGo.tag = tagToSet; modified = true; Debug.Log( $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." ); } catch (Exception innerEx) { // Handle failure during tag creation or the second assignment attempt Debug.LogError( $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" ); return Response.Error( $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." ); } } else { // If the exception was for a different reason, return the original error return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); } } } // Change Layer (using consolidated 'layer' parameter) string layerName = @params["layer"]?.ToString(); if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); if (layerId == -1 && layerName != "Default") { return Response.Error( $"Invalid layer specified: '{layerName}'. Use a valid layer name." ); } if (layerId != -1 && targetGo.layer != layerId) { targetGo.layer = layerId; modified = true; } } // Transform Modifications Vector3? position = ParseVector3(@params["position"] as JArray); Vector3? rotation = ParseVector3(@params["rotation"] as JArray); Vector3? scale = ParseVector3(@params["scale"] as JArray); if (position.HasValue && targetGo.transform.localPosition != position.Value) { targetGo.transform.localPosition = position.Value; modified = true; } if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) { targetGo.transform.localEulerAngles = rotation.Value; modified = true; } if (scale.HasValue && targetGo.transform.localScale != scale.Value) { targetGo.transform.localScale = scale.Value; modified = true; } // --- Component Modifications --- // Note: These might need more specific Undo recording per component // Remove Components if (@params["componentsToRemove"] is JArray componentsToRemoveArray) { foreach (var compToken in componentsToRemoveArray) { // ... (parsing logic as in CreateGameObject) ... string typeName = compToken.ToString(); if (!string.IsNullOrEmpty(typeName)) { var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error if removal failed modified = true; } } } // Add Components (similar to create) if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) { foreach (var compToken in componentsToAddArrayModify) { string typeName = null; JObject properties = null; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } if (!string.IsNullOrEmpty(typeName)) { var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; modified = true; } } } // Set Component Properties var componentErrors = new List(); if (@params["componentProperties"] is JObject componentPropertiesObj) { foreach (var prop in componentPropertiesObj.Properties()) { string compName = prop.Name; JObject propertiesToSet = prop.Value as JObject; if (propertiesToSet != null) { var setResult = SetComponentPropertiesInternal( targetGo, compName, propertiesToSet ); if (setResult != null) { componentErrors.Add(setResult); } else { modified = true; } } } } // Return component errors if any occurred (after processing all components) if (componentErrors.Count > 0) { // Aggregate flattened error strings to make tests/API assertions simpler var aggregatedErrors = new System.Collections.Generic.List(); foreach (var errorObj in componentErrors) { try { var dataProp = errorObj?.GetType().GetProperty("data"); var dataVal = dataProp?.GetValue(errorObj); if (dataVal != null) { var errorsProp = dataVal.GetType().GetProperty("errors"); var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; if (errorsEnum != null) { foreach (var item in errorsEnum) { var s = item?.ToString(); if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); } } } } catch { } } return Response.Error( $"One or more component property operations failed on '{targetGo.name}'.", new { componentErrors = componentErrors, errors = aggregatedErrors } ); } if (!modified) { // Use the new serializer helper // return Response.Success( // $"No modifications applied to GameObject '{targetGo.name}'.", // GetGameObjectData(targetGo)); return Response.Success( $"No modifications applied to GameObject '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } EditorUtility.SetDirty(targetGo); // Mark scene as dirty // Use the new serializer helper return Response.Success( $"GameObject '{targetGo.name}' modified successfully.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // return Response.Success( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); } private static object DeleteGameObject(JToken targetToken, string searchMethod) { // Find potentially multiple objects if name/tag search is used without find_all=false implicitly List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety if (targets.Count == 0) { return Response.Error( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } List deletedObjects = new List(); foreach (var targetGo in targets) { if (targetGo != null) { string goName = targetGo.name; int goId = targetGo.GetInstanceID(); // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(targetGo); deletedObjects.Add(new { name = goName, instanceID = goId }); } } if (deletedObjects.Count > 0) { string message = targets.Count == 1 ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; return Response.Success(message, deletedObjects); } else { // Should not happen if targets.Count > 0 initially, but defensive check return Response.Error("Failed to delete target GameObject(s)."); } } /// /// Selects one or more GameObjects in the Unity Editor. /// This sets Selection.activeGameObject (for single) or Selection.objects (for multiple). /// private static object SelectGameObject(JObject @params, JToken targetToken, string searchMethod) { if (targetToken == null) { // Clear selection if no target specified Selection.activeGameObject = null; Selection.objects = new UnityEngine.Object[0]; return Response.Success("Selection cleared.", new { selectedCount = 0 }); } bool selectAll = @params?["selectAll"]?.ToObject() ?? false; List targets = FindObjectsInternal(targetToken, searchMethod, selectAll, @params); if (targets.Count == 0) { return Response.Error( $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } if (targets.Count == 1) { // Single selection Selection.activeGameObject = targets[0]; return Response.Success( $"Selected GameObject '{targets[0].name}'.", new { selectedCount = 1, selected = new[] { new { name = targets[0].name, instanceID = targets[0].GetInstanceID() } } } ); } else { // Multiple selection Selection.objects = targets.ToArray(); Selection.activeGameObject = targets[0]; // First one becomes active var selectedInfo = targets.Select(go => new { name = go.name, instanceID = go.GetInstanceID() }).ToList(); return Response.Success( $"Selected {targets.Count} GameObjects.", new { selectedCount = targets.Count, selected = selectedInfo } ); } } private static object FindGameObjects( JObject @params, JToken targetToken, string searchMethod ) { bool findAll = @params["findAll"]?.ToObject() ?? false; List foundObjects = FindObjectsInternal( targetToken, searchMethod, findAll, @params ); if (foundObjects.Count == 0) { return Response.Success("No matching GameObjects found.", new List()); } // Check if result would be too large if (foundObjects.Count > 100) { string searchTerm = @params?["searchTerm"]?.ToString() ?? targetToken?.ToString() ?? "unknown"; return Response.Error( $"Too many GameObjects found ({foundObjects.Count}). Response would be too large. " + "Hints to narrow scope:\n" + "1. Use more specific search criteria (exact names instead of partial matches)\n" + "2. Add 'findAll': false to get only the first match\n" + "3. Use different searchMethod: 'by_id', 'by_path', or 'by_name' for exact matches\n" + "4. Search within a specific parent object instead of the entire scene\n" + $"5. Found objects include: {string.Join(", ", foundObjects.Take(10).Select(go => go.name))}{(foundObjects.Count > 10 ? "..." : "")}" ); } // Use the new serializer helper //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); return Response.Success($"Found {results.Count} GameObject(s).", results); } private static object ListChildren( JObject @params, JToken targetToken, string searchMethod ) { if (targetToken == null) { return Response.Error( "'target' parameter is required for list_children. Provide a GameObject name, instance ID, or hierarchy path." ); } // Allow both names: includeInactive (preferred) and legacy searchInactive bool includeInactive = @params["includeInactive"]?.ToObject() ?? @params["searchInactive"]?.ToObject() ?? true; // Depth: 1 = direct children int depth = 1; try { var depthToken = @params["depth"] ?? @params["maxDepth"]; if (depthToken != null && depthToken.Type != JTokenType.Null) { var depthStr = depthToken.ToString().Trim(); if (int.TryParse(depthStr, out var parsedDepth)) { depth = parsedDepth; } else if (double.TryParse(depthStr, out var parsedDouble)) { depth = (int)parsedDouble; } } } catch { /* fall back to default */ } if (depth < 1) { return Response.Error("'depth' must be >= 1."); } // resultMode: auto | inline | file string resultMode = (@params["resultMode"]?.ToString() ?? "auto").Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(resultMode)) resultMode = "auto"; if (resultMode != "auto" && resultMode != "inline" && resultMode != "file") { return Response.Error( $"Invalid resultMode '{resultMode}'. Valid values: auto, inline, file." ); } int maxInlineItems = 200; try { var maxInlineToken = @params["maxInlineItems"] ?? @params["inlineLimit"]; if (maxInlineToken != null && maxInlineToken.Type != JTokenType.Null) { var s = maxInlineToken.ToString().Trim(); if (int.TryParse(s, out var parsed)) { maxInlineItems = parsed; } else if (double.TryParse(s, out var parsedDouble)) { maxInlineItems = (int)parsedDouble; } } } catch { /* fall back */ } if (maxInlineItems < 1) maxInlineItems = 1; // Resolve the target first (respect includeInactive via searchInactive) var findParams = new JObject(); findParams["searchInactive"] = includeInactive; GameObject targetGo = FindObjectInternal(targetToken, searchMethod, findParams); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.\n" + "Next steps:\n" + "1. Use action='find' with searchMethod='by_name' or 'by_path' to locate the exact object\n" + "2. If you have an instanceID, set searchMethod='by_id'\n" + "3. If path contains '/', prefer searchMethod='by_path' (e.g. 'Root/Child')" ); } // Determine whether to return inline tree or write to file (no truncation). // We do a bounded count first so "auto" can decide without building the entire tree in memory. int boundedCount = CountDescendantsUpToDepth(targetGo.transform, depth, includeInactive, maxInlineItems + 1); bool shouldWriteToFile = resultMode == "file" || (resultMode == "auto" && boundedCount > maxInlineItems); if (resultMode == "inline" && boundedCount > maxInlineItems) { return Response.Error( $"Result too large to return inline (>{maxInlineItems} nodes within depth={depth}). This tool will not truncate.\n" + "Next steps:\n" + "1. Set resultMode='file' (or 'auto') to write full JSON to disk\n" + "2. Reduce depth (default is 1)\n" + "3. Set includeInactive=false to reduce scope" ); } if (shouldWriteToFile) { try { string outputAssetsPath = ResolveOutputPathUnderAssets(@params, targetGo, "unity_gameobject_list_children"); string outputDiskPath = ResolveAssetsPathToDiskPath(outputAssetsPath); // Stream JSON to file using iterative depth-limited traversal (no recursion). int writtenCount = 0; int visitedCount = 0; using (var sw = new StreamWriter(outputDiskPath, false, new System.Text.UTF8Encoding(false))) using (var jw = new JsonTextWriter(sw) { Formatting = Formatting.None }) { jw.WriteStartObject(); jw.WritePropertyName("target"); WriteNodeSummary(jw, targetGo, "", 0, includeChildrenArray: false); jw.WritePropertyName("params"); jw.WriteStartObject(); jw.WritePropertyName("depth"); jw.WriteValue(depth); jw.WritePropertyName("includeInactive"); jw.WriteValue(includeInactive); jw.WritePropertyName("resultMode"); jw.WriteValue("file"); jw.WriteEndObject(); jw.WritePropertyName("children"); jw.WriteStartArray(); WriteChildrenTreeIterative(jw, targetGo.transform, depth, includeInactive, ref writtenCount, ref visitedCount); jw.WriteEndArray(); jw.WritePropertyName("meta"); jw.WriteStartObject(); jw.WritePropertyName("includedCount"); jw.WriteValue(writtenCount); jw.WritePropertyName("visitedCount"); jw.WriteValue(visitedCount); jw.WriteEndObject(); jw.WriteEndObject(); jw.Flush(); } // Make it visible in Unity as a text asset (best-effort) try { AssetDatabase.ImportAsset(outputAssetsPath); } catch { } return Response.Success( $"Listed child GameObjects of '{targetGo.name}' (tree up to depth={depth}). Output written to: {outputAssetsPath}", new { target = CreateGameObjectChildSummary(targetGo, "", 0), depth = depth, includeInactive = includeInactive, includedCount = writtenCount, visitedCount = visitedCount, output = new { mode = "file", path = outputAssetsPath }, hints = new[] { "If the result is still too large, reduce 'depth' (default is 1).", "Set 'includeInactive': false to skip inactive subtrees.", "If the target is ambiguous/not found, use action='find' with searchMethod='by_path' (e.g. 'Root/Child') or searchMethod='by_id' (instanceID).", "You can set 'outputPath' under Assets/ to control where the JSON is written.", "If you really want inline output, increase 'maxInlineItems' or keep 'depth' small (no truncation)." } } ); } catch (Exception e) { return Response.Error($"Error listing children to file: {e.Message}"); } } // Inline: build a tree structure in memory (depth-limited, non-recursive). try { int visitedCount = 0; int includedCount = 0; var children = BuildChildrenTreeIterative(targetGo.transform, depth, includeInactive, ref includedCount, ref visitedCount); return Response.Success( $"Listed child GameObjects of '{targetGo.name}' (tree up to depth={depth}).", new { target = CreateGameObjectChildSummary(targetGo, "", 0), depth = depth, includeInactive = includeInactive, includedCount = includedCount, visitedCount = visitedCount, children = children, output = new { mode = "inline" } } ); } catch (Exception e) { return Response.Error($"Error listing children: {e.Message}"); } } private static int CountDescendantsUpToDepth(Transform root, int maxDepth, bool includeInactive, int cap) { if (root == null || maxDepth < 1) return 0; int count = 0; var q = new Queue>(); int childCount = root.childCount; for (int i = 0; i < childCount; i++) { var c = root.GetChild(i); if (c != null) q.Enqueue(new Tuple(c, 1)); } while (q.Count > 0) { var item = q.Dequeue(); var tr = item.Item1; int d = item.Item2; if (tr == null) continue; var go = tr.gameObject; if (go == null) continue; if (!includeInactive && !go.activeInHierarchy) { // Skip subtree continue; } count++; if (count >= cap) return cap; if (d < maxDepth) { int cc = tr.childCount; for (int i = 0; i < cc; i++) { var child = tr.GetChild(i); if (child != null) q.Enqueue(new Tuple(child, d + 1)); } } } return count; } private static List BuildChildrenTreeIterative( Transform root, int maxDepth, bool includeInactive, ref int includedCount, ref int visitedCount ) { var result = new List(); if (root == null || maxDepth < 1) return result; // Frame: (transform, depth, relativePath, childrenListRef, nextChildIndex) var stack = new Stack, int>>(); // Seed with direct children in reverse order so output preserves sibling order for (int i = root.childCount - 1; i >= 0; i--) { var c = root.GetChild(i); if (c != null) { stack.Push(new Tuple, int>(c, 1, c.name, result, 0)); } } // Track node state separately: map Transform instanceID to its node + children list. // Using instanceID avoids holding Transform keys for long. var nodeById = new Dictionary>(); while (stack.Count > 0) { var frame = stack.Pop(); var tr = frame.Item1; int d = frame.Item2; string relPath = frame.Item3; var parentChildren = frame.Item4; int nextChildIndex = frame.Item5; if (tr == null) continue; var go = tr.gameObject; if (go == null) continue; visitedCount++; if (!includeInactive && !go.activeInHierarchy) { // Skip subtree entirely continue; } int id = go.GetInstanceID(); if (!nodeById.TryGetValue(id, out var node)) { // First time: create node and attach to parent node = CreateGameObjectChildSummary(go, relPath, d); var children = new List(); node["children"] = children; parentChildren.Add(node); includedCount++; nodeById[id] = node; // Prepare to traverse children if we can go deeper if (d < maxDepth && tr.childCount > 0) { // Push a continuation frame for this node after its children, then push children frames // Continuation not needed because we store children directly; we just push children frames. var childList = (List)node["children"]; for (int i = tr.childCount - 1; i >= 0; i--) { var c = tr.GetChild(i); if (c != null) { string childRel = string.IsNullOrEmpty(relPath) ? c.name : relPath + "/" + c.name; stack.Push(new Tuple, int>(c, d + 1, childRel, childList, 0)); } } } } else { // Should not generally happen in a tree, but keep safety (no-op). // nextChildIndex currently unused; kept for future extensions. _ = nextChildIndex; } } return result; } private static void WriteChildrenTreeIterative( JsonTextWriter jw, Transform root, int maxDepth, bool includeInactive, ref int includedCount, ref int visitedCount ) { if (root == null || maxDepth < 1) return; // Stack frame for streaming JSON without recursion: // (transform, depth, relativePath, childIndex, childCount, started) var stack = new Stack>(); // Seed direct children in reverse order so they are written in ascending sibling order for (int i = root.childCount - 1; i >= 0; i--) { var c = root.GetChild(i); if (c != null) { stack.Push(new Tuple(c, 1, c.name, 0, 0, false)); } } while (stack.Count > 0) { var frame = stack.Pop(); var tr = frame.Item1; int d = frame.Item2; string relPath = frame.Item3; int childIndex = frame.Item4; int childCount = frame.Item5; bool started = frame.Item6; if (tr == null) continue; var go = tr.gameObject; if (go == null) continue; if (!started) { visitedCount++; if (!includeInactive && !go.activeInHierarchy) { // Skip subtree entirely continue; } // Start node object WriteNodeSummary(jw, go, relPath, d, includeChildrenArray: true); includedCount++; // Prepare to write children if within depth childCount = (d < maxDepth) ? tr.childCount : 0; // Push continuation frame to close this node after children are written stack.Push(new Tuple(tr, d, relPath, 0, childCount, true)); // Push children frames (reverse order for stable output order) for (int i = childCount - 1; i >= 0; i--) { var c = tr.GetChild(i); if (c != null) { string childRel = string.IsNullOrEmpty(relPath) ? c.name : relPath + "/" + c.name; stack.Push(new Tuple(c, d + 1, childRel, 0, 0, false)); } } } else { // Close children array + object jw.WriteEndArray(); jw.WriteEndObject(); } } } private static string ResolveOutputPathUnderAssets(JObject @params, GameObject targetGo, string prefix) { string requested = @params["outputPath"]?.ToString(); if (string.IsNullOrEmpty(requested)) { string dir = "Assets/Codely/ToolOutputs"; string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); requested = $"{dir}/{prefix}_{timestamp}_{targetGo.GetInstanceID()}.json"; } requested = requested.Replace('\\', '/').Trim(); if (!requested.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { throw new Exception($"outputPath must start with 'Assets/'. Provided: '{requested}'"); } return requested.Replace('\\', '/'); } private static string ResolveAssetsPathToDiskPath(string assetsPath) { string requested = (assetsPath ?? "").Replace('\\', '/').Trim(); if (!requested.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { throw new Exception($"Path must start with 'Assets/': '{requested}'"); } string assetsDisk = Application.dataPath.Replace('\\', '/'); string relUnderAssets = requested.Substring("Assets/".Length).TrimStart('/'); string diskPath = Path.Combine(assetsDisk, relUnderAssets).Replace('\\', '/'); string fullDisk = Path.GetFullPath(diskPath).Replace('\\', '/'); string assetsFull = Path.GetFullPath(assetsDisk).Replace('\\', '/'); if ( !fullDisk.StartsWith(assetsFull + "/", StringComparison.OrdinalIgnoreCase) && !string.Equals(fullDisk, assetsFull, StringComparison.OrdinalIgnoreCase) ) { throw new Exception($"outputPath escapes Assets/. Resolved: '{fullDisk}'"); } string fullDir = Path.GetDirectoryName(fullDisk); if (!string.IsNullOrEmpty(fullDir)) { Directory.CreateDirectory(fullDir); } return fullDisk; } private static void WriteNodeSummary( JsonTextWriter jw, GameObject go, string relativePathFromTarget, int depthFromTarget, bool includeChildrenArray ) { jw.WriteStartObject(); jw.WritePropertyName("name"); jw.WriteValue(go.name); jw.WritePropertyName("instanceID"); jw.WriteValue(go.GetInstanceID()); jw.WritePropertyName("hierarchyPath"); jw.WriteValue(GetHierarchyPath(go)); jw.WritePropertyName("relativePath"); jw.WriteValue(relativePathFromTarget ?? string.Empty); jw.WritePropertyName("depth"); jw.WriteValue(depthFromTarget); jw.WritePropertyName("activeSelf"); jw.WriteValue(go.activeSelf); jw.WritePropertyName("activeInHierarchy"); jw.WriteValue(go.activeInHierarchy); jw.WritePropertyName("tag"); jw.WriteValue(go.tag); jw.WritePropertyName("layer"); jw.WriteValue(go.layer); jw.WritePropertyName("isStatic"); jw.WriteValue(go.isStatic); jw.WritePropertyName("childCount"); jw.WriteValue(go.transform != null ? go.transform.childCount : 0); jw.WritePropertyName("siblingIndex"); jw.WriteValue(go.transform != null ? go.transform.GetSiblingIndex() : 0); jw.WritePropertyName("scenePath"); jw.WriteValue(go.scene.path ?? string.Empty); if (includeChildrenArray) { jw.WritePropertyName("children"); jw.WriteStartArray(); } // Caller closes children/object appropriately. } private static Dictionary CreateGameObjectChildSummary( GameObject go, string relativePathFromTarget, int depthFromTarget ) { if (go == null) { return new Dictionary(); } var tr = go.transform; return new Dictionary { { "name", go.name }, { "instanceID", go.GetInstanceID() }, { "hierarchyPath", GetHierarchyPath(go) }, { "relativePath", relativePathFromTarget ?? string.Empty }, { "depth", depthFromTarget }, { "activeSelf", go.activeSelf }, { "activeInHierarchy", go.activeInHierarchy }, { "tag", go.tag }, { "layer", go.layer }, { "isStatic", go.isStatic }, { "childCount", tr != null ? tr.childCount : 0 }, { "siblingIndex", tr != null ? tr.GetSiblingIndex() : 0 }, { "scenePath", go.scene.path ?? string.Empty }, }; } private static string GetHierarchyPath(GameObject go) { if (go == null) return string.Empty; var path = go.name; var parent = go.transform != null ? go.transform.parent : null; while (parent != null) { path = parent.name + "/" + path; parent = parent.parent; } return path; } private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) { GameObject targetGo = FindObjectInternal(target, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." ); } try { // --- Get components, immediately copy to list, and null original array --- Component[] originalComponents = targetGo.GetComponents(); List componentsToIterate = new List(originalComponents ?? Array.Empty()); // Copy immediately, handle null case int componentCount = componentsToIterate.Count; originalComponents = null; // Null the original reference // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); // --- End Copy and Null --- // Check if component serialization would be too large if (componentCount > 50) { return Response.Error( $"GameObject '{targetGo.name}' has too many components ({componentCount}). Response would be too large. " + "Hints to narrow scope:\n" + "1. Use 'manage_gameobject' with action='find' to get basic GameObject info without components\n" + "2. Query specific components by type instead of all components\n" + "3. Use 'includeNonPublicSerialized': false to reduce serialized data per component\n" + $"4. Component types found: {string.Join(", ", componentsToIterate.Take(10).Select(c => c.GetType().Name))}{(componentCount > 10 ? "..." : "")}" ); } var componentData = new List(); for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY { Component c = componentsToIterate[i]; // Use the copy if (c == null) { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); continue; // Safety check } // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); try { var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); if (data != null) // Ensure GetComponentData didn't return null { componentData.Insert(0, data); // Insert at beginning to maintain original order in final list } // else // { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); // } } catch (Exception ex) { Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); // Optionally add placeholder data or just skip componentData.Insert(0, new JObject( // Insert error marker at beginning new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), new JProperty("instanceID", c.GetInstanceID()), new JProperty("error", ex.Message) )); } } // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); // Cleanup the list we created componentsToIterate.Clear(); componentsToIterate = null; return Response.Success( $"Retrieved {componentData.Count} components from '{targetGo.name}'.", componentData // List was built in original order ); } catch (Exception e) { return Response.Error( $"Error getting components from '{targetGo.name}': {e.Message}" ); } } private static object AddComponentToTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; JObject properties = null; // Allow adding component specified directly or via componentsToAdd array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name } else if ( @params["componentsToAdd"] is JArray componentsToAddArray && componentsToAddArray.Count > 0 ) { var compToken = componentsToAddArray.First; if (compToken.Type == JTokenType.String) typeName = compToken.ToString(); else if (compToken is JObject compObj) { typeName = compObj["typeName"]?.ToString(); properties = compObj["properties"] as JObject; } } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToAdd') is required." ); } var addResult = AddComponentInternal(targetGo, typeName, properties); if (addResult != null) return addResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' added to '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); // Return updated GO data } private static object RemoveComponentFromTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string typeName = null; // Allow removing component specified directly or via componentsToRemove array (take first) if (@params["componentName"] != null) { typeName = @params["componentName"]?.ToString(); } else if ( @params["componentsToRemove"] is JArray componentsToRemoveArray && componentsToRemoveArray.Count > 0 ) { typeName = componentsToRemoveArray.First?.ToString(); } if (string.IsNullOrEmpty(typeName)) { return Response.Error( "Component type name ('componentName' or first element in 'componentsToRemove') is required." ); } var removeResult = RemoveComponentInternal(targetGo, typeName); if (removeResult != null) return removeResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } private static object SetComponentPropertyOnTarget( JObject @params, JToken targetToken, string searchMethod ) { GameObject targetGo = FindObjectInternal(targetToken, searchMethod); if (targetGo == null) { return Response.Error( $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." ); } string compName = @params["componentName"]?.ToString(); JObject propertiesToSet = null; if (!string.IsNullOrEmpty(compName)) { // Properties might be directly under componentProperties or nested under the component name if (@params["componentProperties"] is JObject compProps) { propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure } // Support simplified propertyName + propertyValue format for compatibility else if (@params["propertyName"] != null && @params["propertyValue"] != null) { string propName = @params["propertyName"].ToString(); JToken propValue = @params["propertyValue"]; propertiesToSet = new JObject { [propName] = propValue }; } } else { return Response.Error("'componentName' parameter is required."); } if (propertiesToSet == null || !propertiesToSet.HasValues) { return Response.Error( "'componentProperties' dictionary or 'propertyName'/'propertyValue' pair is required." ); } var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); if (setResult != null) return setResult; // Return error EditorUtility.SetDirty(targetGo); // Use the new serializer helper return Response.Success( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) ); } // --- Internal Helpers --- /// /// Parses a JArray like [x, y, z] into a Vector3. /// private static Vector3? ParseVector3(JArray array) { if (array != null && array.Count == 3) { try { return new Vector3( array[0].ToObject(), array[1].ToObject(), array[2].ToObject() ); } catch (Exception ex) { Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); } } return null; } /// /// Finds a single GameObject based on token (ID, name, path) and search method. /// private static GameObject FindObjectInternal( JToken targetToken, string searchMethod, JObject findParams = null ) { // If find_all is not explicitly false, we still want only one for most single-target operations. bool findAll = findParams?["findAll"]?.ToObject() ?? false; // If a specific target ID is given, always find just that one. if ( targetToken?.Type == JTokenType.Integer || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) ) { findAll = false; } List results = FindObjectsInternal( targetToken, searchMethod, findAll, findParams ); return results.Count > 0 ? results[0] : null; } /// /// Core logic for finding GameObjects based on various criteria. /// private static List FindObjectsInternal( JToken targetToken, string searchMethod, bool findAll, JObject findParams = null ) { List results = new List(); string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? true; bool hasExplicitSearchTerm = findParams != null && findParams["searchTerm"] != null && findParams["searchTerm"].Type != JTokenType.Null; // Default search method if not specified if (string.IsNullOrEmpty(searchMethod)) { if (targetToken?.Type == JTokenType.Integer) searchMethod = "by_id"; else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) searchMethod = "by_path"; else searchMethod = "by_name"; // Default fallback } GameObject rootSearchObject = null; // If searching in children, find the initial target first if (searchInChildren && targetToken != null) { rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search if (rootSearchObject == null) { Debug.LogWarning( $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." ); return results; // Return empty if root not found } } switch (searchMethod) { case "by_id": if (int.TryParse(searchTerm, out int instanceId)) { // EditorUtility.InstanceIDToObject is slow, iterate manually if possible // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; var allObjects = GetAllSceneObjects(true); // Fetch all, filter by active state below GameObject obj = allObjects.FirstOrDefault(go => go != null && go.GetInstanceID() == instanceId ); if (obj != null && (searchInactive || obj.activeInHierarchy)) results.Add(obj); } break; case "by_name": var searchPoolName = rootSearchObject ? rootSearchObject .GetComponentsInChildren(true) .Select(t => t.gameObject) : GetAllSceneObjects(true); if (string.IsNullOrEmpty(searchTerm)) { break; } // When searchTerm is explicitly provided, treat it as a fuzzy "contains" filter (case-insensitive). // When only target is provided (no explicit searchTerm), keep exact name matching semantics. if (hasExplicitSearchTerm) { results.AddRange( searchPoolName.Where(go => go != null && !string.IsNullOrEmpty(go.name) && go.name.IndexOf( searchTerm, StringComparison.OrdinalIgnoreCase ) >= 0 && (searchInactive || go.activeInHierarchy) ) ); } else { results.AddRange( searchPoolName.Where(go => go != null && string.Equals( go.name, searchTerm, StringComparison.OrdinalIgnoreCase ) && (searchInactive || go.activeInHierarchy) ) ); } break; case "by_path": // Path is relative to scene root or rootSearchObject Transform foundTransform = rootSearchObject ? rootSearchObject.transform.Find(searchTerm) : GameObject.Find(searchTerm)?.transform; if ( foundTransform != null && (searchInactive || foundTransform.gameObject.activeInHierarchy) ) results.Add(foundTransform.gameObject); break; case "by_tag": var searchPoolTag = rootSearchObject ? rootSearchObject .GetComponentsInChildren(true) .Select(t => t.gameObject) : GetAllSceneObjects(true); results.AddRange( searchPoolTag.Where(go => go != null && go.CompareTag(searchTerm) && (searchInactive || go.activeInHierarchy) ) ); break; case "by_layer": var searchPoolLayer = rootSearchObject ? rootSearchObject .GetComponentsInChildren(true) .Select(t => t.gameObject) : GetAllSceneObjects(true); if (int.TryParse(searchTerm, out int layerIndex)) { results.AddRange( searchPoolLayer.Where(go => go != null && go.layer == layerIndex && (searchInactive || go.activeInHierarchy) ) ); } else { int namedLayer = LayerMask.NameToLayer(searchTerm); if (namedLayer != -1) results.AddRange( searchPoolLayer.Where(go => go != null && go.layer == namedLayer && (searchInactive || go.activeInHierarchy) ) ); } break; case "by_component": Type componentType = FindType(searchTerm); if (componentType != null) { IEnumerable searchPoolComp; if (rootSearchObject) { searchPoolComp = rootSearchObject .GetComponentsInChildren(componentType, searchInactive) .Select(c => (c as Component).gameObject); } else { #if UNITY_2022_2_OR_NEWER // Use FindObjectsByType for Unity 2022.2+ FindObjectsInactive findInactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; searchPoolComp = UnityEngine .Object.FindObjectsByType( componentType, findInactive, FindObjectsSortMode.None ) .Select(c => (c as Component).gameObject); #else // Use deprecated FindObjectsOfType for Unity 2021 and earlier searchPoolComp = UnityEngine .Object.FindObjectsOfType(componentType, searchInactive) .Select(c => (c as Component).gameObject); #endif } results.AddRange( searchPoolComp.Where(go => go != null && (searchInactive || go.activeInHierarchy) ) ); // Ensure GO is valid and respects active filter } else { Debug.LogWarning( $"[ManageGameObject.Find] Component type not found: {searchTerm}" ); } break; case "by_id_or_name_or_path": // Helper method used internally if (int.TryParse(searchTerm, out int id)) { var allObjectsId = GetAllSceneObjects(true); // Internal helper always sees all, filtered below GameObject objById = allObjectsId.FirstOrDefault(go => go != null && go.GetInstanceID() == id ); if (objById != null && (searchInactive || objById.activeInHierarchy)) { results.Add(objById); break; } } GameObject objByPath = GameObject.Find(searchTerm); if (objByPath != null && (searchInactive || objByPath.activeInHierarchy)) { results.Add(objByPath); break; } var allObjectsName = GetAllSceneObjects(true); results.AddRange( allObjectsName.Where(go => go != null && string.Equals( go.name, searchTerm, StringComparison.OrdinalIgnoreCase ) && (searchInactive || go.activeInHierarchy) ) ); break; default: Debug.LogWarning( $"[ManageGameObject.Find] Unknown search method: {searchMethod}" ); break; } // If only one result is needed, return just the first one found. if (!findAll && results.Count > 1) { return new List { results[0] }; } return results.Distinct().ToList(); // Ensure uniqueness } // Helper to get all scene objects efficiently private static IEnumerable GetAllSceneObjects(bool includeInactive) { // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); var allObjects = new List(); foreach (var root in rootObjects) { allObjects.AddRange( root.GetComponentsInChildren(includeInactive) .Select(t => t.gameObject) ); } return allObjects; } /// /// Adds a component by type name and optionally sets properties. /// Returns null on success, or an error response object on failure. /// private static object AddComponentInternal( GameObject targetGo, string typeName, JObject properties ) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error( $"Component type '{typeName}' not found or is not a valid Component." ); } if (!typeof(Component).IsAssignableFrom(componentType)) { return Response.Error($"Type '{typeName}' is not a Component."); } // Prevent adding Transform again if (componentType == typeof(Transform)) { return Response.Error("Cannot add another Transform component."); } // Check for 2D/3D physics component conflicts bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); if (isAdding2DPhysics) { // Check if the GameObject already has any 3D Rigidbody or Collider if ( targetGo.GetComponent() != null || targetGo.GetComponent() != null ) { return Response.Error( $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." ); } } else if (isAdding3DPhysics) { // Check if the GameObject already has any 2D Rigidbody or Collider if ( targetGo.GetComponent() != null || targetGo.GetComponent() != null ) { return Response.Error( $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." ); } } try { // Use Undo.AddComponent for undo support Component newComponent = Undo.AddComponent(targetGo, componentType); if (newComponent == null) { return Response.Error( $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." ); } // Set default values for specific component types if (newComponent is Light light) { // Default newly added lights to directional light.type = LightType.Directional; } // Set properties if provided if (properties != null) { var setResult = SetComponentPropertiesInternal( targetGo, typeName, properties, newComponent ); // Pass the new component instance if (setResult != null) { // If setting properties failed, maybe remove the added component? Undo.DestroyObjectImmediate(newComponent); return setResult; // Return the error from setting properties } } return null; // Success } catch (Exception e) { return Response.Error( $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" ); } } /// /// Removes a component by type name. /// Returns null on success, or an error response object on failure. /// private static object RemoveComponentInternal(GameObject targetGo, string typeName) { Type componentType = FindType(typeName); if (componentType == null) { return Response.Error($"Component type '{typeName}' not found for removal."); } // Prevent removing essential components if (componentType == typeof(Transform)) { return Response.Error("Cannot remove the Transform component."); } Component componentToRemove = targetGo.GetComponent(componentType); if (componentToRemove == null) { return Response.Error( $"Component '{typeName}' not found on '{targetGo.name}' to remove." ); } try { // Handle known dependency chains before removing the primary component. // Example: In URP, UniversalAdditionalLightData depends on Light, so we should // remove the additional data component first to allow Light removal. TryRemoveDependentComponents(targetGo, componentType); // Use Undo.DestroyObjectImmediate for undo support Undo.DestroyObjectImmediate(componentToRemove); return null; // Success } catch (Exception e) { return Response.Error( $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" ); } } /// /// Removes known dependent components that would otherwise block removal of the primary component. /// This keeps behavior robust across render pipelines (e.g., URP additional light data). /// private static void TryRemoveDependentComponents(GameObject targetGo, Type primaryComponentType) { try { // Special-case Light in URP: UniversalAdditionalLightData depends on Light. if (primaryComponentType == typeof(Light)) { // Try to resolve the URP additional light data type without hard assembly references. Type urpAdditionalLightType = FindType("UnityEngine.Rendering.Universal.UniversalAdditionalLightData"); if (urpAdditionalLightType != null) { Component extra = targetGo.GetComponent(urpAdditionalLightType); if (extra != null) { Undo.DestroyObjectImmediate(extra); } } } } catch (Exception ex) { Debug.LogWarning( $"[ManageGameObject] Failed to remove dependent components for '{primaryComponentType.FullName}' on '{targetGo.name}': {ex.Message}" ); } } /// /// Sets properties on a component. /// Returns null on success, or an error response object on failure. /// private static object SetComponentPropertiesInternal( GameObject targetGo, string compName, JObject propertiesToSet, Component targetComponentInstance = null ) { Component targetComponent = targetComponentInstance; if (targetComponent == null) { if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) { targetComponent = targetGo.GetComponent(compType); } else { targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup } } if (targetComponent == null) { return Response.Error( $"Component '{compName}' not found on '{targetGo.name}' to set properties." ); } Undo.RecordObject(targetComponent, "Set Component Properties"); var failures = new List(); foreach (var prop in propertiesToSet.Properties()) { string propName = prop.Name; JToken propValue = prop.Value; try { string failureCode; string failureMessage; bool setResult = SetProperty(targetComponent, propName, propValue, out failureCode, out failureMessage); if (!setResult) { string msg; if (failureCode == "conversion_failed" || failureCode == "exception") { msg = failureMessage ?? $"Conversion failed for '{propName}'."; } else { var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); msg = suggestions.Any() ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; } Debug.LogWarning($"[ManageGameObject] {msg}"); failures.Add(new { property = propName, code = failureCode ?? "member_not_found", message = msg }); } } catch (Exception e) { Debug.LogError( $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" ); failures.Add(new { property = propName, code = "exception", message = $"Error setting '{propName}': {e.Message}" }); } } EditorUtility.SetDirty(targetComponent); return failures.Count == 0 ? null : new { success = false, code = "set_component_property_failed", message = $"One or more properties failed on '{compName}'.", data = new { errors = failures } }; } /// /// Helper to set a property or field via reflection, handling basic types. /// private static bool SetProperty(object target, string memberName, JToken value, out string failureCode, out string failureMessage) { failureCode = null; failureMessage = null; Type type = target.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Use shared serializer to avoid per-call allocation var inputSerializer = InputSerializer; try { // Handle special case for materials with dot notation (material.property) // Examples: material.color, sharedMaterial.color, materials[0].color if (memberName.Contains('.') || memberName.Contains('[')) { // Pass the inputSerializer down for nested conversions bool ok = SetNestedProperty(target, memberName, value, inputSerializer); if (!ok) { failureCode = "member_not_found"; } return ok; } PropertyInfo propInfo = type.GetProperty(memberName, flags); if (propInfo != null && propInfo.CanWrite) { object convertedValue = null; try { // Use the inputSerializer for conversion convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); } catch (Exception ex) { failureCode = "conversion_failed"; failureMessage = $"Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.FullName}): {ex.Message}"; return false; } if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { propInfo.SetValue(target, convertedValue); return true; } failureCode = "conversion_failed"; failureMessage = $"Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.FullName}) from token: {value.ToString(Formatting.None)}"; return false; } else { FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) // Check if !IsLiteral? { object convertedValue = null; try { // Use the inputSerializer for conversion convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); } catch (Exception ex) { failureCode = "conversion_failed"; failureMessage = $"Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.FullName}): {ex.Message}"; return false; } if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { fieldInfo.SetValue(target, convertedValue); return true; } failureCode = "conversion_failed"; failureMessage = $"Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.FullName}) from token: {value.ToString(Formatting.None)}"; return false; } else { // Try NonPublic [SerializeField] fields var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); if (npField != null && npField.GetCustomAttribute() != null) { object convertedValue = null; try { convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); } catch (Exception ex) { failureCode = "conversion_failed"; failureMessage = $"Conversion failed for field '{memberName}' (Type: {npField.FieldType.FullName}): {ex.Message}"; return false; } if (convertedValue != null || value.Type == JTokenType.Null) { npField.SetValue(target, convertedValue); return true; } failureCode = "conversion_failed"; failureMessage = $"Conversion failed for field '{memberName}' (Type: {npField.FieldType.FullName}) from token: {value.ToString(Formatting.None)}"; return false; } } } } catch (Exception ex) { failureCode = "exception"; failureMessage = $"Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}"; } if (failureCode == null) { failureCode = "member_not_found"; } return false; } /// /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") /// // Pass the input serializer for conversions //Using the serializer helper private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) { try { // Split the path into parts (handling both dot notation and array indexing) string[] pathParts = SplitPropertyPath(path); if (pathParts.Length == 0) return false; object currentObject = target; Type currentType = currentObject.GetType(); BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Traverse the path until we reach the final property for (int i = 0; i < pathParts.Length - 1; i++) { string part = pathParts[i]; bool isArray = false; int arrayIndex = -1; // Check if this part contains array indexing if (part.Contains("[")) { int startBracket = part.IndexOf('['); int endBracket = part.IndexOf(']'); if (startBracket > 0 && endBracket > startBracket) { string indexStr = part.Substring( startBracket + 1, endBracket - startBracket - 1 ); if (int.TryParse(indexStr, out arrayIndex)) { isArray = true; part = part.Substring(0, startBracket); } } } // Get the property/field PropertyInfo propInfo = currentType.GetProperty(part, flags); FieldInfo fieldInfo = null; if (propInfo == null) { fieldInfo = currentType.GetField(part, flags); if (fieldInfo == null) { Debug.LogWarning( $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" ); return false; } } // Get the value currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); //Need to stop if current property is null if (currentObject == null) { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." ); return false; } // If this part was an array or list, access the specific index if (isArray) { if (currentObject is Material[]) { var materials = currentObject as Material[]; if (arrayIndex < 0 || arrayIndex >= materials.Length) { Debug.LogWarning( $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" ); return false; } currentObject = materials[arrayIndex]; } else if (currentObject is System.Collections.IList) { var list = currentObject as System.Collections.IList; if (arrayIndex < 0 || arrayIndex >= list.Count) { Debug.LogWarning( $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" ); return false; } currentObject = list[arrayIndex]; } else { Debug.LogWarning( $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." ); return false; } } currentType = currentObject.GetType(); } // Set the final property string finalPart = pathParts[pathParts.Length - 1]; // Special handling for Material properties (shader properties) if (currentObject is Material material && finalPart.StartsWith("_")) { // Use the serializer to convert the JToken value first if (value is JArray jArray) { // Try converting to known types that SetColor/SetVector accept if (jArray.Count == 4) { try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } try { Vector4 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } else if (jArray.Count == 3) { try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color } else if (jArray.Count == 2) { try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } } else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) { try { material.SetFloat(finalPart, value.ToObject(inputSerializer)); return true; } catch { } } else if (value.Type == JTokenType.Boolean) { try { material.SetFloat(finalPart, value.ToObject(inputSerializer) ? 1f : 0f); return true; } catch { } } else if (value.Type == JTokenType.String) { // Try converting to Texture using the serializer/converter try { Texture texture = value.ToObject(inputSerializer); if (texture != null) { material.SetTexture(finalPart, texture); return true; } } catch { } } Debug.LogWarning( $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" ); return false; } // For standard properties (not shader specific) PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); if (finalPropInfo != null && finalPropInfo.CanWrite) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalPropInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); if (finalFieldInfo != null) { // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); if (convertedValue != null || value.Type == JTokenType.Null) { finalFieldInfo.SetValue(currentObject, convertedValue); return true; } else { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } else { Debug.LogWarning( $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" ); } } } catch (Exception ex) { Debug.LogError( $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" ); } return false; } /// /// Split a property path into parts, handling both dot notation and array indexers /// private static string[] SplitPropertyPath(string path) { // Handle complex paths with both dots and array indexers List parts = new List(); int startIndex = 0; bool inBrackets = false; for (int i = 0; i < path.Length; i++) { char c = path[i]; if (c == '[') { inBrackets = true; } else if (c == ']') { inBrackets = false; } else if (c == '.' && !inBrackets) { // Found a dot separator outside of brackets parts.Add(path.Substring(startIndex, i - startIndex)); startIndex = i + 1; } } if (startIndex < path.Length) { parts.Add(path.Substring(startIndex)); } return parts.ToArray(); } /// /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. /// // Pass the input serializer private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) { if (token == null || token.Type == JTokenType.Null) { if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) { Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); return Activator.CreateInstance(targetType); } return null; } // Fast-path for common Unity structs where we want to gracefully accept both // object-style {x,y,z} and array-style [x,y,z] representations. try { if (targetType == typeof(Vector3)) { return ParseJTokenToVector3(token); } if (targetType == typeof(Vector2)) { return ParseJTokenToVector2(token); } if (targetType == typeof(Quaternion)) { return ParseJTokenToQuaternion(token); } if (targetType == typeof(Color)) { return ParseJTokenToColor(token); } if (targetType == typeof(Rect)) { return ParseJTokenToRect(token); } if (targetType == typeof(Bounds)) { return ParseJTokenToBounds(token); } } catch (Exception ex) { Debug.LogWarning( $"[ConvertJTokenToType] Fallback parse for {targetType.FullName} failed: {ex.Message}\nToken: {token.ToString(Formatting.None)}" ); // Fall through to serializer-based conversion below } try { // Use the provided serializer instance which includes our custom converters return token.ToObject(targetType, inputSerializer); } catch (JsonSerializationException jsonEx) { Debug.LogError( $"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}" ); // As a last resort for known vector types, try the fallback parsers once more to // avoid hard failures during property setting. try { if (targetType == typeof(Vector3)) return ParseJTokenToVector3(token); if (targetType == typeof(Vector2)) return ParseJTokenToVector2(token); if (targetType == typeof(Quaternion)) return ParseJTokenToQuaternion(token); if (targetType == typeof(Color)) return ParseJTokenToColor(token); if (targetType == typeof(Rect)) return ParseJTokenToRect(token); if (targetType == typeof(Bounds)) return ParseJTokenToBounds(token); } catch (Exception fallbackEx) { Debug.LogError( $"[ConvertJTokenToType] Secondary fallback for {targetType.FullName} also failed: {fallbackEx.Message}\nToken: {token.ToString(Formatting.None)}" ); } // If everything failed, rethrow so callers can surface a clear error. throw; } catch (ArgumentException argEx) { Debug.LogError( $"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}" ); throw; } catch (Exception ex) { Debug.LogError( $"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}" ); throw; } } // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- // Keep them temporarily for reference or if specific fallback logic is ever needed. private static Vector3 ParseJTokenToVector3(JToken token) { // ... (implementation - likely replaced by Vector3Converter) ... // Consider removing these if the serializer handles them reliably. if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) { return new Vector3(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject()); } if (token is JArray arr && arr.Count >= 3) { return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); return Vector3.zero; } private static Vector2 ParseJTokenToVector2(JToken token) { // ... (implementation - likely replaced by Vector2Converter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) { return new Vector2(obj["x"].ToObject(), obj["y"].ToObject()); } if (token is JArray arr && arr.Count >= 2) { return new Vector2(arr[0].ToObject(), arr[1].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); return Vector2.zero; } private static Quaternion ParseJTokenToQuaternion(JToken token) { // ... (implementation - likely replaced by QuaternionConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) { return new Quaternion(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject(), obj["w"].ToObject()); } if (token is JArray arr && arr.Count >= 4) { return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); return Quaternion.identity; } private static Color ParseJTokenToColor(JToken token) { // ... (implementation - likely replaced by ColorConverter) ... if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) { return new Color(obj["r"].ToObject(), obj["g"].ToObject(), obj["b"].ToObject(), obj["a"].ToObject()); } if (token is JArray arr && arr.Count >= 4) { return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); return Color.white; } private static Rect ParseJTokenToRect(JToken token) { // ... (implementation - likely replaced by RectConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) { return new Rect(obj["x"].ToObject(), obj["y"].ToObject(), obj["width"].ToObject(), obj["height"].ToObject()); } if (token is JArray arr && arr.Count >= 4) { return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); return Rect.zero; } private static Bounds ParseJTokenToBounds(JToken token) { // ... (implementation - likely replaced by BoundsConverter) ... if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) { // Requires Vector3 conversion, which should ideally use the serializer too Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) return new Bounds(center, size); } // Array fallback for Bounds is less intuitive, maybe remove? // if (token is JArray arr && arr.Count >= 6) // { // return new Bounds(new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()), new Vector3(arr[3].ToObject(), arr[4].ToObject(), arr[5].ToObject())); // } Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); return new Bounds(Vector3.zero, Vector3.zero); } // --- End Redundant Parse Helpers --- /// /// Finds a specific UnityEngine.Object based on a find instruction JObject. /// Primarily used by UnityEngineObjectConverter during deserialization. /// // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) { string findTerm = instruction["find"]?.ToString(); string method = instruction["method"]?.ToString()?.ToLower(); string componentName = instruction["component"]?.ToString(); // Specific component to get if (string.IsNullOrEmpty(findTerm)) { Debug.LogWarning("Find instruction missing 'find' term."); return null; } // Use a flexible default search method if none provided string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first if (typeof(Material).IsAssignableFrom(targetType) || typeof(Texture).IsAssignableFrom(targetType) || typeof(ScriptableObject).IsAssignableFrom(targetType) || targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. typeof(AudioClip).IsAssignableFrom(targetType) || typeof(AnimationClip).IsAssignableFrom(targetType) || typeof(Font).IsAssignableFrom(targetType) || typeof(Shader).IsAssignableFrom(targetType) || typeof(ComputeShader).IsAssignableFrom(targetType) || typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check { // Try loading directly by path/GUID first UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); if (asset != null) return asset; asset = AssetDatabase.LoadAssetAtPath(findTerm); // Try generic if type specific failed if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; // If direct path failed, try finding by name/type using FindAssets string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name string[] guids = AssetDatabase.FindAssets(searchFilter); if (guids.Length == 1) { asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); if (asset != null) return asset; } else if (guids.Length > 1) { Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); // Optionally return the first one? Or null? Returning null is safer. return null; } // If still not found, fall through to scene search (though unlikely for assets) } // --- Scene Object Search --- // Find the GameObject using the internal finder GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); if (foundGo == null) { // Don't warn yet, could still be an asset not found above // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); return null; } // Now, get the target object/component from the found GameObject if (targetType == typeof(GameObject)) { return foundGo; // We were looking for a GameObject } else if (typeof(Component).IsAssignableFrom(targetType)) { Type componentToGetType = targetType; if (!string.IsNullOrEmpty(componentName)) { Type specificCompType = FindType(componentName); if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) { componentToGetType = specificCompType; } else { Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); } } Component foundComp = foundGo.GetComponent(componentToGetType); if (foundComp == null) { Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); } return foundComp; } else { Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); return null; } } /// /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. /// Searches already-loaded assemblies, prioritizing runtime script assemblies. /// private static Type FindType(string typeName) { if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) { return resolvedType; } // Log the resolver error if type wasn't found if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"[FindType] {error}"); } return null; } } /// /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. /// Prioritizes runtime (Player) assemblies over Editor assemblies. /// internal static class ComponentResolver { private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); /// /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. /// Never uses Assembly.LoadFrom. /// public static bool TryResolve(string nameOrFullName, out Type type, out string error) { error = string.Empty; type = null!; // Handle null/empty input if (string.IsNullOrWhiteSpace(nameOrFullName)) { error = "Component name cannot be null or empty"; return false; } // 1) Exact cache hits if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; type = Type.GetType(nameOrFullName, throwOnError: false); if (IsValidComponent(type)) { Cache(type); return true; } // 2) Search loaded assemblies (prefer Player assemblies) var candidates = FindCandidates(nameOrFullName); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #if UNITY_EDITOR // 3) Last resort: Editor-only TypeCache (fast index) var tc = TypeCache.GetTypesDerivedFrom() .Where(t => NamesMatch(t, nameOrFullName)); candidates = PreferPlayer(tc).ToList(); if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } #endif error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; type = null!; return false; } private static bool NamesMatch(Type t, string q) => t.Name.Equals(q, StringComparison.Ordinal) || (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); private static bool IsValidComponent(Type t) => t != null && typeof(Component).IsAssignableFrom(t); private static void Cache(Type t) { if (t.FullName != null) CacheByFqn[t.FullName] = t; CacheByName[t.Name] = t; } private static List FindCandidates(string query) { bool isShort = !query.Contains('.'); var loaded = AppDomain.CurrentDomain.GetAssemblies(); #if UNITY_EDITOR // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) var playerAsmNames = new HashSet( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); IEnumerable playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); IEnumerable editorAsms = loaded.Except(playerAsms); #else IEnumerable playerAsms = loaded; IEnumerable editorAsms = Array.Empty(); #endif static IEnumerable SafeGetTypes(System.Reflection.Assembly a) { try { return a.GetTypes(); } catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } } Func match = isShort ? (t => t.Name.Equals(query, StringComparison.Ordinal)) : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); var fromPlayer = playerAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var fromEditor = editorAsms.SelectMany(SafeGetTypes) .Where(IsValidComponent) .Where(match); var list = new List(fromPlayer); if (list.Count == 0) list.AddRange(fromEditor); return list; } #if UNITY_EDITOR private static IEnumerable PreferPlayer(IEnumerable seq) { var player = new HashSet( UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), StringComparer.Ordinal); return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); } #endif private static string Ambiguity(string query, IEnumerable cands) { var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + "\nProvide a fully qualified type name to disambiguate."; } /// /// Gets all accessible property and field names from a component type. /// public static List GetAllComponentProperties(Type componentType) { if (componentType == null) return new List(); var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite) .Select(p => p.Name); var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) .Where(f => !f.IsInitOnly && !f.IsLiteral) .Select(f => f.Name); // Also include SerializeField private fields (common in Unity) var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) .Where(f => f.GetCustomAttribute() != null) .Select(f => f.Name); return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); } /// /// Uses AI to suggest the most likely property matches for a user's input. /// public static List GetAIPropertySuggestions(string userInput, List availableProperties) { if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) return new List(); // Simple caching to avoid repeated AI calls for the same input var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) return cached; try { var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + $"User requested: \"{userInput}\"\n" + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + $"Find 1-3 most likely matches considering:\n" + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + $"- camelCase vs PascalCase vs spaces\n" + $"- Similar meaning/semantics\n" + $"- Common Unity naming patterns\n\n" + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + $"If confidence is low (<70%), return empty string.\n\n" + $"Examples:\n" + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + $"- \"Health Points\" → \"healthPoints, hp\"\n" + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; // For now, we'll use a simple rule-based approach that mimics AI behavior // This can be replaced with actual AI calls later var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); PropertySuggestionCache[cacheKey] = suggestions; return suggestions; } catch (Exception ex) { Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); return new List(); } } private static readonly Dictionary> PropertySuggestionCache = new(); /// /// Rule-based suggestions that mimic AI behavior for property matching. /// This provides immediate value while we could add real AI integration later. /// private static List GetRuleBasedSuggestions(string userInput, List availableProperties) { var suggestions = new List(); var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); foreach (var property in availableProperties) { var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); // Exact match after cleaning if (cleanedProperty == cleanedInput) { suggestions.Add(property); continue; } // Check if property contains all words from input var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) { suggestions.Add(property); continue; } // Levenshtein distance for close matches if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) { suggestions.Add(property); } } // Prioritize exact matches, then by similarity return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) .Take(3) .ToList(); } /// /// Calculates Levenshtein distance between two strings for similarity matching. /// private static int LevenshteinDistance(string s1, string s2) { if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; if (string.IsNullOrEmpty(s2)) return s1.Length; var matrix = new int[s1.Length + 1, s2.Length + 1]; for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; for (int i = 1; i <= s1.Length; i++) { for (int j = 1; j <= s2.Length; j++) { int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; matrix[i, j] = Math.Min(Math.Min( matrix[i - 1, j] + 1, // deletion matrix[i, j - 1] + 1), // insertion matrix[i - 1, j - 1] + cost); // substitution } } return matrix[s1.Length, s2.Length]; } } // Continue ManageGameObject class with Ensure methods public static partial class ManageGameObject { // --- Ensure Methods (Idempotent Operations) --- /// /// Ensures a GameObject has a specific component. Idempotent - only adds if missing. /// private static object EnsureComponent(JObject @params, JToken targetToken, string searchMethod) { try { var writeCheck = WriteGuard.CheckWriteAllowed("ensure_component"); if (writeCheck != null) return writeCheck; string componentType = @params["component_type"]?.ToString(); if (string.IsNullOrEmpty(componentType)) return Response.Error("'component_type' parameter required for ensure_component."); GameObject go = FindObjectInternal(targetToken, searchMethod); if (go == null) return Response.Error($"GameObject not found."); Type compType = FindType(componentType); if (compType == null) return Response.Error($"Component type '{componentType}' not found."); Component existingComp = go.GetComponent(compType); if (existingComp != null) { // Already exists - no change, so dirty=false (idempotent) return new { success = true, message = $"Component '{componentType}' already exists on '{go.name}'.", data = new { gameObject = go.name, component = componentType, alreadyExists = true }, state_delta = StateComposer.CreateSceneDelta(dirty: false) }; } Component newComp = go.AddComponent(compType); EditorUtility.SetDirty(go); StateComposer.IncrementRevision(); return new { success = true, message = $"Component '{componentType}' added to '{go.name}'.", data = new { gameObject = go.name, component = componentType, alreadyExists = false }, state_delta = StateComposer.CreateSceneDelta(dirty: true) }; } catch (Exception e) { return Response.Error($"Failed to ensure component: {e.Message}"); } } /// /// Ensures a Renderer has a specific material assigned. Idempotent. /// private static object EnsureRendererMaterial(JObject @params, JToken targetToken, string searchMethod) { try { var writeCheck = WriteGuard.CheckWriteAllowed("ensure_renderer_material"); if (writeCheck != null) return writeCheck; string materialPath = @params["material"]?.ToString(); if (string.IsNullOrEmpty(materialPath)) return Response.Error("'material' parameter required for ensure_renderer_material."); GameObject go = FindObjectInternal(targetToken, searchMethod); if (go == null) return Response.Error($"GameObject not found."); Renderer renderer = go.GetComponent(); if (renderer == null) return Response.Error($"GameObject '{go.name}' has no Renderer component."); Material material = AssetDatabase.LoadAssetAtPath(materialPath); if (material == null) return Response.Error($"Material not found at: {materialPath}"); // Check if material is already assigned if (renderer.sharedMaterial == material) { return new { success = true, message = $"Material already assigned.", data = new { gameObject = go.name, material = materialPath, alreadyAssigned = true }, state_delta = StateComposer.CreateSceneDelta(dirty: false) }; } renderer.sharedMaterial = material; EditorUtility.SetDirty(go); StateComposer.IncrementRevision(); return new { success = true, message = $"Material assigned to renderer.", data = new { gameObject = go.name, material = materialPath, alreadyAssigned = false }, state_delta = StateComposer.CreateSceneDelta(dirty: true) }; } catch (Exception e) { return Response.Error($"Failed to ensure renderer material: {e.Message}"); } } /// /// Ensures a MeshCollider has a specific mesh assigned. Idempotent. /// private static object EnsureMeshColliderMesh(JObject @params, JToken targetToken, string searchMethod) { try { var writeCheck = WriteGuard.CheckWriteAllowed("ensure_mesh_collider_mesh"); if (writeCheck != null) return writeCheck; string meshPath = @params["mesh"]?.ToString(); if (string.IsNullOrEmpty(meshPath)) return Response.Error("'mesh' parameter required for ensure_mesh_collider_mesh."); GameObject go = FindObjectInternal(targetToken, searchMethod); if (go == null) return Response.Error($"GameObject not found."); MeshCollider collider = go.GetComponent(); if (collider == null) return Response.Error($"GameObject '{go.name}' has no MeshCollider component."); Mesh mesh = AssetDatabase.LoadAssetAtPath(meshPath); if (mesh == null) return Response.Error($"Mesh not found at: {meshPath}"); if (collider.sharedMesh == mesh) { return new { success = true, message = $"Mesh already assigned.", data = new { gameObject = go.name, mesh = meshPath, alreadyAssigned = true }, state_delta = StateComposer.CreateSceneDelta(dirty: false) }; } collider.sharedMesh = mesh; EditorUtility.SetDirty(go); StateComposer.IncrementRevision(); return new { success = true, message = $"Mesh assigned to collider.", data = new { gameObject = go.name, mesh = meshPath, alreadyAssigned = false }, state_delta = StateComposer.CreateSceneDelta(dirty: true) }; } catch (Exception e) { return Response.Error($"Failed to ensure mesh collider mesh: {e.Message}"); } } /// /// Ensures a Prefab's default sprite is set. Idempotent. /// private static object EnsurePrefabDefaultSprite(JObject @params) { try { var writeCheck = WriteGuard.CheckWriteAllowed("ensure_prefab_default_sprite"); if (writeCheck != null) return writeCheck; string prefabPath = @params["prefab"]?.ToString(); string spritePath = @params["sprite"]?.ToString(); if (string.IsNullOrEmpty(prefabPath)) return Response.Error("'prefab' parameter required."); if (string.IsNullOrEmpty(spritePath)) return Response.Error("'sprite' parameter required."); GameObject prefab = AssetDatabase.LoadAssetAtPath(prefabPath); if (prefab == null) return Response.Error($"Prefab not found at: {prefabPath}"); Sprite sprite = AssetDatabase.LoadAssetAtPath(spritePath); if (sprite == null) return Response.Error($"Sprite not found at: {spritePath}"); SpriteRenderer spriteRenderer = prefab.GetComponent(); if (spriteRenderer == null) return Response.Error($"Prefab '{prefabPath}' has no SpriteRenderer component."); if (spriteRenderer.sprite == sprite) { return new { success = true, message = $"Sprite already assigned.", data = new { prefab = prefabPath, sprite = spritePath, alreadyAssigned = true }, state_delta = StateComposer.CreateAssetDelta(new[] { new { path = prefabPath, imported = false, hasMeta = true } }) }; } spriteRenderer.sprite = sprite; EditorUtility.SetDirty(prefab); AssetDatabase.SaveAssets(); StateComposer.IncrementRevision(); return new { success = true, message = $"Sprite assigned to prefab.", data = new { prefab = prefabPath, sprite = spritePath, alreadyAssigned = false }, state_delta = StateComposer.CreateAssetDelta(new[] { new { path = prefabPath, imported = false, hasMeta = true } }) }; } catch (Exception e) { return Response.Error($"Failed to ensure prefab sprite: {e.Message}"); } } /// /// Create batch operation: execute multiple write-only GameObject operations in sequence. /// Supports alias capture (captureAs) and ref resolution ($alias). /// private static object HandleCreateBatch(JObject @params) { var opsToken = @params["ops"] as JArray; if (opsToken == null || opsToken.Count == 0) { return Response.Error("'ops' array is required for create_batch action."); } // Guardrail: keep batches small enough for reliable planning/retry (parity with TS client) if (opsToken.Count > MaxBatchOps) { return Response.Error( $"Too many ops for create_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." ); } string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; if (mode != "stop_on_error" && mode != "continue_on_error") { return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); } // create_batch is write-only (no reads). We still track whether there are // write ops so we can apply additional guardrails for deterministic targeting. var writeOps = new HashSet(StringComparer.OrdinalIgnoreCase) { "ensure_component", "ensure_renderer_material", "ensure_mesh_collider_mesh", "ensure_prefab_default_sprite", "create", "modify", "delete", "add_component", "remove_component", "set_component_property", }; bool hasWriteOps = false; foreach (var token in opsToken) { var op = token as JObject; if (op == null) continue; string opId = op["id"]?.ToString() ?? "unknown"; var opAction = op["action"]?.ToString()?.ToLower(); if (string.IsNullOrEmpty(opAction)) continue; if (opAction == "set_component_properties") opAction = "set_component_property"; if (!writeOps.Contains(opAction)) { return Response.Error( $"Op '{opId}' invalid: create_batch only supports write ops (no reads like '{opAction}')." ); } hasWriteOps = true; } // Extra guardrail for create_batch: enforce deterministic targeting (parity with TS client). if (hasWriteOps) { foreach (var token in opsToken) { var op = token as JObject; if (op == null) continue; string opId = op["id"]?.ToString() ?? "unknown"; string opAction = op["action"]?.ToString()?.ToLower(); if (string.IsNullOrEmpty(opAction)) continue; if (opAction == "set_component_properties") opAction = "set_component_property"; // Only validate deterministic targeting for write ops that can take target/parent. if (!writeOps.Contains(opAction)) continue; var opParams = op["params"] as JObject ?? new JObject(); // target determinism: allow instanceID (number), $alias, or by_path targeting. var searchMethod = opParams["searchMethod"]?.ToString()?.ToLower(); var targetTok = opParams["target"]; if (targetTok != null && targetTok.Type == JTokenType.String) { var t = targetTok.ToString(); var isAlias = t.StartsWith("$"); var isPath = searchMethod == "by_path"; if (!isAlias && !isPath) { return Response.Error( $"Op '{opId}' invalid: create_batch requires deterministic target. Use instanceID (number), $alias, or targetRef.hierarchy_path (searchMethod='by_path'). Do not use by_name targets inside create_batch." ); } } // If the op uses targetRef.name, it will become by_name later; reject early for determinism. if (targetTok == null && opParams["targetRef"] is JObject tr && tr["name"] != null) { return Response.Error( $"Op '{opId}' invalid: create_batch requires deterministic target. Use instanceID (number), $alias, or targetRef.hierarchy_path (searchMethod='by_path'). Do not use by_name targets inside create_batch." ); } // parent determinism: allow instanceID (number) or $alias. var parentTok = opParams["parent"]; if (parentTok != null && parentTok.Type == JTokenType.String) { var p = parentTok.ToString(); if (!p.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: create_batch requires deterministic parent. Use instanceID (number) or $alias for parent (avoid name-based parenting in create_batch)." ); } } } } var aliases = new Dictionary(); var results = new List>(); var stateDeltas = new List(); int succeeded = 0; int failed = 0; foreach (var opToken in opsToken) { var op = opToken as JObject; if (op == null) { results.Add(new Dictionary { ["id"] = "unknown", ["success"] = false, ["message"] = "Invalid op format" }); failed++; if (mode == "stop_on_error") break; continue; } string opId = op["id"]?.ToString() ?? "unknown"; string opAction = op["action"]?.ToString()?.ToLower(); bool allowFailure = op["allowFailure"]?.ToObject() ?? false; string captureAs = op["captureAs"]?.ToString(); if (string.IsNullOrEmpty(opAction)) { results.Add(new Dictionary { ["id"] = opId, ["success"] = false, ["message"] = "Op action is required" }); failed++; if (mode == "stop_on_error" && !allowFailure) break; continue; } if (opAction == "batch") { results.Add(new Dictionary { ["id"] = opId, ["success"] = false, ["message"] = "Nested batch is not allowed" }); failed++; if (mode == "stop_on_error" && !allowFailure) break; continue; } // Build params for the individual operation var opParams = op["params"] as JObject ?? new JObject(); opParams = ResolveRefs(opParams, aliases); opParams["action"] = opAction; // Execute the operation try { var opResult = HandleCommand(opParams); bool opSuccess = true; string opMessage = "Success"; string opCode = null; object opData = null; int? instanceId = null; object opStateDelta = null; if (opResult is Dictionary resultDict) { if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) opSuccess = s; if (resultDict.TryGetValue("message", out var msgObj)) opMessage = msgObj?.ToString(); if (resultDict.TryGetValue("code", out var codeObj)) opCode = codeObj?.ToString(); if (resultDict.TryGetValue("state_delta", out var sd)) opStateDelta = sd; if (resultDict.TryGetValue("data", out var dataObj)) { opData = dataObj; // Try to extract instanceID for alias capture if (dataObj is Dictionary dataDict) { instanceId = ExtractInstanceId(dataDict); } } else { opData = resultDict; instanceId = ExtractInstanceId(resultDict); } } else { opData = opResult; try { var sdProp = opResult?.GetType()?.GetProperty("state_delta"); if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); } catch { } } if (opStateDelta != null) stateDeltas.Add(opStateDelta); // Best-effort instanceID extraction for alias capture (supports anonymous objects and JTokens) if (!instanceId.HasValue) { instanceId = ExtractInstanceIdFromAny(opData) ?? ExtractInstanceIdFromAny(opResult); } // Capture alias if specified and operation succeeded if (opSuccess && !string.IsNullOrEmpty(captureAs) && instanceId.HasValue) { aliases[captureAs] = instanceId.Value; } var resultEntry = new Dictionary { ["id"] = opId, ["action"] = opAction, ["success"] = opSuccess, ["message"] = opMessage }; if (opCode != null) resultEntry["code"] = opCode; if (opData != null) resultEntry["data"] = opData; results.Add(resultEntry); if (opSuccess) succeeded++; else { failed++; if (mode == "stop_on_error" && !allowFailure) break; } } catch (Exception e) { results.Add(new Dictionary { ["id"] = opId, ["action"] = opAction, ["success"] = false, ["message"] = e.Message, ["code"] = "exception" }); failed++; if (mode == "stop_on_error" && !allowFailure) break; } } var mergedDelta = stateDeltas.Count > 0 ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) : null; bool success = failed == 0; var message = success ? "Unity GameObject create_batch completed successfully." : "Unity GameObject create_batch completed with errors."; var response = new Dictionary { ["mode"] = mode, ["aliases"] = aliases, ["summary"] = new Dictionary { ["total"] = opsToken.Count, ["succeeded"] = succeeded, ["failed"] = failed }, ["results"] = results, ["success"] = success, ["message"] = message }; if (!success) { response["code"] = "create_batch_failed"; response["error"] = message; } if (mergedDelta != null) { response["state_delta"] = mergedDelta; } return response; } /// /// Edit batch operation: "find-then-write" edits with early-stop on 0 targets found. /// /// Contract: /// - Phase 1: one or more `find` ops (must provide captureAs) to resolve deterministic instanceIDs. /// - Phase 2: write ops that must target the captured `$alias` (no by_name/by_path targets in edit_batch writes). /// - If a find resolves 0 targets, the workflow early-stops and returns success=true (not an error). /// private static object HandleEditBatch(JObject @params) { var opsToken = @params["ops"] as JArray; if (opsToken == null || opsToken.Count == 0) { return Response.Error("'ops' array is required for edit_batch action."); } if (opsToken.Count > MaxBatchOps) { return Response.Error( $"Too many ops for edit_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." ); } string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; if (mode != "stop_on_error" && mode != "continue_on_error") { return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); } var writeOps = new HashSet(StringComparer.OrdinalIgnoreCase) { "ensure_component", "ensure_renderer_material", "ensure_mesh_collider_mesh", "ensure_prefab_default_sprite", "create", "modify", "delete", "add_component", "remove_component", "set_component_property", }; var aliases = new Dictionary(); var results = new List>(); var stateDeltas = new List(); int succeeded = 0; int failed = 0; bool seenWrite = false; int findOpsCount = 0; int writeOpsCount = 0; foreach (var opToken in opsToken) { var op = opToken as JObject; if (op == null) { results.Add(new Dictionary { ["id"] = "unknown", ["success"] = false, ["message"] = "Invalid op format" }); failed++; if (mode == "stop_on_error") break; continue; } string opId = op["id"]?.ToString() ?? "unknown"; string opAction = op["action"]?.ToString()?.ToLower(); bool allowFailure = op["allowFailure"]?.ToObject() ?? false; string captureAs = op["captureAs"]?.ToString(); if (string.IsNullOrEmpty(opAction)) { results.Add(new Dictionary { ["id"] = opId, ["success"] = false, ["message"] = "Op action is required" }); failed++; if (mode == "stop_on_error" && !allowFailure) break; continue; } if (opAction == "set_component_properties") opAction = "set_component_property"; if (opAction == "batch" || opAction == "create_batch" || opAction == "edit_batch") { return Response.Error($"Op '{opId}' invalid: nested batch is not allowed"); } bool isWriteOp = writeOps.Contains(opAction); if (!isWriteOp) { // Phase 1: find only if (opAction != "find") { return Response.Error( $"Op '{opId}' invalid: edit_batch only supports read op action 'find' before write ops." ); } if (seenWrite) { return Response.Error( $"Op '{opId}' invalid: edit_batch requires all 'find' ops to come before write ops." ); } if (allowFailure) { return Response.Error( $"Op '{opId}' invalid: allowFailure is not supported for find ops in edit_batch." ); } if (string.IsNullOrEmpty(captureAs) || !captureAs.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: find op must provide captureAs starting with '$' (e.g. '$target')." ); } if (aliases.ContainsKey(captureAs)) { return Response.Error($"Duplicate captureAs alias: {captureAs}"); } // Build params for find (force deterministic single-result behavior) var opParams = op["params"] as JObject ?? new JObject(); if (opParams["findAll"]?.ToObject() == true) { return Response.Error($"Op '{opId}' invalid: findAll must be false for edit_batch."); } opParams["findAll"] = false; opParams["action"] = "find"; var opResult = HandleCommand(opParams); bool opSuccess = true; string opMessage = "Success"; string opCode = null; object opData = null; object opStateDelta = null; if (opResult is Dictionary resultDict) { if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) opSuccess = s; if (resultDict.TryGetValue("message", out var msgObj)) opMessage = msgObj?.ToString(); if (resultDict.TryGetValue("code", out var codeObj)) opCode = codeObj?.ToString(); if (resultDict.TryGetValue("state_delta", out var sd)) opStateDelta = sd; if (resultDict.TryGetValue("data", out var dataObj)) opData = dataObj; else opData = resultDict; } else { opData = opResult; try { var sdProp = opResult?.GetType()?.GetProperty("state_delta"); if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); } catch { } } if (opStateDelta != null) stateDeltas.Add(opStateDelta); var resultEntry = new Dictionary { ["id"] = opId, ["action"] = "find", ["success"] = opSuccess, ["message"] = opMessage }; if (opCode != null) resultEntry["code"] = opCode; if (opData != null) resultEntry["data"] = opData; results.Add(resultEntry); if (!opSuccess) { failed++; if (mode == "stop_on_error") break; continue; } // Expect a list of GameObject results (0 or 1, since findAll=false) int foundCount = 0; object first = null; if (opData is List list) { foundCount = list.Count; if (list.Count > 0) first = list[0]; } else if (opData is JArray jarr) { foundCount = jarr.Count; if (jarr.Count > 0) first = jarr[0]; } if (foundCount == 0) { // Early stop (not an error) var mergedDelta = stateDeltas.Count > 0 ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) : null; var early = new Dictionary { ["mode"] = mode, ["status"] = "early_stop", ["reason"] = "no_targets_found", ["aliases"] = aliases, ["summary"] = new Dictionary { ["total"] = results.Count, ["succeeded"] = succeeded, ["failed"] = failed }, ["results"] = results, ["success"] = true, ["message"] = "Unity GameObject edit_batch early-stopped (0 targets found)." }; if (mergedDelta != null) early["state_delta"] = mergedDelta; return early; } if (foundCount > 1) { return Response.Error( $"Op '{opId}' invalid: edit_batch find must return at most 1 result (set findAll=false)." ); } int? instanceId = ExtractInstanceIdFromAny(first); if (!instanceId.HasValue) { return Response.Error( $"Op '{opId}' invalid: edit_batch could not extract instanceID from find result." ); } aliases[captureAs] = instanceId.Value; succeeded++; findOpsCount++; continue; } // Phase 2: write ops (must target aliases) seenWrite = true; writeOpsCount++; if (!string.IsNullOrEmpty(captureAs)) { return Response.Error( $"Op '{opId}' invalid: captureAs is not supported for write ops in edit_batch (capture aliases using 'find')." ); } var rawParams = op["params"] as JObject ?? new JObject(); if (rawParams["targetRef"] is JObject) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must use target '$alias' (not targetRef)." ); } // If target/parent are strings, they must be $aliases (deterministic) var targetTok = rawParams["target"]; if (targetTok != null && targetTok.Type == JTokenType.Integer) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." ); } if (targetTok != null && targetTok.Type == JTokenType.String) { var t = targetTok.ToString(); if (!t.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." ); } } if (targetTok is JObject targetObj && targetObj["ref"] != null) { var r = targetObj["ref"]?.ToString() ?? ""; if (!r.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." ); } } var parentTok = rawParams["parent"]; if (parentTok != null && parentTok.Type == JTokenType.Integer) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." ); } if (parentTok != null && parentTok.Type == JTokenType.String) { var p = parentTok.ToString(); if (!p.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." ); } } if (parentTok is JObject parentObj && parentObj["ref"] != null) { var r = parentObj["ref"]?.ToString() ?? ""; if (!r.StartsWith("$")) { return Response.Error( $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." ); } } // Build params for the individual operation (resolve $aliases to instanceIDs) var opParamsWrite = ResolveRefs(rawParams, aliases); opParamsWrite["action"] = opAction; try { var opResult = HandleCommand(opParamsWrite); bool opSuccess = true; string opMessage = "Success"; string opCode = null; object opData = null; int? instanceId = null; object opStateDelta = null; if (opResult is Dictionary resultDict) { if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) opSuccess = s; if (resultDict.TryGetValue("message", out var msgObj)) opMessage = msgObj?.ToString(); if (resultDict.TryGetValue("code", out var codeObj)) opCode = codeObj?.ToString(); if (resultDict.TryGetValue("state_delta", out var sd)) opStateDelta = sd; if (resultDict.TryGetValue("data", out var dataObj)) { opData = dataObj; if (dataObj is Dictionary dataDict) { instanceId = ExtractInstanceId(dataDict); } } else { opData = resultDict; instanceId = ExtractInstanceId(resultDict); } } else { opData = opResult; try { var sdProp = opResult?.GetType()?.GetProperty("state_delta"); if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); } catch { } } if (opStateDelta != null) stateDeltas.Add(opStateDelta); if (!instanceId.HasValue) { instanceId = ExtractInstanceIdFromAny(opData) ?? ExtractInstanceIdFromAny(opResult); } var resultEntry = new Dictionary { ["id"] = opId, ["action"] = opAction, ["success"] = opSuccess, ["message"] = opMessage }; if (opCode != null) resultEntry["code"] = opCode; if (opData != null) resultEntry["data"] = opData; results.Add(resultEntry); if (opSuccess) succeeded++; else { failed++; if (mode == "stop_on_error" && !allowFailure) break; } } catch (Exception e) { results.Add(new Dictionary { ["id"] = opId, ["action"] = opAction, ["success"] = false, ["message"] = e.Message, ["code"] = "exception" }); failed++; if (mode == "stop_on_error" && !allowFailure) break; } } if (findOpsCount == 0) { return Response.Error("edit_batch requires at least one find op with captureAs before write ops."); } if (writeOpsCount == 0) { return Response.Error("edit_batch requires at least one write op after find ops."); } var merged = stateDeltas.Count > 0 ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) : null; bool success = failed == 0; var message = success ? "Unity GameObject edit_batch completed successfully." : "Unity GameObject edit_batch completed with errors."; var response = new Dictionary { ["mode"] = mode, ["aliases"] = aliases, ["summary"] = new Dictionary { ["total"] = opsToken.Count, ["succeeded"] = succeeded, ["failed"] = failed }, ["results"] = results, ["success"] = success, ["message"] = message }; if (!success) { response["code"] = "edit_batch_failed"; response["error"] = message; } if (merged != null) { response["state_delta"] = merged; } return response; } /// /// Resolve $alias references in params to actual instanceIDs. /// private static JObject ResolveRefs(JObject obj, Dictionary aliases) { var result = new JObject(); foreach (var prop in obj.Properties()) { result[prop.Name] = ResolveRefValue(prop.Value, aliases); } return result; } private static JToken ResolveRefValue(JToken value, Dictionary aliases) { if (value == null) return null; switch (value.Type) { case JTokenType.String: string strVal = value.ToString(); if (strVal.StartsWith("$") && aliases.TryGetValue(strVal, out int id)) { return id; } return value; case JTokenType.Object: var objVal = value as JObject; // Check for {ref: "$alias"} pattern if (objVal != null && objVal.Count == 1 && objVal["ref"] != null) { string refStr = objVal["ref"].ToString(); if (refStr.StartsWith("$") && aliases.TryGetValue(refStr, out int refId)) { return refId; } } // Recursively resolve nested objects return ResolveRefs(objVal, aliases); case JTokenType.Array: var arrVal = value as JArray; var newArr = new JArray(); foreach (var item in arrVal) { newArr.Add(ResolveRefValue(item, aliases)); } return newArr; default: return value; } } /// /// Extract instanceID from result dictionary. /// private static int? ExtractInstanceId(Dictionary dict) { if (dict.TryGetValue("instanceID", out var id1) && id1 is int i1) return i1; if (dict.TryGetValue("instanceId", out var id2) && id2 is int i2) return i2; if (dict.TryGetValue("id", out var id3) && id3 is int i3) return i3; // Check nested data if (dict.TryGetValue("data", out var dataObj) && dataObj is Dictionary dataDict) { if (dataDict.TryGetValue("instanceID", out var did1) && did1 is int di1) return di1; if (dataDict.TryGetValue("instanceId", out var did2) && did2 is int di2) return di2; if (dataDict.TryGetValue("id", out var did3) && did3 is int di3) return di3; } return null; } /// /// Extract instanceID from any object shape (anonymous objects, dictionaries, JTokens). /// Used by batch alias capture. /// private static int? ExtractInstanceIdFromAny(object obj) { if (obj == null) return null; try { if (obj is int ii) return ii; if (obj is long ll) return (int)ll; if (obj is Dictionary dict) { return ExtractInstanceId(dict); } if (obj is JObject jobj) { return ExtractInstanceIdFromJToken(jobj); } if (obj is JToken jt) { return ExtractInstanceIdFromJToken(jt); } // Reflection (anonymous objects) var t = obj.GetType(); var prop = t.GetProperty("instanceID") ?? t.GetProperty("instanceId") ?? t.GetProperty("id"); if (prop != null) { var v = prop.GetValue(obj); var parsed = ExtractInstanceIdFromAny(v); if (parsed.HasValue) return parsed.Value; } var dataProp = t.GetProperty("data"); if (dataProp != null) { var dataObj = dataProp.GetValue(obj); var parsed = ExtractInstanceIdFromAny(dataObj); if (parsed.HasValue) return parsed.Value; } // Last resort: serialize to JObject and search var j = JObject.FromObject(obj); return ExtractInstanceIdFromJToken(j); } catch { return null; } } private static int? ExtractInstanceIdFromJToken(JToken token) { if (token == null) return null; try { if (token.Type == JTokenType.Integer) { return token.ToObject(); } if (token.Type != JTokenType.Object) { return null; } var obj = token as JObject; if (obj == null) return null; var direct = obj["instanceID"] ?? obj["instanceId"] ?? obj["id"]; if (direct != null) { if (direct.Type == JTokenType.Integer) return direct.ToObject(); if (direct.Type == JTokenType.String && int.TryParse(direct.ToString(), out var parsed)) return parsed; } var data = obj["data"]; if (data != null) { return ExtractInstanceIdFromJToken(data); } return null; } catch { return null; } } } }