ModernUO/dev-docs/generic-commands.md
Kamron Batman c9831b9680
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.
2026-09-10 19:36:30 -07:00

239 lines
10 KiB
Markdown

# 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.