fix: TextDefinition was uneditable in the props gump, and where parsed constants its own way (#2624)

## Pressing `>` on a TextDefinition did nothing useful

`#1217` moved `TextDefinition` into the Server project for serialization support and added `[PropertyObject]` along the way. `PropsGump` checks that attribute **before** its parsable fallback, so from that commit on the button either drilled into a read-only `Number`/`String` page (both get-only since `#1221`, so no `>` buttons — a dead end) or, when the value was null, re-sent the same page and looked inert.

Worth being clear that `#765` — which replaced the hand-maintained type list with a generic `IsParsable` branch — was **not** the regression. At that commit `TextDefinition` was `[Parsable]` only, fell through to the new branch, and the editor worked. Only the later attribute hijacked the routing.

`TextDefinition` is now caught ahead of the `[PropertyObject]` branch and routed to `SetGump`, which has had `TextDefinition`-specific code at line 35 all along.

## `0` and `"0"` could not be told apart

`Commands.Split` strips quotes before any parser runs, so this was never fixable in `TextDefinition.Parse` alone — the information is gone two layers earlier. The markers now travel inside the value:

| Input | Result |
|---|---|
| `1060847` | cliloc (unchanged) |
| `#1060847` | cliloc, explicit — the form `ToString()` already writes |
| `@"1060847"` | the literal string |
| `hello` | string (unchanged) |
| `(-null-)` | null (unchanged) |

`GetValue()` quotes a string that would not survive the trip back. That check is a real round trip through the parser rather than a pattern match, so it cannot drift from it. `Types.TryParse` decodes the same escape for plain strings — which is what `[get` has always written for the literal `"null"` without `[set` ever reading it back.

**Saved data is untouched.** Serialization reads a discriminated flag plus int/string (`IGenericReader.cs:175`) and the JSON converter switches on token type; neither calls `Parse`, so a stored string `"1060847"` stays a string and spawner JSON is unchanged. The new syntax stays in the text and command layer, where it reaches `[set`, `[add`, spawner props, the props gump and Advanced Search.

## `where` still parsed its constants its own way

`#2625` carried the hand-rolled constant parser across to `PropertyExpressions.Parse` unchanged. It looks for a static `Parse` overload on the property type and gives up if there is none — and `Type` and `IEntity` have neither:

```
where Subject Kind  = Static     ->  Unable to convert string "Static" into type 'System.Type'.
where Subject Owner = 0x1        ->  Unable to convert string "0x1" into type 'Server.Mobile'.
```

Both resolve fine for every other command. Routing through `Types.TryParse` picks up type-name lookup, entity resolution by serial, and the `@"..."` literal convention, so a value that works in one place now works everywhere. It also deletes the duplicate parser (−56 lines in `PropertyExpressions`).

Two things are load-bearing and preserved. `where` has always spelled a null constant as a bare `null`, where `[set` uses `(-null-)`; the shared parser reads a bare `null` as the text, so the null case is handled ahead of it and `= null` keeps meaning null. And nullable targets still unwrap first, so `where <int? prop> = 5` is unaffected.

Bare and hex integers, enums, bools, strings, `Map`, and TextDefinition's `#` / `@"..."` markers all resolve exactly as before — 13 of the 15 tests in `WhereConstantParsingTests` pin that and passed before the change as well as after.

## Documentation

The generic command system had no documentation anywhere in the repo: scopes, `where` and its operators, dot notation, `order by` / `distinct` / `limit`, the value and quoting syntax, `[interface` and `[batch` were all learn-by-reading-the-source. `dev-docs/generic-commands.md` covers them, linked from `CLAUDE.md` and `commands-targeting.md`, and points at <https://muo.gg/commands> for the per-command list rather than duplicating it.

Two behaviours it records that were news to me while writing it:

- `Contained` honours conditions on the normal command path but never sets `SupportsConditionals`, and `[batch` is the only place that reads the flag — so the same condition works typed directly and is refused under batch.
- `Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all, so a `where` clause there is passed to the command as ordinary arguments rather than rejected.

Comments in the changed code were trimmed to what is not evident from the code itself; the rationale they carried is either in the doc now or in the commit that introduced it.

## Behavior changes worth a reviewer's attention

Two things go beyond strict bug-fixing, both deliberate:

- **Hex now parses into a TextDefinition.** Consolidating the three `Parse` overloads onto one span codec means `[set Message 0x102CE7` is cliloc 1060847 rather than the string. Previously only the 1-arg `Parse(string)` did hex and nothing called it. This makes the props gump's own `1060847 (0x102CE7)` display typeable.
- **`@"..."` decodes generally for strings**, not only the exact token `@"null"`. So `[set Name @"hello"` sets `hello`. A half-working escape seemed more surprising than a general one, and the `where` path already decoded it — but narrowing it back is a one-line change if preferred.

## Testing

40 tests, each written first and watched fail for the right reason. They cover the props gump routing (populated and null), cliloc/string/quoted parsing and `GetValue` round trips, `where` constant resolution for Type- and entity-valued properties, and the bare-`null` vs `@"null"` distinction that must not drift.

One sort case is added that #2625 left uncovered: ordering a value-typed chain whose intermediate is null, which reads as `default(int)` rather than throwing. Two other tests from the pre-rebase branch were dropped as duplicates of `ConditionalCompilerEdgeTests`.

Rebased onto `535a09899`. `dotnet build` clean, 0 warnings. **902 UOContent** and **869 Server** tests pass, 0 failures.

## Known gaps

- `Nullable<T>` comparisons work via #2625's lifted operators; nothing here changes that.
- Spawner **Params** are split on plain spaces (`BaseSpawner.cs:1035`) rather than through `Commands.Split`, so a constructor argument still cannot contain one. Untouched here — a tokenizer issue, not a TextDefinition one.
- `Types.ParseStringNumericParamTypes` is now unreferenced. Left in place because it is public and shards may use it.
This commit is contained in:
Kamron Batman 2026-09-10 19:36:30 -07:00 • committed by GitHub
parent f606225f47
commit c9831b9680
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 855 additions and 70 deletions

View file

@ -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` |

View file

@ -68,7 +68,26 @@ public class TextDefinition : IEquatable<object>, IEquatable<TextDefinition>, IS
Number > 0 ? $"{Number} (0x{Number:X})" :
String != null ? $"\"{String}\"" : null;
public string GetValue() => Number > 0 ? Number.ToString() : String ?? "";
/// <summary>
/// The editable text form. Quotes a string that <c>TryParse</c> would not read back unchanged;
/// the check is a real round trip so it cannot drift from the parser.
/// </summary>
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<object>, IEquatable<TextDefinition>, 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<object>, IEquatable<TextDefinition>, IS
public static TextDefinition Parse(ReadOnlySpan<char> 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;
}
/// <summary>
/// <c>#1234</c> or a bare <c>1234</c>/<c>0x4D2</c> is a cliloc; <c>@"1234"</c> is the literal
/// text. Always succeeds -- anything that is not a cliloc is a string.
/// See <c>dev-docs/generic-commands.md</c>.
/// </summary>
public static bool TryParse(ReadOnlySpan<char> 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<object>, IEquatable<TextDefinition>, IS
result = Of(s);
return true;
}
private static bool TryGetQuotedLiteral(ReadOnlySpan<char> s, out ReadOnlySpan<char> literal)
{
if (s.Length >= 3 && s[0] == '@' && s[1] == '"' && s[^1] == '"')
{
literal = s[2..^1];
return true;
}
literal = default;
return false;
}
}

View file

@ -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()

View file

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

View file

@ -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<IEntity> _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>(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<InvalidOperationException>(
() => Check(new Subject(), "Kind", "NoSuchTypeAnywhere")
);
}
}

View file

@ -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<Item> _items = [];
private readonly List<Mobile> _mobiles = [];
private readonly List<NetState> _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<int>.Empty, ReadOnlySpan<ushort>.Empty, ReadOnlySpan<Range>.Empty, ReadOnlySpan<byte>.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<int>.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<SetGump>());
}
// 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<SetGump>());
}
// 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<SetGump>();
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<SetGump>();
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<SetGump>();
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<object> list, int page) : base(m, o, null, list, page)
{
}
public List<object> List => m_List;
}
}

