using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; using Server.Commands.Generic; namespace Server.Engines.AdvancedSearch; /// /// Turns a property-test string from the Advanced Search gump into a compiled predicate. The /// grammar is the gump's own -- ~ negates a leaf, @ is AND, | is OR and binds /// looser, and the string operators double up (> is "starts with" on a string) -- but /// each leaf becomes the same a where clause compiles, so there is /// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match". /// /// /// Runs on the search workers, off the game loop: binding is reflection, compiling is /// Expression.Compile, and neither touches game state. The one exception is a value that /// names an entity by serial, which resolves through the world -- /// the same read the previous per-entity evaluator made, now made once per type instead. /// public static class AdvancedSearchConditions { // Runtime type -> its public readable instance properties, for the case-insensitive name scan. private static readonly ConcurrentDictionary _properties = new(); private static readonly Func _never = static _ => false; /// /// Per-search memo shared by every worker. Predicates are keyed twice: by the runtime type /// seen, and by the type the predicate was actually compiled for -- the most derived type /// that declares one of the properties -- so every subclass of Item that does not hide /// Hue shares one compiled Hue = 5. /// public sealed class Cache { internal readonly ConcurrentDictionary> ByRuntimeType = new(); internal readonly ConcurrentDictionary> ByCompiledType = new(); } public static Func GetPredicate(Cache cache, Type runtimeType, string propertyTest) => cache.ByRuntimeType.GetOrAdd( runtimeType, static (type, state) => Build(state.cache, type, state.propertyTest), (cache, propertyTest) ); /// Compiles without a cache. Test seam and one-off use. public static Func Compile(Type runtimeType, string propertyTest) => Build(new Cache(), runtimeType, propertyTest); private static Func Build(Cache cache, Type runtimeType, string propertyTest) { var groups = Parse(runtimeType, propertyTest, out var compiledType); if (groups == null) { return _never; } return cache.ByCompiledType.GetOrAdd( compiledType, static (type, groups) => { try { return ConditionalCompiler.Build(type, groups).Compile(); } catch (Exception) { return _never; } }, groups ); } // OR of ANDs, which is what splitting on '|' and then on '@' yields. A group with a dead leaf // is dropped; no live group left means nothing can match, reported as null. private static ICondition[][] Parse(Type runtimeType, string propertyTest, out Type compiledType) { Type mostDerived = null; var groups = new List(); foreach (var orPart in propertyTest.Split('|')) { var group = new List { TypeCondition.Default }; var alive = true; foreach (var andPart in orPart.Split('@')) { var leaf = Leaf(runtimeType, andPart, out var declaringType); if (leaf == null) { alive = false; break; } group.Add(leaf); // Every declaring type is an ancestor of the runtime type (or the type itself), so // they nest; the predicate is compiled for the most derived one any leaf needs and // serves every runtime type that resolves the same properties. if (mostDerived == null || declaringType.IsAssignableTo(mostDerived)) { mostDerived = declaringType; } } if (alive) { groups.Add(group.ToArray()); } } compiledType = mostDerived ?? runtimeType; return groups.Count > 0 ? groups.ToArray() : null; } private static ICondition Leaf(Type runtimeType, ReadOnlySpan expression, out Type declaringType) { declaringType = runtimeType; expression = expression.Trim(); if (expression.Length == 0) { return null; } var negate = false; if (expression[0] == '~') { negate = true; expression = expression[1..]; } var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex); if (operatorSpan.Length == 0) { return null; } var propertyName = expression[..operatorIndex].Trim(); var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim(); if (valuePart.Length == 0) { return null; } var chain = Resolve(runtimeType, propertyName); if (chain == null) { return null; } declaringType = chain[0].DeclaringType!; var property = new Property(chain); var type = property.Type; var op = operatorSpan.ToString(); var value = valuePart.ToString(); ICondition condition; if (type == typeof(string)) { condition = StringLeaf(property, negate, op, value); } else if (type == typeof(double) || type == typeof(float)) { condition = EpsilonLeaf(property, negate, op, value); } else { condition = ComparisonLeaf(property, negate, op, value); } return condition != null && Probe(condition, runtimeType) ? condition : null; } // A leaf the compiler rejects -- a relational operator on a type with no CompareTo -- is // "no match" for that leaf, not an error for the whole search. private static bool Probe(ICondition condition, Type runtimeType) { try { condition.Build(Expression.Parameter(runtimeType, "probe")); return true; } catch (Exception) { return false; } } private static ICondition StringLeaf(Property property, bool negate, string op, string value) { var (stringOp, ignoreCase) = op switch { "=" or "==" => (StringOperator.Equal, false), "!" or "!=" => (StringOperator.NotEqual, false), ">" => (StringOperator.StartsWith, false), "<" => (StringOperator.EndsWith, false), "~" => (StringOperator.Contains, false), "~>" => (StringOperator.StartsWith, true), "~<" => (StringOperator.EndsWith, true), "~~" => (StringOperator.Contains, true), "~=" => (StringOperator.Equal, true), "~!" => (StringOperator.NotEqual, true), _ => ((StringOperator?)null, false) }; if (stringOp == null) { return null; } // `null` is the null string for equality, as it is in a where clause; for the substring // operators, where a null needle means nothing, it is the four-letter word. if (value == "null" && stringOp is not (StringOperator.Equal or StringOperator.NotEqual)) { value = @"@""null"""; } return new StringCondition(property, negate, stringOp.Value, value, ignoreCase); } private static ICondition EpsilonLeaf(Property property, bool negate, string op, string value) { var comparison = MapOperator(op); if (comparison == null) { return null; } // A float property parses its value as a float first, so the widened constant carries the // same rounding the property's own value does. double parsed; if (property.Type == typeof(float)) { if (!float.TryParse(value, null, out var f)) { return null; } parsed = f; } else if (!double.TryParse(value, null, out parsed)) { return null; } return new EpsilonCondition(property, negate, comparison.Value, parsed, AdvancedSearchUtilities.CalculateEpsilon(value)); } private static ICondition ComparisonLeaf(Property property, bool negate, string op, string value) { var comparison = MapOperator(op); if (comparison == null) { return null; } var type = property.Type; var underlying = Nullable.GetUnderlyingType(type); object parsed; if (value == "null" && (underlying != null || !type.IsValueType)) { parsed = null; } else if (underlying == null && type == typeof(bool)) { // The gump accepts the switch words as well as the literals. if (comparison is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual)) { return null; } parsed = value.ToLowerInvariant() switch { "true" or "1" or "enabled" or "on" => true, "false" or "0" or "disabled" or "off" => false, _ => null }; if (parsed == null) { return null; } } else if (Types.TryParse(underlying ?? type, value, out parsed) != null) { return null; } return new ComparisonCondition(property, negate, comparison.Value, parsed); } private static ComparisonOperator? MapOperator(string op) => op switch { "=" or "==" => ComparisonOperator.Equal, "!" or "!=" => ComparisonOperator.NotEqual, ">" => ComparisonOperator.Greater, "<" => ComparisonOperator.Lesser, ">=" => ComparisonOperator.GreaterEqual, "<=" => ComparisonOperator.LesserEqual, _ => null }; // Case-insensitive, first readable match per link, the way the gump has always resolved a // name. A dotted name walks into the property's type. private static PropertyInfo[] Resolve(Type type, ReadOnlySpan name) { var count = name.Count('.') + 1; var chain = new PropertyInfo[count]; for (var i = 0; i < count; ++i) { var dot = name.IndexOf('.'); var segment = dot == -1 ? name : name[..dot]; name = dot == -1 ? default : name[(dot + 1)..]; var found = Find(type, segment.Trim()); if (found == null) { return null; } chain[i] = found; type = found.PropertyType; } return chain; } private static PropertyInfo Find(Type type, ReadOnlySpan name) { var properties = _properties.GetOrAdd(type, static t => Readable(t)); for (var i = 0; i < properties.Length; ++i) { if (name.InsensitiveEquals(properties[i].Name)) { return properties[i]; } } return null; } private static PropertyInfo[] Readable(Type type) { var all = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); var readable = new List(all.Length); for (var i = 0; i < all.Length; ++i) { if (all[i].CanRead && all[i].GetIndexParameters().Length == 0) { readable.Add(all[i]); } } return readable.ToArray(); } /// /// A floating-point comparison with the tolerance the gump derives from the typed value: a /// value with no decimal point compares to within 1E-10, one with ten or more decimals to /// within the last digit typed. /// private sealed class EpsilonCondition : ICondition { private static readonly MethodInfo _abs = typeof(Math).GetMethod(nameof(Math.Abs), [typeof(double)])!; private readonly Property _property; private readonly bool _not; private readonly ComparisonOperator _operator; private readonly double _value; private readonly double _epsilon; public EpsilonCondition(Property property, bool not, ComparisonOperator op, double value, double epsilon) { _property = property; _not = not; _operator = op; _value = value; _epsilon = epsilon; } public Expression Build(ParameterExpression target) => PropertyExpressions.Chain( target, _property, read => { var value = read.Type == typeof(double) ? read : Expression.Convert(read, typeof(double)); var constant = Expression.Constant(_value); var epsilon = Expression.Constant(_epsilon); var distance = Expression.Call(_abs, Expression.Subtract(value, constant)); Expression test = _operator switch { ComparisonOperator.Equal => Expression.LessThan(distance, epsilon), ComparisonOperator.NotEqual => Expression.GreaterThanOrEqual(distance, epsilon), ComparisonOperator.Greater => Expression.GreaterThan(value, Expression.Add(constant, epsilon)), ComparisonOperator.Lesser => Expression.LessThan(value, Expression.Subtract(constant, epsilon)), ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(value, Expression.Subtract(constant, epsilon)), ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(value, Expression.Add(constant, epsilon)), _ => throw new InvalidOperationException("Invalid comparison operator.") }; return _not ? Expression.Not(test) : test; }, Expression.Constant(false) ); } }