Merge pull request #256 from dsarno/feat/robust-component-resolver

Feat/robust component resolver
main
dsarno 2025-09-03 10:06:54 -07:00 committed by GitHub
commit 4401de15d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1071 additions and 142 deletions

View File

@ -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}");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78ee39b9744834fe390a4c7c5634eb5a

View File

@ -0,0 +1,14 @@
{
"name": "TestAsmdef",
"rootNamespace": "TestNamespace",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 72f6376fa7bdc4220b11ccce20108cdc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -3,6 +3,7 @@
"rootNamespace": "",
"references": [
"MCPForUnity.Editor",
"TestAsmdef",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],

View File

@ -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<string> sampleProperties;
[SetUp]
public void SetUp()
{
sampleProperties = new List<string>
{
"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<string>());
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<string> { "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<string> { "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");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e4468da1a15349029e52570b84ec4b0

View File

@ -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");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c15ba6502927e4901a43826c43debd7c

View File

@ -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<Rigidbody>();
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<string> { "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<string> { "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<Rigidbody>();
// 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<Rigidbody>();
// 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
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5931268353eab4ea5baa054e6231e824

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be61633e00d934610ac1ff8192ffbe3d

View File

@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq;
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 @@ namespace MCPForUnity.Editor.Tools
"'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 @@ namespace MCPForUnity.Editor.Tools
&& 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 @@ namespace MCPForUnity.Editor.Tools
{
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 @@ namespace MCPForUnity.Editor.Tools
}
}
/// <summary>
/// Helper to find a Type by name, searching relevant assemblies.
/// Needed for creating ScriptableObjects or finding component types by name.
/// </summary>
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 ---

View File

@ -1,3 +1,4 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
@ -5,6 +6,7 @@ using System.Reflection;
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
/// </summary>
public static class ManageGameObject
{
// Shared JsonSerializer to avoid per-call allocation overhead
private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
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 @@ namespace MCPForUnity.Editor.Tools
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 @@ namespace MCPForUnity.Editor.Tools
}
// Set Component Properties
var componentErrors = new List<object>();
if (@params["componentProperties"] is JObject componentPropertiesObj)
{
foreach (var prop in componentPropertiesObj.Properties())
@ -773,12 +800,26 @@ namespace MCPForUnity.Editor.Tools
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 @@ namespace MCPForUnity.Editor.Tools
// --- Internal Helpers ---
/// <summary>
/// Parses a JArray like [x, y, z] into a Vector3.
/// </summary>
private static Vector3? ParseVector3(JArray array)
{
if (array != null && array.Count == 3)
{
try
{
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}");
}
}
return null;
}
/// <summary>
/// Finds a single GameObject based on token (ID, name, path) and search method.
/// </summary>
@ -1464,7 +1528,18 @@ namespace MCPForUnity.Editor.Tools
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 @@ namespace MCPForUnity.Editor.Tools
Undo.RecordObject(targetComponent, "Set Component Properties");
var failures = new List<string>();
foreach (var prop in propertiesToSet.Properties())
{
string propName = prop.Name;
@ -1481,14 +1557,16 @@ namespace MCPForUnity.Editor.Tools
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 @@ namespace MCPForUnity.Editor.Tools
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 });
}
/// <summary>
@ -1513,25 +1592,8 @@ namespace MCPForUnity.Editor.Tools
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<JsonConverter>
{
// 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 @@ namespace MCPForUnity.Editor.Tools
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<SerializeField>() != 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 @@ namespace MCPForUnity.Editor.Tools
/// <summary>
/// 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.
/// </summary>
private static Type FindType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return null;
// Handle fully qualified names first
Type type = Type.GetType(typeName);
if (type != null) return type;
// 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
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;
}
// 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;
}
// Search remaining assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Except(priorityAssemblies))
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
{
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}");
}
return resolvedType;
}
// Log the resolver error if type wasn't found
if (!string.IsNullOrEmpty(error))
{
Debug.LogWarning($"[FindType] {error}");
}
return null;
}
}
/// <summary>
/// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions.
/// Prioritizes runtime (Player) assemblies over Editor assemblies.
/// </summary>
internal static class ComponentResolver
{
private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal);
private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal);
/// <summary>
/// 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.
/// </summary>
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;
}
Debug.LogWarning($"[FindType] Type not found after extensive search: '{typeName}'");
return null; // Not found
// 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<Component>()
.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<Type> 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<string>(
UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name),
StringComparer.Ordinal);
IEnumerable<System.Reflection.Assembly> playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name));
IEnumerable<System.Reflection.Assembly> editorAsms = loaded.Except(playerAsms);
#else
IEnumerable<System.Reflection.Assembly> playerAsms = loaded;
IEnumerable<System.Reflection.Assembly> editorAsms = Array.Empty<System.Reflection.Assembly>();
#endif
static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly a)
{
try { return a.GetTypes(); }
catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; }
}
Func<Type, bool> 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<Type>(fromPlayer);
if (list.Count == 0) list.AddRange(fromEditor);
return list;
}
#if UNITY_EDITOR
private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> seq)
{
var player = new HashSet<string>(
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<Type> 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.";
}
/// <summary>
/// Parses a JArray like [x, y, z] into a Vector3.
/// Gets all accessible property and field names from a component type.
/// </summary>
private static Vector3? ParseVector3(JArray array)
public static List<string> GetAllComponentProperties(Type componentType)
{
if (array != null && array.Count == 3)
if (componentType == null) return new List<string>();
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<SerializeField>() != null)
.Select(f => f.Name);
return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList();
}
/// <summary>
/// Uses AI to suggest the most likely property matches for a user's input.
/// </summary>
public static List<string> GetAIPropertySuggestions(string userInput, List<string> availableProperties)
{
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
return new List<string>();
// 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
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<string>();
}
}
private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new();
/// <summary>
/// Rule-based suggestions that mimic AI behavior for property matching.
/// This provides immediate value while we could add real AI integration later.
/// </summary>
private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties)
{
var suggestions = new List<string>();
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)
{
// Use ToObject for potentially better handling than direct indexing
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
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();
}
/// <summary>
/// Calculates Levenshtein distance between two strings for similarity matching.
/// </summary>
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
}