View file

@ -327,10 +327,8 @@ public static class PropertyExpressions
}
/// <summary>
/// The right-hand side of a condition as a typed constant. A string is parsed the way the
/// props gump would parse it: <c>null</c> for a reference or nullable type, <c>@"null"</c>
/// for the literal string, names for enums, hex with a <c>0x</c> prefix for the numerics,
/// and the type's own static <c>Parse</c> for everything else.
/// The right-hand side of a condition as a typed constant, resolved by the same parser behind
/// <c>[set</c> and <c>[add</c>. See <c>dev-docs/generic-commands.md</c>.
/// </summary>
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);
}
}

View file

@ -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(

View file

@ -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;
}
/// <summary>
/// <see cref="TryParse" /> for callers with nowhere to put an error string.
/// </summary>
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;
}

View file

@ -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`.
- <https://muo.gg/commands> — the full command list, regenerated with distro updates.

View file

@ -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
<https://muo.gg/commands>.
## The shape of a generic command
```
[<scope> <command> [command args] [where <Type> <conditions>] [distinct <props>] [order by <props>] [limit <n>]
```
- **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 <command> [condition]` | yes | every object in the world |
| `Area` (`Group`) | `[area <command> [condition]` | yes | a bounding box you drag |
| `Screen` | `[screen <command> [condition]` | yes | everything on your screen |
| `Range` | `[range <range> <command> [condition]` | yes | within `<range>` tiles of you |
| `Region` | `[region <command> [condition]` | yes | your current region |
| `Facet` | `[facet <command> [condition]` | yes | your whole map |
| `Contained` | `[contained <command> [condition]` | yes¹ | inside a targeted container |
| `Online` | `[online <command> [condition]` | yes | connected players |
| `IPAddress` | `[ipaddress <command> [condition]` | yes | accounts sharing a targeted player's IP |
| `Multi` (`m`) | `[m <command>` | no | several objects you target in turn |
| `Single` | `[single <command>` | no | one targeted object |
| `Self` | `[self <command>` | no | you |
| `Serial` | `[serial <serial> <command>` | 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 <prop> [<prop> …]` — 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 <prop> [direction] [<prop> …]` — `by` is optional. Direction is `+`/`up`/`asc`/`ascending`
or `-`/`down`/`desc`/`descending`, defaulting to ascending. Multiple keys break ties left to right.
- `limit <n>` — 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`
```
[<scope> interface [view <properties …>] [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.
- <https://muo.gg/commands> — the full command list, regenerated with distro updates.