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:
parent
f606225f47
commit
c9831b9680
11 changed files with 855 additions and 70 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
239
dev-docs/generic-commands.md
Normal file
239
dev-docs/generic-commands.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue