diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..ba7b3f5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Object property lists (tooltips) | `dev-docs/property-lists.md` | | Gump (UI dialog) system | `dev-docs/gump-system.md` | | Commands & targeting | `dev-docs/commands-targeting.md` | +| Generic commands (`where`/`order by`/`distinct`, dot notation, `@""` literals, `[batch`, `[interface`) | `dev-docs/generic-commands.md` | | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | | Server hardware requirements | `dev-docs/server-requirements.md` | diff --git a/Projects/Server/Text/TextDefinition.cs b/Projects/Server/Text/TextDefinition.cs index 4951c2290..0dfcc7ba8 100644 --- a/Projects/Server/Text/TextDefinition.cs +++ b/Projects/Server/Text/TextDefinition.cs @@ -68,7 +68,26 @@ public class TextDefinition : IEquatable, IEquatable, IS Number > 0 ? $"{Number} (0x{Number:X})" : String != null ? $"\"{String}\"" : null; - public string GetValue() => Number > 0 ? Number.ToString() : String ?? ""; + /// + /// The editable text form. Quotes a string that TryParse would not read back unchanged; + /// the check is a real round trip so it cannot drift from the parser. + /// + public string GetValue() + { + if (Number > 0) + { + return Number.ToString(); + } + + if (String == null) + { + return ""; + } + + return TryParse(String, null, out var parsed) && parsed.Number == 0 && parsed.String == String + ? String + : $"@\"{String}\""; + } public static implicit operator TextDefinition(int v) => Of(v); @@ -120,15 +139,7 @@ public class TextDefinition : IEquatable, IEquatable, IS public static bool operator !=(TextDefinition left, TextDefinition right) => !Equals(left, right); - public static TextDefinition Parse(string value) - { - if (value == null) - { - return null; - } - - return Utility.ToInt32(value, out var i) ? Of(i) : Of(value); - } + public static TextDefinition Parse(string value) => value == null ? null : Parse(value.AsSpan(), null); public static TextDefinition Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); @@ -137,13 +148,30 @@ public class TextDefinition : IEquatable, IEquatable, IS public static TextDefinition Parse(ReadOnlySpan s, IFormatProvider provider) { - // We don't trim - return int.TryParse(s, provider, out var label) ? Of(label) : Of(s); + TryParse(s, provider, out var result); + return result; } + /// + /// #1234 or a bare 1234/0x4D2 is a cliloc; @"1234" is the literal + /// text. Always succeeds -- anything that is not a cliloc is a string. + /// See dev-docs/generic-commands.md. + /// public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out TextDefinition result) { - if (int.TryParse(s, provider, out var label)) + if (TryGetQuotedLiteral(s, out var literal)) + { + result = Of(literal); + return true; + } + + if (s.Length > 1 && s[0] == '#' && Utility.ToInt32(s[1..], out var marked)) + { + result = Of(marked); + return true; + } + + if (Utility.ToInt32(s, out var label)) { result = Of(label); return true; @@ -153,4 +181,16 @@ public class TextDefinition : IEquatable, IEquatable, IS result = Of(s); return true; } + + private static bool TryGetQuotedLiteral(ReadOnlySpan s, out ReadOnlySpan literal) + { + if (s.Length >= 3 && s[0] == '@' && s[1] == '"' && s[^1] == '"') + { + literal = s[2..^1]; + return true; + } + + literal = default; + return false; + } } diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs index 6283dd99a..a7c266589 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -94,6 +94,20 @@ public class ChainedBindingSortTests : IDisposable Assert.Equal(0, comparer.Compare(blank, alsoBlank)); } + // A null intermediate on a value-typed chain reads as default(int) rather than throwing. + [Fact] + public void SortingOnAValueTypeChainStillSurvivesANullIntermediate() + { + var valued = Teleporter(TextDefinition.Of(1000)); + var blank = Teleporter(null); + + var comparer = Sorter("Message.Number"); + + // A null Message reads as default(int) == 0, so it sorts below cliloc 1000. + Assert.True(comparer.Compare(blank, valued) < 0); + Assert.True(comparer.Compare(valued, blank) > 0); + } + // An unchained binding never had the problem and must keep working untouched. [Fact] public void SortingOnAPlainBindingIsUnchanged() diff --git a/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs new file mode 100644 index 000000000..f8502e193 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs @@ -0,0 +1,139 @@ +using Server; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// Commands.Split strips quotes before any parser runs, so "0" and 0 arrive identical -- the +// markers are the only way to say which was meant. See dev-docs/generic-commands.md. +public class TextDefinitionParsingTests +{ + private static TextDefinition Parse(string value) + { + Assert.Null(Types.TryParse(typeof(TextDefinition), value, out var parsed)); + return parsed as TextDefinition; + } + + [Theory] + [InlineData("1060847", 1060847)] + [InlineData("#1060847", 1060847)] + public void ClilocsParseToNumber(string entered, int expected) + { + var td = Parse(entered); + + Assert.NotNull(td); + Assert.Equal(expected, td.Number); + Assert.Null(td.String); + } + + // ToString() already emits '#', so this closes the round trip. + [Fact] + public void ClilocMarkerRoundTripsThroughToString() + { + var td = TextDefinition.Of(1060847); + + Assert.Equal("#1060847", td.ToString()); + Assert.Equal(td, Parse(td.ToString())); + } + + [Theory] + [InlineData("hello", "hello")] + [InlineData(@"@""0""", "0")] + [InlineData(@"@""1060847""", "1060847")] + [InlineData(@"@""null""", "null")] + [InlineData(@"@""#5""", "#5")] + public void QuotedLiteralsForceAString(string entered, string expected) + { + var td = Parse(entered); + + Assert.NotNull(td); + Assert.Equal(0, td.Number); + Assert.Equal(expected, td.String); + } + + // Bare integers stay clilocs, so existing content is unaffected. + [Fact] + public void BareZeroStaysAnEmptyCliloc() + { + var td = Parse("0"); + + Assert.NotNull(td); + Assert.Equal(0, td.Number); + Assert.Null(td.String); + } + + // GetValue() fills the props-gump edit box; an ambiguous string must come out quoted. + [Theory] + [InlineData("1060847")] + [InlineData("0")] + [InlineData("#5")] + public void AmbiguousStringsAreEmittedQuotedSoTheyReadBack(string text) + { + var round = Parse(TextDefinition.Of(text).GetValue()); + + Assert.NotNull(round); + Assert.Equal(0, round.Number); + Assert.Equal(text, round.String); + } + + [Fact] + public void UnambiguousValuesAreEmittedPlain() + { + Assert.Equal("Hail, traveller.", TextDefinition.Of("Hail, traveller.").GetValue()); + Assert.Equal("1060847", TextDefinition.Of(1060847).GetValue()); + } + + [Fact] + public void NullSentinelClearsTheValue() + { + Assert.Null(Types.TryParse(typeof(TextDefinition), "(-null-)", out var parsed)); + Assert.Null(parsed); + } +} + +// [get emits @"null" for the literal string "null"; [set has to decode it to round trip. +[Collection("Sequential UOContent Tests")] +public class StringEscapeRoundTripTests +{ + [Fact] + public void QuotedNullSetsTheLiteralStringNull() + { + Assert.Null(Types.TryParse(typeof(string), @"@""null""", out var parsed)); + + Assert.Equal("null", parsed); + } + + [Fact] + public void BareNullSentinelStillClearsTheValue() + { + Assert.Null(Types.TryParse(typeof(string), "(-null-)", out var parsed)); + + Assert.Null(parsed); + } + + [Fact] + public void GetOutputPastesBackIntoSet() + { + var item = new Static(0x1F13) { Name = "null" }; + + try + { + var from = new Mobile { AccessLevel = AccessLevel.Developer }; + var shown = Properties.GetValue(from, item, "Name"); + + // "Name = @"null"" -> the value half is what a GM copies. + var value = shown[(shown.IndexOf('=') + 2)..]; + Assert.Equal(@"@""null""", value); + + item.Name = "changed"; + Properties.SetValue(from, item, "Name", value); + + Assert.Equal("null", item.Name); + } + finally + { + item.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs new file mode 100644 index 000000000..34d0ca482 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Commands; +using Server.Commands.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands; + +// `where` constants resolve through Types.TryParse like every other command. Type- and +// entity-valued properties have no static Parse, so the old local parser threw on them. +[Collection("Sequential UOContent Tests")] +public class WhereConstantParsingTests : IDisposable +{ + private readonly List _entities = []; + + public void Dispose() + { + for (var i = 0; i < _entities.Count; i++) + { + _entities[i].Delete(); + } + + _entities.Clear(); + } + + public class Subject + { + [CommandProperty(AccessLevel.GameMaster)] + public Type Kind { get; set; } = typeof(Static); + + [CommandProperty(AccessLevel.GameMaster)] + public Mobile Owner { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public Item Thing { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public string Label { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public TextDefinition Message { get; set; } + + [CommandProperty(AccessLevel.GameMaster)] + public Map Facet { get; set; } = Map.Felucca; + + [CommandProperty(AccessLevel.GameMaster)] + public int Count { get; set; } = 0x1F13; + + [CommandProperty(AccessLevel.GameMaster)] + public SkillName Craft { get; set; } = SkillName.Magery; + } + + private static bool Check(object target, string binding, string value, + ComparisonOperator op = ComparisonOperator.Equal) + { + var prop = new Property(binding); + prop.BindTo(typeof(Subject), PropertyAccess.Read); + + var compiled = ConditionalCompiler.Compile( + typeof(Subject), + [TypeCondition.Default, new ComparisonCondition(prop, false, op, value)] + ); + + return compiled.Verify(target); + } + + private T Track(T entity) where T : IEntity + { + _entities.Add(entity); + return entity; + } + + [Fact] + public void TypeValuedPropertiesResolveByName() + { + var s = new Subject(); + + Assert.True(Check(s, "Kind", "Static")); + Assert.False(Check(s, "Kind", "Container")); + } + + [Fact] + public void EntityValuedPropertiesResolveBySerial() + { + var mob = Track(new Mobile()); + var item = Track(new Static(0x1F13)); + var s = new Subject { Owner = mob, Thing = item }; + + Assert.True(Check(s, "Owner", mob.Serial.ToString())); + Assert.True(Check(s, "Thing", item.Serial.ToString())); + Assert.False(Check(s, "Thing", (item.Serial + 1).ToString())); + } + + // `where` spells null as a bare `null`, not [set's (-null-); every existing clause relies on it. + [Fact] + public void BareNullStillMeansNull() + { + var s = new Subject(); + + Assert.True(Check(s, "Label", "null")); + Assert.True(Check(s, "Owner", "null")); + + s.Label = "set"; + s.Owner = Track(new Mobile()); + + Assert.False(Check(s, "Label", "null")); + Assert.False(Check(s, "Owner", "null")); + } + + [Fact] + public void QuotedNullStillMeansTheLiteralString() + { + var s = new Subject { Label = "null" }; + + Assert.True(Check(s, "Label", @"@""null""")); + Assert.False(Check(s, "Label", "null")); + } + + [Theory] + [InlineData("Facet", "Felucca", true)] + [InlineData("Facet", "Trammel", false)] + [InlineData("Count", "0x1F13", true)] + [InlineData("Count", "7955", true)] + [InlineData("Count", "0x1F14", false)] + [InlineData("Craft", "Magery", true)] + [InlineData("Craft", "Anatomy", false)] + public void ExistingConstantFormsKeepWorking(string binding, string value, bool expected) + { + Assert.Equal(expected, Check(new Subject(), binding, value)); + } + + [Theory] + [InlineData("#1060847", true)] + [InlineData("#1060848", false)] + public void TextDefinitionMarkersReachTheWhereClause(string value, bool expected) + { + var s = new Subject { Message = TextDefinition.Of(1060847) }; + + Assert.Equal(expected, Check(s, "Message", value)); + } + + [Fact] + public void QuotedLiteralsReachTextDefinitionsInWhereToo() + { + var s = new Subject { Message = TextDefinition.Of("1060847") }; + + Assert.True(Check(s, "Message", @"@""1060847""")); + Assert.False(Check(s, "Message", "1060847")); + } + + [Fact] + public void UnresolvableConstantsStillReportAnError() + { + Assert.Throws( + () => Check(new Subject(), "Kind", "NoSuchTypeAnywhere") + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs new file mode 100644 index 000000000..f6dac1546 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Server; +using Server.Gumps; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace UOContent.Tests.Gumps; + +// TextDefinition carries [PropertyObject], which the props gump checks before its parsable +// fallback -- so the row needs an explicit branch or it drills into a read-only dead end. +[Collection("Sequential UOContent Tests")] +public class PropsGumpTextDefinitionTests : IDisposable +{ + // Mirrors PropertiesGump.MaxEntriesPerPage, which is private. + private const int MaxEntriesPerPage = 15; + + private readonly List _items = []; + private readonly List _mobiles = []; + private readonly List _states = []; + + public void Dispose() + { + for (var i = 0; i < _mobiles.Count; i++) + { + _mobiles[i].NetState = null; + _mobiles[i].Delete(); + } + + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + for (var i = 0; i < _states.Count; i++) + { + _states[i].Dispose(); + } + + _mobiles.Clear(); + _items.Clear(); + _states.Clear(); + } + + private (Mobile From, NetState State) CreateStaff() + { + var ns = PacketTestUtilities.CreateTestNetState(); + _states.Add(ns); + + var from = new Mobile { AccessLevel = AccessLevel.GameMaster }; + from.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _mobiles.Add(from); + + from.NetState = ns; + ns.Mobile = from; + + return (from, ns); + } + + private SkillTeleporter CreateTeleporter(TextDefinition message) + { + var tp = new SkillTeleporter { Message = message }; + tp.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + _items.Add(tp); + return tp; + } + + // Opens the props gump on whichever page holds `propName` and presses that row's gold '>'. + private static void PressPropertyButton(Mobile from, NetState ns, object o, string propName) + { + var probe = new TestPropsGump(from, o); + var list = probe.List; + + var index = -1; + for (var i = 0; i < list.Count; i++) + { + if (list[i] is PropertyInfo p && p.Name == propName) + { + index = i; + break; + } + } + + Assert.True(index >= 0, $"{o.GetType().Name} has no visible '{propName}' property row."); + + var page = index / MaxEntriesPerPage; + var buttonId = index - page * MaxEntriesPerPage + 3; + + var gump = new TestPropsGump(from, o, list, page); + gump.OnResponse(ns, EmptyInfo(buttonId)); + } + + private static RelayInfo EmptyInfo(int buttonId) => + new(buttonId, ReadOnlySpan.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty); + + private static RelayInfo TextInfo(int buttonId, int entryId, string text) + { + var block = Encoding.BigEndianUnicode.GetBytes(text); + var ids = new[] { (ushort)entryId }; + var ranges = new[] { new Range(0, block.Length) }; + + return new RelayInfo(buttonId, ReadOnlySpan.Empty, ids, ranges, block); + } + + [Fact] + public void PressingSetOnAPopulatedTextDefinitionOpensTheEditor() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of(1060847)); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + Assert.NotNull(ns.FindGump()); + } + + // With no object to drill into, the old path re-sent the same page and looked inert. + [Fact] + public void PressingSetOnANullTextDefinitionOpensTheEditor() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(null); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + Assert.NotNull(ns.FindGump()); + } + + // Numeric input always wins, so "0" is cliloc 0 (empty), never the string "0". + [Theory] + [InlineData("1060847", 1060847, null)] + [InlineData("0", 0, null)] + [InlineData("Hail, traveller.", 0, "Hail, traveller.")] + public void EditorRoundTripsClilocAndString(string entered, int expectedNumber, string expectedString) + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(null); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, TextInfo(1, 0, entered)); + + Assert.NotNull(tp.Message); + Assert.Equal(expectedNumber, tp.Message.Number); + Assert.Equal(expectedString, tp.Message.String); + } + + // The edit box is seeded from GetValue(), so a cliloc-looking string must survive the trip. + [Fact] + public void EditorPreservesAStringThatLooksLikeACliloc() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of("1060847")); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, TextInfo(1, 0, tp.Message.GetValue())); + + Assert.NotNull(tp.Message); + Assert.Equal(0, tp.Message.Number); + Assert.Equal("1060847", tp.Message.String); + } + + [Fact] + public void EditorNullButtonClearsTheValue() + { + var (from, ns) = CreateStaff(); + var tp = CreateTeleporter(TextDefinition.Of("Hail, traveller.")); + + PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message)); + + var setGump = ns.FindGump(); + Assert.NotNull(setGump); + + setGump.OnResponse(ns, EmptyInfo(2)); + + Assert.Null(tp.Message); + } + + // Exposes the protected list/page plumbing so a test can aim at a specific property row. + private sealed class TestPropsGump : PropertiesGump + { + public TestPropsGump(Mobile m, object o) : base(m, o) + { + } + + public TestPropsGump(Mobile m, object o, List list, int page) : base(m, o, null, list, page) + { + } + + public List List => m_List; + } +} diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs index 68f30447c..0689d130a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs @@ -327,10 +327,8 @@ public static class PropertyExpressions } /// - /// The right-hand side of a condition as a typed constant. A string is parsed the way the - /// props gump would parse it: null for a reference or nullable type, @"null" - /// for the literal string, names for enums, hex with a 0x prefix for the numerics, - /// and the type's own static Parse for everything else. + /// The right-hand side of a condition as a typed constant, resolved by the same parser behind + /// [set and [add. See dev-docs/generic-commands.md. /// public static ConstantExpression Constant(Type type, object value) { @@ -346,62 +344,12 @@ public static class PropertyExpressions { var underlying = Nullable.GetUnderlyingType(type); + // `where` spells null as a bare `null`, not [set's (-null-), so it precedes the parser. if (text == "null" && (underlying != null || !type.IsValueType)) { return null; } - var target = underlying ?? type; - - if (target == typeof(string)) - { - return text == @"@""null""" ? "null" : text; - } - - if (target.IsEnum) - { - return Enum.Parse(target, text, true); - } - - if (target == typeof(bool)) - { - return bool.Parse(text); - } - - var parseNumber = target.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringNumericParamTypes, - null - ); - - if (parseNumber != null) - { - var style = NumberStyles.Integer; - - if (text.InsensitiveStartsWith("0x")) - { - style = NumberStyles.HexNumber; - text = text[2..]; - } - - return parseNumber.Invoke(null, [text, style]); - } - - var parseGeneral = target.GetMethod( - "Parse", - BindingFlags.Public | BindingFlags.Static, - null, - Types.ParseStringParamTypes, - null - ); - - if (parseGeneral != null) - { - return parseGeneral.Invoke(null, [text, null]); - } - - throw new InvalidOperationException($"Unable to convert string \"{text}\" into type '{type}'."); + return Types.ParseOrThrow(underlying ?? type, text); } } diff --git a/Projects/UOContent/Gumps/Props/PropsGump.cs b/Projects/UOContent/Gumps/Props/PropsGump.cs index eda11e287..00fd2557d 100644 --- a/Projects/UOContent/Gumps/Props/PropsGump.cs +++ b/Projects/UOContent/Gumps/Props/PropsGump.cs @@ -305,6 +305,12 @@ namespace Server.Gumps from.SendGump(new PropertiesGump(from, mobile, m_Stack, m_List, m_Page)); from.SendGump(new SkillsGump(from, mobile)); } + // Must stay ahead of [PropertyObject]: TextDefinition carries that + // attribute, but Number and String are get-only so drilling in is a dead end. + else if (IsType(type, OfText)) + { + from.SendGump(new SetGump(prop, from, m_Object, this)); + } else if (HasAttribute(type, OfPropertyObject, true)) { from.SendGump( diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index ca5b9422d..184773e84 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -192,6 +192,35 @@ namespace Server return false; } + // @"..." is the literal text inside, for values the bare text would be read as something + // else. See dev-docs/generic-commands.md. + private static bool TryGetQuotedLiteral(string value, out string literal) + { + if (value?.Length >= 3 && value[0] == '@' && value[1] == '"' && value[^1] == '"') + { + literal = value[2..^1]; + return true; + } + + literal = null; + return false; + } + + /// + /// for callers with nowhere to put an error string. + /// + public static object ParseOrThrow(Type type, string value) + { + var error = TryParse(type, value, out var constructed); + + if (error != null) + { + throw new InvalidOperationException(error); + } + + return constructed; + } + // Do not use this in "Parse" methods, it may cause a stack overflow public static string TryParse(Type type, string value, out object constructed) { @@ -244,7 +273,8 @@ namespace Server if (IsType(type, OfString)) { - constructed = value; + // Decodes what InternalGetValue writes, so [get output pastes back into [set. + constructed = TryGetQuotedLiteral(value, out var literal) ? literal : value; return null; } diff --git a/dev-docs/commands-targeting.md b/dev-docs/commands-targeting.md index d4e96c793..48a28c8bf 100644 --- a/dev-docs/commands-targeting.md +++ b/dev-docs/commands-targeting.md @@ -339,3 +339,9 @@ public override void OnCast() | `Projects/Server/Targeting/TargetCancelType.cs` | Cancel types | | `Projects/Server/Targeting/LandTarget.cs` | Land target | | `Projects/Server/Targeting/StaticTarget.cs` | Static target | + +## See Also + +- `dev-docs/generic-commands.md` — the generic command system: scopes, `where` conditions, + `order by` / `distinct` / `limit`, dot notation, value and quoting syntax, `[interface`, `[batch`. +- — the full command list, regenerated with distro updates. diff --git a/dev-docs/generic-commands.md b/dev-docs/generic-commands.md new file mode 100644 index 000000000..54019faf9 --- /dev/null +++ b/dev-docs/generic-commands.md @@ -0,0 +1,239 @@ +# Generic Commands (finding and manipulating entities) + +How staff find a set of objects and run a command against all of them: scopes, `where` conditions, +`order by` / `distinct` / `limit`, dot notation, value syntax, `[interface` and `[batch`. + +This covers the **generic command system** only. For the full per-command list (`[add`, `[props`, +`[tele`, …) see the in-game commands page shipped with the distro, published at +. + +## The shape of a generic command + +``` +[ [command args] [where ] [distinct ] [order by ] [limit ] +``` + +- **scope** — which objects to consider (`Global`, `Area`, `Region`, …). +- **command** — what to do to each (`Delete`, `Props`, `Set`, `Count`, `Interface`, …). +- **modifiers** — optional filters applied to the found set before the command runs. + +``` +[global count where Item Movable = false +[area delete where Item ItemID = 0x1F13 +[region interface where Mobile Hits < 10 order by Hits limit 20 +``` + +Commands opt into which scopes they support and whether they act on Items, Mobiles or both, so not +every command works under every scope. A command that does not support the scope reports +*"That is either an invalid command name or one that does not support this modifier."* + +## Scopes + +| Scope | Usage | Conditions? | Selects | +|---|---|---|---| +| `Global` | `[global [condition]` | yes | every object in the world | +| `Area` (`Group`) | `[area [condition]` | yes | a bounding box you drag | +| `Screen` | `[screen [condition]` | yes | everything on your screen | +| `Range` | `[range [condition]` | yes | within `` tiles of you | +| `Region` | `[region [condition]` | yes | your current region | +| `Facet` | `[facet [condition]` | yes | your whole map | +| `Contained` | `[contained [condition]` | yes¹ | inside a targeted container | +| `Online` | `[online [condition]` | yes | connected players | +| `IPAddress` | `[ipaddress [condition]` | yes | accounts sharing a targeted player's IP | +| `Multi` (`m`) | `[m ` | no | several objects you target in turn | +| `Single` | `[single ` | no | one targeted object | +| `Self` | `[self ` | no | you | +| `Serial` | `[serial ` | no | one object by serial | + +`Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all — a `where` clause there is +not rejected, it is passed through to the command as ordinary arguments. + +¹ `Contained` honours conditions on the normal command path, but it never sets the +`SupportsConditionals` flag, and `[batch` is the one place that checks it. So a condition works +under `[contained` typed directly and is refused under `[batch` with that scope. + +## `where` + +The **first token after `where` is a type name**, and it is required. It filters the set to objects +of that type (subclasses included) and fixes the type whose properties the rest of the clause reads. + +``` +[global count where Item -- every Item +[global count where BaseCreature Hits < 10 +``` + +Only properties marked `[CommandProperty]` are visible, and your access level must meet the +attribute's read level. + +### Operators + +| Operator | Meaning | +|---|---| +| `=`, `==`, `is` | equal | +| `!=` | not equal | +| `>`, `<`, `>=`, `<=` | relational (needs a comparable type) | +| `=~`, `~=`, `==~`, `~==`, `is~`, `~is` | equal, case-insensitive | +| `!=~`, `~!=` | not equal, case-insensitive | +| `starts`, `ends`, `contains` | substring tests | +| `starts~`, `ends~`, `contains~` | substring tests, case-insensitive | + +The `~` may lead or trail — `~contains` and `contains~` are the same operator. + +Relational operators on a type with no ordering (no `IComparable`) are rejected rather than +silently misbehaving. Equality on such a type compares **by value**, not by reference. + +### Combining conditions + +Conditions separated by whitespace are ANDed. `or` (or `||`) starts a new alternative group, and +`not` (or `!`) negates the single condition that follows it. + +``` +[global count where Item Movable = true Hue = 0 +[global count where Item Hue = 0 or Hue = 1 +[global count where Item not Movable = true +``` + +## Dot notation + +A binding may walk a chain of properties: + +``` +[global count where SkillTeleporter Message.Number = 1060847 +[global interface where BaseCreature ControlMaster.Name =~ bob +``` + +If a link partway along the chain is null, the object simply does not match — it is not an error, +and it does not stop the sweep. The same applies under `not`: an unreadable binding never matches. + +For `order by` and `distinct`, which have no "no match" to give, a null link reads as the property +type's default (`0`, `null`, …). + +Chains are **read-only**. `[set Message.Number 5` fails when the intermediate's members are +get-only, as `TextDefinition`'s are. + +## Values + +A comparison constant is resolved by the same parser behind `[set`, `[add`, spawner props and the +props gump, so a value that works in one place works in all of them. + +| Form | Means | +|---|---| +| `123`, `-4` | a number | +| `0x1F13` | a number, hex | +| `true` / `false` | a boolean | +| `Magery` | an enum member, case-insensitive | +| `Felucca` | a `Map` | +| `Static`, `BaseCreature` | a `Type`, by name | +| `0x40001234` | an entity, resolved by serial | +| `hello world` | a string — quote it in the command line if it contains spaces | +| `null` | null (in `where` only — see below) | +| `(-null-)` | null (in `[set` / `[add` / spawner props) | +| `@"text"` | the literal text inside, for values that would otherwise be read as something else | +| `#1234` | a `TextDefinition` cliloc, explicitly | + +### Quoting, and why `@"..."` exists + +The command tokenizer strips real quotes before any parser sees them, so `"0"` and `0` arrive +identical. `@"..."` is the in-band escape that survives: + +``` +[set Name @"null" -- the four-letter string, not a null +[set Message @"1060847" -- the string "1060847", not cliloc 1060847 +[set Message #1060847 -- cliloc 1060847, explicitly +[set Message 1060847 -- cliloc 1060847 (a bare integer is always a cliloc) +``` + +`[get` writes the same form back for any value that would otherwise be misread, so its output can +be pasted straight into `[set`. + +### The one inconsistency: `null` + +`where` spells a null constant as a bare `null`. `[set` and friends use `(-null-)`, and read a bare +`null` as the four-letter string. This predates the shared parser and is preserved deliberately — +every existing `where … = null` clause depends on it. + +``` +[global count where Item Name = null -- Name is null +[set Name (-null-) -- set Name to null +[set Name null -- set Name to the string "null" +``` + +## `distinct`, `order by`, `limit` + +``` +[global interface where Item order by Hue desc limit 50 +[global interface where Mobile distinct Name order by Name +``` + +- `distinct [ …]` — keeps one object per distinct combination of those properties. It + sorts internally to do so, so it also reorders the set; add `order by` if the order matters. +- `order by [direction] [ …]` — `by` is optional. Direction is `+`/`up`/`asc`/`ascending` + or `-`/`down`/`desc`/`descending`, defaulting to ascending. Multiple keys break ties left to right. +- `limit ` — keeps the first `n` after the others have run. + +Keywords are case-insensitive, and **the order you type them does not matter**: they always apply +as `where` → `distinct` → `order by` → `limit`. + +## `[interface` + +``` +[ interface [view ] [condition] +``` + +Opens a gump listing every match instead of acting on them. Each row can be inspected, and `view` +adds columns for the properties you name. This is the safest way to see what a condition selects +before running something destructive with the same clause. + +``` +[global interface where Item Movable = false ItemID = 0x1F13 +[global interface view Hue Name where Mobile Hits < 10 +``` + +## `[batch` + +`[batch` opens a gump that runs **several commands against one found set**. Type `[batch` with no +arguments; the gump has three parts: + +- **Scope** — pick one of the scopes above. +- **Condition** — the whole clause, and it must start with `where`. +- **Commands** — one or more entries, each with a command and an optional **Object**. + +Every command runs against the same matched set, in the order listed. It is the tool for "find +these once, then do three things to them" without re-running an expensive sweep, and for +multi-step edits that would otherwise race against their own filter — a `where Hue = 0` clause +re-evaluated after the first command has changed `Hue` would no longer match. + +The **Object** field is a property chain that redirects that one command onto a sub-object of each +match. Leave it blank to act on the match itself; set it to `Backpack` to act on each mobile's +backpack instead. Objects whose chain is null or unreadable are skipped for that command. + +Logging is suppressed automatically when the set is larger than 20 objects. + +## Gotchas + +- The type after `where` is mandatory; `where Movable = true` is a parse error, not a wildcard. +- Only `[CommandProperty]` members are reachable, and write access is checked separately from read. +- Relational operators need a comparable type; equality is available on everything. +- A bare integer targeting a `TextDefinition` is always a cliloc. Use `@"123"` for the string. +- `where … = null` and `[set … (-null-)` are different spellings of the same idea. See above. +- Spawner **Params** are split on plain spaces, not the quoting tokenizer, so a constructor + argument cannot contain a space. Spawner **Props** use the normal tokenizer and value syntax. +- Test a destructive clause with `[interface` or `count` first. + +## Key files + +| Concern | File | +|---|---| +| Scopes | `Projects/UOContent/Commands/Generic/Implementors/` | +| `where` parsing, operators | `Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs` | +| Condition/sort/distinct compilation | `Projects/UOContent/Commands/Generic/Extensions/Compilers/` | +| Modifier parsing and apply order | `Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs` | +| Value parsing | `Projects/UOContent/Utilities/Types.cs` | +| Command tokenizer | `Projects/Server/Commands.cs` (`Commands.Split`) | +| `[batch` | `Projects/UOContent/Commands/Batch.cs` | +| `[interface` | `Projects/UOContent/Commands/Generic/Commands/Interface.cs` | + +## See also + +- `dev-docs/commands-targeting.md` — registering commands and the targeting system. +- — the full command list, regenerated with distro updates.