diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs new file mode 100644 index 00000000..b38e5188 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs @@ -0,0 +1,18 @@ +using UnityEngine; + +namespace TestNamespace +{ + public class CustomComponent : MonoBehaviour + { + [SerializeField] + private string customText = "Hello from custom asmdef!"; + + [SerializeField] + private float customFloat = 42.0f; + + void Start() + { + Debug.Log($"CustomComponent started: {customText}, value: {customFloat}"); + } + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs.meta new file mode 100644 index 00000000..7d949648 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 78ee39b9744834fe390a4c7c5634eb5a \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef new file mode 100644 index 00000000..7430d4ad --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef @@ -0,0 +1,14 @@ +{ + "name": "TestAsmdef", + "rootNamespace": "TestNamespace", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef.meta b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef.meta new file mode 100644 index 00000000..21f1fe32 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/TestAsmdef.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 72f6376fa7bdc4220b11ccce20108cdc +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef index 864596ed..1207b26f 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "", "references": [ "MCPForUnity.Editor", + "TestAsmdef", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs new file mode 100644 index 00000000..a1b1ea77 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using static MCPForUnity.Editor.Tools.ManageGameObject; + +namespace MCPForUnityTests.Editor.Tools +{ + public class AIPropertyMatchingTests + { + private List sampleProperties; + + [SetUp] + public void SetUp() + { + sampleProperties = new List + { + "maxReachDistance", + "maxHorizontalDistance", + "maxVerticalDistance", + "moveSpeed", + "healthPoints", + "playerName", + "isEnabled", + "mass", + "velocity", + "transform" + }; + } + + [Test] + public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() + { + var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); + + Assert.IsNotEmpty(properties, "Transform should have properties"); + Assert.Contains("position", properties, "Transform should have position property"); + Assert.Contains("rotation", properties, "Transform should have rotation property"); + Assert.Contains("localScale", properties, "Transform should have localScale property"); + } + + [Test] + public void GetAllComponentProperties_ReturnsEmpty_ForNullType() + { + var properties = ComponentResolver.GetAllComponentProperties(null); + + Assert.IsEmpty(properties, "Null type should return empty list"); + } + + [Test] + public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties); + + Assert.IsEmpty(suggestions, "Null input should return no suggestions"); + } + + [Test] + public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties); + + Assert.IsEmpty(suggestions, "Empty input should return no suggestions"); + } + + [Test] + public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List()); + + Assert.IsEmpty(suggestions, "Empty property list should return no suggestions"); + } + + [Test] + public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties); + + Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces"); + Assert.AreEqual(1, suggestions.Count, "Should return exactly one match for exact match"); + } + + [Test] + public void GetAIPropertySuggestions_FindsMultipleWordMatches() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties); + + Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance"); + Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); + Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance"); + } + + [Test] + public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S + + Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital"); + } + + [Test] + public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms() + { + var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties); + + // Note: Current algorithm might not find "mass" but should handle it gracefully + Assert.IsNotNull(suggestions, "Should return valid suggestions list"); + } + + [Test] + public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber() + { + // Test with input that might match many properties + var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties); + + Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer"); + } + + [Test] + public void GetAIPropertySuggestions_CachesResults() + { + var input = "Max Reach Distance"; + + // First call + var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); + + // Second call should use cache (tested indirectly by ensuring consistency) + var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); + + Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent"); + CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical"); + } + + [Test] + public void GetAIPropertySuggestions_HandlesUnityNamingConventions() + { + var unityStyleProperties = new List { "isKinematic", "useGravity", "maxLinearVelocity" }; + + var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties); + var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties); + var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties); + + Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention"); + Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention"); + Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention"); + } + + [Test] + public void GetAIPropertySuggestions_PrioritizesExactMatches() + { + var properties = new List { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" }; + var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties); + + Assert.IsNotEmpty(suggestions, "Should find suggestions"); + Assert.AreEqual("speed", suggestions[0], "Exact match should be prioritized first"); + } + + [Test] + public void GetAIPropertySuggestions_HandlesCaseInsensitive() + { + var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties); + var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties); + + Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input"); + Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input"); + } + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta new file mode 100644 index 00000000..31bddd75 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e4468da1a15349029e52570b84ec4b0 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs new file mode 100644 index 00000000..d19afbb5 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs @@ -0,0 +1,142 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using MCPForUnity.Editor.Tools; +using static MCPForUnity.Editor.Tools.ManageGameObject; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ComponentResolverTests + { + [Test] + public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() + { + bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve Transform component"); + Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() + { + bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); + Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForCustomComponentShortName() + { + bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve CustomComponent"); + Assert.IsNotNull(type, "Should return valid type"); + Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() + { + bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); + Assert.IsNotNull(type, "Should return valid type"); + Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); + Assert.AreEqual("TestNamespace.CustomComponent", type.FullName, "Should have correct full name"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type"); + Assert.IsEmpty(error, "Should have no error message"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForNonExistentComponent() + { + bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve non-existent component"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + Assert.That(error, Does.Contain("not found"), "Error should mention component not found"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForEmptyString() + { + bool result = ComponentResolver.TryResolve("", out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve empty string"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + } + + [Test] + public void TryResolve_ReturnsFalse_ForNullString() + { + bool result = ComponentResolver.TryResolve(null, out Type type, out string error); + + Assert.IsFalse(result, "Should not resolve null string"); + Assert.IsNull(type, "Should return null type"); + Assert.IsNotEmpty(error, "Should have error message"); + Assert.That(error, Does.Contain("null or empty"), "Error should mention null or empty"); + } + + [Test] + public void TryResolve_CachesResolvedTypes() + { + // First call + bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); + + // Second call should use cache + bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); + + Assert.IsTrue(result1, "First call should succeed"); + Assert.IsTrue(result2, "Second call should succeed"); + Assert.AreSame(type1, type2, "Should return same type instance (cached)"); + Assert.IsEmpty(error1, "First call should have no error"); + Assert.IsEmpty(error2, "Second call should have no error"); + } + + [Test] + public void TryResolve_PrefersPlayerAssemblies() + { + // Test that custom user scripts (in Player assemblies) are found + bool result = ComponentResolver.TryResolve("TicTacToe3D", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve user script from Player assembly"); + Assert.IsNotNull(type, "Should return valid type"); + + // Verify it's not from an Editor assembly by checking the assembly name + string assemblyName = type.Assembly.GetName().Name; + Assert.That(assemblyName, Does.Not.Contain("Editor"), + "User script should come from Player assembly, not Editor assembly"); + } + + [Test] + public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() + { + // This test would need duplicate component names to be meaningful + // For now, test with a built-in component that should not have duplicates + bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); + + Assert.IsTrue(result, "Transform should resolve uniquely"); + Assert.AreEqual(typeof(Transform), type, "Should return correct type"); + Assert.IsEmpty(error, "Should have no ambiguity error"); + } + + [Test] + public void ResolvedType_IsValidComponent() + { + bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); + + Assert.IsTrue(result, "Should resolve Rigidbody"); + Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); + Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || + typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); + } + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta new file mode 100644 index 00000000..c4c339a8 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c15ba6502927e4901a43826c43debd7c \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs new file mode 100644 index 00000000..db2b525d --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEditor; +using UnityEngine.TestTools; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageGameObjectTests + { + private GameObject testGameObject; + + [SetUp] + public void SetUp() + { + // Create a test GameObject for each test + testGameObject = new GameObject("TestObject"); + } + + [TearDown] + public void TearDown() + { + // Clean up test GameObject + if (testGameObject != null) + { + UnityEngine.Object.DestroyImmediate(testGameObject); + } + } + + [Test] + public void HandleCommand_ReturnsError_ForNullParams() + { + var result = ManageGameObject.HandleCommand(null); + + Assert.IsNotNull(result, "Should return a result object"); + // Note: Actual error checking would need access to Response structure + } + + [Test] + public void HandleCommand_ReturnsError_ForEmptyParams() + { + var emptyParams = new JObject(); + var result = ManageGameObject.HandleCommand(emptyParams); + + Assert.IsNotNull(result, "Should return a result object for empty params"); + } + + [Test] + public void HandleCommand_ProcessesValidCreateAction() + { + var createParams = new JObject + { + ["action"] = "create", + ["name"] = "TestCreateObject" + }; + + var result = ManageGameObject.HandleCommand(createParams); + + Assert.IsNotNull(result, "Should return a result for valid create action"); + + // Clean up - find and destroy the created object + var createdObject = GameObject.Find("TestCreateObject"); + if (createdObject != null) + { + UnityEngine.Object.DestroyImmediate(createdObject); + } + } + + [Test] + public void ComponentResolver_Integration_WorksWithRealComponents() + { + // Test that our ComponentResolver works with actual Unity components + var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error); + + Assert.IsTrue(transformResult, "Should resolve Transform component"); + Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type"); + Assert.IsEmpty(error, "Should have no error for valid component"); + } + + [Test] + public void ComponentResolver_Integration_WorksWithBuiltInComponents() + { + var components = new[] + { + ("Rigidbody", typeof(Rigidbody)), + ("Collider", typeof(Collider)), + ("Renderer", typeof(Renderer)), + ("Camera", typeof(Camera)), + ("Light", typeof(Light)) + }; + + foreach (var (componentName, expectedType) in components) + { + var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error); + + // Some components might not resolve (abstract classes), but the method should handle gracefully + if (result) + { + Assert.IsTrue(expectedType.IsAssignableFrom(actualType), + $"{componentName} should resolve to assignable type"); + } + else + { + Assert.IsNotEmpty(error, $"Should have error message for {componentName}"); + } + } + } + + [Test] + public void PropertyMatching_Integration_WorksWithRealGameObject() + { + // Add a Rigidbody to test real property matching + var rigidbody = testGameObject.AddComponent(); + + var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody)); + + Assert.IsNotEmpty(properties, "Rigidbody should have properties"); + Assert.Contains("mass", properties, "Rigidbody should have mass property"); + Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property"); + + // Test AI suggestions + var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties); + Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'"); + } + + [Test] + public void PropertyMatching_HandlesMonoBehaviourProperties() + { + var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour)); + + Assert.IsNotEmpty(properties, "MonoBehaviour should have properties"); + Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property"); + Assert.Contains("name", properties, "MonoBehaviour should have name property"); + Assert.Contains("tag", properties, "MonoBehaviour should have tag property"); + } + + [Test] + public void PropertyMatching_HandlesCaseVariations() + { + var testProperties = new List { "maxReachDistance", "playerHealth", "movementSpeed" }; + + var testCases = new[] + { + ("max reach distance", "maxReachDistance"), + ("Max Reach Distance", "maxReachDistance"), + ("MAX_REACH_DISTANCE", "maxReachDistance"), + ("player health", "playerHealth"), + ("movement speed", "movementSpeed") + }; + + foreach (var (input, expected) in testCases) + { + var suggestions = ComponentResolver.GetAIPropertySuggestions(input, testProperties); + Assert.Contains(expected, suggestions, $"Should suggest {expected} for input '{input}'"); + } + } + + [Test] + public void ErrorHandling_ReturnsHelpfulMessages() + { + // This test verifies that error messages are helpful and contain suggestions + var testProperties = new List { "mass", "velocity", "drag", "useGravity" }; + var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties); + + // Even if no perfect match, should return valid list + Assert.IsNotNull(suggestions, "Should return valid suggestions list"); + + // Test with completely invalid input + var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties); + Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully"); + } + + [Test] + public void PerformanceTest_CachingWorks() + { + var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); + var input = "Test Property Name"; + + // First call - populate cache + var startTime = System.DateTime.UtcNow; + var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties); + var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; + + // Second call - should use cache + startTime = System.DateTime.UtcNow; + var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties); + var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; + + Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical"); + CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly"); + + // Second call should be faster (though this test might be flaky) + Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower"); + } + + [Test] + public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() + { + // Arrange - add Transform and Rigidbody components to test with + var transform = testGameObject.transform; + var rigidbody = testGameObject.AddComponent(); + + // Create a params object with mixed valid and invalid properties + var setPropertiesParams = new JObject + { + ["action"] = "modify", + ["target"] = testGameObject.name, + ["search_method"] = "by_name", + ["componentProperties"] = new JObject + { + ["Transform"] = new JObject + { + ["localPosition"] = new JObject { ["x"] = 1.0f, ["y"] = 2.0f, ["z"] = 3.0f }, // Valid + ["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation) + ["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid + }, + ["Rigidbody"] = new JObject + { + ["mass"] = 5.0f, // Valid + ["invalidProp"] = "test", // Invalid - doesn't exist + ["useGravity"] = true // Valid + } + } + }; + + // Store original values to verify changes + var originalLocalPosition = transform.localPosition; + var originalLocalScale = transform.localScale; + var originalMass = rigidbody.mass; + var originalUseGravity = rigidbody.useGravity; + + Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); + + // Expect the warning logs from the invalid properties + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'rotatoin' not found")); + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'invalidProp' not found")); + + // Act + var result = ManageGameObject.HandleCommand(setPropertiesParams); + + Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); + Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}"); + Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}"); + + // Assert - verify that valid properties were set despite invalid ones + Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition, + "Valid localPosition should be set even with other invalid properties"); + Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale, + "Valid localScale should be set even with other invalid properties"); + Assert.AreEqual(5.0f, rigidbody.mass, 0.001f, + "Valid mass should be set even with other invalid properties"); + Assert.AreEqual(true, rigidbody.useGravity, + "Valid useGravity should be set even with other invalid properties"); + + // Verify the result indicates errors (since we had invalid properties) + Assert.IsNotNull(result, "Should return a result object"); + + // The collect-and-continue behavior means we should get an error response + // that contains info about the failed properties, but valid ones were still applied + // This proves the collect-and-continue behavior is working + } + + [Test] + public void SetComponentProperties_ContinuesAfterException() + { + // Arrange - create scenario that might cause exceptions + var rigidbody = testGameObject.AddComponent(); + + // Set initial values that we'll change + rigidbody.mass = 1.0f; + rigidbody.useGravity = true; + + var setPropertiesParams = new JObject + { + ["action"] = "modify", + ["target"] = testGameObject.name, + ["search_method"] = "by_name", + ["componentProperties"] = new JObject + { + ["Rigidbody"] = new JObject + { + ["mass"] = 2.5f, // Valid - should be set + ["velocity"] = "invalid_type", // Invalid type - will cause exception + ["useGravity"] = false // Valid - should still be set after exception + } + } + }; + + // Expect the error logs from the invalid property + LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Unexpected error converting token to UnityEngine.Vector3")); + LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("SetProperty.*Failed to set 'velocity'")); + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found")); + + // Act + var result = ManageGameObject.HandleCommand(setPropertiesParams); + + // Assert - verify that valid properties before AND after the exception were still set + Assert.AreEqual(2.5f, rigidbody.mass, 0.001f, + "Mass should be set even if later property causes exception"); + Assert.AreEqual(false, rigidbody.useGravity, + "UseGravity should be set even if previous property caused exception"); + + Assert.IsNotNull(result, "Should return a result even with exceptions"); + + // The key test: processing continued after the exception and set useGravity + // This proves the collect-and-continue behavior works even with exceptions + } + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta new file mode 100644 index 00000000..cd9b0d92 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5931268353eab4ea5baa054e6231e824 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/AssemblyInfo.cs b/UnityMcpBridge/Editor/AssemblyInfo.cs new file mode 100644 index 00000000..30a86a36 --- /dev/null +++ b/UnityMcpBridge/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] \ No newline at end of file diff --git a/UnityMcpBridge/Editor/AssemblyInfo.cs.meta b/UnityMcpBridge/Editor/AssemblyInfo.cs.meta new file mode 100644 index 00000000..343ff10e --- /dev/null +++ b/UnityMcpBridge/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be61633e00d934610ac1ff8192ffbe3d \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs index bef4f9ab..723ef528 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -7,6 +7,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class +using static MCPForUnity.Editor.Tools.ManageGameObject; #if UNITY_6000_0_OR_NEWER using PhysicsMaterialType = UnityEngine.PhysicsMaterial; @@ -201,15 +202,16 @@ private static object CreateAsset(JObject @params) "'scriptClass' property required when creating ScriptableObject asset." ); - Type scriptType = FindType(scriptClassName); + Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null; if ( scriptType == null || !typeof(ScriptableObject).IsAssignableFrom(scriptType) ) { - return Response.Error( - $"Script class '{scriptClassName}' not found or does not inherit from ScriptableObject." - ); + var reason = scriptType == null + ? (string.IsNullOrEmpty(error) ? "Type not found." : error) + : "Type found but does not inherit from ScriptableObject."; + return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); } ScriptableObject so = ScriptableObject.CreateInstance(scriptType); @@ -353,10 +355,21 @@ prop.Value is JObject componentProperties && componentProperties.HasValues ) // e.g., {"bobSpeed": 2.0} { - // Find the component on the GameObject using the name from the JSON key - // Using GetComponent(string) is convenient but might require exact type name or be ambiguous. - // Consider using FindType helper if needed for more complex scenarios. - Component targetComponent = gameObject.GetComponent(componentName); + // Resolve component type via ComponentResolver, then fetch by Type + Component targetComponent = null; + bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); + if (resolved) + { + targetComponent = gameObject.GetComponent(compType); + } + + // Only warn about resolution failure if component also not found + if (targetComponent == null && !resolved) + { + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" + ); + } if (targetComponent != null) { @@ -937,8 +950,8 @@ private static bool ApplyMaterialProperties(Material mat, JObject properties) { string propName = floatProps["name"]?.ToString(); if ( - !string.IsNullOrEmpty(propName) && floatProps["value"]?.Type == JTokenType.Float - || floatProps["value"]?.Type == JTokenType.Integer + !string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) ) { try @@ -1220,46 +1233,6 @@ private static object ConvertJTokenToType(JToken token, Type targetType) } } - /// - /// Helper to find a Type by name, searching relevant assemblies. - /// Needed for creating ScriptableObjects or finding component types by name. - /// - private static Type FindType(string typeName) - { - if (string.IsNullOrEmpty(typeName)) - return null; - - // Try direct lookup first (common Unity types often don't need assembly qualified name) - var type = - Type.GetType(typeName) - ?? Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule") - ?? Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI") - ?? Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule"); - - if (type != null) - return type; - - // If not found, search loaded assemblies (slower but more robust for user scripts) - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - // Look for non-namespaced first - type = assembly.GetType(typeName, false, true); // throwOnError=false, ignoreCase=true - if (type != null) - return type; - - // Check common namespaces if simple name given - type = assembly.GetType("UnityEngine." + typeName, false, true); - if (type != null) - return type; - type = assembly.GetType("UnityEditor." + typeName, false, true); - if (type != null) - return type; - // Add other likely namespaces if needed (e.g., specific plugins) - } - - Debug.LogWarning($"[FindType] Type '{typeName}' not found in any loaded assembly."); - return null; // Not found - } // --- Data Serialization --- diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 970eca8b..0e19382b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -1,3 +1,4 @@ +#nullable disable using System; using System.Collections.Generic; using System.Linq; @@ -5,6 +6,7 @@ using Newtonsoft.Json; // Added for JsonSerializationException using Newtonsoft.Json.Linq; using UnityEditor; +using UnityEditor.Compilation; // For CompilationPipeline using UnityEditor.SceneManagement; using UnityEditorInternal; using UnityEngine; @@ -19,10 +21,29 @@ namespace MCPForUnity.Editor.Tools /// public static class ManageGameObject { + // 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)) @@ -283,11 +304,16 @@ private static object CreateGameObject(JObject @params) 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." - ); // Name is essential + ); + } createdNewObject = true; } catch (ArgumentException) @@ -759,6 +785,7 @@ string searchMethod } // Set Component Properties + var componentErrors = new List(); if (@params["componentProperties"] is JObject componentPropertiesObj) { foreach (var prop in componentPropertiesObj.Properties()) @@ -773,12 +800,26 @@ string searchMethod propertiesToSet ); if (setResult != null) - return setResult; - modified = true; + { + componentErrors.Add(setResult); + } + else + { + modified = true; + } } } } + // Return component errors if any occurred (after processing all components) + if (componentErrors.Count > 0) + { + return Response.Error( + $"One or more component property operations failed on '{targetGo.name}'.", + new { componentErrors = componentErrors } + ); + } + if (!modified) { // Use the new serializer helper @@ -1097,6 +1138,29 @@ string searchMethod // --- 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. /// @@ -1464,7 +1528,18 @@ private static object SetComponentPropertiesInternal( Component targetComponentInstance = null ) { - Component targetComponent = targetComponentInstance ?? targetGo.GetComponent(compName); + 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( @@ -1474,6 +1549,7 @@ private static object SetComponentPropertiesInternal( Undo.RecordObject(targetComponent, "Set Component Properties"); + var failures = new List(); foreach (var prop in propertiesToSet.Properties()) { string propName = prop.Name; @@ -1481,14 +1557,16 @@ private static object SetComponentPropertiesInternal( try { - if (!SetProperty(targetComponent, propName, propValue)) + bool setResult = SetProperty(targetComponent, propName, propValue); + if (!setResult) { - // Log warning if property could not be set - Debug.LogWarning( - $"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}'). Property might not exist, be read-only, or type mismatch." - ); - // Optionally return an error here instead of just logging - // return Response.Error($"Could not set property '{propName}' on component '{compName}'."); + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); + var 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(msg); } } catch (Exception e) @@ -1496,12 +1574,13 @@ private static object SetComponentPropertiesInternal( Debug.LogError( $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" ); - // Optionally return an error here - // return Response.Error($"Error setting property '{propName}' on '{compName}': {e.Message}"); + failures.Add($"Error setting '{propName}': {e.Message}"); } } EditorUtility.SetDirty(targetComponent); - return null; // Success (or partial success if warnings were logged) + return failures.Count == 0 + ? null + : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); } /// @@ -1513,25 +1592,8 @@ private static bool SetProperty(object target, string memberName, JToken value) BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - // --- Use a dedicated serializer for input conversion --- - // Define this somewhere accessible, maybe static readonly field - JsonSerializerSettings inputSerializerSettings = new JsonSerializerSettings - { - Converters = new List - { - // Add specific converters needed for INPUT deserialization if different from output - new Vector3Converter(), - new Vector2Converter(), - new QuaternionConverter(), - new ColorConverter(), - new RectConverter(), - new BoundsConverter(), - new UnityEngineObjectConverter() // Crucial for finding references from instructions - } - // No ReferenceLoopHandling needed typically for input - }; - JsonSerializer inputSerializer = JsonSerializer.Create(inputSerializerSettings); - // --- End Serializer Setup --- + // Use shared serializer to avoid per-call allocation + var inputSerializer = InputSerializer; try { @@ -1573,6 +1635,20 @@ private static bool SetProperty(object target, string memberName, JToken value) Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } + else + { + // Try NonPublic [SerializeField] fields + var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (npField != null && npField.GetCustomAttribute() != null) + { + object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + npField.SetValue(target, convertedValue); + return true; + } + } + } } } catch (Exception ex) @@ -2070,84 +2146,288 @@ public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Ty /// - /// Helper to find a Type by name, searching relevant assemblies. + /// 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 (string.IsNullOrEmpty(typeName)) - return null; + 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); - // Handle fully qualified names first - Type type = Type.GetType(typeName); - if (type != null) return type; + /// + /// 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; + } - // Handle common namespaces implicitly (add more as needed) - string[] namespaces = { "UnityEngine", "UnityEngine.UI", "UnityEngine.AI", "UnityEngine.Animations", "UnityEngine.Audio", "UnityEngine.EventSystems", "UnityEngine.InputSystem", "UnityEngine.Networking", "UnityEngine.Rendering", "UnityEngine.SceneManagement", "UnityEngine.Tilemaps", "UnityEngine.U2D", "UnityEngine.Video", "UnityEditor", "UnityEditor.AI", "UnityEditor.Animations", "UnityEditor.Experimental.GraphView", "UnityEditor.IMGUI.Controls", "UnityEditor.PackageManager.UI", "UnityEditor.SceneManagement", "UnityEditor.UI", "UnityEditor.U2D", "UnityEditor.VersionControl" }; // Add more relevant namespaces + private static bool NamesMatch(Type t, string q) => + t.Name.Equals(q, StringComparison.Ordinal) || + (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); - foreach (string ns in namespaces) { - type = Type.GetType($"{ns}.{typeName}, {ns.Split('.')[0]}.CoreModule") ?? // Heuristic: Check CoreModule first for UnityEngine/UnityEditor - Type.GetType($"{ns}.{typeName}, {ns.Split('.')[0]}"); // Try assembly matching namespace root - if (type != null) return type; - } + 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; + } - // If not found, search all loaded assemblies (slower, last resort) - // Prioritize assemblies likely to contain game/editor types - Assembly[] priorityAssemblies = { - Assembly.Load("Assembly-CSharp"), // Main game scripts - Assembly.Load("Assembly-CSharp-Editor"), // Main editor scripts - // Add other important project assemblies if known - }; - foreach (var assembly in priorityAssemblies.Where(a => a != null)) - { - type = assembly.GetType(typeName) ?? assembly.GetType("UnityEngine." + typeName) ?? assembly.GetType("UnityEditor." + typeName); - if (type != null) return type; - } + 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(); + } - // Search remaining assemblies - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Except(priorityAssemblies)) + /// + /// 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 { - try { // Protect against assembly loading errors - type = assembly.GetType(typeName); - if (type != null) return type; - // Also check with common namespaces if simple name given - foreach (string ns in namespaces) { - type = assembly.GetType($"{ns}.{typeName}"); - if (type != null) return type; - } - } catch (Exception ex) { - Debug.LogWarning($"[FindType] Error searching assembly {assembly.FullName}: {ex.Message}"); - } + 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(); } - - Debug.LogWarning($"[FindType] Type not found after extensive search: '{typeName}'"); - return null; // Not found } + private static readonly Dictionary> PropertySuggestionCache = new(); + /// - /// Parses a JArray like [x, y, z] into a Vector3. + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. /// - private static Vector3? ParseVector3(JArray array) + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) { - if (array != null && array.Count == 3) + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) { - try + var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + // Exact match after cleaning + if (cleanedProperty == cleanedInput) { - // Use ToObject for potentially better handling than direct indexing - return new Vector3( - array[0].ToObject(), - array[1].ToObject(), - array[2].ToObject() - ); + suggestions.Add(property); + continue; } - catch (Exception ex) + + // Check if property contains all words from input + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) { - Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); + suggestions.Add(property); + continue; + } + + // Levenshtein distance for close matches + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); } } - return null; + + // 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]; } + // Removed duplicate ParseVector3 - using the one at line 1114 + // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. // They are now in Helpers.GameObjectSerializer }