feat(types): support legacy RunUO Parse(string) types; AdvancedSearch reuses it

Server.Types.Parse now discovers either the modern IParsable Parse(string,
IFormatProvider) or a legacy RunUO Parse(string) (cached in the ConcurrentDictionary),
and TryParse gates on the discovered method instead of the IParsable interface. This
restores parsing for pre-IParsable types like Faction and Town, which have only a
static Parse(string) and were previously unreachable (they fell through to
Convert.ChangeType and failed) — fixing them for [set, spawners, and the conditional
compiler as well.

AdvancedSearch's CompareValues now delegates all non-hot-path types to Types
unconditionally, so reference types, IParsable value types, and legacy Parse(string)
types are all searchable and compared by value. Adds a legacy-Parse test. Full suite 535/535.
This commit is contained in:
Kamron Batman 2026-07-20 09:19:20 -07:00
parent 1adf282c6b
commit 8997aaf498
3 changed files with 52 additions and 14 deletions

View file

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

View file

@ -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>(T propertyValue, T parsedValue, ReadOnlySpan<char> operatorSpan) where T : INumber<T> =>

View file

@ -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<T> (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<Type, MethodInfo> _parseMethods = new();
// A static string Parse method: the modern IParsable<T> 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<T> (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
{