diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs index 4c0650e48..9176fae4a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs @@ -96,4 +96,24 @@ public class AdvancedSearchUtilitiesTests Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); } + + // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — + // the Faction/Town shape. Types must still discover its Parse by reflection. + private sealed class LegacyParseType + { + public string Value { get; private init; } + public static LegacyParseType Parse(string s) => new() { Value = s }; + public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + } + + [Fact] + public void CompareValues_LegacyParseString_ParsedViaTypes() + { + // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the + // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. + var prop = LegacyParseType.Parse("alpha"); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); + } } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index bdd5314f4..2c349847d 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -128,17 +128,13 @@ public static class AdvancedSearchUtilities }; } // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity - // properties resolved by serial, ...) and value types exposing IParsable (Guid, - // DateTimeOffset, ...). Delegate parsing to the shared, thread-safe Types converter so the - // target is parsed into the property's real type, then compare by value. A string is - // allocated here, but this is the uncommon path; the common types never reach it. - if (!propertyType.IsValueType || Types.IsParsable(propertyType)) - { - return Types.TryParse(propertyType, valuePart.ToString(), out var parsedValue) == null && - CompareReference(propertyValue!, parsedValue, operatorSpan); - } - - return false; + // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy + // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, + // thread-safe Types converter so the target is parsed into the property's real type, then + // compare by value. A string is allocated here, but this is the uncommon path; the common + // types never reach it. Types returns a non-null message when it can't parse -> no match. + return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && + CompareReference(propertyValue!, parsed, operatorSpan); } public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index 5edac64e6..4497f2d12 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -11,6 +11,8 @@ namespace Server { public static readonly Type[] ParseStringParamTypes = { typeof(string), typeof(IFormatProvider) }; public static readonly Type[] ParseStringNumericParamTypes = { typeof(string), typeof(NumberStyles) }; + // Legacy RunUO signature: a static Parse(string) that predates IParsable (e.g. Faction, Town). + public static readonly Type[] ParseStringSingleParamTypes = { typeof(string) }; public static readonly Type OfByte = typeof(byte); public static readonly Type OfSByte = typeof(sbyte); @@ -116,12 +118,29 @@ namespace Server private static readonly ConcurrentDictionary _parseMethods = new(); + // A static string Parse method: the modern IParsable Parse(string, IFormatProvider), or a + // legacy RunUO Parse(string). Cached per type; null if the type has neither. (Span-based Parse + // can't be reflection-invoked — a ReadOnlySpan can't be boxed into the args array — so the + // string overloads are what we bind to.) + public static MethodInfo GetParseMethod(Type t) => + _parseMethods.GetOrAdd( + t, + static type => type.GetMethod("Parse", ParseStringParamTypes) + ?? type.GetMethod("Parse", ParseStringSingleParamTypes) + ); + public static object Parse(Type t, string value) { - var method = _parseMethods.GetOrAdd(t, static type => type.GetMethod("Parse", ParseStringParamTypes)); + var method = GetParseMethod(t); + if (method == null) + { + return null; + } // Fresh args array per call — a shared static array would race across concurrent callers. - return method?.Invoke(null, new object[] { value, null }); + // Arg shape depends on which overload we bound to (IParsable 2-arg vs legacy 1-arg). + var args = method.GetParameters().Length == 2 ? new object[] { value, null } : new object[] { value }; + return method.Invoke(null, args); } // Do not use this in "Parse" methods, it may cause a stack overflow @@ -221,7 +240,10 @@ namespace Server } } - if (IsParsable(type)) + // IParsable (Parse(string, IFormatProvider)) or a legacy RunUO Parse(string). Gating on + // the discovered method rather than the IParsable interface keeps pre-IParsable types + // (Faction, Town, ...) parseable for backwards compatibility. + if (GetParseMethod(type) != null) { try {