ModernUO/Projects/UOContent/Commands/Generic/Commands/Commands.cs
Kamron Batman aae173a797
feat(network): allowlist false-positive IPs, escalate on behavior (#2556)
## Why

The shard owner, on a Starlink CGNAT address, was blocked by the imported reputation blocklist.

The cause was not CrowdSec. The address was a literal line in `ip-blocklist.txt`, so `BlocklistFilter` denied it at accept and then promoted it — and clearing the CrowdSec decision could not fix it either, because the file entry re-reports within `promoteSuppression` of every reconnect attempt.

This is structural, not a one-off. Reputation feeds list shared consumer address space constantly: on CGNAT one public address fronts many subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else behind it is blocked with them. Where leases rotate, a listing says little about whoever holds the address now. Around 1,000 Starlink addresses sit in the current list.

So exemptions go where they cost nothing, and escalation is driven by what a connection actually does.

## Generator — `tools/Export-IpBlocklist.ps1`

`-AllowlistFile` takes multiple paths, subtracted from the merged set before the output is written. Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set:

- `ip-allowlist.txt` — operator exemptions, created once and **never rewritten**
- `ip-allowlist-<name>.txt` — a carve-out you built, regenerable and copyable between shards

**Subtraction is range-correct.** An allowlisted address inside a blocked CIDR splits that CIDR around the hole rather than being silently ignored. This also fixes `-ExcludeAnonymizers`, which parsed CIDR entries into `$anonCidr` and then only ever subtracted singles.

**No carve-out ships.** A carve-out names a real network, and which ones a shard should exempt depends on where its players actually are — so publishing one would make that policy call for every shard and put a specific provider's address space in the repo. The script builds them on request instead:

```powershell
.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
```

Carve-outs are **discovered, not configured**: every `ip-allowlist*.txt` beside the output is subtracted, by the generator and by the shard, so a file an admin adds needs no config edit and no code change. Each carries an `asn=` marker in its header, which is how `-RefreshCarveouts` rebuilds it without the script keeping a list of anyone's networks; a hand-written allowlist has no marker and is never rewritten.

Prefixes come from **announcements, not ownership records**, because registry data disagrees with what is actually routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and `206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink.

Editing an allowlist bypasses `-MinInterval`, so a just-added exemption isn't indistinguishable from the allowlist not working. A Starlink carve-out, if you build one, costs **~4,300 IPs + ~144 CIDRs of 4.2M (0.10%)**.

## Allowlists

**`FileAllowlist`** reads the same files the generator subtracts, so an operator entry means "leave this address alone" for real. Subtraction alone only covers being *blocked*; behavioural detections never consult the blocklist, so without this a carve-out was quietly routed around — one scanner behind a shared address was enough to get everyone behind it contributed and firewalled, with nothing in the shard's own config explaining why. Reading the files also means an entry applies on the next reload rather than the next regeneration, which is what matters when someone is complaining now.

**`LoginAllowlist`** is earned by authenticating, with a 90-day TTL because an address that logged in years ago is a stranger. Its own store rather than `Account.LoginIPs`, which has no timestamps and cannot be backfilled. An entry is evidence rather than a licence: 10 suppressed contributions in an hour revokes it, and a fresh login forgives the tally.

Both are consulted **only after the blocklist has already matched**, so a normal accept pays nothing for them and the accept gate stays allowlist-free. `BanExemptions` combines them behind `BanChannel.IsExempt` and suppresses escalation only — every local defence still applies.

Two limits, both deliberate and documented in the class: `LoginAllowlist` **cannot bootstrap** (an entry is only earned by getting in, so it never repairs an existing false positive), and it is weakest on rotating CGNAT. That is why `FileAllowlist` is the fix for those, and why it is manual.

## Behavioural detection

| Reason | Trigger |
|---|---|
| `silent-connect` | Reaped after 5s having sent **zero bytes** |
| `invalid-seed` | Opened with a zero seed |
| `foreign-protocol` | Positively identified as HTTP, TLS or SSH |

**`ForeignProtocol` inverts the test.** Asking "is this a good UO client?" cannot work: `LoginEncryption.ClientDecrypt` is a byte-for-byte stream XOR, so a legitimate client with encryption enabled when the shard expects none sends a structurally perfect connection whose payload is noise. "Speaks HTTP" is safe where "unreadable" is not — however misconfigured a UO client is, it never sends `GET / HTTP/1.1`.

Nothing assumes arrival framing. TCP has no message boundaries, so a rule of the form "these bytes must arrive together" is broken by construction and drops real players on poor links. A prefix match with too few bytes to confirm waits for more. A four-byte seed can legitimately spell `GET ` (the address 71.69.84.32) or `0x16 0x03 0x0?` (22.3.x.x), so confirmation requires the request line to continue in printable ASCII or an actual ClientHello inside a plausible record — a real client's fifth byte is a packet id (`0x80`, `0x91`, `0xEF`), none of them printable, so those collisions fall through.

Everything is keyed on **bytes-received rather than elapsed time**. A connection that sent something and ran out of time is far more likely a slow link than an attack, and banning those produces the worst failure mode available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being firewalled off.

## `AutoDenylist`

A short-lived local hold (15m) on behavioural detections, as `IConnectionFilter` + `IBanReporter` over one store so the engine detection sites never reach into content.

This closes the gap where a flood pays for a socket, buffer and `NetState` slot per connection while waiting for the OS bouncer — the verdicts that matter most are reachable only *after* reading bytes — and it is the entire defence on a shard running no bouncer, which is the default config. Not persisted: a holding pen that survives restarts is a ban without a ban's review.

Cost: one dictionary lookup on a usually-empty dict per accept.

## `BanReasons`

Centralises the reason slugs. `IsBehavioral` is an **opt-in** set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption or entering a local denylist.

This caught a real bug during review: the first cut of the exemption swallowed `manual` admin bans (`Commands.cs`, three sites in `AdminGump`) for any allowlisted address.

## Fixes found in review

- **`BanConfiguration.Settings` was null until `Configure()` ran**, while the reap path dereferences it every `Slice()`. A harness driving `NetState.Slice()` directly hit an NRE that presented as flaky because it depended on whether an earlier test had already called `Configure()` — which is why it failed on some CI platforms and not others. Now starts at the record's defaults, with idempotency tracked by a flag; this also removes the same latent NRE from the pre-existing rate-limit path.
- **`-AllowlistFile` was typed `[string]`** while documented and used as a list, so passing two paths would have collapsed them into one string.

## Layout and docs

Content network code moves out of `Misc/` into `UOContent/Network/`, one concern per folder — `AutoDenylist/`, `Blocklist/`, `CrowdSec/`, `Firewall/`, `LoginAllowlist/`, `Packets/`. **Namespaces are untouched**, so these are pure file moves (git tracks all 16 as renames).

`dev-docs/ip-bans-and-allowlists.md` documents the subsystem, leading with the operator process for unblocking a player — including the three things that look sufficient and are not: deleting the CrowdSec decision alone, editing `ip-blocklist.txt` by hand, and `cscli allowlists` alone. `.gitignore` covers the new config files.

## Testing

Build clean. **Server.Tests 810 passed**, **UOContent.Tests 637 passed**, zero warnings. This branch adds 38 tests; the rest of the delta is main's, since this is rebased on current `main`.

New coverage: TTL boundary and renewal, private-address exclusion, manual-ban-never-exempt, unopted-reason-never-exempt, strike revocation, quiet-window reset, login forgiveness, file-allowlist CIDR coverage, file-allowlist not spending the earned list's strikes, denylist expiry-on-read, cap enforcement, lapsed-entry reclaim, HTTP/TLS/SSH identification, seed-collision fall-through, and encrypted-login-is-not-foreign.

Generator verified end-to-end against live feeds: a clean run ships no carve-out, `-AddCarveout starlink -Asn 14593` fetches and collapses 213 prefixes to 115 ranges in 0.1s over 4.2M entries, `-RefreshCarveouts` rediscovers it by its `asn=` marker, a hand-written allowlist is left untouched, and deleting a carve-out drops it rather than having it rewritten. CIDR splitting verified exhaustively: a single-IP hole in a /24 leaves exactly 255 of 256 addresses blocked.

## Operator note

Existing installs are unaffected until the generator next runs, which creates `ip-allowlist.txt` and nothing else. To unblock someone: add the address to that file and delete any live CrowdSec decision — the existing ban outlives the config change. The shard picks the entry up on its next reload, so re-running the generator is optional.

A shard whose players are on CGNAT (satellite, mobile, or an ISP short on IPv4) will likely also want `-AddCarveout`; see `dev-docs/ip-bans-and-allowlists.md`.

## Also included: a latent CI failure this PR surfaced

`fix(tests): serialize test classes that rent through STArrayPool` touches a property-list test file that has nothing to do with this feature. It is here because it was failing macOS CI, and it is trivially cherry-pickable out if you would rather it went to `main` on its own — **which may be the better call, since it is failing `main` today.**

CI has since gone green with it applied.

`STArrayPool` is single-threaded by design and its bucket cache is a plain `static`, not `[ThreadStatic]`, with a check-then-act initialize in `Return()`:

```csharp
var cacheBuckets = _cacheBuckets ?? InitializeBuckets();
```

Two threads both see null, both initialize, and the loser trips `Debug.Assert(_cacheBuckets is null)`. Anything renting from it has to stay off parallel test threads — which is what the `DisableParallelization` collections are for.

- `ObjectPropertyListReentrancyTests` and `ObjectPropertyListNestedBuildTests` (added in #2555) build property lists, which rent the interpolation buffer, but were not in the sequential collection — unlike `PropertyListInvalidationDuringBuildTests` in the same file. This is a **latent failure already on `main`**; it is timing-dependent, so it shows on some platforms and not others.
- `AutoDenylistTests` (added here) has the same exposure: its cap tests reach `AutoDenylist.Sweep`, which rents a `PooledRefList` without `mt`. The blocklist tests need no marking because `BlocklistSnapshot.Build` asks for the `mt` pool explicitly.

No production change — `STArrayPool` is the right pool on the game loop, where both `Sweep` and the property list actually run.

## Deliberately not included

Waiting for a fragmented four-byte seed at `AwaitingSeed`. It looked like a bug but the disconnect is a deliberate defence: only pre-0xEF clients reach it (0xEF goes through `HandlePacket`, which already waits for its 21 bytes), and waiting converts an instant drop into a full 5s slot hold for a client sending one or two bytes, or a loris dribbling a byte every few seconds. Against a fixed 4096-entry `MaxConnections` table that trades capacity that matters for a fragmentation case a reconnect already fixes.
2026-07-30 23:12:17 -07:00

1280 lines
40 KiB
C#

using System;
using System.Collections.Generic;
using System.Reflection;
using Server.Accounting;
using Server.Engines.Help;
using Server.Factions;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
using Server.Network;
using Server.Network.Bans;
using Server.Spells;
namespace Server.Commands.Generic
{
public static class TargetCommands
{
public static List<BaseCommand> AllCommands { get; } = new();
public static void Configure()
{
Register(new KillCommand(true));
Register(new KillCommand(false));
Register(new HideCommand(true));
Register(new HideCommand(false));
Register(new KickCommand(true));
Register(new KickCommand(false));
Register(new FirewallCommand());
Register(new TeleCommand());
Register(new SetCommand());
Register(new AliasedSetCommand(AccessLevel.GameMaster, "Immortal", "blessed", "true", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "Invul", "blessed", "true", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "Mortal", "blessed", "false", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "NoInvul", "blessed", "false", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "Squelch", "squelched", "true", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "Unsquelch", "squelched", "false", ObjectTypes.Mobiles));
Register(new AliasedSetCommand(AccessLevel.GameMaster, "ShaveHair", "HairItemID", "0", ObjectTypes.Mobiles));
Register(
new AliasedSetCommand(
AccessLevel.GameMaster,
"ShaveBeard",
"FacialHairItemID",
"0",
ObjectTypes.Mobiles
)
);
Register(new GetCommand());
Register(new GetTypeCommand());
Register(new DeleteCommand());
Register(new RestockCommand());
Register(new DismountCommand());
Register(new AddCommand());
Register(new AddToPackCommand());
Register(new TellCommand(true));
Register(new TellCommand(false));
Register(new PrivSoundCommand());
Register(new IncreaseCommand());
Register(new OpenBrowserCommand());
Register(new CountCommand());
Register(new InterfaceCommand());
Register(new RefreshHouseCommand());
Register(new ConditionCommand());
Register(new FactionKickCommand(FactionKickType.Kick));
Register(new FactionKickCommand(FactionKickType.Ban));
Register(new FactionKickCommand(FactionKickType.Unban));
Register(new BringToPackCommand());
Register(new TraceLockdownCommand());
Register(new LocationCommand());
}
public static void Register(BaseCommand command)
{
AllCommands.Add(command);
var impls = BaseCommandImplementor.Implementors;
for (var i = 0; i < impls.Count; ++i)
{
var impl = impls[i];
if ((command.Supports & impl.SupportRequirement) != 0)
{
impl.Register(command);
}
}
}
}
public class ConditionCommand : BaseCommand
{
public ConditionCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Simple | CommandSupport.Complex | CommandSupport.Self;
Commands = new[] { "Condition" };
ObjectTypes = ObjectTypes.All;
Usage = "Condition <condition>";
Description = "Checks that the given condition matches a targeted object.";
ListOptimized = true;
}
public override void ExecuteList(CommandEventArgs e, List<object> list)
{
try
{
var args = e.Arguments;
var condition = ObjectConditional.Parse(e.Mobile, ref args);
for (var i = 0; i < list.Count; ++i)
{
if (condition.CheckCondition(list[i]))
{
AddResponse("True - that object matches the condition.");
}
else
{
AddResponse("False - that object does not match the condition.");
}
}
}
catch (Exception ex)
{
e.Mobile.SendMessage(ex.Message);
}
}
}
public class BringToPackCommand : BaseCommand
{
public BringToPackCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllItems;
Commands = new[] { "BringToPack" };
ObjectTypes = ObjectTypes.Items;
Usage = "BringToPack";
Description = "Brings a targeted item to your backpack.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is Item item)
{
if (e.Mobile.PlaceInBackpack(item))
{
AddResponse("The item has been placed in your backpack.");
}
else
{
AddResponse("Your backpack could not hold the item.");
}
}
}
}
public class RefreshHouseCommand : BaseCommand
{
public RefreshHouseCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Simple;
Commands = new[] { "RefreshHouse" };
ObjectTypes = ObjectTypes.Items;
Usage = "RefreshHouse";
Description = "Refreshes a targeted house sign.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is HouseSign sign)
{
var house = sign.Owner;
if (house == null)
{
LogFailure("That sign has no house attached.");
}
else
{
house.RefreshDecay();
AddResponse("The house has been refreshed.");
}
}
else
{
LogFailure("That is not a house sign.");
}
}
}
public class CountCommand : BaseCommand
{
public CountCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Complex;
Commands = new[] { "Count" };
ObjectTypes = ObjectTypes.All;
Usage = "Count";
Description =
"Counts the number of objects that a command modifier would use. Generally used with condition arguments.";
ListOptimized = true;
}
public override void ExecuteList(CommandEventArgs e, List<object> list)
{
if (list.Count == 1)
{
AddResponse("There is one matching object.");
}
else
{
AddResponse($"There are {list.Count} matching objects.");
}
}
}
public class OpenBrowserCommand : BaseCommand
{
public OpenBrowserCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllMobiles;
Commands = new[] { "OpenBrowser", "OB" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "OpenBrowser <url>";
Description = "Opens the web browser of a targeted player to a specified url.";
}
public static void OpenBrowser_Callback(Mobile from, bool okay, Mobile gm, string url, bool echo)
{
if (okay)
{
if (echo)
{
gm.SendMessage($"{from.Name} : has opened their web browser to : {url}");
}
from.LaunchBrowser(url);
}
else
{
if (echo)
{
gm.SendMessage($"{from.Name} : has chosen not to open their web browser to : {url}");
}
from.SendMessage("You have chosen not to open your web browser.");
}
}
public void Execute(CommandEventArgs e, object obj, bool echo)
{
if (e.Length == 1)
{
var mob = (Mobile)obj;
var from = e.Mobile;
if (mob.Player)
{
var ns = mob.NetState;
if (ns == null)
{
LogFailure("That player is not online.");
}
else
{
var url = e.GetString(0);
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} requesting to open web browser of {CommandLogging.Format(mob)} to {url}"
);
if (echo)
{
AddResponse("Awaiting user confirmation...");
}
else
{
AddResponse("Open web browser request sent.");
}
mob.SendGump(
new OpenBrowserWarningGump(
url,
okay => OpenBrowser_Callback(mob, okay, from, url, echo)
)
);
}
}
else
{
LogFailure("That is not a player.");
}
}
else
{
LogFailure("Format: OpenBrowser <url>");
}
}
private class OpenBrowserWarningGump : StaticWarningGump<OpenBrowserWarningGump>
{
public override string Content { get; }
public override int Width => 320;
public override int Height => 240;
public OpenBrowserWarningGump(string url, Action<bool> callback) : base(callback) =>
Content = $"A game master is requesting to open your web browser to the following URL:<br>{url}";
}
public override void Execute(CommandEventArgs e, object obj)
{
Execute(e, obj, true);
}
public override void ExecuteList(CommandEventArgs e, List<object> list)
{
for (var i = 0; i < list.Count; ++i)
{
Execute(e, list[i], false);
}
}
}
public class IncreaseCommand : BaseCommand
{
public IncreaseCommand()
{
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.All;
Commands = new[] { "Increase", "Inc" };
ObjectTypes = ObjectTypes.Both;
Usage = "Increase {<propertyName> <offset> ...}";
Description = "Increases the value of a specified property by the specified offset.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is BaseMulti)
{
LogFailure("This command does not work on multis.");
}
else if (e.Length >= 2)
{
var result = Properties.IncreaseValue(e.Mobile, obj, e.Arguments);
if (result is "The property has been increased." or "The properties have been increased." or "The property has been decreased." or "The properties have been decreased." or "The properties have been changed.")
{
AddResponse(result);
}
else
{
LogFailure(result);
}
}
else
{
LogFailure("Format: Increase {<propertyName> <offset> ...}");
}
}
}
public class PrivSoundCommand : BaseCommand
{
public PrivSoundCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllMobiles;
Commands = new[] { "PrivSound" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "PrivSound <index>";
Description = "Plays a sound to a given target.";
}
public override void Execute(CommandEventArgs e, object obj)
{
var from = e.Mobile;
if (e.Length == 1)
{
var index = e.GetInt32(0);
var mob = (Mobile)obj;
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} playing sound {index} for {CommandLogging.Format(mob)}"
);
mob.SendSound(index);
}
else
{
from.SendMessage("Format: PrivSound <index>");
}
}
}
public class TellCommand : BaseCommand
{
private readonly bool m_InGump;
public TellCommand(bool inGump)
{
m_InGump = inGump;
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.AllMobiles;
ObjectTypes = ObjectTypes.Mobiles;
if (inGump)
{
Commands = new[] { "Message", "Msg" };
Usage = "Message <text>";
Description = "Sends a message to a targeted player.";
}
else
{
Commands = new[] { "Tell" };
Usage = "Tell <text>";
Description = "Sends a system message to a targeted player.";
}
}
public override void Execute(CommandEventArgs e, object obj)
{
var mob = (Mobile)obj;
var from = e.Mobile;
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} {(m_InGump ? "messaging" : "telling")} {CommandLogging.Format(mob)} \"{e.ArgString}\""
);
if (m_InGump)
{
mob.SendGump(new MessageSentGump(mob, from.Name, e.ArgString));
}
else
{
mob.SendMessage(e.ArgString);
}
}
}
public class AddToPackCommand : BaseCommand
{
public AddToPackCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.All;
Commands = new[] { "AddToPack", "AddToCont" };
ObjectTypes = ObjectTypes.Both;
ListOptimized = true;
Usage = "AddToPack <name> [params] [set {<propertyName> <value> ...}]";
Description =
"Adds an item by name to the backpack of a targeted player or npc, or a targeted container. Optional constructor parameters. Optional set property list.";
}
public override void ExecuteList(CommandEventArgs e, List<object> list)
{
if (e.Arguments.Length == 0)
{
return;
}
var packs = new List<Container>(list.Count);
for (var i = 0; i < list.Count; ++i)
{
var obj = list[i];
Container cont = null;
if (obj is Mobile mobile)
{
cont = mobile.Backpack;
}
else if (obj is Container container)
{
cont = container;
}
if (cont != null)
{
packs.Add(cont);
}
else
{
LogFailure("That is not a container.");
}
}
Add.Invoke(e.Mobile, e.Mobile.Location, e.Mobile.Location, e.Arguments, packs);
}
}
public class AddCommand : BaseCommand
{
public AddCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.Simple | CommandSupport.Self;
Commands = new[] { "Add" };
ObjectTypes = ObjectTypes.All;
Usage = "Add [<name> [params] [set {<propertyName> <value> ...}]]";
Description =
"Adds an item or npc by name to a targeted location. Optional constructor parameters. Optional set property list. If no arguments are specified, this brings up a categorized add menu.";
}
public override bool ValidateArgs(BaseCommandImplementor impl, CommandEventArgs e)
{
if (e.Length < 1)
{
e.Mobile.SendGump(new CategorizedAddGump(e.Mobile));
return false;
}
var match = e.GetString(0).Trim();
if (e.GetContext<Type>("exactMatch", out var exactMatch))
{
return true;
}
exactMatch = AddGump.ExactMatch(match);
if (exactMatch != null)
{
e.SetContext("exactMatch", exactMatch);
return true;
}
if (match.Length < 3)
{
e.Mobile.SendMessage("Invalid search string.");
e.Mobile.SendGump(new AddGump(match, 0, [], false));
return false;
}
if (!e.GetContext<ConstructorInfo[]>("matches", out var matches))
{
matches = e.SetContext("matches", AddGump.MatchEmptyCtor(match));
}
e.Mobile.SendMessage("No type with that name was found.");
e.Mobile.SendGump(new AddGump(match, 0, matches, true));
return false;
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is not IPoint3D ip)
{
return;
}
var p = ip switch
{
Item item => item.GetWorldTop(),
Mobile m => m.Location,
_ => new Point3D(ip)
};
if (e.GetContext<Type>("exactMatch", out var exactMatch))
{
Add.Invoke(e.Mobile, p, p, exactMatch, e.Arguments.AsSpan(1));
return;
}
e.Mobile.SendMessage("No type with that name was found.");
}
}
public class TeleCommand : BaseCommand
{
public TeleCommand()
{
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.Simple;
Commands = new[] { "Teleport", "Tele" };
ObjectTypes = ObjectTypes.All;
Usage = "Teleport";
Description = "Teleports your character to a targeted location.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is not IPoint3D p)
{
return;
}
var from = e.Mobile;
SpellHelper.GetSurfaceTop(ref p);
// CommandLogging.WriteLine( from, "{0} {1} teleporting to {2}", from.AccessLevel, CommandLogging.Format( from ), new Point3D( p ) );
var fromLoc = from.Location;
var toLoc = new Point3D(p);
from.Location = toLoc;
from.ProcessDelta();
if (!from.Hidden)
{
Effects.SendLocationParticles(
EffectItem.Create(fromLoc, from.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
2023
);
Effects.SendLocationParticles(
EffectItem.Create(toLoc, from.Map, EffectItem.DefaultDuration),
0x3728,
10,
10,
5023
);
from.PlaySound(0x1FE);
}
}
}
public class DismountCommand : BaseCommand
{
public DismountCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllMobiles;
Commands = new[] { "Dismount" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "Dismount";
Description = "Forcefully dismounts a given target.";
}
public override void Execute(CommandEventArgs e, object obj)
{
var from = e.Mobile;
var mob = (Mobile)obj;
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} dismounting {CommandLogging.Format(mob)}"
);
var takenAction = false;
for (var i = 0; i < mob.Items.Count; ++i)
{
var item = mob.Items[i];
if (item is IMountItem mountItem)
{
var mount = mountItem.Mount;
if (mount != null)
{
mount.Rider = null;
takenAction = true;
}
if (mob.Items.IndexOf(item) == -1)
{
--i;
}
}
}
for (var i = 0; i < mob.Items.Count; ++i)
{
var item = mob.Items[i];
if (item.Layer == Layer.Mount)
{
takenAction = true;
item.Delete();
--i;
}
}
if (takenAction)
{
AddResponse("They have been dismounted.");
}
else
{
LogFailure("They were not mounted.");
}
}
}
public class RestockCommand : BaseCommand
{
public RestockCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllNPCs;
Commands = new[] { "Restock" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "Restock";
Description =
"Manually restocks a targeted vendor, refreshing the quantity of every item the vendor sells to the maximum. This also invokes the maximum quantity adjustment algorithms.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is BaseVendor vendor)
{
CommandLogging.WriteLine(
e.Mobile,
$"{e.Mobile.AccessLevel} {CommandLogging.Format(e.Mobile)} restocking {CommandLogging.Format(vendor)}"
);
vendor.Restock();
AddResponse("The vendor has been restocked.");
}
else
{
AddResponse("That is not a vendor.");
}
}
}
public class GetTypeCommand : BaseCommand
{
public GetTypeCommand()
{
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.All;
Commands = new[] { "GetType" };
ObjectTypes = ObjectTypes.All;
Usage = "GetType";
Description = "Gets the type name of a targeted object.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj == null)
{
AddResponse("The object is null.");
}
else
{
var type = obj.GetType();
if (type.DeclaringType == null)
{
AddResponse($"The type of that object is {type.Name}.");
}
else
{
AddResponse($"The type of that object is {type.FullName}.");
}
}
}
}
public class GetCommand : BaseCommand
{
public GetCommand()
{
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.All;
Commands = new[] { "Get" };
ObjectTypes = ObjectTypes.All;
Usage = "Get <propertyName>";
Description = "Gets one or more property values by name of a targeted object.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (e.Length >= 1)
{
for (var i = 0; i < e.Length; ++i)
{
var result = Properties.GetValue(e.Mobile, obj, e.GetString(i));
if (result is "Property not found." or "Property is write only." || result.StartsWithOrdinal("Getting this property"))
{
LogFailure(result);
}
else
{
AddResponse(result);
}
}
}
else
{
LogFailure("Format: Get <propertyName>");
}
}
}
public class AliasedSetCommand : BaseCommand
{
private readonly string m_Name;
private readonly string m_Value;
public AliasedSetCommand(AccessLevel level, string command, string name, string value, ObjectTypes objects)
{
m_Name = name;
m_Value = value;
AccessLevel = level;
if (objects == ObjectTypes.Items)
{
Supports = CommandSupport.AllItems;
}
else if (objects == ObjectTypes.Mobiles)
{
Supports = CommandSupport.AllMobiles;
}
else
{
Supports = CommandSupport.All;
}
Commands = new[] { command };
ObjectTypes = objects;
Usage = command;
Description = $"Sets the {name} property to {value}.";
}
public override void Execute(CommandEventArgs e, object obj)
{
var result = Properties.SetValue(e.Mobile, obj, m_Name, m_Value);
if (result == "Property has been set.")
{
AddResponse(result);
}
else
{
LogFailure(result);
}
}
}
public class SetCommand : BaseCommand
{
public SetCommand()
{
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.All;
Commands = new[] { "Set" };
ObjectTypes = ObjectTypes.Both;
Usage = "Set <propertyName> <value> [...]";
Description = "Sets one or more property values by name of a targeted object.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (e.Length >= 2)
{
for (var i = 0; i + 1 < e.Length; i += 2)
{
var result = Properties.SetValue(e.Mobile, obj, e.GetString(i), e.GetString(i + 1));
if (result == "Property has been set.")
{
AddResponse(result);
}
else
{
LogFailure(result);
}
}
}
else
{
LogFailure("Format: Set <propertyName> <value>");
}
}
}
public class DeleteCommand : BaseCommand
{
public DeleteCommand()
{
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllNPCs | CommandSupport.AllItems;
Commands = new[] { "Delete", "Remove", "Rm" };
ObjectTypes = ObjectTypes.Both;
Usage = "Delete";
Description = "Deletes a targeted item or mobile. Does not delete players.";
}
private void OnConfirmCallback(Mobile from, bool okay, CommandEventArgs e, List<object> list)
{
var flushToLog = false;
if (okay)
{
AddResponse("Delete command confirmed.");
if (list.Count > 20)
{
CommandLogging.Enabled = false;
NetState.FlushAll();
}
base.ExecuteList(e, list);
if (list.Count > 20)
{
flushToLog = true;
CommandLogging.Enabled = true;
}
}
else
{
AddResponse("Delete command aborted.");
}
Flush(from, flushToLog);
}
public override void ExecuteList(CommandEventArgs e, List<object> list)
{
if (list.Count > 1)
{
var from = e.Mobile;
from.SendGump(
new DeleteObjectsNoticeGump(list.Count, okay => OnConfirmCallback(from, okay, e, list))
);
AddResponse("Awaiting confirmation...");
}
else
{
base.ExecuteList(e, list);
}
}
private class DeleteObjectsNoticeGump : StaticWarningGump<DeleteObjectsNoticeGump>
{
public override int Width => 420;
public override int Height => 280;
public override string Content { get; }
public DeleteObjectsNoticeGump(int count, Action<bool> callback) : base(callback) =>
Content = $"You are about to delete {count} objects. This cannot be undone without a full server revert.<br><br>Continue?";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is Item item)
{
CommandLogging.WriteLine(
e.Mobile,
$"{e.Mobile.AccessLevel} {CommandLogging.Format(e.Mobile)} deleting {CommandLogging.Format(item)}"
);
item.Delete();
AddResponse("The item has been deleted.");
}
else if (obj is Mobile mobile && !mobile.Player)
{
CommandLogging.WriteLine(
e.Mobile,
$"{e.Mobile.AccessLevel} {CommandLogging.Format(e.Mobile)} deleting {CommandLogging.Format(mobile)}"
);
mobile.Delete();
AddResponse("The mobile has been deleted.");
}
else
{
LogFailure("That cannot be deleted.");
}
}
}
public class KillCommand : BaseCommand
{
private readonly bool m_Value;
public KillCommand(bool value)
{
m_Value = value;
AccessLevel = AccessLevel.GameMaster;
Supports = CommandSupport.AllMobiles;
Commands = value ? new[] { "Kill" } : new[] { "Resurrect", "Res" };
ObjectTypes = ObjectTypes.Mobiles;
if (value)
{
Usage = "Kill";
Description = "Kills a targeted player or npc.";
}
else
{
Usage = "Resurrect";
Description = "Resurrects a targeted ghost.";
}
}
public override void Execute(CommandEventArgs e, object obj)
{
var mob = (Mobile)obj;
var from = e.Mobile;
if (m_Value)
{
if (!mob.Alive)
{
LogFailure("They are already dead.");
}
else if (!mob.CanBeDamaged())
{
LogFailure("They cannot be harmed.");
}
else
{
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} killing {CommandLogging.Format(mob)}"
);
mob.Kill();
AddResponse("They have been killed.");
}
}
else
{
if (mob.IsDeadBondedPet)
{
if (mob is BaseCreature bc)
{
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} resurrecting {CommandLogging.Format(mob)}"
);
bc.PlaySound(0x214);
bc.FixedEffect(0x376A, 10, 16);
bc.ResurrectPet();
AddResponse("It has been resurrected.");
}
}
else if (!mob.Alive)
{
CommandLogging.WriteLine(
from,
$"{ from.AccessLevel} {CommandLogging.Format(from)} resurrecting {CommandLogging.Format(mob)}"
);
mob.PlaySound(0x214);
mob.FixedEffect(0x376A, 10, 16);
mob.Resurrect();
AddResponse("They have been resurrected.");
}
else
{
LogFailure("They are not dead.");
}
}
}
}
public class HideCommand : BaseCommand
{
private readonly bool m_Value;
public HideCommand(bool value)
{
m_Value = value;
AccessLevel = AccessLevel.Counselor;
Supports = CommandSupport.AllMobiles;
Commands = new[] { value ? "Hide" : "Unhide" };
ObjectTypes = ObjectTypes.Mobiles;
if (value)
{
Usage = "Hide";
Description = "Makes a targeted mobile disappear in a puff of smoke.";
}
else
{
Usage = "Unhide";
Description = "Makes a targeted mobile appear in a puff of smoke.";
}
}
public override void Execute(CommandEventArgs e, object obj)
{
var m = (Mobile)obj;
CommandLogging.WriteLine(
e.Mobile,
$"{e.Mobile.AccessLevel} {CommandLogging.Format(e.Mobile)} {(m_Value ? "hiding" : "unhiding")} {CommandLogging.Format(m)}"
);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y, m.Z + 4), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y, m.Z), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y, m.Z - 4), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X, m.Y + 1, m.Z + 4), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X, m.Y + 1, m.Z), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X, m.Y + 1, m.Z - 4), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y + 1, m.Z + 11), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y + 1, m.Z + 7), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y + 1, m.Z + 3), m.Map, 0x3728, 13);
Effects.SendLocationEffect(new Point3D(m.X + 1, m.Y + 1, m.Z - 1), m.Map, 0x3728, 13);
m.PlaySound(0x228);
m.Hidden = m_Value;
if (m_Value)
{
AddResponse("They have been hidden.");
}
else
{
AddResponse("They have been revealed.");
}
}
}
public class FirewallCommand : BaseCommand
{
public FirewallCommand()
{
AccessLevel = AccessLevel.Administrator;
Supports = CommandSupport.AllMobiles;
Commands = new[] { "Firewall" };
ObjectTypes = ObjectTypes.Mobiles;
Usage = "Firewall";
Description =
"Adds a targeted player to the firewall (list of blocked IP addresses). This command does not ban or kick.";
}
public override void Execute(CommandEventArgs e, object obj)
{
var from = e.Mobile;
var targ = (Mobile)obj;
var state = targ.NetState;
if (state != null)
{
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {CommandLogging.Format(targ)}"
);
try
{
Firewall.Add(new SingleIpFirewallEntry(state.Address));
BanChannel.Report(state.Address, TimeSpan.Zero, BanReasons.Manual);
AddResponse("They have been firewalled.");
}
catch (Exception ex)
{
LogFailure(ex.Message);
}
}
else
{
LogFailure("They are not online.");
}
}
}
public class KickCommand : BaseCommand
{
private readonly bool m_Ban;
public KickCommand(bool ban)
{
m_Ban = ban;
AccessLevel = ban ? AccessLevel.Administrator : AccessLevel.GameMaster;
Supports = CommandSupport.AllMobiles;
Commands = new[] { ban ? "Ban" : "Kick" };
ObjectTypes = ObjectTypes.Mobiles;
if (ban)
{
Usage = "Ban";
Description = "Bans the account of a targeted player.";
}
else
{
Usage = "Kick";
Description = "Disconnects a targeted player.";
}
}
public override void Execute(CommandEventArgs e, object obj)
{
var from = e.Mobile;
var targ = (Mobile)obj;
if (from.AccessLevel > targ.AccessLevel)
{
NetState fromState = from.NetState, targState = targ.NetState;
if (fromState != null && targState != null)
{
if (fromState.Account is Account && targState.Account is Account targAccount)
{
CommandLogging.WriteLine(
from,
$"{from.AccessLevel} {CommandLogging.Format(from)} {(m_Ban ? "banning" : "kicking")} {CommandLogging.Format(targ)}"
);
targ.Say(m_Ban ? "I've been banned." : "I've been kicked");
AddResponse($"They have been {(m_Ban ? "banned" : "kicked")}.");
targState.Disconnect($"Banned by {from}.");
if (m_Ban)
{
targAccount.Banned = true;
targAccount.SetUnspecifiedBan(from);
from.SendGump(new BanDurationGump(targAccount));
}
}
}
else if (targState == null)
{
LogFailure("They are not online.");
}
}
else
{
LogFailure("You do not have the required access level to do this.");
}
}
}
public class TraceLockdownCommand : BaseCommand
{
public TraceLockdownCommand()
{
AccessLevel = AccessLevel.Administrator;
Supports = CommandSupport.Simple;
Commands = new[] { "TraceLockdown" };
ObjectTypes = ObjectTypes.Items;
Usage = "TraceLockdown";
Description = "Finds the BaseHouse for which a targeted item is locked down or secured.";
}
public override void Execute(CommandEventArgs e, object obj)
{
if (obj is not Item item)
{
return;
}
if (!item.IsLockedDown && !item.IsSecure)
{
LogFailure("That is not locked down.");
return;
}
foreach (var house in BaseHouse.AllHouses)
{
if (house.HasSecureItem(item) || house.HasLockedDownItem(item))
{
e.Mobile.SendGump(new PropertiesGump(e.Mobile, house));
return;
}
}
LogFailure("No house was found.");
}
}
}