#W# Merge: Resolved Merge Conflit from myChanges.

This commit is contained in:
WarrentyExpired 2026-09-12 16:33:05 -04:00
commit 275519b1ff
189 changed files with 11049 additions and 4330 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
"version": "4.0.0",
"version": "4.1.0",
"commands": [
"ModernUOSchemaGenerator"
]

View file

@ -44,6 +44,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
| Object property lists (tooltips) | `dev-docs/property-lists.md` |
| Gump (UI dialog) system | `dev-docs/gump-system.md` |
| Commands & targeting | `dev-docs/commands-targeting.md` |
| Generic commands (`where`/`order by`/`distinct`, dot notation, `@""` literals, `[batch`, `[interface`) | `dev-docs/generic-commands.md` |
| Event system | `dev-docs/events.md` |
| Threading model | `dev-docs/threading-model.md` |
| Server hardware requirements | `dev-docs/server-requirements.md` |

View file

@ -75,7 +75,7 @@
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
<Version>3.10.91</Version>
<Version>3.10.94</Version>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<AdditionalFiles Include="..\..\Rules.ruleset" />

View file

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
namespace Server.Tests.Network;
/// <summary>
/// Minimal IAccount so a test NetState looks authenticated.
/// </summary>
public class MockAccount : IAccount
{
public int TotalGold { get; }
public int TotalPlat { get; }
public bool DepositGold(int amount) => throw new NotImplementedException();
public bool DepositPlat(int amount) => throw new NotImplementedException();
public bool WithdrawGold(int amount) => throw new NotImplementedException();
public bool WithdrawPlat(int amount) => throw new NotImplementedException();
public long GetTotalGold() => throw new NotImplementedException();
public int CompareTo(IAccount other) => throw new NotImplementedException();
public string Username { get; }
public string Email { get; set; }
public AccessLevel AccessLevel { get; set; }
public int Length { get; }
public int Limit { get; set; } = 6; // Default to 6 character slots
public int Count { get; }
private readonly Dictionary<int, Mobile> _mobiles = new();
public Mobile this[int index]
{
get => _mobiles.GetValueOrDefault(index);
set => _mobiles[index] = value;
}
public DateTime Created { get; set; }
public Serial Serial { get; }
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
public bool Deleted { get; }
public void Delete() => throw new NotImplementedException();
public bool TrySetUsername(string username) => throw new NotImplementedException();
public void SetPassword(string password) => throw new NotImplementedException();
public bool CheckPassword(string password) => throw new NotImplementedException();
}

View file

@ -20,7 +20,13 @@ public static class PacketTestUtilities
/// Uses a real Socket and RingSocket with actual buffers.
/// Must be disposed after use (use 'using' statement).
/// </summary>
public static NetState CreateTestNetState()
public static NetState CreateTestNetState() => CreateTestNetState(out _);
/// <summary>
/// As <see cref="CreateTestNetState()"/>, also handing back the peer socket so a test can read what the
/// server delivered.
/// </summary>
public static NetState CreateTestNetState(out Socket client)
{
NetState.Slice(); // Process disconnects/disposes
@ -65,6 +71,7 @@ public static class PacketTestUtilities
var testSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
testSocket.Connect(IPAddress.Loopback, _testPort);
_testSocketClients.Add(testSocket);
client = testSocket;
// Slice until we have a new NetState instance added
// AcceptEx is asynchronous, so we may need to wait/retry

View file

@ -5,9 +5,9 @@
<RootNamespace>Server.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.85" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -0,0 +1,173 @@
using System;
using Server.Items;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class LockedDownContainerDecayTests
{
public LockedDownContainerDecayTests()
{
DecayScheduler.ResetForTests();
// Content assigns these at startup (BaseHouse); the engine tests have no content.
if (Item.LockedDownFlag == 0)
{
Item.LockedDownFlag = 1;
Item.SecureFlag = 2;
}
}
private class TestChest : Container
{
public TestChest() : base(0xE43)
{
}
}
private class KeepsContentsChest : Container
{
public KeepsContentsChest() : base(0xE43)
{
}
public override bool ContentsDecay => false;
}
private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true };
private static TestChest PlaceLockedDownChest(int x)
{
var chest = new TestChest { Movable = false };
chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca);
chest.IsLockedDown = true;
return chest;
}
[Fact]
public void ItemDroppedIntoLockedDownContainer_IsTracked()
{
var chest = PlaceLockedDownChest(120);
var loot = NewLoot();
chest.AddItem(loot);
Assert.True(loot.CanDecay());
Assert.True(DecayScheduler.IsRegistered(loot));
chest.Delete();
}
[Fact]
public void ItemInOrdinaryContainer_IsNotTracked()
{
var chest = new TestChest();
chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
var loot = NewLoot();
chest.AddItem(loot);
Assert.False(loot.CanDecay());
Assert.False(DecayScheduler.IsRegistered(loot));
chest.Delete();
}
[Fact]
public void LockingAndReleasingContainer_RegistersAndUnregistersContents()
{
var chest = new TestChest { Movable = false };
chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca);
var loot = NewLoot();
chest.AddItem(loot);
Assert.False(DecayScheduler.IsRegistered(loot));
chest.IsLockedDown = true;
Assert.True(DecayScheduler.IsRegistered(loot));
chest.IsSecure = true; // secure containers keep their contents
Assert.False(DecayScheduler.IsRegistered(loot));
chest.IsSecure = false;
Assert.True(DecayScheduler.IsRegistered(loot));
chest.IsLockedDown = false;
Assert.False(DecayScheduler.IsRegistered(loot));
chest.Delete();
}
[Fact]
public void ContainerThatKeepsContents_DoesNotTrackThem()
{
var board = new KeepsContentsChest { Movable = false };
board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca);
board.IsLockedDown = true;
var piece = NewLoot();
board.AddItem(piece);
Assert.False(DecayScheduler.IsRegistered(piece));
board.Delete();
}
[Fact]
public void NestedContainerContents_AreNotTracked()
{
var chest = PlaceLockedDownChest(124);
var bag = new TestChest();
chest.AddItem(bag);
var loot = NewLoot();
bag.AddItem(loot);
Assert.True(DecayScheduler.IsRegistered(bag));
Assert.False(DecayScheduler.IsRegistered(loot));
chest.Delete();
}
[Fact]
public void LockedDownItemInsideLockedDownContainer_IsNotTracked()
{
var chest = PlaceLockedDownChest(125);
var loot = NewLoot();
chest.AddItem(loot);
loot.IsLockedDown = true;
Assert.False(DecayScheduler.IsRegistered(loot));
chest.Delete();
}
[Fact]
public void TrackedContents_DecayOnSchedule()
{
var start = Core._now;
try
{
var chest = PlaceLockedDownChest(126);
var loot = NewLoot();
chest.AddItem(loot);
var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2);
for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256))
{
Core._now = now;
DecayScheduler.ProcessTick(now);
}
Assert.True(loot.Deleted, "Contents of a locked-down container must decay.");
Assert.False(chest.Deleted, "The locked-down container itself must not decay.");
chest.Delete();
}
finally
{
Core._now = start;
}
}
}

View file

@ -416,41 +416,6 @@ public class ClientEnumeratorTests
}
}
private class MockAccount : IAccount
{
public int TotalGold { get; }
public int TotalPlat { get; }
public bool DepositGold(int amount) => throw new NotImplementedException();
public bool DepositPlat(int amount) => throw new NotImplementedException();
public bool WithdrawGold(int amount) => throw new NotImplementedException();
public bool WithdrawPlat(int amount) => throw new NotImplementedException();
public long GetTotalGold() => throw new NotImplementedException();
public int CompareTo(IAccount other) => throw new NotImplementedException();
public string Username { get; }
public string Email { get; set; }
public AccessLevel AccessLevel { get; set; }
public int Length { get; }
public int Limit { get; set; } = 6; // Default to 6 character slots
public int Count { get; }
private readonly Dictionary<int, Mobile> _mobiles = new();
public Mobile this[int index]
{
get => _mobiles.GetValueOrDefault(index);
set => _mobiles[index] = value;
}
public DateTime Created { get; set; }
public Serial Serial { get; }
public void Deserialize(IGenericReader reader) => throw new NotImplementedException();
public void Serialize(IGenericWriter writer) => throw new NotImplementedException();
public bool Deleted { get; }
public void Delete() => throw new NotImplementedException();
public bool TrySetUsername(string username) => throw new NotImplementedException();
public void SetPassword(string password) => throw new NotImplementedException();
public bool CheckPassword(string password) => throw new NotImplementedException();
}
private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location)
{
// Create test NetState with real socket and buffers

View file

@ -0,0 +1,272 @@
using System;
using System.Diagnostics;
using System.Net.Sockets;
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// Send-side behaviour of a NetState after its disconnect is handed to the socket.
/// </summary>
[Collection("Sequential Server Tests")]
public class NetStateDisconnectTests
{
private static NetState CreateAuthenticatedNetState()
{
var ns = PacketTestUtilities.CreateTestNetState();
ns.Account = new MockAccount(); // skips the unattached-socket sweep
return ns;
}
private static (NetState ns, Mobile m) CreateClient(string name)
{
var ns = CreateAuthenticatedNetState();
var m = new Mobile(World.NewMobile) { Name = name };
m.DefaultMobileInit();
ns.Mobile = m;
m.NetState = ns;
return (ns, m);
}
[Fact]
public void Send_AfterDisconnectHandedToSocket_DropsPacket()
{
var ns = CreateAuthenticatedNetState();
try
{
ns.Disconnect("test");
NetState.Slice(); // handoff; the in-flight recv keeps it pending
Assert.True(ns.Running);
Assert.True(ns._socket.DisconnectPending);
Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
ns.Send([0x73, 0x00]);
Assert.True(ns.CannotSendPackets());
Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
}
finally
{
ns.Dispose();
}
}
[Fact]
public void Send_AfterImmediateDisconnect_DropsPacket()
{
var ns = CreateAuthenticatedNetState();
// Immediate branch of RingSocket.Disconnect(): Connected drops, DisconnectPending never set,
// Disconnected event lands next Slice()
try
{
NetState.SocketManager.DisconnectImmediate(ns._socket);
Assert.True(ns.Running);
Assert.False(ns._socket.Connected);
Assert.False(ns._socket.DisconnectPending);
ns.Send([0x73, 0x00]);
Assert.True(ns.CannotSendPackets());
Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes);
}
finally
{
ns.Dispose();
}
}
[Fact]
public void Send_BeforeDisconnect_IsDeliveredThenPeerSeesEof()
{
var ns = PacketTestUtilities.CreateTestNetState(out var client);
ns.Account = new MockAccount();
try
{
byte[] payload = [0x8C, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
ns.Send(payload);
ns.Disconnect("redirect");
NetState.Slice(); // flush, then handoff
Assert.True(ns._socket.DisconnectPending);
var received = new byte[payload.Length];
var total = 0;
var deadline = Stopwatch.StartNew();
while (total < payload.Length && deadline.ElapsedMilliseconds < 5000)
{
NetState.Slice();
if (client.Poll(1000, SelectMode.SelectRead))
{
var read = client.Receive(received, total, payload.Length - total, SocketFlags.None);
Assert.NotEqual(0, read);
total += read;
}
}
Assert.Equal(payload, received);
// FIN follows once the send completes
var eof = -1;
while (eof != 0 && deadline.ElapsedMilliseconds < 5000)
{
NetState.Slice();
if (client.Poll(1000, SelectMode.SelectRead))
{
eof = client.Receive(received);
}
}
Assert.Equal(0, eof);
}
finally
{
ns.Dispose();
client.Close();
}
}
[Fact]
public void Send_LargeBeforeDisconnect_IsFullyDrainedThenPeerSeesEof()
{
var ns = PacketTestUtilities.CreateTestNetState(out var client);
ns.Account = new MockAccount();
try
{
// Several posts' worth, larger than the loopback kernel buffers, so the drain spans completions
const int chunk = 32 * 1024;
var payload = new byte[6 * chunk];
new System.Random(7).NextBytes(payload);
for (var offset = 0; offset < payload.Length; offset += chunk)
{
ns.Send(payload.AsSpan(offset, chunk));
}
ns.Disconnect("redirect");
NetState.Slice(); // flush, then handoff
Assert.True(ns._socket.DisconnectPending);
var received = new byte[payload.Length];
var total = 0;
var deadline = Stopwatch.StartNew();
while (total < payload.Length && deadline.ElapsedMilliseconds < 10000)
{
NetState.Slice();
if (client.Poll(1000, SelectMode.SelectRead))
{
var read = client.Receive(received, total, payload.Length - total, SocketFlags.None);
Assert.NotEqual(0, read);
total += read;
}
}
Assert.Equal(payload.Length, total);
Assert.Equal(payload, received);
var eof = -1;
while (eof != 0 && deadline.ElapsedMilliseconds < 10000)
{
NetState.Slice();
if (client.Poll(1000, SelectMode.SelectRead))
{
eof = client.Receive(received);
}
}
Assert.Equal(0, eof);
}
finally
{
ns.Dispose();
client.Close();
}
}
[Fact]
public void PendingDisconnect_ForceClosesAfterDrainTimeout()
{
var ns = CreateAuthenticatedNetState();
try
{
ns.Disconnect("test");
NetState.Slice(); // handoff; the in-flight recv keeps it pending and arms the deadline
Assert.True(ns._socket.DisconnectPending);
var armed = Core.TickCount;
ns.CheckAlive(armed + NetState.DrainTimeoutMs - 1);
Assert.True(ns._socket.Connected);
ns.CheckAlive(armed + NetState.DrainTimeoutMs);
Assert.False(ns._socket.Connected);
}
finally
{
ns.Dispose();
}
}
[Fact]
public void SendBufferExhausted_WhileDisconnectQueued_KeepsFirstReason()
{
var ns = CreateAuthenticatedNetState();
try
{
var capacity = ns._socket.SendBuffer.PhysicalSize;
ns.Send(new byte[capacity + 1]); // cannot fit; queues the disconnect
ns.Send(new byte[capacity + 2]); // same tick; must not re-report
Assert.Contains($"needed {capacity + 1}", ns._disconnectReason);
}
finally
{
ns.Dispose();
}
}
[Fact]
public void CancelAllTrades_CancelsEveryTrade()
{
var (from, fromMobile) = CreateClient("from");
var (to, toMobile) = CreateClient("to");
try
{
from.AddTrade(to);
var trade = from.FindTrade(toMobile);
Assert.NotNull(trade);
Assert.True(trade.Valid);
from.CancelAllTrades();
Assert.False(trade.Valid);
Assert.Null(from.Trades);
Assert.Null(to.Trades);
Assert.Null(from.FindTrade(toMobile));
Assert.Null(to.FindTrade(fromMobile));
}
finally
{
from.Mobile = null;
from.Dispose();
fromMobile.Delete();
to.Mobile = null;
to.Dispose();
toMobile.Delete();
}
}
}

View file

@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using Server;
using Xunit;
namespace Server.Tests;
public class ObjectPropertyListHashTests
{
private static int BuildHash(params (int cliloc, string arg)[] properties)
{
var opl = new ObjectPropertyList(null);
foreach (var (cliloc, arg) in properties)
{
if (arg == null)
{
opl.Add(cliloc);
}
else
{
opl.Add(cliloc, arg.AsSpan());
}
}
opl.Terminate();
return opl.Hash;
}
// Two properties sharing an argument: the XOR fold mixed it in twice and cancelled it, so
// "10%" and "5%" produced the same revision and the client kept the stale tooltip.
[Fact]
public void RepeatedArgument_DoesNotCancelOut()
{
var ten = BuildHash(
(1063752, "10"),
(1063737, "10"),
(1063740, null)
);
var five = BuildHash(
(1063752, "5"),
(1063737, "5"),
(1063740, null)
);
Assert.NotEqual(ten, five);
}
// XOR is self-inverse: any value mixed in an even number of times vanished.
[Fact]
public void DuplicateProperty_ChangesHash()
{
var once = BuildHash((1060658, null));
var twice = BuildHash((1060658, null), (1060658, null));
Assert.NotEqual(once, twice);
}
// XOR is commutative, so emission order was invisible to the hash but visible in the tooltip.
[Fact]
public void PropertyOrder_ChangesHash()
{
var forward = BuildHash((1060658, "Alpha"), (1060659, "Beta"));
var reversed = BuildHash((1060659, "Beta"), (1060658, "Alpha"));
Assert.NotEqual(forward, reversed);
}
[Fact]
public void SwappedArguments_ChangeHash()
{
var forward = BuildHash((1063752, "10"), (1063737, "5"));
var swapped = BuildHash((1063752, "5"), (1063737, "10"));
Assert.NotEqual(forward, swapped);
}
[Fact]
public void IdenticalContent_ProducesIdenticalHash()
{
var first = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null));
var second = BuildHash((1063752, "10"), (1063737, "5"), (1063740, null));
Assert.Equal(first, second);
}
// The client masks 0x40000000 off the 0xDC revision to match the 0xD6 hash, so the hash has
// to stay below that bit.
[Fact]
public void Hash_StaysWithinTheRevisionMask()
{
var opl = new ObjectPropertyList(null);
opl.Add(1063752, "A rather long argument that pushes the buffer past its initial size".AsSpan());
opl.Add(1063737, "12345");
opl.Add(1063740);
opl.Terminate();
Assert.Equal(0x40000000, opl.Hash & ~0x3FFFFFF);
}
// 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26
// kept bits, so a counter ticking down never repeats the revision it just had.
[Fact]
public void ShortNumericArguments_ConsecutiveValuesDiffer()
{
var previous = BuildHash((1060584, "0"));
for (var charges = 1; charges < 20000; charges++)
{
var current = BuildHash((1060584, charges.ToString()));
Assert.NotEqual(previous, current);
previous = current;
}
}
[Fact]
public void SmallPropertyBlocks_StayWellDistributed()
{
const int count = 20000;
var withArgument = new HashSet<int>();
var withoutArgument = new HashSet<int>();
for (var i = 0; i < count; i++)
{
withArgument.Add(BuildHash((1060584, i.ToString())));
withoutArgument.Add(BuildHash((1060000 + i, null)));
}
// Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the
// bound holds for any seed. A hash that stopped mixing collapses far past it.
Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}");
Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}");
}
[Fact]
public void EmptyList_IsNonZeroAndDistinctFromPopulated()
{
var empty = new ObjectPropertyList(null);
empty.Terminate();
Assert.NotEqual(0, empty.Hash);
Assert.Equal(0x40000000, empty.Hash & ~0x3FFFFFF);
Assert.NotEqual(empty.Hash, BuildHash((1060658, null)));
}
}

View file

@ -20,7 +20,7 @@ internal class RoundTripEntity : ISerializable
{
}
public void Serialize(IGenericWriter writer)
public virtual void Serialize(IGenericWriter writer)
{
writer.Write(Value);
writer.Write(Name);
@ -33,6 +33,15 @@ internal class RoundTripEntity : ISerializable
}
}
internal class ThrowingRoundTripEntity : RoundTripEntity
{
public ThrowingRoundTripEntity(Serial serial) : base(serial)
{
}
public override void Serialize(IGenericWriter writer) => throw new InvalidOperationException("broken serializer");
}
[Collection("Sequential Server Tests")]
public class GenericEntityPersistenceRoundTripTests
{
@ -161,4 +170,143 @@ public class GenericEntityPersistenceRoundTripTests
Directory.Delete(dir, true);
}
}
/// <summary>
/// A serializer throwing on a worker used to be an unhandled exception on that thread.
/// The worker records it, finishes the drain so the handshake completes, and the loop
/// fails the save.
/// </summary>
[Fact]
public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes()
{
var source = new SerializationChunkSource();
var workers = new SerializationThreadWorker[2];
for (var i = 0; i < workers.Length; i++)
{
workers[i] = new SerializationThreadWorker(i, source);
workers[i].AllocateHeap();
}
var persistence = new RoundTripPersistence(2002);
try
{
for (var i = 1; i <= 100; i++)
{
var serial = (Serial)(uint)i;
persistence.EntitiesBySerial[serial] = i == 50
? new ThrowingRoundTripEntity(serial)
: new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" };
}
foreach (var worker in workers)
{
worker.Wake();
}
source.SetOwner(persistence);
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
source.PushSlotRanges(persistence, slotCount);
source.Flush();
foreach (var worker in workers)
{
worker.Sleep();
}
Exception error = null;
foreach (var worker in workers)
{
error ??= worker.Error;
}
Assert.IsType<InvalidOperationException>(error);
persistence.PostWorldSave();
// The next drain starts clean.
foreach (var worker in workers)
{
worker.Wake();
}
foreach (var worker in workers)
{
worker.Sleep();
Assert.Null(worker.Error);
}
}
finally
{
persistence.Unregister();
foreach (var worker in workers)
{
worker.Exit();
}
}
}
/// <summary>
/// A segment that cannot be written used to be logged and dropped, publishing a save
/// without those entities (the loader then deletes them). It now fails the save.
/// </summary>
[Fact]
public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment()
{
var source = new SerializationChunkSource();
var workers = new SerializationThreadWorker[2];
for (var i = 0; i < workers.Length; i++)
{
workers[i] = new SerializationThreadWorker(i, source);
workers[i].AllocateHeap();
}
var previousWorkers = World._threadWorkers;
World._threadWorkers = workers;
var persistence = new RoundTripPersistence(2003);
var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
for (var i = 1; i <= 100; i++)
{
var serial = (Serial)(uint)i;
persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" };
}
// Deliberately not registered: the writer cannot index the type.
foreach (var worker in workers)
{
worker.Wake();
}
source.SetOwner(persistence);
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
source.PushSlotRanges(persistence, slotCount);
source.Flush();
foreach (var worker in workers)
{
worker.Sleep();
}
Assert.Throws<InvalidOperationException>(() => persistence.WriteSnapshot(dir));
persistence.PostWorldSave();
}
finally
{
persistence.Unregister();
foreach (var worker in workers)
{
worker.Exit();
}
World._threadWorkers = previousWorkers;
Directory.Delete(dir, true);
}
}
}

View file

@ -0,0 +1,83 @@
using System;
using System.IO;
using Xunit;
namespace Server.Tests;
/// <summary>
/// The publish protocol: a complete snapshot is staged next to Saves/ before the previous
/// save is touched, and an interrupted publish is finished at the next boot or save.
/// </summary>
[Collection("Sequential Server Tests")]
public class StagedSavePublishTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"muo-staged-{Guid.NewGuid():N}");
private readonly string _previousSavePath;
public StagedSavePublishTests()
{
Directory.CreateDirectory(_root);
_previousSavePath = World.SavePath;
World.SetSavePathForTest(Path.Combine(_root, "Saves"));
}
public void Dispose()
{
World.SetSavePathForTest(_previousSavePath);
try
{
Directory.Delete(_root, true);
}
catch
{
// best effort
}
}
private static void WriteMarker(string dir, string name)
{
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "marker.txt"), name);
}
private static string ReadMarker(string dir) => File.ReadAllText(Path.Combine(dir, "marker.txt"));
[Fact]
public void NothingStaged_RecoveryIsANoOp()
{
WriteMarker(World.SavePath, "current");
World.RecoverStagedSave();
Assert.Equal("current", ReadMarker(World.SavePath));
Assert.Single(Directory.GetDirectories(_root));
}
[Fact]
public void StagedSave_ReplacesSaves_AndKeepsThePreviousOne()
{
WriteMarker(World.SavePath, "old");
WriteMarker(World.StagedSavePath, "new");
World.RecoverStagedSave();
Assert.Equal("new", ReadMarker(World.SavePath));
Assert.False(Directory.Exists(World.StagedSavePath));
var aside = Array.FindAll(Directory.GetDirectories(_root), d => d.Contains(".previous-"));
Assert.Single(aside);
Assert.Equal("old", ReadMarker(aside[0]));
}
[Fact]
public void StagedSave_WithNoSaves_IsPublished()
{
WriteMarker(World.StagedSavePath, "new");
World.RecoverStagedSave();
Assert.Equal("new", ReadMarker(World.SavePath));
Assert.Single(Directory.GetDirectories(_root));
}
}

View file

@ -153,6 +153,27 @@ public partial class Container : Item
public virtual bool IsDecoContainer => !Movable && !IsLockedDown && !IsSecure && Parent == null && !LiftOverride;
/// <summary>
/// True when this container's direct contents decay on their own schedule: a locked-down,
/// non-secure container in a house. Containers whose contents are part of the object
/// (game boards, aquariums) override this to false.
/// </summary>
public virtual bool ContentsDecay => IsLockedDown && !IsSecure;
/// <summary>
/// Re-evaluates decay registration for every direct child. Call when
/// <see cref="ContentsDecay" /> may have changed.
/// </summary>
public void UpdateContentsDecayRegistration()
{
var items = Items;
for (var i = 0; i < items.Count; i++)
{
items[i].UpdateDecayRegistration();
}
}
public static int GlobalMaxItems { get; set; } = 125;
public static int GlobalMaxWeight { get; set; } = 400;

View file

@ -426,8 +426,14 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
get => GetTempFlag(LockedDownFlag);
set
{
if (GetTempFlag(LockedDownFlag) == value)
{
return;
}
SetTempFlag(LockedDownFlag, value);
InvalidateProperties();
OnSecurityChanged();
}
}
@ -437,8 +443,26 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
get => GetTempFlag(SecureFlag);
set
{
if (GetTempFlag(SecureFlag) == value)
{
return;
}
SetTempFlag(SecureFlag, value);
InvalidateProperties();
OnSecurityChanged();
}
}
// Lockdown and secure status decide whether this item and, for a container, its direct
// contents are decay-eligible (see CanDecay); re-evaluate both.
private void OnSecurityChanged()
{
UpdateDecayRegistration();
if (this is Container container)
{
container.UpdateContentsDecayRegistration();
}
}
@ -2351,10 +2375,15 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public bool AtPoint(int x, int y) => m_Location.m_X == x && m_Location.m_Y == y;
public virtual bool CanDecay() => Decays && Parent == null && Map != Map.Internal;
// Ground items decay; so do the direct contents of a container whose ContentsDecay is true
// (a locked-down, non-secure house container), unless the content item is itself locked
// down or secured. Nested containers do not propagate: only direct children qualify.
public virtual bool CanDecay() =>
Decays && Map != Map.Internal &&
(Parent == null || Parent is Container { ContentsDecay: true } && !IsLockedDown && !IsSecure);
public virtual bool OnDecay() =>
CanDecay() && Region.Find(Location, Map).OnDecay(this);
CanDecay() && Region.Find(GetWorldLocation(), Map).OnDecay(this);
public DateTime ScheduledDecayTime
{

View file

@ -61,6 +61,9 @@ public partial class NetState
/// </summary>
public static IIORingGroup Ring => _socketManager?.Ring;
// Test hook
internal static RingSocketManager SocketManager => _socketManager;
/// <summary>
/// Waits for network I/O completions or until the specified timeout expires.
/// Used by the game loop to sleep efficiently while remaining responsive to network events.
@ -107,6 +110,9 @@ public partial class NetState
return;
}
// Seed from a real tick; a zero default suppresses the sweep when ticks start negative
_nextAliveCheck = Core.TickCount;
// Initialize IP rate limiter
_ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token);
@ -539,6 +545,11 @@ public partial class NetState
// - Waits for in-flight I/O to complete
// - Ensures buffers aren't released while kernel is still using them
ns._socket.Disconnect();
if (ns._socket.DisconnectPending)
{
ns.ArmDrainDeadline(curTicks);
}
}
}

View file

@ -36,6 +36,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
private const int HuePickerCap = 512;
private const int MenuCap = 512;
private const int PacketPerSecondThreshold = 3000;
internal const long DrainTimeoutMs = 10000; // graceful disconnect gets this long to drain
private static readonly Queue<NetState> _flushPending = new(2048);
private static readonly Queue<NetState> _pendingDisconnects = new(256); // Processed AFTER flush
@ -54,7 +55,9 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
private bool _disconnectQueued; // Queued for disconnect processing (after flush)
private long[] _packetThrottles;
private long[] _packetCounts;
private string _disconnectReason = string.Empty;
internal string _disconnectReason = string.Empty;
private long _drainDeadline;
private bool _drainDeadlineArmed;
internal ParserState _parserState = ParserState.AwaitingNextPacket;
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
@ -294,7 +297,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
for (var i = Trades.Count - 1; i >= 0; --i)
{
if (Trades != null)
// RemoveTrade() nulls the list once empty
if (Trades == null)
{
break;
}
@ -489,6 +493,12 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
return;
}
// Closing; nothing to report
if (!_running || _socket == null)
{
return;
}
// Never drop silently: the client would stay connected while missing game state.
if (!GetSendBuffer(out var buffer))
{
@ -552,6 +562,12 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
/// </remarks>
private void SendBufferExhausted(int needed, int writable)
{
// One report per disconnect; the first reason wins
if (_disconnectQueued)
{
return;
}
var sendBuffer = _socket?.SendBuffer;
var unacked = sendBuffer?.InFlightBytes ?? 0;
var capacity = sendBuffer?.PhysicalSize ?? 0;
@ -1053,31 +1069,52 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
return ParserState.AwaitingNextPacket;
}
// Bounds the graceful drain. Send completions keep NextActivityCheck moving, so a slow peer
// could otherwise hold a closing socket open indefinitely.
internal void ArmDrainDeadline(long curTicks)
{
if (!_drainDeadlineArmed)
{
_drainDeadlineArmed = true;
_drainDeadline = curTicks + DrainTimeoutMs;
}
}
public void CheckAlive(long curTicks)
{
if (_socket == null || NextActivityCheck - curTicks >= 0)
if (_socket == null)
{
return;
}
if (_socket.DisconnectPending)
{
LogInfo("Force disconnecting stuck socket...");
_socketManager.DisconnectImmediate(_socket);
}
else
{
// Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
// The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
if (_account != null && Mobile == null)
ArmDrainDeadline(curTicks); // transport-initiated drains are first seen here
if (curTicks - _drainDeadline >= 0 || NextActivityCheck - curTicks < 0)
{
this.SendClientVersionRequest();
return;
LogInfo("Force disconnecting stuck socket...");
_socketManager.DisconnectImmediate(_socket);
}
LogInfo("Disconnecting due to inactivity...");
Disconnect("Disconnecting due to inactivity.");
return;
}
if (NextActivityCheck - curTicks >= 0)
{
return;
}
// Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
// The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
if (_account != null && Mobile == null)
{
this.SendClientVersionRequest();
return;
}
LogInfo("Disconnecting due to inactivity...");
Disconnect("Disconnecting due to inactivity.");
}
public void Trace(ReadOnlySpan<byte> buffer)
@ -1123,8 +1160,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
}
/// <summary>
/// Requests a graceful disconnect. The disconnect is queued and processed after the flush
/// queue in Slice(), ensuring Send() calls made in the same tick are processed first.
/// Requests a graceful disconnect. Processed after the flush queue in Slice(): sends made before
/// that handoff are flushed first, sends after it are dropped (see CannotSendPackets).
/// </summary>
public void Disconnect(string reason)
{

View file

@ -6,8 +6,8 @@ public static class OutgoingPackets
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool CannotSendPackets(this NetState ns) =>
// Do not check for NetState.Running. Packets are sent to a "disconnected" socket as part of the OnDisconnect events
// up until the socket is closed. Closing the connection is done synchronously, therefore packets will not be sent
// once the Mobile.NetState is null.
ns == null || ns.SocketHandle == 0 || ns.BlockAllPackets;
// Running is not checked: sends between Disconnect() and the Slice() handoff must still go out.
// After the handoff the socket is draining (DisconnectPending) or closing (!Connected); new writes
// only keep the buffer from draining.
ns == null || ns.SocketHandle == 0 || !ns._socket.Connected || ns._socket.DisconnectPending || ns.BlockAllPackets;
}

View file

@ -46,6 +46,13 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
// under the empirically confirmed ~510-char ceiling. For multi-line content use AddChunked().
public const int MaxArgumentLength = 504;
// 0xD6 header: packet id, length, unknown, serial, unknown, hash. Properties start after it.
private const int HeaderLength = 15;
// Terminate writes the bare hash into 0xD6, SendOPLInfo writes Hash with bit 30 set, and the
// client recovers one from the other by masking off 0x40000000. The hash must stay below it.
private const int HashMask = 0x3FFFFFF;
private int _hash;
private int _stringNumbersIndex;
private byte[] _buffer;
@ -89,7 +96,7 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void Reset()
{
_bufferPos = 15;
_bufferPos = HeaderLength;
_hash = 0;
_stringNumbersIndex = 0;
Header = 0;
@ -120,6 +127,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
Resize(length);
}
// xxHash3 over the finished property block. Order and repetition sensitive, unlike the
// XOR fold it replaces, which collided whenever properties were reordered or shared an
// argument.
_hash = (int)(HashUtility.ComputeHash64(_buffer.AsSpan(HeaderLength, _bufferPos - HeaderLength)) & HashMask);
var writer = new SpanWriter(_buffer);
writer.Seek(_bufferPos, SeekOrigin.Begin);
writer.Write(0);
@ -129,12 +141,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
writer.WritePacketLength();
}
private void AddHash(int val)
{
_hash ^= val & 0x3FFFFFF;
_hash ^= (val >> 26) & 0x3F;
}
public void Add(int number)
{
if (number == 0)
@ -148,8 +154,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = "";
}
AddHash(number);
var length = _bufferPos + 6;
while (length > _buffer.Length)
{
@ -245,9 +249,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = chars.ToString();
}
AddHash(number);
AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)
@ -295,9 +296,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
HeaderArgs = chars.ToString();
}
AddHash(number);
AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)

View file

@ -28,7 +28,17 @@ namespace Server;
public interface IGenericEntityPersistence
{
string Name { get; }
int EntityCount { get; }
void DeserializeIndexes(string savePath, Dictionary<ulong, string> typesDb);
/// <summary>
/// Enumerates the live entities. Diagnostics only: the dictionary must not be mutated while
/// enumerating, so callers snapshot the sequence before doing anything that can add or delete.
/// </summary>
IEnumerable<ISerializable> EnumerateEntities();
}
public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource
@ -85,6 +95,16 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
public Dictionary<Serial, T> EntitiesBySerial { get; } = new();
public int EntityCount => EntitiesBySerial.Count;
public IEnumerable<ISerializable> EnumerateEntities()
{
foreach (var entity in EntitiesBySerial.Values)
{
yield return entity;
}
}
public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this(
name,
priority,
@ -138,6 +158,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
_selfPosition,
_selfLength
);
throw;
}
binPosition += _selfLength;
@ -185,6 +206,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
}
catch (Exception error)
{
// Never publish a partial snapshot: entities missing from the idx are deleted on load.
logger.Error(
error,
"Error writing segment: (Thread: {Thread} - {Start}, {Records} records)",
@ -192,6 +214,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
segment.HeapStart,
segment.RecordCount
);
throw;
}
}
}
@ -298,9 +321,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
private ushort GetTypeIndex(T entity)
{
// Every path into EntitiesBySerial registers the type first, so this cannot fire.
// If it ever does, the segment-level catch in WriteSnapshot logs it and moves on —
// the failed segment's records are dropped from the idx while binPosition rewinds,
// so treat any occurrence as a serious bug in an insertion path, not a bad entity.
// If it does, the save fails; treat it as a bug in an insertion path, not a bad entity.
if (!_typeIndexes.TryGetValue(entity.GetType(), out var typeIndex))
{
throw new InvalidOperationException(

View file

@ -34,6 +34,21 @@ public abstract class Persistence
public bool Register() => _registry.Add(this);
/// <summary>Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order.</summary>
public static IEnumerable<IGenericEntityPersistence> EntityPersistences
{
get
{
foreach (var entry in _registry)
{
if (entry is IGenericEntityPersistence entityPersistence)
{
yield return entityPersistence;
}
}
}
}
public void Unregister() => _registry.Remove(this);
public static void Load(string path)

View file

@ -78,6 +78,12 @@ public class SerializationThreadWorker
internal List<int> Lengths => _lengths;
internal List<IGenericSerializable> BufferEntities => _bufferEntities;
/// <summary>
/// First serializer exception during the drain. The drain continues so the handshake
/// completes; the loop fails the save once every worker has paused.
/// </summary>
public Exception Error { get; private set; }
/// <summary>
/// Releases the write logs after the snapshot is written so serialized entity
/// references don't linger between saves. Capacity is retained: the logs regrow to
@ -154,6 +160,25 @@ public class SerializationThreadWorker
public ReadOnlySpan<byte> GetHeap(int start, int length) => _heap.AsSpan(start, length);
private long ProcessChunk(in SerializationChunkSource.Chunk chunk, BufferWriter writer)
{
try
{
return ProcessChunkCore(in chunk, writer);
}
catch (Exception ex)
{
Error ??= ex;
if (chunk.Buffer != null)
{
_chunkSource.Return(chunk.Buffer, chunk.Count);
}
return 0;
}
}
private long ProcessChunkCore(in SerializationChunkSource.Chunk chunk, BufferWriter writer)
{
if (chunk.Single != null)
{
@ -213,6 +238,7 @@ public class SerializationThreadWorker
public void DrainInline()
{
ReleaseWriteLogs();
Error = null;
var writer = new BufferWriter(_heap, true);
var entities = 0L;
@ -238,6 +264,7 @@ public class SerializationThreadWorker
while (worker._startEvent.WaitOne())
{
worker.ReleaseWriteLogs();
worker.Error = null;
var writer = new BufferWriter(worker._heap, true);
var entities = 0L;

View file

@ -34,13 +34,13 @@
</Target>
<ItemGroup>
<ProjectReference Include="..\Logger\Logger.csproj" />
<PackageReference Include="IORingGroup" Version="1.0.10" />
<PackageReference Include="IORingGroup" Version="1.0.11" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
<PackageReference Include="System.IO.Hashing" Version="10.0.11" />
<PackageReference Include="System.IO.Hashing" Version="10.0.12" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.1.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.1.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" />
@ -50,5 +50,8 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>UOContent.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ModernSpawner.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View file

@ -68,7 +68,26 @@ public class TextDefinition : IEquatable<object>, IEquatable<TextDefinition>, IS
Number > 0 ? $"{Number} (0x{Number:X})" :
String != null ? $"\"{String}\"" : null;
public string GetValue() => Number > 0 ? Number.ToString() : String ?? "";
/// <summary>
/// The editable text form. Quotes a string that <c>TryParse</c> would not read back unchanged;
/// the check is a real round trip so it cannot drift from the parser.
/// </summary>
public string GetValue()
{
if (Number > 0)
{
return Number.ToString();
}
if (String == null)
{
return "";
}
return TryParse(String, null, out var parsed) && parsed.Number == 0 && parsed.String == String
? String
: $"@\"{String}\"";
}
public static implicit operator TextDefinition(int v) => Of(v);
@ -120,15 +139,7 @@ public class TextDefinition : IEquatable<object>, IEquatable<TextDefinition>, IS
public static bool operator !=(TextDefinition left, TextDefinition right) => !Equals(left, right);
public static TextDefinition Parse(string value)
{
if (value == null)
{
return null;
}
return Utility.ToInt32(value, out var i) ? Of(i) : Of(value);
}
public static TextDefinition Parse(string value) => value == null ? null : Parse(value.AsSpan(), null);
public static TextDefinition Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider);
@ -137,13 +148,30 @@ public class TextDefinition : IEquatable<object>, IEquatable<TextDefinition>, IS
public static TextDefinition Parse(ReadOnlySpan<char> s, IFormatProvider provider)
{
// We don't trim
return int.TryParse(s, provider, out var label) ? Of(label) : Of(s);
TryParse(s, provider, out var result);
return result;
}
/// <summary>
/// <c>#1234</c> or a bare <c>1234</c>/<c>0x4D2</c> is a cliloc; <c>@"1234"</c> is the literal
/// text. Always succeeds -- anything that is not a cliloc is a string.
/// See <c>dev-docs/generic-commands.md</c>.
/// </summary>
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider provider, out TextDefinition result)
{
if (int.TryParse(s, provider, out var label))
if (TryGetQuotedLiteral(s, out var literal))
{
result = Of(literal);
return true;
}
if (s.Length > 1 && s[0] == '#' && Utility.ToInt32(s[1..], out var marked))
{
result = Of(marked);
return true;
}
if (Utility.ToInt32(s, out var label))
{
result = Of(label);
return true;
@ -153,4 +181,16 @@ public class TextDefinition : IEquatable<object>, IEquatable<TextDefinition>, IS
result = Of(s);
return true;
}
private static bool TryGetQuotedLiteral(ReadOnlySpan<char> s, out ReadOnlySpan<char> literal)
{
if (s.Length >= 3 && s[0] == '@' && s[1] == '"' && s[^1] == '"')
{
literal = s[2..^1];
return true;
}
literal = default;
return false;
}
}

View file

@ -122,6 +122,8 @@ public static class World
UseMultiThreadedSaves = ServerConfiguration.GetOrUpdateSetting("world.useMultithreadedSaves", true);
}
internal static void SetSavePathForTest(string path) => SavePath = path;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void WaitForWriteCompletion() => _diskWriteHandle.WaitOne();
@ -195,6 +197,8 @@ public static class World
logger.Information("Loading world");
var watch = Stopwatch.StartNew();
RecoverStagedSave();
Persistence.Load(SavePath);
EventSink.InvokeWorldLoad();
@ -271,6 +275,8 @@ public static class World
{
try
{
RecoverStagedSave();
// Allocate the heaps for the GC
foreach (var worker in _threadWorkers)
{
@ -322,22 +328,49 @@ public static class World
}
Persistence.SerializeAll();
PauseSerializationThreads();
LogWorkerBalance();
EventSink.InvokeWorldSave();
}
catch (Exception ex)
{
exception = ex;
}
WorldState = WorldState.WritingSave;
ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
// Always join the workers; any serializer exception fails the save.
try
{
PauseSerializationThreads();
}
catch (Exception ex)
{
exception ??= ex;
}
for (var i = 0; i < _threadWorkers.Length; i++)
{
exception ??= _threadWorkers[i].Error;
}
if (exception == null)
{
LogWorkerBalance();
try
{
EventSink.InvokeWorldSave();
}
catch (Exception ex)
{
logger.Error(ex, "A WorldSave handler failed");
Persistence.TraceException(ex);
}
}
watch.Stop();
if (exception == null)
{
WorldState = WorldState.WritingSave;
ThreadPool.QueueUserWorkItem(WriteFiles, snapshotPath);
var duration = watch.Elapsed.TotalSeconds;
logger.Information("Saving world {Status} ({Duration:F2} seconds)", "done", duration);
@ -349,6 +382,9 @@ public static class World
Persistence.TraceException(exception);
BroadcastStaff(0x35, true, "World save failed! Check the logs!");
_diskWriteHandle.Set();
FinishWorldSave();
}
}
@ -361,17 +397,7 @@ public static class World
logger.Information("Writing world save snapshot");
Persistence.WriteSnapshotAll(snapshotPath);
try
{
EventSink.InvokeWorldSavePostSnapshot(SavePath, snapshotPath);
PathUtility.MoveDirectoryContents(snapshotPath, SavePath);
Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
}
catch (Exception ex)
{
Persistence.TraceException(ex);
}
PublishSnapshot(snapshotPath);
watch.Stop();
logger.Information("Writing world save snapshot {Status} ({Duration:F2} seconds)", "done", watch.Elapsed.TotalSeconds);
@ -388,6 +414,90 @@ public static class World
Core.LoopContext.Post(FinishWorldSave);
}
/// <summary>
/// A complete snapshot is staged here before the previous save is touched; a staged
/// directory is always a complete save newer than <see cref="SavePath" />.
/// </summary>
internal static string StagedSavePath => SavePath + ".next";
// Stage, let subscribers archive the previous save, then rename the staged save into place.
private static void PublishSnapshot(string snapshotPath)
{
var staging = StagedSavePath;
if (Directory.Exists(staging))
{
SetAside(staging, "unpublished");
}
MoveDirectory(snapshotPath, staging);
PublishStagedSave(archive: true);
}
private static void PublishStagedSave(bool archive)
{
var staging = StagedSavePath;
if (archive)
{
EventSink.InvokeWorldSavePostSnapshot(SavePath, staging);
}
if (Directory.Exists(SavePath))
{
SetAside(SavePath, "previous");
}
MoveDirectory(staging, SavePath);
Directory.SetLastWriteTimeUtc(SavePath, Core.Now);
}
/// <summary>
/// Finishes an interrupted publish. Runs at boot (before load) and before every save;
/// whatever is at Saves/ is set aside, never deleted.
/// </summary>
internal static void RecoverStagedSave()
{
var staging = StagedSavePath;
if (!Directory.Exists(staging))
{
return;
}
logger.Warning(
"A complete world save was staged at {Staging} but never published; publishing it now.",
staging
);
PublishStagedSave(archive: false);
}
private static void SetAside(string path, string reason)
{
var aside = $"{path}.{reason}-{Core.Now:yyyy-MM-dd-HH-mm-ss-fff}";
MoveDirectory(path, aside);
logger.Warning("Set aside {Path} as {Aside}; delete or archive it by hand.", path, aside);
}
// Atomic rename on one volume, file-by-file move otherwise.
private static void MoveDirectory(string source, string destination)
{
try
{
Directory.Move(source, destination);
}
catch (IOException)
{
if (Directory.Exists(destination))
{
throw;
}
PathUtility.MoveDirectoryContents(source, destination);
}
}
private static void FinishWorldSave()
{
WorldState = WorldState.Running;

View file

@ -109,6 +109,8 @@ internal static class TestServerInitializer
World.Load();
World.ExitSerializationThreads();
DecayScheduler.Configure();
// Without npc-speeds.json every BaseCreature constructor throws.
Server.Mobiles.NPCSpeeds.Configure();
Server.Engines.Spawners.SpawnerJsonSerializer.Configure();
if (TileDataLoaded)

View file

@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// `sort by` and `distinct` compile the same property chain the conditionals do, and had the same
// crash on a null link in the middle. Ordering cannot answer "no match" the way a condition can,
// so an unreadable binding reads as the default value instead -- which is what a null link means.
[Collection("Sequential UOContent Tests")]
public class ChainedBindingSortTests : IDisposable
{
private readonly List<Item> _items = [];
public void Dispose()
{
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
_items.Clear();
}
private SkillTeleporter Teleporter(TextDefinition message)
{
var tp = new SkillTeleporter { Message = message };
_items.Add(tp);
return tp;
}
private static Property Bind(string binding)
{
var prop = new Property(binding);
prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read);
return prop;
}
private static IComparer<object> Sorter(string binding) =>
SortCompiler.Compile<object>(
typeof(SkillTeleporter),
[new OrderInfo(Bind(binding), true)]
);
private static IComparer<object> Distincter(string binding) =>
DistinctCompiler.Compile<object>(
typeof(SkillTeleporter),
[Bind(binding)]
);
[Fact]
public void SortingOnAChainSurvivesANullIntermediate()
{
var named = Teleporter(TextDefinition.Of("Alpha"));
var blank = Teleporter(null);
var comparer = Sorter("Message.String");
// A consistent total order is the contract; which end the blanks land on is not asserted.
var forward = comparer.Compare(named, blank);
Assert.NotEqual(0, forward);
Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(blank, named)));
Assert.Equal(0, comparer.Compare(blank, blank));
Assert.Equal(0, comparer.Compare(named, named));
}
[Fact]
public void SortingOnAChainStillOrdersReadableValues()
{
var alpha = Teleporter(TextDefinition.Of("Alpha"));
var beta = Teleporter(TextDefinition.Of("Beta"));
var comparer = Sorter("Message.String");
Assert.True(comparer.Compare(alpha, beta) < 0);
Assert.True(comparer.Compare(beta, alpha) > 0);
}
[Fact]
public void DistinctOnAChainSurvivesANullIntermediate()
{
var named = Teleporter(TextDefinition.Of("Alpha"));
var blank = Teleporter(null);
var alsoBlank = Teleporter(null);
var comparer = Distincter("Message.String");
Assert.NotEqual(0, comparer.Compare(named, blank));
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()
{
var low = Teleporter(null);
low.Hue = 1;
var high = Teleporter(null);
high.Hue = 2;
var comparer = Sorter("Hue");
Assert.True(comparer.Compare(low, high) < 0);
Assert.True(comparer.Compare(high, low) > 0);
Assert.Equal(0, comparer.Compare(low, low));
}
}

View file

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value
// type (Message.Number) reused a temp while its value was still live, so every pair compared
// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is
// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port.
[Collection("Sequential UOContent Tests")]
public class ConditionalCompilerEdgeTests : IDisposable
{
private readonly List<Item> _items = [];
public void Dispose()
{
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
_items.Clear();
}
private SkillTeleporter Teleporter(TextDefinition message)
{
var tp = new SkillTeleporter { Message = message };
_items.Add(tp);
return tp;
}
private static Property Bind(Type type, string binding)
{
var prop = new Property(binding);
prop.BindTo(type, PropertyAccess.Read);
return prop;
}
[Fact]
public void SortingOnAChainedValueTypeOrdersValues()
{
var low = Teleporter(TextDefinition.Of(1060847));
var high = Teleporter(TextDefinition.Of(1060848));
var comparer = SortCompiler.Compile<object>(
typeof(SkillTeleporter),
[new OrderInfo(Bind(typeof(SkillTeleporter), "Message.Number"), true)]
);
Assert.True(comparer.Compare(low, high) < 0);
Assert.True(comparer.Compare(high, low) > 0);
Assert.Equal(0, comparer.Compare(low, low));
}
[Fact]
public void DistinctOnAChainedValueTypeSeparatesValues()
{
var low = Teleporter(TextDefinition.Of(1060847));
var high = Teleporter(TextDefinition.Of(1060848));
var alsoLow = Teleporter(TextDefinition.Of(1060847));
var comparer = DistinctCompiler.Compile<object>(
typeof(SkillTeleporter),
[Bind(typeof(SkillTeleporter), "Message.Number")]
);
Assert.NotEqual(0, comparer.Compare(low, high));
Assert.Equal(0, comparer.Compare(low, alsoLow));
}
public class Subject
{
[CommandProperty(AccessLevel.GameMaster)]
public Rectangle2D Bounds { get; set; } = new(new Point2D(1, 2), new Point2D(3, 4));
}
private static bool Check(ComparisonOperator op, string value)
{
var compiled = ConditionalCompiler.Compile(
typeof(Subject),
[TypeCondition.Default, new ComparisonCondition(Bind(typeof(Subject), "Bounds"), false, op, value)]
);
return compiled.Verify(new Subject());
}
[Fact]
public void NonComparableStructComparesByValueEquality()
{
Assert.True(Check(ComparisonOperator.Equal, "(1, 2)+(3, 4)"));
Assert.False(Check(ComparisonOperator.Equal, "(1, 2)+(3, 5)"));
Assert.False(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 4)"));
Assert.True(Check(ComparisonOperator.NotEqual, "(1, 2)+(3, 5)"));
}
// Only == and != are meaningful without a CompareTo; a relational operator is an error at
// compile time, not garbage at run time.
[Theory]
[InlineData(ComparisonOperator.Greater)]
[InlineData(ComparisonOperator.GreaterEqual)]
[InlineData(ComparisonOperator.Lesser)]
[InlineData(ComparisonOperator.LesserEqual)]
public void NonComparableStructRejectsRelationalOperators(ComparisonOperator op)
{
Assert.Throws<InvalidOperationException>(() => Check(op, "(1, 2)+(3, 4)"));
}
}

View file

@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// The distinct comparer doubles as an IEqualityComparer: objects that compare equal on every
// listed property must hash the same, whatever shape the property is -- an int, a reference that
// may be null, a struct, or a chain with a null link in the middle.
[Collection("Sequential UOContent Tests")]
public class DistinctComparerTests : IDisposable
{
private readonly List<Item> _items = [];
public void Dispose()
{
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
_items.Clear();
}
private SkillTeleporter Teleporter(Action<SkillTeleporter> setup = null)
{
var tp = new SkillTeleporter();
setup?.Invoke(tp);
_items.Add(tp);
return tp;
}
private static IEqualityComparer<object> Comparer(params string[] bindings)
{
var props = new Property[bindings.Length];
for (var i = 0; i < bindings.Length; i++)
{
props[i] = new Property(bindings[i]);
props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read);
}
return (IEqualityComparer<object>)DistinctCompiler.Compile<object>(typeof(SkillTeleporter), props);
}
[Fact]
public void EqualObjectsHashTheSameAcrossPropertyShapes()
{
var comparer = Comparer("Hue", "Name", "Location", "Message.String");
var one = Teleporter(tp =>
{
tp.Hue = 7;
tp.Name = "Gate";
tp.Location = new Point3D(1, 2, 3);
tp.Message = TextDefinition.Of("Alpha");
});
var two = Teleporter(tp =>
{
tp.Hue = 7;
tp.Name = "Gate";
tp.Location = new Point3D(1, 2, 3);
tp.Message = TextDefinition.Of("Alpha");
});
Assert.True(comparer.Equals(one, two));
Assert.Equal(comparer.GetHashCode(one), comparer.GetHashCode(two));
}
[Fact]
public void NullReferencesAndNullChainLinksHashWithoutThrowing()
{
var comparer = Comparer("Name", "Message.String");
var blank = Teleporter();
var alsoBlank = Teleporter();
Assert.True(comparer.Equals(blank, alsoBlank));
Assert.Equal(comparer.GetHashCode(blank), comparer.GetHashCode(alsoBlank));
}
[Fact]
public void DifferingObjectsAreNotEqual()
{
var comparer = Comparer("Hue", "Location");
var one = Teleporter(tp => tp.Hue = 1);
var two = Teleporter(tp => tp.Hue = 2);
Assert.False(comparer.Equals(one, two));
}
}

View file

@ -0,0 +1,103 @@
using System;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Xunit;
namespace UOContent.Tests.Commands;
// The conditional compiler emits into a fresh dynamic assembly for every command invocation --
// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one
// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant
// types, so a comparison against anything narrower or unsigned than int threw instead of running.
public class EmitterRobustnessTests
{
public class Subject
{
[CommandProperty(AccessLevel.GameMaster)]
public byte Tiny { get; set; } = 5;
[CommandProperty(AccessLevel.GameMaster)]
public sbyte Signed { get; set; } = -5;
[CommandProperty(AccessLevel.GameMaster)]
public short Small { get; set; } = -300;
[CommandProperty(AccessLevel.GameMaster)]
public ushort Key { get; set; } = 40000;
[CommandProperty(AccessLevel.GameMaster)]
public uint Big { get; set; } = 3_000_000_000;
[CommandProperty(AccessLevel.GameMaster)]
public ulong Huge { get; set; } = 18_000_000_000_000_000_000;
}
private static bool Check(string binding, ComparisonOperator op, string value)
{
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(new Subject());
}
[Theory]
[InlineData("Tiny", "5")]
[InlineData("Signed", "-5")]
[InlineData("Small", "-300")]
[InlineData("Key", "40000")]
[InlineData("Big", "3000000000")]
[InlineData("Huge", "18000000000000000000")]
public void NarrowAndUnsignedIntegersCompareByEquality(string binding, string value)
{
Assert.True(Check(binding, ComparisonOperator.Equal, value));
Assert.False(Check(binding, ComparisonOperator.NotEqual, value));
}
[Theory]
[InlineData("Tiny", "4")]
[InlineData("Signed", "-6")]
[InlineData("Small", "-301")]
[InlineData("Key", "39999")]
[InlineData("Big", "2999999999")]
[InlineData("Huge", "17999999999999999999")]
public void NarrowAndUnsignedIntegersCompareRelationally(string binding, string value)
{
Assert.True(Check(binding, ComparisonOperator.Greater, value));
Assert.False(Check(binding, ComparisonOperator.Lesser, value));
}
// Unsigned values above the signed range must not wrap into a negative comparison.
[Fact]
public void UnsignedComparisonsDoNotWrapThroughSignedMath()
{
Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647"));
Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807"));
}
[Fact]
public void EmittedAssembliesAreCollectible()
{
var prop = new Property("Tiny");
prop.BindTo(typeof(Subject), PropertyAccess.Read);
var compiled = ConditionalCompiler.Build(
typeof(Subject),
[TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")]
).Compile();
var method = compiled.Method;
Assert.True(
method.IsCollectible,
"The conditional compiler compiles one delegate per command invocation; code that is "
+ $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, "
+ "so every [global where would grow the process for good."
);
}
}

View file

@ -0,0 +1,143 @@
using System;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Xunit;
namespace UOContent.Tests.Commands;
// A `where` clause against a Nullable<T> property. No [CommandProperty] in the tree is nullable
// yet, so nothing is broken in practice -- but the conditional compiler should not fall over on
// one either: a set value compares as its underlying type, an unset one equals `null` and
// satisfies no relation, just as C#'s lifted operators would have it.
public class NullablePropertyConditionTests
{
public class Subject
{
[CommandProperty(AccessLevel.GameMaster)]
public int? Count { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public TimeSpan? Delay { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public SkillName? Skill { get; set; }
}
private static bool Check(Subject subject, string binding, ComparisonOperator op, string value)
{
var prop = new Property(binding);
prop.BindTo(typeof(Subject), PropertyAccess.Read);
var conditional = new ObjectConditional(
typeof(Subject),
[[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]]
);
return conditional.CheckCondition(subject);
}
[Fact]
public void SetValueComparesByEquality()
{
var subject = new Subject { Count = 5 };
Assert.True(Check(subject, "Count", ComparisonOperator.Equal, "5"));
Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "4"));
Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "4"));
Assert.False(Check(subject, "Count", ComparisonOperator.NotEqual, "5"));
}
[Fact]
public void SetValueComparesRelationally()
{
var subject = new Subject { Count = 5 };
Assert.True(Check(subject, "Count", ComparisonOperator.Greater, "4"));
Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "4"));
Assert.True(Check(subject, "Count", ComparisonOperator.GreaterEqual, "5"));
Assert.True(Check(subject, "Count", ComparisonOperator.LesserEqual, "5"));
}
[Fact]
public void UnsetValueEqualsNull()
{
Assert.True(Check(new Subject(), "Count", ComparisonOperator.Equal, "null"));
Assert.False(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.Equal, "null"));
Assert.True(Check(new Subject { Count = 5 }, "Count", ComparisonOperator.NotEqual, "null"));
}
// Lifted semantics: null is never greater, lesser, or equal to a value -- only unequal.
[Fact]
public void UnsetValueSatisfiesNoRelation()
{
var subject = new Subject();
Assert.False(Check(subject, "Count", ComparisonOperator.Equal, "0"));
Assert.True(Check(subject, "Count", ComparisonOperator.NotEqual, "0"));
Assert.False(Check(subject, "Count", ComparisonOperator.Greater, "0"));
Assert.False(Check(subject, "Count", ComparisonOperator.GreaterEqual, "0"));
Assert.False(Check(subject, "Count", ComparisonOperator.Lesser, "0"));
Assert.False(Check(subject, "Count", ComparisonOperator.LesserEqual, "0"));
}
// A struct that compares through CompareTo rather than a primitive operator.
[Fact]
public void NullableStructComparesThroughCompareTo()
{
var set = new Subject { Delay = TimeSpan.FromSeconds(5) };
Assert.True(Check(set, "Delay", ComparisonOperator.Equal, "00:00:05"));
Assert.True(Check(set, "Delay", ComparisonOperator.Greater, "00:00:04"));
Assert.False(Check(set, "Delay", ComparisonOperator.Lesser, "00:00:04"));
Assert.False(Check(set, "Delay", ComparisonOperator.Equal, "null"));
var unset = new Subject();
Assert.True(Check(unset, "Delay", ComparisonOperator.Equal, "null"));
Assert.False(Check(unset, "Delay", ComparisonOperator.Greater, "00:00:04"));
Assert.False(Check(unset, "Delay", ComparisonOperator.Lesser, "00:00:04"));
}
[Fact]
public void NullableEnumComparesByNameAndOrder()
{
var set = new Subject { Skill = SkillName.Magery };
Assert.True(Check(set, "Skill", ComparisonOperator.Equal, "Magery"));
Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "Anatomy"));
Assert.True(Check(set, "Skill", ComparisonOperator.Greater, "Alchemy"));
Assert.False(Check(set, "Skill", ComparisonOperator.Equal, "null"));
var unset = new Subject();
Assert.True(Check(unset, "Skill", ComparisonOperator.Equal, "null"));
Assert.False(Check(unset, "Skill", ComparisonOperator.Equal, "Magery"));
Assert.False(Check(unset, "Skill", ComparisonOperator.Greater, "Alchemy"));
}
// `sort by` on a nullable: values order by the underlying type and an unset value takes a
// consistent place at one end, the same convention a null reference already had.
[Fact]
public void SortingOnANullableOrdersValuesAndPlacesUnsetConsistently()
{
var prop = new Property("Count");
prop.BindTo(typeof(Subject), PropertyAccess.Read);
var comparer = SortCompiler.Compile<object>(typeof(Subject), [new OrderInfo(prop, true)]);
var low = new Subject { Count = 1 };
var high = new Subject { Count = 2 };
var unset = new Subject();
Assert.True(comparer.Compare(low, high) < 0);
Assert.True(comparer.Compare(high, low) > 0);
Assert.Equal(0, comparer.Compare(low, low));
var forward = comparer.Compare(high, unset);
Assert.NotEqual(0, forward);
Assert.Equal(-Math.Sign(forward), Math.Sign(comparer.Compare(unset, high)));
Assert.Equal(0, comparer.Compare(unset, new Subject()));
}
}

View file

@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands.Generic;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// `where` compiles conditions to IL. Two shapes were broken there: a property type with value
// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and
// a chained binding dereferenced every link with no null guard (so the first object with a null
// intermediate took the whole sweep down with an NRE).
[Collection("Sequential UOContent Tests")]
public class ObjectConditionalTests : IDisposable
{
private readonly List<Item> _items = [];
private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer };
public void Dispose()
{
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
_items.Clear();
_from.Delete();
}
private SkillTeleporter Teleporter(TextDefinition message)
{
var tp = new SkillTeleporter { Message = message };
_items.Add(tp);
return tp;
}
private bool Check(object target, params string[] condition)
{
var args = new string[condition.Length + 1];
args[0] = nameof(SkillTeleporter);
Array.Copy(condition, 0, args, 1, condition.Length);
return ObjectConditional.ParseDirect(_from, args, 0, args.Length).CheckCondition(target);
}
[Fact]
public void EqualityUsesValueSemanticsNotReferenceIdentity()
{
var tp = Teleporter(TextDefinition.Of(1060847));
Assert.True(Check(tp, "Message", "=", "1060847"));
}
[Fact]
public void InequalityUsesValueSemanticsNotReferenceIdentity()
{
var tp = Teleporter(TextDefinition.Of(1060847));
Assert.False(Check(tp, "Message", "!=", "1060847"));
Assert.True(Check(tp, "Message", "!=", "1234567"));
}
[Fact]
public void StringValuedDefinitionsCompareByValue()
{
var tp = Teleporter(TextDefinition.Of("Hail, traveller."));
Assert.True(Check(tp, "Message", "=", "Hail, traveller."));
Assert.False(Check(tp, "Message", "=", "Farewell."));
}
[Fact]
public void NullComparisonStillWorks()
{
Assert.True(Check(Teleporter(null), "Message", "=", "null"));
Assert.False(Check(Teleporter(TextDefinition.Of(1060847)), "Message", "=", "null"));
}
// The sweep case: [global where SkillTeleporter Message.Number = X hits teleporters whose
// Message was never set long before it hits one that matches.
[Fact]
public void ChainedBindingOnANullIntermediateIsFalseNotAnException()
{
var blank = Teleporter(null);
Assert.False(Check(blank, "Message.Number", "=", "1060847"));
}
[Fact]
public void ChainedBindingStillMatchesWhenIntermediateIsPresent()
{
var tp = Teleporter(TextDefinition.Of(1060847));
Assert.True(Check(tp, "Message.Number", "=", "1060847"));
Assert.False(Check(tp, "Message.Number", "=", "1234567"));
}
[Fact]
public void ChainedBindingOnANullIntermediateIsFalseUnderNegationToo()
{
var blank = Teleporter(null);
Assert.False(Check(blank, "not", "Message.Number", "=", "1060847"));
}
// The string operators (contains / starts / ends / =~) compile through StringCondition, which
// chains the same way ComparisonCondition does and so had the same null-intermediate crash.
[Theory]
[InlineData("contains")]
[InlineData("contains~")]
[InlineData("starts")]
[InlineData("ends")]
[InlineData("=~")]
[InlineData("!=~")]
public void StringOperatorsOnANullIntermediateAreFalseNotAnException(string oper)
{
var blank = Teleporter(null);
Assert.False(Check(blank, "Message.String", oper, "gate"));
}
[Fact]
public void StringOperatorsOnANullIntermediateAreFalseUnderNegationToo()
{
var blank = Teleporter(null);
Assert.False(Check(blank, "not", "Message.String", "contains", "gate"));
}
[Fact]
public void StringOperatorsStillMatchThroughAChain()
{
var tp = Teleporter(TextDefinition.Of("Moongate"));
Assert.True(Check(tp, "Message.String", "contains", "gate"));
Assert.True(Check(tp, "Message.String", "starts", "Moon"));
Assert.True(Check(tp, "Message.String", "ends", "gate"));
Assert.True(Check(tp, "Message.String", "=~", "moongate"));
Assert.False(Check(tp, "Message.String", "contains", "portal"));
}
// The chain resolves here -- Message is set -- but its String is null because the definition
// holds a cliloc. That is the final value, which StringCondition already guarded.
[Fact]
public void StringOperatorsHandleANullFinalValue()
{
var tp = Teleporter(TextDefinition.Of(1060847));
Assert.False(Check(tp, "Message.String", "contains", "gate"));
Assert.False(Check(tp, "Message.String", "=~", "gate"));
}
[Fact]
public void UnchainedStringOperatorsStillWork()
{
var tp = Teleporter(null);
tp.Name = "Moongate";
Assert.True(Check(tp, "Name", "contains", "gate"));
Assert.True(Check(tp, "Name", "=~", "moongate"));
Assert.False(Check(tp, "Name", "contains", "portal"));
}
// Guards for the comparison paths the equality change must not disturb. Ints, strings and
// enums are IComparable, so they route through CompareTo and never reach CompareEquality --
// these prove that routing is intact.
[Fact]
public void NumericComparisonsStillWork()
{
var tp = Teleporter(null);
tp.Hue = 42;
Assert.True(Check(tp, "Hue", "=", "42"));
Assert.False(Check(tp, "Hue", "=", "43"));
Assert.True(Check(tp, "Hue", ">", "41"));
Assert.True(Check(tp, "Hue", "<", "43"));
Assert.True(Check(tp, "Hue", "!=", "43"));
}
[Fact]
public void StringComparisonsStillWork()
{
var tp = Teleporter(null);
tp.Name = "gate";
Assert.True(Check(tp, "Name", "=", "gate"));
Assert.False(Check(tp, "Name", "=", "portal"));
}
[Fact]
public void EnumComparisonsStillWork()
{
var tp = Teleporter(null);
tp.Skill = SkillName.Magery;
Assert.True(Check(tp, "Skill", "=", "Magery"));
Assert.False(Check(tp, "Skill", "=", "Anatomy"));
}
}

View file

@ -0,0 +1,139 @@
using Server;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// Commands.Split strips quotes before any parser runs, so "0" and 0 arrive identical -- the
// markers are the only way to say which was meant. See dev-docs/generic-commands.md.
public class TextDefinitionParsingTests
{
private static TextDefinition Parse(string value)
{
Assert.Null(Types.TryParse(typeof(TextDefinition), value, out var parsed));
return parsed as TextDefinition;
}
[Theory]
[InlineData("1060847", 1060847)]
[InlineData("#1060847", 1060847)]
public void ClilocsParseToNumber(string entered, int expected)
{
var td = Parse(entered);
Assert.NotNull(td);
Assert.Equal(expected, td.Number);
Assert.Null(td.String);
}
// ToString() already emits '#', so this closes the round trip.
[Fact]
public void ClilocMarkerRoundTripsThroughToString()
{
var td = TextDefinition.Of(1060847);
Assert.Equal("#1060847", td.ToString());
Assert.Equal(td, Parse(td.ToString()));
}
[Theory]
[InlineData("hello", "hello")]
[InlineData(@"@""0""", "0")]
[InlineData(@"@""1060847""", "1060847")]
[InlineData(@"@""null""", "null")]
[InlineData(@"@""#5""", "#5")]
public void QuotedLiteralsForceAString(string entered, string expected)
{
var td = Parse(entered);
Assert.NotNull(td);
Assert.Equal(0, td.Number);
Assert.Equal(expected, td.String);
}
// Bare integers stay clilocs, so existing content is unaffected.
[Fact]
public void BareZeroStaysAnEmptyCliloc()
{
var td = Parse("0");
Assert.NotNull(td);
Assert.Equal(0, td.Number);
Assert.Null(td.String);
}
// GetValue() fills the props-gump edit box; an ambiguous string must come out quoted.
[Theory]
[InlineData("1060847")]
[InlineData("0")]
[InlineData("#5")]
public void AmbiguousStringsAreEmittedQuotedSoTheyReadBack(string text)
{
var round = Parse(TextDefinition.Of(text).GetValue());
Assert.NotNull(round);
Assert.Equal(0, round.Number);
Assert.Equal(text, round.String);
}
[Fact]
public void UnambiguousValuesAreEmittedPlain()
{
Assert.Equal("Hail, traveller.", TextDefinition.Of("Hail, traveller.").GetValue());
Assert.Equal("1060847", TextDefinition.Of(1060847).GetValue());
}
[Fact]
public void NullSentinelClearsTheValue()
{
Assert.Null(Types.TryParse(typeof(TextDefinition), "(-null-)", out var parsed));
Assert.Null(parsed);
}
}
// [get emits @"null" for the literal string "null"; [set has to decode it to round trip.
[Collection("Sequential UOContent Tests")]
public class StringEscapeRoundTripTests
{
[Fact]
public void QuotedNullSetsTheLiteralStringNull()
{
Assert.Null(Types.TryParse(typeof(string), @"@""null""", out var parsed));
Assert.Equal("null", parsed);
}
[Fact]
public void BareNullSentinelStillClearsTheValue()
{
Assert.Null(Types.TryParse(typeof(string), "(-null-)", out var parsed));
Assert.Null(parsed);
}
[Fact]
public void GetOutputPastesBackIntoSet()
{
var item = new Static(0x1F13) { Name = "null" };
try
{
var from = new Mobile { AccessLevel = AccessLevel.Developer };
var shown = Properties.GetValue(from, item, "Name");
// "Name = @"null"" -> the value half is what a GM copies.
var value = shown[(shown.IndexOf('=') + 2)..];
Assert.Equal(@"@""null""", value);
item.Name = "changed";
Properties.SetValue(from, item, "Name", value);
Assert.Equal("null", item.Name);
}
finally
{
item.Delete();
}
}
}

View file

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands;
using Server.Commands.Generic;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands;
// `where` constants resolve through Types.TryParse like every other command. Type- and
// entity-valued properties have no static Parse, so the old local parser threw on them.
[Collection("Sequential UOContent Tests")]
public class WhereConstantParsingTests : IDisposable
{
private readonly List<IEntity> _entities = [];
public void Dispose()
{
for (var i = 0; i < _entities.Count; i++)
{
_entities[i].Delete();
}
_entities.Clear();
}
public class Subject
{
[CommandProperty(AccessLevel.GameMaster)]
public Type Kind { get; set; } = typeof(Static);
[CommandProperty(AccessLevel.GameMaster)]
public Mobile Owner { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public Item Thing { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public string Label { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public TextDefinition Message { get; set; }
[CommandProperty(AccessLevel.GameMaster)]
public Map Facet { get; set; } = Map.Felucca;
[CommandProperty(AccessLevel.GameMaster)]
public int Count { get; set; } = 0x1F13;
[CommandProperty(AccessLevel.GameMaster)]
public SkillName Craft { get; set; } = SkillName.Magery;
}
private static bool Check(object target, string binding, string value,
ComparisonOperator op = ComparisonOperator.Equal)
{
var prop = new Property(binding);
prop.BindTo(typeof(Subject), PropertyAccess.Read);
var compiled = ConditionalCompiler.Compile(
typeof(Subject),
[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]
);
return compiled.Verify(target);
}
private T Track<T>(T entity) where T : IEntity
{
_entities.Add(entity);
return entity;
}
[Fact]
public void TypeValuedPropertiesResolveByName()
{
var s = new Subject();
Assert.True(Check(s, "Kind", "Static"));
Assert.False(Check(s, "Kind", "Container"));
}
[Fact]
public void EntityValuedPropertiesResolveBySerial()
{
var mob = Track(new Mobile());
var item = Track(new Static(0x1F13));
var s = new Subject { Owner = mob, Thing = item };
Assert.True(Check(s, "Owner", mob.Serial.ToString()));
Assert.True(Check(s, "Thing", item.Serial.ToString()));
Assert.False(Check(s, "Thing", (item.Serial + 1).ToString()));
}
// `where` spells null as a bare `null`, not [set's (-null-); every existing clause relies on it.
[Fact]
public void BareNullStillMeansNull()
{
var s = new Subject();
Assert.True(Check(s, "Label", "null"));
Assert.True(Check(s, "Owner", "null"));
s.Label = "set";
s.Owner = Track(new Mobile());
Assert.False(Check(s, "Label", "null"));
Assert.False(Check(s, "Owner", "null"));
}
[Fact]
public void QuotedNullStillMeansTheLiteralString()
{
var s = new Subject { Label = "null" };
Assert.True(Check(s, "Label", @"@""null"""));
Assert.False(Check(s, "Label", "null"));
}
[Theory]
[InlineData("Facet", "Felucca", true)]
[InlineData("Facet", "Trammel", false)]
[InlineData("Count", "0x1F13", true)]
[InlineData("Count", "7955", true)]
[InlineData("Count", "0x1F14", false)]
[InlineData("Craft", "Magery", true)]
[InlineData("Craft", "Anatomy", false)]
public void ExistingConstantFormsKeepWorking(string binding, string value, bool expected)
{
Assert.Equal(expected, Check(new Subject(), binding, value));
}
[Theory]
[InlineData("#1060847", true)]
[InlineData("#1060848", false)]
public void TextDefinitionMarkersReachTheWhereClause(string value, bool expected)
{
var s = new Subject { Message = TextDefinition.Of(1060847) };
Assert.Equal(expected, Check(s, "Message", value));
}
[Fact]
public void QuotedLiteralsReachTextDefinitionsInWhereToo()
{
var s = new Subject { Message = TextDefinition.Of("1060847") };
Assert.True(Check(s, "Message", @"@""1060847"""));
Assert.False(Check(s, "Message", "1060847"));
}
[Fact]
public void UnresolvableConstantsStillReportAnError()
{
Assert.Throws<InvalidOperationException>(
() => Check(new Subject(), "Kind", "NoSuchTypeAnywhere")
);
}
}

View file

@ -0,0 +1,232 @@
using System;
using Server;
using Server.Engines.AdvancedSearch;
using Xunit;
namespace UOContent.Tests;
// The Advanced Search property test compiles through the same conditions a `where` clause does.
// The grammar is the gump's own; these pin its operators and precedence, and that a leaf which
// cannot be resolved or parsed is "no match" rather than an exception on the search worker.
public class AdvancedSearchConditionsTests
{
public sealed class Inner
{
public int Value { get; set; } = 7;
}
public sealed class LegacyParseType
{
public string Value { get; private init; }
public static LegacyParseType Parse(string s) => new() { Value = s };
public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value;
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
}
public class Subject
{
public int Hue { get; set; } = 5;
public string Name { get; set; } = "Moongate";
public bool Movable { get; set; } = true;
public bool Visible { get; set; }
public double Weight { get; set; } = 0.1 + 0.2;
public float Ratio { get; set; } = 0.1f;
public TimeSpan Delay { get; set; } = TimeSpan.FromMinutes(5);
public Guid Id { get; set; } = Guid.Parse("00000000-0000-0000-0000-000000000001");
public Layer Layer { get; set; } = Layer.OneHanded;
public object Reference { get; set; } = new();
public LegacyParseType Legacy { get; set; } = LegacyParseType.Parse("alpha");
public Inner Child { get; set; } = new();
public int? Maybe { get; set; }
}
public sealed class Derived : Subject
{
}
private static bool Check(string test, Subject subject = null) =>
AdvancedSearchConditions.Compile(typeof(Subject), test)(subject ?? new Subject());
[Theory]
[InlineData("Hue=abc")] // not a number
[InlineData("Hue=99999999999")] // overflows int
[InlineData("Hue=0xZZ")] // bad hex
[InlineData("Layer=Bogus")] // not an enum member
[InlineData("Delay=notaspan")]
[InlineData("Bogus=1")] // no such property
[InlineData("Hue=")] // no value
[InlineData("Hue")] // no operator
public void UnusableLeafIsNoMatchAndDoesNotThrow(string test)
{
var ex = Record.Exception(() => Assert.False(Check(test)));
Assert.Null(ex);
}
// The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity.
[Fact]
public void UnusableLeafStaysNoMatchUnderNegation()
{
Assert.False(Check("~Hue=abc"));
Assert.False(Check("~Bogus=1"));
}
[Fact]
public void NumericOperators()
{
Assert.True(Check("Hue=5"));
Assert.True(Check("Hue==5"));
Assert.True(Check("Hue=0x5"));
Assert.False(Check("Hue!=5"));
Assert.True(Check("Hue!4"));
Assert.True(Check("Hue>4"));
Assert.True(Check("Hue<6"));
Assert.True(Check("Hue>=5"));
Assert.True(Check("Hue<=5"));
Assert.False(Check("Hue~5"));
}
[Fact]
public void NegationPrefix()
{
Assert.False(Check("~Hue=5"));
Assert.True(Check("~Hue=4"));
}
// '|' binds looser than '@'.
[Theory]
[InlineData("Movable=1", true)]
[InlineData("Visible=1", false)]
[InlineData("Visible=1@Visible=1|Movable=1", true)] // (F&&F)||T
[InlineData("Movable=1|Visible=1@Visible=1", true)] // T||(F&&F)
[InlineData("Movable=1@Visible=1", false)]
[InlineData("Movable=1@Movable=1", true)]
[InlineData("Visible=1|Visible=1", false)]
public void Precedence(string test, bool expected)
{
Assert.Equal(expected, Check(test));
}
[Theory]
[InlineData("Name=Moongate", true)]
[InlineData("Name=moongate", false)]
[InlineData("Name!Moongate", false)]
[InlineData("Name>Moon", true)]
[InlineData("Name<gate", true)]
[InlineData("Name~ong", true)]
[InlineData("Name~>moon", true)]
[InlineData("Name~<GATE", true)]
[InlineData("Name~~ONG", true)]
[InlineData("Name~=MOONGATE", true)]
[InlineData("Name~!MOONGATE", false)]
[InlineData("Name>=Moon", false)] // no such string operator
public void StringOperators(string test, bool expected)
{
Assert.Equal(expected, Check(test));
}
[Fact]
public void StringNullIsTheNullStringForEqualityAndTextOtherwise()
{
var unnamed = new Subject { Name = null };
Assert.True(Check("Name=null", unnamed));
Assert.False(Check("Name=null"));
Assert.False(Check("Name~null", unnamed));
Assert.True(Check("Name~null", new Subject { Name = "nullable" }));
}
[Fact]
public void BooleanAcceptsSwitchWordsAndOnlyEquality()
{
Assert.True(Check("Movable=true"));
Assert.True(Check("Movable=1"));
Assert.True(Check("Movable=on"));
Assert.True(Check("Movable=Enabled"));
Assert.True(Check("Visible=off"));
Assert.False(Check("Movable=maybe"));
Assert.False(Check("Movable>0"));
}
[Fact]
public void EnumIgnoresCaseAndOrdersByValue()
{
Assert.True(Check("Layer=onehanded"));
Assert.True(Check("Layer>Invalid"));
Assert.False(Check("Layer=TwoHanded"));
}
// Floating point compares to a tolerance derived from the typed value.
[Fact]
public void FloatingPointUsesEpsilon()
{
Assert.True(Check("Weight=0.3"));
Assert.False(Check("Weight=0.31"));
Assert.True(Check("Weight>0.2"));
Assert.True(Check("Weight<=0.3"));
Assert.True(Check("Ratio=0.1"));
Assert.False(Check("Ratio=0.2"));
}
[Fact]
public void TimeSpanParsesAndCompares()
{
Assert.True(Check("Delay=00:05:00"));
Assert.False(Check("Delay=00:10:00"));
Assert.True(Check("Delay>00:01:00"));
}
[Fact]
public void ValueTypeWithoutHotPathParsesThroughTypes()
{
Assert.True(Check("Id=00000000-0000-0000-0000-000000000001"));
Assert.False(Check("Id=00000000-0000-0000-0000-000000000002"));
}
// A pre-IParsable type with only a static Parse(string) is still searchable, compared against
// a real parsed instance rather than the raw text.
[Fact]
public void LegacyParseStringParsesThroughTypes()
{
Assert.True(Check("Legacy=alpha"));
Assert.False(Check("Legacy=beta"));
}
// A reference type with no CompareTo answers equality only; ordering is no match, not a throw.
[Fact]
public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow()
{
var ex = Record.Exception(() => Assert.False(Check("Reference>whatever")));
Assert.Null(ex);
}
[Fact]
public void DottedNameWalksIntoTheProperty()
{
Assert.True(Check("Child.Value=7"));
Assert.False(Check("Child.Value=8"));
Assert.False(Check("Child.Value=7", new Subject { Child = null }));
Assert.False(Check("~Child.Value=7", new Subject { Child = null }));
}
[Fact]
public void NullableCompares()
{
Assert.True(Check("Maybe=null"));
Assert.False(Check("Maybe=5"));
Assert.True(Check("Maybe=5", new Subject { Maybe = 5 }));
Assert.True(Check("Maybe>4", new Subject { Maybe = 5 }));
}
// Two runtime types resolving the same declared property share one compiled predicate.
[Fact]
public void SubclassesShareThePredicateCompiledForTheDeclaringType()
{
var cache = new AdvancedSearchConditions.Cache();
var forBase = AdvancedSearchConditions.GetPredicate(cache, typeof(Subject), "Hue=5");
var forDerived = AdvancedSearchConditions.GetPredicate(cache, typeof(Derived), "Hue=5");
Assert.Same(forBase, forDerived);
Assert.True(forDerived(new Derived()));
}
}

View file

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Engines.AdvancedSearch;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
// Every sort must give the same sequence from any arrival order: key first, then serial.
[Collection("Sequential UOContent Tests")]
public class AdvancedSearchResultOrderTests : IDisposable
{
private readonly List<Item> _items = [];
public void Dispose()
{
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
_items.Clear();
}
private AdvancedSearchResult Result(Item item, string name = "same", Map map = null)
{
_items.Add(item);
return new AdvancedSearchResult(name, item.GetType(), item.Location, map ?? Map.Felucca, null) { Entity = item };
}
private AdvancedSearchResult[] EqualKeyed()
{
var results = new AdvancedSearchResult[6];
for (var i = 0; i < results.Length; i++)
{
results[i] = Result(new Item(0x1));
}
return results;
}
private static AdvancedSearchResult[] Shuffled(AdvancedSearchResult[] inOrder, params int[] permutation)
{
var shuffled = new AdvancedSearchResult[inOrder.Length];
for (var i = 0; i < permutation.Length; i++)
{
shuffled[i] = inOrder[permutation[i]];
}
return shuffled;
}
private static void AssertSameSequenceFromAnyArrival(
AdvancedSearchResult[] inOrder,
IComparer<AdvancedSearchResult> comparer
)
{
var first = Shuffled(inOrder, 3, 0, 5, 1, 4, 2);
var second = Shuffled(inOrder, 5, 4, 3, 2, 1, 0);
Array.Sort(first, comparer);
Array.Sort(second, comparer);
Assert.Equal(first, second);
}
public static IEnumerable<object[]> Comparers()
{
yield return [AdvancedSearchResultSerialComparer.Instance];
yield return [AdvancedSearchResultTypeComparer.Instance];
yield return [AdvancedSearchResultTypeComparer.InstanceReverse];
yield return [AdvancedSearchResultNameComparer.Instance];
yield return [AdvancedSearchResultNameComparer.InstanceReverse];
yield return [AdvancedSearchResultMapComparer.Instance];
yield return [AdvancedSearchResultMapComparer.InstanceReverse];
yield return [AdvancedSearchResultSelectedComparer.Instance];
yield return [AdvancedSearchResultSelectedComparer.InstanceReverse];
}
[Theory]
[MemberData(nameof(Comparers))]
public void EqualKeysOrderBySerialFromAnyArrivalOrder(IComparer<AdvancedSearchResult> comparer)
{
var inOrder = EqualKeyed();
AssertSameSequenceFromAnyArrival(inOrder, comparer);
var sorted = Shuffled(inOrder, 3, 0, 5, 1, 4, 2);
Array.Sort(sorted, comparer);
Assert.Equal(inOrder, sorted);
}
// Off-map results compare equal on range.
[Fact]
public void RangeComparerOrdersOffMapResultsBySerial()
{
var from = new Mobile();
from.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel);
try
{
var inOrder = EqualKeyed();
AssertSameSequenceFromAnyArrival(inOrder, new AdvancedSearchRangeComparer(from));
AssertSameSequenceFromAnyArrival(inOrder, new AdvancedSearchRangeComparer(from, true));
}
finally
{
from.Delete();
}
}
[Fact]
public void ReverseFlipsTheKeyNotTheTieBreak()
{
var a1 = Result(new Item(0x1), "alpha");
var a2 = Result(new Item(0x1), "alpha");
var b1 = Result(new Item(0x1), "beta");
var results = new[] { b1, a2, a1 };
Array.Sort(results, AdvancedSearchResultNameComparer.Instance);
Assert.Equal([a1, a2, b1], results);
Array.Sort(results, AdvancedSearchResultNameComparer.InstanceReverse);
Assert.Equal([b1, a1, a2], results);
}
}

View file

@ -7,21 +7,27 @@ namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AdvancedSearchTypesTests
{
public class Subject
{
public Poison Venom { get; set; }
}
[Fact]
public void CompareValues_Poison_ReferenceTypeParsedViaTypes()
public void Poison_ReferenceTypeParsedViaTypes()
{
PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set
// Poison is a reference type implementing ISpanParsable; it can't use the compile-time span
// path and routes through the shared Server.Types converter. Poison.Parse returns the
// registered singleton, so "= Lethal" is a reference-equality match — this is the case that
// previously compared a Poison against the raw string and always failed.
var prop = Poison.Lethal;
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "="));
// Poison is a reference type implementing ISpanParsable; its value routes through the shared
// Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a
// reference-equality match — this is the case that previously compared a Poison against the
// raw string and always failed.
var subject = new Subject { Venom = Poison.Lethal };
Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject));
Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject));
var ex = Record.Exception(() =>
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "=")));
Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject)));
Assert.Null(ex);
}
}

View file

@ -1,119 +0,0 @@
using System;
using Server;
using Server.Engines.AdvancedSearch;
using Xunit;
namespace UOContent.Tests;
public class AdvancedSearchUtilitiesTests
{
[Theory]
[InlineData("abc")] // not a number -> was FormatException
[InlineData("99999999999")] // overflows int -> was OverflowException
[InlineData("0xZZ")] // bad hex -> was FormatException
public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value)
{
var ex = Record.Exception(() =>
{
var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">");
Assert.False(result);
});
Assert.Null(ex);
}
[Theory]
[InlineData("Bogus")] // not a member -> was ArgumentException
[InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException
public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value)
{
var ex = Record.Exception(() =>
{
var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "=");
Assert.False(result);
});
Assert.Null(ex);
}
[Fact]
public void CompareValues_ValidEnum_IgnoresCase()
{
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "="));
}
[Theory]
// leaf value is "T"/"F"; evalLeaf returns leaf=="T"
[InlineData("T", true)]
[InlineData("F", false)]
[InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F)
[InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F)
[InlineData("T@F", false)]
[InlineData("T@T", true)]
[InlineData("F|F", false)]
public void EvaluateBoolean_Precedence(string expr, bool expected)
{
// State is unused here; the leaf evaluator just checks the span equals "T".
var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T"));
Assert.Equal(expected, result);
}
[Fact]
public void CompareValues_ReferenceType_EqualityByString_NoThrow()
{
// A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw,
// and ordering operators must return false rather than throwing.
var ex = Record.Exception(() =>
{
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">"));
});
Assert.Null(ex);
}
[Fact]
public void CompareValues_TimeSpan_ParsesViaSpanParsable()
{
// TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently
// returned no-match. ISpanParsable<TimeSpan> parses it correctly.
var prop = TimeSpan.FromMinutes(5);
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "="));
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">"));
}
[Fact]
public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow()
{
var ex = Record.Exception(() =>
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "=")));
Assert.Null(ex);
}
[Fact]
public void CompareValues_Guid_ValueTypeParsedViaTypes()
{
// Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and
// compared by value.
var g = Guid.Parse("00000000-0000-0000-0000-000000000001");
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "="));
}
// A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface —
// the Faction/Town shape. Types must still discover its Parse by reflection.
private sealed class LegacyParseType
{
public string Value { get; private init; }
public static LegacyParseType Parse(string s) => new() { Value = s };
public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value;
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
}
[Fact]
public void CompareValues_LegacyParseString_ParsedViaTypes()
{
// Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the
// legacy Parse by reflection, so we compare against a real parsed instance, not the raw text.
var prop = LegacyParseType.Parse("alpha");
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "="));
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Server;
using Server.Engines.AdvancedSearch;
using Server.Items;
@ -48,6 +49,110 @@ public class AdvancedSearchWorkerTests
}
}
// End to end through Wake/Push/Sleep on real entities: the property test is compiled on the
// worker, memoized per type, and applied to items and mobiles alike.
[Fact]
public void Worker_PropertyTest_FiltersItemsAndMobiles()
{
var worker = new AdvancedSearchThreadWorker();
var results = new ConcurrentQueue<AdvancedSearchResult>();
var ignore = new ConcurrentQueue<IEntity>();
var filter = new AdvancedSearchFilter
{
FilterPropertyTest = true,
PropertyTest = "Hue > 0",
};
var plain = new Item(0x1);
var hued = new Item(0x1) { Hue = 42 };
var huedToo = new Gold(1) { Hue = 7 };
var mobile = new Mobile { Hue = 1002 };
try
{
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
worker.Push(plain);
worker.Push(hued);
worker.Push(huedToo);
worker.Push(mobile);
worker.Sleep();
var matched = new HashSet<IEntity>();
foreach (var r in results)
{
matched.Add(r.Entity);
}
Assert.Equal(3, matched.Count);
Assert.Contains(hued, matched);
Assert.Contains(huedToo, matched);
Assert.Contains(mobile, matched);
Assert.DoesNotContain(plain, matched);
}
finally
{
plain.Delete();
hued.Delete();
huedToo.Delete();
mobile.Delete();
worker.Exit();
}
}
// The map boxes are independent checks. Ticking several used to reject everything, because
// each ticked map was applied as "must be on this map"; an entity on any ticked map passes.
[Fact]
public void Worker_SeveralMapsTicked_MatchesAnyOfThem()
{
var worker = new AdvancedSearchThreadWorker();
var results = new ConcurrentQueue<AdvancedSearchResult>();
var ignore = new ConcurrentQueue<IEntity>();
var filter = new AdvancedSearchFilter
{
FilterFelucca = true,
FilterTrammel = true,
FilterInternalMap = true,
HideValidInternalMap = false,
};
var fel = new Item(0x1);
fel.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
var tram = new Item(0x1);
tram.MoveToWorld(new Point3D(1000, 1000, 0), Map.Trammel);
var internalItem = new Item(0x1); // starts on Map.Internal
var malas = new Item(0x1);
malas.MoveToWorld(new Point3D(1000, 1000, 0), Map.Malas);
try
{
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
worker.Push(fel);
worker.Push(tram);
worker.Push(internalItem);
worker.Push(malas);
worker.Sleep();
var matched = new HashSet<IEntity>();
foreach (var r in results)
{
matched.Add(r.Entity);
}
Assert.Contains(fel, matched);
Assert.Contains(tram, matched);
Assert.Contains(internalItem, matched);
Assert.DoesNotContain(malas, matched);
}
finally
{
fel.Delete();
tram.Delete();
internalItem.Delete();
malas.Delete();
worker.Exit();
}
}
[Fact]
public void Worker_DeletedEntity_IsSkipped()
{

View file

@ -0,0 +1,36 @@
using Server.Engines.Plants;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class PlantItemPropertyListTests
{
// The getter used to re-initialize without a Reset, appending another copy of every property
// per read. SendOPLPacketTo and SendPropertiesTo both go through it, so a pre-7.0.12 client
// looking at a plant grew the buffer without bound.
[Fact]
public void OldClientPropertyList_BuildsOnceAndIsStableAcrossReads()
{
var plant = new PlantItem();
try
{
var first = plant.OldClientPropertyList;
var length = first.Buffer.Length;
var hash = first.Hash;
var second = plant.OldClientPropertyList;
var third = plant.OldClientPropertyList;
Assert.Same(first, second);
Assert.Same(first, third);
Assert.Equal(length, third.Buffer.Length);
Assert.Equal(hash, third.Hash);
}
finally
{
plant.Delete();
}
}
}

View file

@ -11,12 +11,16 @@ public class SpawnerDiscoveryValidationTests
[JsonDiscoverableType("dup")]
private sealed record DupA : SpawnerDto
{
public override IReadOnlyList<SpawnerEntry> EntryView => null;
protected override BaseSpawner CreateEmpty() => new Spawner();
}
[JsonDiscoverableType("dup")]
private sealed record DupB : SpawnerDto
{
public override IReadOnlyList<SpawnerEntry> EntryView => null;
protected override BaseSpawner CreateEmpty() => new Spawner();
}

View file

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using Server;
using Server.Engines.Spawners;
using Server.Tests;
using Xunit;
namespace UOContent.Tests.Engines.Spawners.Json;
[Collection("Sequential UOContent Tests")]
public class SpawnerDtoEntryTests
{
[Fact]
public void Export_WritesEntriesAtSameJsonPosition_WithDisabledOnlyWhenSet()
{
var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit", "Bird");
spawner.Entries[1].Disabled = true;
var json = SpawnerJsonSerializer.SerializeCompact(new List<SpawnerDto> { spawner.ToDto() });
Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json);
Assert.Contains("{ \"name\": \"Bird\"", json);
Assert.Equal(1, CountOccurrences(json, "\"disabled\": true"));
spawner.Delete();
}
[Fact]
public void Import_AdoptsDeserializedEntryObjects()
{
var json = """
[{"$type":"Spawner","guid":"11111111-1111-1111-1111-111111111111","location":[1500,1500,0],"map":"Felucca","count":1,"minDelay":"00:05:00","maxDelay":"00:10:00","homeRange":4,"entries":[{"name":"Rabbit","probability":100,"maxCount":1,"disabled":true}]}]
""";
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
var spawner = dtos![0].ToSpawner();
Assert.Single(spawner.Entries);
Assert.True(spawner.Entries[0].Disabled);
Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]);
spawner.Delete();
}
private static int CountOccurrences(string source, string value)
{
var count = 0;
var index = 0;
while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += value.Length;
}
return count;
}
}

View file

@ -0,0 +1,243 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using Server;
using Server.Engines.Spawners;
using Server.Tests;
using Server.Text;
using Xunit;
namespace UOContent.Tests.Engines.Spawners;
[Collection("Sequential UOContent Tests")]
public class SpawnerEntryOwnershipTests
{
[Fact]
public void Disabled_DefaultsFalse_AndRoundTripsBinary()
{
var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird");
Assert.False(spawner.Entries[0].Disabled);
Assert.True(spawner.Entries[0].Enabled);
spawner.Entries[1].Disabled = true;
var bytes = SpawnerBlob.Write(spawner);
var loaded = SpawnerBlob.Read<Spawner>(bytes, (Serial)0x40001234);
Assert.Equal(2, loaded.Entries.Count);
Assert.False(loaded.Entries[0].Disabled);
Assert.True(loaded.Entries[1].Disabled);
spawner.Delete();
loaded.Delete();
}
[Fact]
public void Disabled_IsOmittedFromJsonWhenFalse_AndWrittenWhenTrue()
{
var entry = new SpawnerEntry("Rabbit");
var json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options);
Assert.DoesNotContain("disabled", json);
entry.Disabled = true;
json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options);
Assert.Contains("\"disabled\": true", json);
}
[Fact]
public void AddRemoveClear_OperateOnOwnerList()
{
var spawner = new Spawner();
Assert.Empty(spawner.Entries);
var a = spawner.AddEntry("Rabbit", 100, 2, false);
var b = spawner.AddEntry("Bird", 50, 1, false, "Hue 33", null);
Assert.Equal(2, spawner.Entries.Count);
Assert.Same(a, spawner.Entries[0]);
Assert.Equal("Hue 33", spawner.Entries[1].Properties);
spawner.RemoveEntry(a);
Assert.Single(spawner.Entries);
Assert.Same(b, spawner.Entries[0]);
spawner.RemoveAllEntries();
Assert.Empty(spawner.Entries);
spawner.Delete();
}
[Fact]
public void Start_WorksAfterStop_WhenOwnerHasEntries()
{
var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit");
Assert.True(spawner.Running);
spawner.Stop();
Assert.False(spawner.Running);
spawner.Start();
Assert.True(spawner.Running);
spawner.Delete();
}
[Fact]
public void Dupe_ClonesEntriesIntoIndependentList()
{
var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird");
source.Entries[1].Disabled = true;
var copy = new Spawner();
source.Dupe(copy);
Assert.Equal(2, copy.Entries.Count);
Assert.NotSame(source.Entries[0], copy.Entries[0]);
Assert.Equal("Bird", copy.Entries[1].SpawnedName);
Assert.True(copy.Entries[1].Disabled);
Assert.NotEqual(source.Guid, copy.Guid);
source.AddEntry("Orc", 100, 1, false);
Assert.Equal(2, copy.Entries.Count);
source.Delete();
copy.Delete();
}
[Fact]
public void CopyEntriesTo_ReplacesTargetEntries()
{
var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit");
var target = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Orc", "Troll");
source.CopyEntriesTo(target);
Assert.Single(target.Entries);
Assert.Equal("Rabbit", target.Entries[0].SpawnedName);
Assert.NotSame(source.Entries[0], target.Entries[0]);
source.Delete();
target.Delete();
}
[Fact]
public void CopyEntriesTo_Self_IsNoOp()
{
var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird");
var first = spawner.Entries[0];
var second = spawner.Entries[1];
spawner.CopyEntriesTo(spawner);
Assert.Equal(2, spawner.Entries.Count);
Assert.Same(first, spawner.Entries[0]);
Assert.Same(second, spawner.Entries[1]);
spawner.Delete();
}
[Fact]
public void RemoveEntry_ForeignEntry_IsIgnored()
{
var a = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit");
var b = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Bird");
var foreign = b.Entries[0];
var spawned = new Item(0x1f13);
foreign.AddToSpawned(spawned);
a.RemoveEntry(foreign);
Assert.Single(a.Entries);
Assert.Equal("Rabbit", a.Entries[0].SpawnedName);
Assert.Single(b.Entries);
Assert.Same(foreign, b.Entries[0]);
Assert.False(spawned.Deleted);
Assert.Single(foreign.Spawned);
spawned.Delete();
a.Delete();
b.Delete();
}
// Manual: remove the Skip, run, and read the numbers from the thrown assertion.
[Fact(Skip = "manual benchmark")]
public void Benchmark_SpawnPath_Manual()
{
const int iterations = 100_000;
const int warmup = 1_000;
var report = new ValueStringBuilder(stackalloc char[512]);
report.Append('\n', 1);
foreach (var entryCount in new[] { 1, 10, 50 })
{
var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10));
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
// SpawnedMaxCount = 0 keeps every entry full, so Spawn() does selection only.
for (var i = 0; i < entryCount; i++)
{
spawner.AddEntry("Rabbit", 100, 0, false);
}
for (var i = 0; i < warmup; i++)
{
spawner.Spawn();
}
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
spawner.Spawn();
}
sw.Stop();
var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations;
report.Append(
$"Spawn() entries={entryCount,2}: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n"
);
spawner.Delete();
}
{
var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10));
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
for (var i = 0; i < 10; i++)
{
spawner.AddEntry("Rabbit", 100, 1, false);
}
spawner.Spawn();
Assert.Single(spawner.Spawned);
var rabbit = spawner.Spawned.Keys.First();
for (var i = 0; i < warmup; i++)
{
spawner.Remove(rabbit);
}
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
spawner.Remove(rabbit);
}
sw.Stop();
var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations;
report.Append(
$"Remove(ISpawnable) entries=10: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n"
);
(rabbit as Mobile)?.Delete();
spawner.Delete();
}
var summary = report.ToString();
report.Dispose();
throw new Xunit.Sdk.XunitException(summary);
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Reflection;
using Server.Engines.Spawners;
using Server.Tests;
using Xunit;
namespace UOContent.Tests.Engines.Spawners;
[Collection("Sequential UOContent Tests")]
public class SpawnerFixtureCapture
{
// Freezes the BaseSpawner v12 save layout as test input; refuses to run against newer code.
[SkippableFact]
public void CaptureLegacyBlobs()
{
Skip.If(Environment.GetEnvironmentVariable("MODERNUO_CAPTURE_SPAWNER_FIXTURES") != "1");
var version = (int)typeof(BaseSpawner)
.GetField("SerializationVersion", BindingFlags.NonPublic | BindingFlags.Static)!
.GetRawConstantValue()!;
Assert.True(version == 12, $"Fixtures must be captured with BaseSpawner v12; current version is {version}.");
var dir = Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures");
Directory.CreateDirectory(dir);
var spawner = new Spawner(2, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird")
{
Name = "FixtureSpawner"
};
spawner.Entries[1].Properties = "Hue 33";
File.WriteAllBytes(Path.Combine(dir, "spawner.v12-v1.bin"), SpawnerBlob.Write(spawner));
var proximity = new ProximitySpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, 5, "boo", true, "Rat", "Bird")
{
Name = "FixtureProximity"
};
File.WriteAllBytes(Path.Combine(dir, "proximity.v12-v1-v0.bin"), SpawnerBlob.Write(proximity));
var region = new RegionSpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, "Orc", "Troll")
{
Name = "FixtureRegion"
};
File.WriteAllBytes(Path.Combine(dir, "region.v12-v1-v0.bin"), SpawnerBlob.Write(region));
spawner.Delete();
proximity.Delete();
region.Delete();
}
}

View file

@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.Serialization;
using Server;
using Server.Engines.Spawners;
using Server.Mobiles;
using Server.Tests;
using Xunit;
namespace UOContent.Tests.Engines.Spawners;
[SerializationGenerator(0)]
public partial class TestEntry : SpawnerEntry
{
// Dirty tracking is resolved on the declared type, so a derived entry re-declares its owner.
[DirtyTrackingEntity]
private BaseSpawner Owner => Parent;
[SerializableField(0)]
private string _tag;
public TestEntry(BaseSpawner parent) : base(parent)
{
}
public TestEntry(BaseSpawner parent, string name, int probability, int maxCount, string properties, string parameters)
: base(parent, name, probability, maxCount, properties, parameters)
{
}
}
[SerializationGenerator(0)]
public partial class HookRecordingSpawner : Spawner
{
[SerializedIgnoreDupe]
[SerializableField(0)]
private List<TestEntry> _testEntries;
public List<string> Log { get; } = [];
public bool VetoNext { get; set; }
public HookRecordingSpawner()
{
}
public HookRecordingSpawner(Serial serial) : base(serial)
{
}
public override IReadOnlyList<SpawnerEntry> Entries => _testEntries ?? (IReadOnlyList<SpawnerEntry>)Array.Empty<SpawnerEntry>();
protected override ReadOnlySpan<SpawnerEntry> EntrySpan =>
ReadOnlySpan<SpawnerEntry>.CastUp(CollectionsMarshal.AsSpan(_testEntries));
protected override SpawnerEntry CreateEntry(string name, int probability, int maxCount, string properties, string parameters) =>
new TestEntry(this, name, probability, maxCount, properties, parameters) { Tag = "made" };
protected override void AddEntryCore(SpawnerEntry entry)
{
TestEntries ??= [];
AddToTestEntries((TestEntry)entry);
}
protected override bool RemoveEntryCore(SpawnerEntry entry)
{
if (entry is not TestEntry te || _testEntries?.Contains(te) != true)
{
return false;
}
RemoveFromTestEntries(te);
return true;
}
protected override void ClearEntriesCore()
{
if (_testEntries?.Count > 0)
{
ClearTestEntries();
}
}
protected override void AdoptEntries(IReadOnlyList<SpawnerEntry> entries)
{
ClearEntriesCore();
for (var i = 0; i < entries.Count; i++)
{
var e = entries[i];
TestEntry te;
if (e is TestEntry existing)
{
te = existing;
}
else
{
te = (TestEntry)CloneEntry(e);
TransferSpawned(e, te);
}
te.SetParent(this);
AddEntryCore(te);
}
}
/// <summary>Test hook: adopt entries built elsewhere, exercising the conversion path.</summary>
public void AdoptForTest(IReadOnlyList<SpawnerEntry> entries) => AdoptEntries(entries);
/// <summary>Test hook: rebuild the Spawned registry without a full save round trip.</summary>
public void RebuildSpawnedForTest() => RebuildSpawned();
// Spawner's rebuild runs before _testEntries is read; rebuild again here.
[AfterDeserialization]
private void AfterDeserialization() => RebuildSpawned();
protected override SpawnerEntry CloneEntry(SpawnerEntry source)
{
var clone = (TestEntry)base.CloneEntry(source);
clone.Tag = (source as TestEntry)?.Tag ?? clone.Tag;
return clone;
}
protected override void OnStarted() => Log.Add("started");
protected override void OnStopped() => Log.Add("stopped");
protected override bool OnBeforeSpawn(SpawnerEntry entry)
{
Log.Add($"before:{entry.SpawnedName}");
if (VetoNext)
{
VetoNext = false;
return false;
}
return true;
}
protected override void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"configure:{entry.SpawnedName}");
protected override Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map)
{
Log.Add($"position:{entry.SpawnedName}");
return Location;
}
protected override void OnSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"spawned:{entry.SpawnedName}");
protected override void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) =>
Log.Add($"death:{entry.SpawnedName}:{killer?.Name ?? "none"}");
}
[Collection("Sequential UOContent Tests")]
public class SpawnerHookTests
{
private static HookRecordingSpawner Place()
{
var spawner = new HookRecordingSpawner();
spawner.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10));
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
return spawner;
}
[Fact]
public void Hooks_FireInOrder_OnSpawnAndStartStop()
{
var spawner = Place();
spawner.AddEntry("Rabbit", 100, 1, false);
spawner.Log.Clear();
spawner.Stop();
spawner.Start();
spawner.Spawn();
Assert.Equal(
["stopped", "started", "before:Rabbit", "configure:Rabbit", "position:Rabbit", "spawned:Rabbit"],
spawner.Log
);
Assert.Single(spawner.Spawned);
spawner.Delete();
}
[Fact]
public void Hooks_FireForItemEntries()
{
var spawner = Place();
spawner.AddEntry("Gold", 100, 1, false);
spawner.Log.Clear();
spawner.Spawn();
Assert.Equal(
["before:Gold", "configure:Gold", "position:Gold", "spawned:Gold"],
spawner.Log
);
Assert.IsAssignableFrom<Item>(Assert.Single(spawner.Spawned).Key);
spawner.Delete();
}
[Fact]
public void NextSpawn_OnStoppedSpawner_FiresOnStarted()
{
var spawner = Place();
spawner.AddEntry("Rabbit", 100, 1, false);
spawner.Stop();
spawner.Log.Clear();
spawner.NextSpawn = TimeSpan.FromSeconds(5);
Assert.True(spawner.Running);
Assert.Equal(["started"], spawner.Log);
spawner.Delete();
}
[Fact]
public void OnBeforeSpawn_CanVeto()
{
var spawner = Place();
spawner.AddEntry("Rabbit", 100, 1, false);
spawner.VetoNext = true;
spawner.Spawn();
Assert.Empty(spawner.Spawned);
Assert.Contains("before:Rabbit", spawner.Log);
Assert.DoesNotContain("spawned:Rabbit", spawner.Log);
spawner.Delete();
}
[Fact]
public void Death_NotifiesOwningSpawner_BeforeUnlink()
{
var spawner = Place();
spawner.AddEntry("Rabbit", 100, 1, false);
spawner.Spawn();
var rabbit = Assert.Single(spawner.Spawned).Key as BaseCreature;
Assert.NotNull(rabbit);
var killer = new PlayerMobile { Name = "Hunter" };
rabbit.LastKiller = killer;
rabbit.Kill();
Assert.Contains("death:Rabbit:Hunter", spawner.Log);
killer.Delete();
spawner.Delete();
}
[Fact]
public void OwnEntryType_SurvivesBinaryRoundTripAndDupe()
{
var spawner = Place();
spawner.AddEntry("Rabbit", 100, 1, false);
((TestEntry)spawner.Entries[0]).Tag = "kept";
spawner.Spawn();
Assert.Single(spawner.Spawned);
var loaded = SpawnerBlob.Read<HookRecordingSpawner>(SpawnerBlob.Write(spawner), (Serial)0x40009999);
Assert.Equal("kept", ((TestEntry)loaded.Entries[0]).Tag);
Assert.Single(loaded.Entries[0].Spawned);
Assert.Single(loaded.Spawned);
var copy = new HookRecordingSpawner();
spawner.Dupe(copy);
Assert.Equal("kept", ((TestEntry)copy.Entries[0]).Tag);
Assert.Empty(copy.Entries[0].Spawned);
spawner.Delete();
loaded.Delete();
copy.Delete();
}
[Fact]
public void AdoptEntries_ConvertsForeignEntries_KeepingLiveSpawns()
{
var source = new Spawner();
source.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10));
source.MoveToWorld(new Point3D(1502, 1502, 0), Map.Felucca);
source.AddEntry("Rabbit", 100, 1, false);
source.Spawn();
var rabbit = Assert.Single(source.Spawned).Key;
var adopter = Place();
adopter.AdoptForTest(source.Entries);
var adopted = Assert.Single(adopter.Entries);
Assert.IsType<TestEntry>(adopted);
Assert.Equal("Rabbit", adopted.SpawnedName);
Assert.Same(rabbit, Assert.Single(adopted.Spawned));
Assert.Empty(source.Entries[0].Spawned);
adopter.RebuildSpawnedForTest();
Assert.Same(rabbit, Assert.Single(adopter.Spawned).Key);
source.Delete();
Assert.False(rabbit.Deleted);
adopter.Delete();
Assert.True(rabbit.Deleted);
}
}

View file

@ -0,0 +1,90 @@
using System;
using System.IO;
using Server;
using Server.Engines.Spawners;
using Server.Items;
using Server.Tests;
using Xunit;
namespace UOContent.Tests.Engines.Spawners;
internal static class SpawnerBlob
{
public static byte[] Write(Item item)
{
var writer = new BufferWriter(true);
item.Serialize(writer);
return writer.Buffer[..(int)writer.Position];
}
public static T Read<T>(byte[] bytes, Serial serial) where T : Item
{
var item = (T)Activator.CreateInstance(typeof(T), serial)!;
var reader = new BufferReader(bytes);
item.Deserialize(reader);
return item;
}
}
[Collection("Sequential UOContent Tests")]
public class SpawnerSaveMigrationTests
{
private static byte[] Fixture(string name) =>
File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures", name));
[Fact]
public void V12Spawner_EntriesAreAdoptedByOwner()
{
var loaded = SpawnerBlob.Read<Spawner>(Fixture("spawner.v12-v1.bin"), (Serial)0x40001001);
Assert.Equal("FixtureSpawner", loaded.Name);
Assert.Equal(2, loaded.Entries.Count);
Assert.Equal("Rabbit", loaded.Entries[0].SpawnedName);
Assert.Equal("Hue 33", loaded.Entries[1].Properties);
Assert.False(loaded.Entries[0].Disabled);
Assert.NotNull(loaded.Spawned);
Assert.Empty(loaded.Spawned);
loaded.Delete();
}
[Fact]
public void V12ProximityAndRegion_EntriesAreAdoptedThroughSubclasses()
{
var prox = SpawnerBlob.Read<ProximitySpawner>(Fixture("proximity.v12-v1-v0.bin"), (Serial)0x40001002);
Assert.Equal(2, prox.Entries.Count);
Assert.Equal("Rat", prox.Entries[0].SpawnedName);
Assert.Equal("Bird", prox.Entries[1].SpawnedName);
Assert.Equal(5, prox.TriggerRange);
Assert.True(prox.InstantFlag);
var region = SpawnerBlob.Read<RegionSpawner>(Fixture("region.v12-v1-v0.bin"), (Serial)0x40001003);
Assert.Equal(2, region.Entries.Count);
Assert.Equal("Orc", region.Entries[0].SpawnedName);
Assert.Equal("Troll", region.Entries[1].SpawnedName);
prox.Delete();
region.Delete();
}
[Fact]
public void NewFormat_RoundTripsByteIdentical_WithLiveSpawnReferences()
{
var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
spawner.Spawn();
Assert.Single(spawner.Spawned);
var first = SpawnerBlob.Write(spawner);
var loaded = SpawnerBlob.Read<Spawner>(first, (Serial)0x40001004);
Assert.Single(loaded.Entries);
Assert.Single(loaded.Entries[0].Spawned);
Assert.Single(loaded.Spawned);
var second = SpawnerBlob.Write(loaded);
Assert.Equal(first, second);
loaded.Delete();
spawner.Delete();
}
}

View file

@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using Server;
using Server.Gumps;
using Server.Items;
using Server.Network;
using Server.Tests.Network;
using Xunit;
namespace UOContent.Tests.Gumps;
// TextDefinition carries [PropertyObject], which the props gump checks before its parsable
// fallback -- so the row needs an explicit branch or it drills into a read-only dead end.
[Collection("Sequential UOContent Tests")]
public class PropsGumpTextDefinitionTests : IDisposable
{
// Mirrors PropertiesGump.MaxEntriesPerPage, which is private.
private const int MaxEntriesPerPage = 15;
private readonly List<Item> _items = [];
private readonly List<Mobile> _mobiles = [];
private readonly List<NetState> _states = [];
public void Dispose()
{
for (var i = 0; i < _mobiles.Count; i++)
{
_mobiles[i].NetState = null;
_mobiles[i].Delete();
}
for (var i = 0; i < _items.Count; i++)
{
_items[i].Delete();
}
for (var i = 0; i < _states.Count; i++)
{
_states[i].Dispose();
}
_mobiles.Clear();
_items.Clear();
_states.Clear();
}
private (Mobile From, NetState State) CreateStaff()
{
var ns = PacketTestUtilities.CreateTestNetState();
_states.Add(ns);
var from = new Mobile { AccessLevel = AccessLevel.GameMaster };
from.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
_mobiles.Add(from);
from.NetState = ns;
ns.Mobile = from;
return (from, ns);
}
private SkillTeleporter CreateTeleporter(TextDefinition message)
{
var tp = new SkillTeleporter { Message = message };
tp.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
_items.Add(tp);
return tp;
}
// Opens the props gump on whichever page holds `propName` and presses that row's gold '>'.
private static void PressPropertyButton(Mobile from, NetState ns, object o, string propName)
{
var probe = new TestPropsGump(from, o);
var list = probe.List;
var index = -1;
for (var i = 0; i < list.Count; i++)
{
if (list[i] is PropertyInfo p && p.Name == propName)
{
index = i;
break;
}
}
Assert.True(index >= 0, $"{o.GetType().Name} has no visible '{propName}' property row.");
var page = index / MaxEntriesPerPage;
var buttonId = index - page * MaxEntriesPerPage + 3;
var gump = new TestPropsGump(from, o, list, page);
gump.OnResponse(ns, EmptyInfo(buttonId));
}
private static RelayInfo EmptyInfo(int buttonId) =>
new(buttonId, ReadOnlySpan<int>.Empty, ReadOnlySpan<ushort>.Empty, ReadOnlySpan<Range>.Empty, ReadOnlySpan<byte>.Empty);
private static RelayInfo TextInfo(int buttonId, int entryId, string text)
{
var block = Encoding.BigEndianUnicode.GetBytes(text);
var ids = new[] { (ushort)entryId };
var ranges = new[] { new Range(0, block.Length) };
return new RelayInfo(buttonId, ReadOnlySpan<int>.Empty, ids, ranges, block);
}
[Fact]
public void PressingSetOnAPopulatedTextDefinitionOpensTheEditor()
{
var (from, ns) = CreateStaff();
var tp = CreateTeleporter(TextDefinition.Of(1060847));
PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message));
Assert.NotNull(ns.FindGump<SetGump>());
}
// With no object to drill into, the old path re-sent the same page and looked inert.
[Fact]
public void PressingSetOnANullTextDefinitionOpensTheEditor()
{
var (from, ns) = CreateStaff();
var tp = CreateTeleporter(null);
PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message));
Assert.NotNull(ns.FindGump<SetGump>());
}
// Numeric input always wins, so "0" is cliloc 0 (empty), never the string "0".
[Theory]
[InlineData("1060847", 1060847, null)]
[InlineData("0", 0, null)]
[InlineData("Hail, traveller.", 0, "Hail, traveller.")]
public void EditorRoundTripsClilocAndString(string entered, int expectedNumber, string expectedString)
{
var (from, ns) = CreateStaff();
var tp = CreateTeleporter(null);
PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message));
var setGump = ns.FindGump<SetGump>();
Assert.NotNull(setGump);
setGump.OnResponse(ns, TextInfo(1, 0, entered));
Assert.NotNull(tp.Message);
Assert.Equal(expectedNumber, tp.Message.Number);
Assert.Equal(expectedString, tp.Message.String);
}
// The edit box is seeded from GetValue(), so a cliloc-looking string must survive the trip.
[Fact]
public void EditorPreservesAStringThatLooksLikeACliloc()
{
var (from, ns) = CreateStaff();
var tp = CreateTeleporter(TextDefinition.Of("1060847"));
PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message));
var setGump = ns.FindGump<SetGump>();
Assert.NotNull(setGump);
setGump.OnResponse(ns, TextInfo(1, 0, tp.Message.GetValue()));
Assert.NotNull(tp.Message);
Assert.Equal(0, tp.Message.Number);
Assert.Equal("1060847", tp.Message.String);
}
[Fact]
public void EditorNullButtonClearsTheValue()
{
var (from, ns) = CreateStaff();
var tp = CreateTeleporter(TextDefinition.Of("Hail, traveller."));
PressPropertyButton(from, ns, tp, nameof(SkillTeleporter.Message));
var setGump = ns.FindGump<SetGump>();
Assert.NotNull(setGump);
setGump.OnResponse(ns, EmptyInfo(2));
Assert.Null(tp.Message);
}
// Exposes the protected list/page plumbing so a test can aim at a specific property row.
private sealed class TestPropsGump : PropertiesGump
{
public TestPropsGump(Mobile m, object o) : base(m, o)
{
}
public TestPropsGump(Mobile m, object o, List<object> list, int page) : base(m, o, null, list, page)
{
}
public List<object> List => m_List;
}
}

View file

@ -0,0 +1,61 @@
using System;
using System.IO;
using Server;
using Server.Guilds;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class GuildSerializePurityTests
{
private static PlayerMobile CreatePlayer()
{
var m = new PlayerMobile(World.NewMobile);
m.DefaultMobileInit();
m.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
return m;
}
[Fact]
public void SerializeDoesNotRecalculateFealtyOrMutateState()
{
var leader = CreatePlayer();
var guild = new Guild(leader, "Purity Test Guild", "PTG");
var staleFealty = Core.Now - TimeSpan.FromDays(3);
guild.LastFealty = staleFealty;
var writer = new BufferWriter(true);
guild.Serialize(writer);
Assert.Equal(staleFealty, guild.LastFealty);
Assert.Same(leader, guild.Leader);
var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
writer.Seek(0, SeekOrigin.Begin);
guild.Serialize(writer);
var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
Assert.Equal(first, second);
writer.Close();
guild.Disband();
leader.Delete();
}
[Fact]
public void RunMaintenanceRecalculatesStaleFealty()
{
var leader = CreatePlayer();
var guild = new Guild(leader, "Maintenance Test Guild", "MTG");
guild.LastFealty = Core.Now - TimeSpan.FromDays(3);
guild.RunMaintenance();
Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1));
guild.Disband();
leader.Delete();
}
}

View file

@ -0,0 +1,33 @@
using Server;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
[Collection("Sequential UOContent Tests")]
public class AIDeactivationTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Deactivate_WithoutWorldMap_StopsTimer(bool internalMap, bool controlled)
{
var creature = new PetTestStub();
try
{
creature.Controlled = controlled;
creature.Map = internalMap ? Map.Internal : null;
creature.AIObject.Activate();
Assert.True(creature.AIObject.AITimer.Running);
creature.AIObject.Deactivate();
Assert.False(creature.AIObject.AITimer.Running);
}
finally
{
creature.Delete();
}
}
}

View file

@ -45,8 +45,9 @@ public class GuardFollowTests
// Without a move intent, guard-following only steps on the think grid.
Assert.True(hasIntent, "guard-following must register a move intent");
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
Assert.Equal(0.1, currentSpeed);
// The return is paced by FollowMoveSpeed; the think clock stays on the active value
// and the per-step speed flip must not undo either.
Assert.Equal(0.2, currentSpeed);
Assert.Equal(0.1, currentMoveSpeed);
}

View file

@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable
{
var bc = NewCreature();
Assert.Equal(0.3, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
// 0 = no override; the resolved pace comes from CurrentMoveSpeed.
Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0, bc.PassiveMoveSpeed);
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
}
@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable
bc.SetSpeed(0.2, 0.4);
Assert.Equal(0.2, bc.ActiveMoveSpeed);
Assert.Equal(0.4, bc.PassiveMoveSpeed);
Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Fact]
@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable
bc.ActiveMoveSpeed = 0;
Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
bc.SetCurrentSpeedToActive();
Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock
}
[Fact]
@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable
bc.ScaleMoveSpeed(1.0 / 1.2);
Assert.Equal(0.5, bc.ActiveMoveSpeed);
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
}
[Fact]
@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable
bc.MigrateMoveSpeeds();
Assert.Equal(0.35, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock
Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Theory]
@ -211,9 +214,9 @@ public class MoveSpeedTests : IDisposable
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
// The v22 tail is the last block; exact consumption catches any offset mistake.
// The BaseCreature tail is the last block; exact consumption catches any offset mistake.
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed);
Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed);
Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed);
Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed);
}
}

View file

@ -67,6 +67,59 @@ public class PetOrderTests : IDisposable
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
}
[Fact]
public void Stop_WhileAttacking_ResumedFollowTargetsTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; // persistent = Follow
pet.ControlOrder = OrderType.Attack; // transient
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
pet.ControlOrder = OrderType.Stop;
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Equal(master, pet.ControlTarget);
}
[Fact]
public void Stop_WhileAttacking_ResumedFollowKeepsAPetFriendAsItsTarget()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = new PlayerMobile(World.NewMobile);
friend.DefaultMobileInit();
friend.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map);
_created.Add(friend);
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlTarget = friend; // a pet friend said "all follow me"
pet.ControlOrder = OrderType.Follow;
pet.ControlTarget = victim; // then "all kill" — the attack order takes the target
pet.ControlOrder = OrderType.Attack;
pet.ControlOrder = OrderType.Stop;
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Equal(friend, pet.ControlTarget); // still the friend, not the owner
}
[Fact]
public void Stop_WhileAttacking_ResumedFollowSurvivesTheNextThink()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
pet.ControlOrder = OrderType.Attack;
pet.ControlOrder = OrderType.Stop; // resumes Follow
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder); // not dropped to idle
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
}
[Fact]
public void Stop_WhileFollowing_CancelsToIdleNone()
{
@ -176,6 +229,38 @@ public class PetOrderTests : IDisposable
Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts
}
[Fact]
public void ReleaseOrder_ClearsTheMaster_AndStartsTheDeleteCountdown()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IsBonded = true;
var followers = master.Followers;
pet.ControlOrder = OrderType.Release;
Assert.False(pet.Controlled);
Assert.Null(pet.ControlMaster);
Assert.False(pet.IsBonded);
Assert.Equal(followers - pet.ControlSlots, master.Followers);
Assert.True(pet.PendingDeleteTimer?.Running);
Assert.Equal(pet.Location, pet.Home);
}
[Fact]
public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var followers = master.Followers;
// What the loyalty drain assigns when loyalty reaches zero.
pet.ControlOrder = OrderType.Release;
Assert.False(pet.Controlled);
Assert.Null(pet.ControlMaster);
Assert.Equal(followers - pet.ControlSlots, master.Followers);
Assert.True(pet.PendingDeleteTimer?.Running);
}
[Fact]
public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation()
{
@ -185,7 +270,7 @@ public class PetOrderTests : IDisposable
pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor
pet.Spawner = null;
pet.AIObject.DoOrderRelease();
pet.ControlOrder = OrderType.Release;
Assert.Equal(loc, pet.Home); // released where it stands, not the stale point
}
@ -211,6 +296,67 @@ public class PetOrderTests : IDisposable
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
}
[Fact]
public void Login_RestoredStay_KeepsItsPostAnchor()
{
var post = new Point3D(1005, 1005, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // Home = post
pet.ChangeAIType(pet.AI); // what AfterDeserialization does: fresh AI, PersistentOrder = None
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
PetLoginHandler.DeriveFollowerOrders(master); // master within 12 tiles
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(post, pet.Home); // not zeroed by a proximity-derived Follow
}
[Fact]
public void Login_RestoredNone_NearMaster_IssuesFollow()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
pet.ControlOrder = OrderType.Stop; // -> None, no standing order
pet.ChangeAIType(pet.AI);
master.Hidden = true;
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
Assert.True(master.Hidden); // system-issued: nobody revealed
}
[Fact]
public void Login_RestoredAttack_FarFromMaster_IssuesStay()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0));
pet.ControlOrder = OrderType.Attack; // rests with no valid target
pet.ChangeAIType(pet.AI);
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(pet.Location, pet.Home);
}
[Fact]
public void Login_RestoredAttack_NearMaster_FollowsTheMaster_NotTheVictim()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim); // saved mid-fight: ControlTarget = victim
pet.ChangeAIType(pet.AI); // post-load fresh AI
PetLoginHandler.DeriveFollowerOrders(master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
}
[Fact]
public void Stop_WhileFollowing_CancelsToIdle_NonML()
{
@ -233,4 +379,665 @@ public class PetOrderTests : IDisposable
Core.Expansion = previous;
}
}
// The parameterless ctor fully initializes a player; the Serial ctor leaves that to Deserialize.
private PlayerMobile SpawnPlayer(Point3D loc)
{
var pm = new PlayerMobile { Player = true };
pm.MoveToWorld(loc, Map.Felucca);
_created.Add(pm);
return pm;
}
// Administrative commands are not a change of what the pet is doing: it keeps fighting.
[Fact]
public void Drop_MidAttack_KeepsTheAttackAndItsTarget()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow; // standing order
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
Assert.Same(victim, pet.Combatant);
}
[Fact]
public void Rename_MidAttack_KeepsTheAttack()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Rename, master);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
}
[Fact]
public void FriendRefusal_MidAttack_KeepsTheAttack()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend); // already a friend -> refusal
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
pet.IssueOrder(OrderType.Friend, master, friend);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
}
// Nothing to resume into: the interrupted attack's target is gone.
[Fact]
public void Drop_MidAttack_WithTheTargetGone_FallsBackToTheStandingOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
victim.Delete();
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
// Resuming an attack must not re-run the aggression that ordering it performed.
[Fact]
public void Drop_MidAttack_DoesNotRepeatTheHarm()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = new PetTestStub();
victim.MoveToWorld(new Point3D(1003, 1000, 0), pet.Map);
_created.Add(victim);
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Attack, master, victim);
var aggressors = victim.Aggressors.Count;
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(aggressors, victim.Aggressors.Count);
}
[Fact]
public void Friend_Refused_RestsAtPersistentOrder_AndObeyDoesNotRepeat()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow; // persistent = Follow
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend); // already a friend -> refusal 1049691
pet.IssueOrder(OrderType.Friend, pet.ControlMaster, friend);
Assert.Equal(OrderType.Follow, pet.ControlOrder); // never rests at Friend
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
// Obey must not repeat the refusal.
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void Unfriend_OfNonFriend_RestsAtPersistentOrder()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Stay; // persistent = Stay
var stranger = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Unfriend, pet.ControlMaster, stranger);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
}
[Fact]
public void Friend_Accepted_LeavesTheStandingOrderAlone()
{
var post = new Point3D(1001, 1000, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // the owner's standing order, anchored at the post
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Friend, master, friend);
Assert.True(pet.IsPetFriend(friend));
Assert.Equal(OrderType.Stay, pet.ControlOrder); // still staying
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder); // owner's order not rewritten
Assert.Equal(post, pet.Home); // and not re-anchored
}
[Fact]
public void Unfriend_Accepted_LeavesTheStandingOrderAlone()
{
var post = new Point3D(1001, 1000, 0);
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), post);
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Stay;
pet.IssueOrder(OrderType.Unfriend, master, friend);
Assert.False(pet.IsPetFriend(friend));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(post, pet.Home);
}
[Fact]
public void Rename_RestsAtARestableOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
pet.IssueOrder(OrderType.Rename, master);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
}
[Fact]
public void Drop_OnAPetThatCannotDrop_StillResolves()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Stay;
pet.IsDeadPet = true; // refuses to drop
pet.IssueOrder(OrderType.Drop, master);
Assert.Equal(OrderType.Stay, pet.ControlOrder);
}
[Fact]
public void Stop_ResolvesToARestableOrder_FromEveryPrevious()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
OrderType[] previousOrders = [OrderType.Come, OrderType.Attack, OrderType.Guard, OrderType.Follow, OrderType.Stay, OrderType.None];
for (var i = 0; i < previousOrders.Length; i++)
{
pet.ControlOrder = previousOrders[i];
pet.ControlOrder = OrderType.Stop;
Assert.True(BaseAI.IsRestableOrder(pet.ControlOrder));
Assert.NotEqual(OrderType.Stop, pet.ControlOrder);
}
}
[Fact]
public void IssueOrder_RevealsTheIssuer_NeverTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
pet.IssueOrder(OrderType.Stay, friend);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void SystemIssuedOrder_RevealsNobody()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
master.Hidden = true;
pet.ControlOrder = OrderType.Follow; // raw assignment = system-issued
Assert.True(master.Hidden);
}
[Fact]
public void EndPickTarget_Attack_SetsCombatantAndFocus()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.AIObject.EndPickTarget(master, victim, OrderType.Attack);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(victim, pet.ControlTarget);
Assert.Same(victim, pet.Combatant);
Assert.Same(victim, pet.FocusMob);
Assert.True(pet.Warmode);
Assert.Equal(1, pet.CombatantSets); // the Issue phase is the only writer
pet.AIObject.Obey(); // the tick does not rewrite it
Assert.Equal(1, pet.CombatantSets);
}
[Fact]
public void ReIssuedAttack_OnTheSameTarget_DoesNotRewriteCombatantOrFlapWarmode()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim);
Assert.Equal(1, pet.CombatantSets);
Assert.True(pet.Warmode);
// Dropping Warmode would null Combatant and make the re-issue replay DoHarmful.
pet.IssueOrder(OrderType.Attack, master, victim);
Assert.Equal(1, pet.CombatantSets);
Assert.True(pet.Warmode);
Assert.Same(victim, pet.Combatant);
Assert.Same(victim, pet.FocusMob);
}
[Fact]
public void OrderedAttack_ReassertsTheCommandedTarget_AfterAnAggressorStealsCombatant()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var victim = SpawnPlayer(new Point3D(1003, 1000, 0));
var other = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.IssueOrder(OrderType.Attack, master, victim);
// what OnAggressiveAction does
pet.Combatant = other;
Assert.Same(other, pet.Combatant);
pet.AIObject.Obey(); // the tick puts the kill order back on the commanded target
Assert.Same(victim, pet.Combatant);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
}
[Fact]
public void Rename_WhileFollowing_KeepsFollowingTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IssueOrder(OrderType.Follow, master, master);
pet.IssueOrder(OrderType.Rename, master); // the menu passes no target
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget); // restored, not null
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder); // no "no one to follow" -> None
}
[Fact]
public void TransferRefused_ResumesFollowingTheMaster_NotTheRecipient()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IssueOrder(OrderType.Follow, master, master);
var recipient = SpawnPlayer(new Point3D(1002, 1000, 0)); // no NetState -> the transfer is refused
pet.IssueOrder(OrderType.Transfer, master, recipient);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.True(pet.Controlled);
Assert.Same(master, pet.ControlMaster);
}
[Fact]
public void SameOrderTwice_ReRunsIssue()
{
var postA = new Point3D(1005, 1005, 0);
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA);
pet.ControlOrder = OrderType.Stay; // Home = A
pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map);
pet.ControlOrder = OrderType.Stay; // reissued: re-anchor
Assert.Equal(pet.Location, pet.Home);
}
[Fact]
public void LoyaltyRelease_AndManualRelease_ProduceTheSameEndState()
{
var (masterA, petA) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var (masterB, petB) = Spawn(new Point3D(1100, 1100, 0), new Point3D(1101, 1100, 0));
petA.Name = "Rex";
petB.Name = "Rex";
petA.IsBonded = true;
petB.IsBonded = true;
petA.IssueOrder(OrderType.Release, masterA); // player
petB.ControlOrder = OrderType.Release; // what the loyalty drain does
PetTestStub[] pets = [petA, petB];
for (var i = 0; i < pets.Length; i++)
{
var pet = pets[i];
Assert.False(pet.Controlled);
Assert.Null(pet.ControlMaster);
Assert.False(pet.IsBonded);
Assert.Null(pet.Name);
Assert.Equal(OrderType.None, pet.ControlOrder);
Assert.True(pet.PendingDeleteTimer?.Running);
Assert.Equal(pet.Location, pet.Home);
}
Assert.Equal(masterA.Followers, masterB.Followers);
}
[Fact]
public void SummonedPet_Released_IsKilledNotReleased()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Summoned = true;
pet.SummonMaster = master;
pet.ControlOrder = OrderType.Release;
Assert.True(pet.Deleted || !pet.Alive);
}
[Fact]
public void Stop_WithNoStandingOrder_IdlesAnchoredWhereItStands()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
Assert.Equal(OrderType.Come, pet.ControlOrder); // fresh tame: no standing order, Home = Zero
Assert.Equal(Point3D.Zero, pet.Home);
pet.ControlOrder = OrderType.Stop; // what a vendor does after SetControlMaster(buyer)
Assert.Equal(OrderType.None, pet.ControlOrder);
Assert.Equal(pet.Location, pet.Home); // anchored: no unbounded wander
}
[Fact]
public void Release_ClearsFriendsAndTheStandingOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Guard; // persistent = Guard
pet.IssueOrder(OrderType.Release, master);
Assert.False(pet.IsPetFriend(friend));
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
}
[Fact]
public void PetDeath_IssuesFollowMaster_WithoutRevealingAnyone()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.IsBonded = true;
pet.ControlOrder = OrderType.Stay;
pet.ControlTarget = null;
master.Hidden = true;
pet.Kill(); // bonded pet death -> IsDeadPet, follows the master
Assert.True(pet.IsDeadPet);
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(master, pet.ControlTarget);
Assert.True(master.Hidden);
Assert.False(pet.Warmode);
}
[Fact]
public void ObeyOnALegacyTransientOrder_FallsBackToPersistent()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow; // persistent = Follow
// a pre-refactor save resting at Rename
var field = typeof(BaseCreature).GetField("_controlOrder", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
field!.SetValue(pet, OrderType.Rename);
Assert.Equal(OrderType.Rename, pet.ControlOrder);
pet.AIObject.Obey();
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void SpeechCommand_FromAFriend_RevealsTheFriend_NotTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
// "all stay" keyword 0x170
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "all stay", MessageType.Regular, 0x3B2, [0x170]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void ContextMenuCommand_FromAFriend_RevealsTheFriend_NotTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
master.Hidden = true;
friend.Hidden = true;
new InternalEntry(3006114, 14, OrderType.Stay, true).OnClick(friend, pet); // Command: Stay
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.False(friend.Hidden);
Assert.True(master.Hidden);
}
[Fact]
public void ContextMenuCommand_FromAFriend_RefusesNonFriendOrders()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Follow;
new InternalEntry(3006107, 14, OrderType.Guard, true).OnClick(friend, pet); // Command: Guard
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
[Fact]
public void ContextMenuRename_LeavesThePetOnARestableOrder()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.ControlOrder = OrderType.Follow;
new InternalEntry(3006098, 14, OrderType.Rename, true).OnClick(master, pet); // Rename
Assert.Equal(OrderType.Follow, pet.ControlOrder);
}
// Come with no standing order (fresh tame or post-load) must settle into one.
[Fact]
public void RestingCome_WithNoStandingOrder_SettlesIntoStayBesideTheMaster()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
Assert.Equal(OrderType.Come, pet.ControlOrder);
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
pet.AIObject.Obey(); // within 2 tiles -> Stay
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
Assert.Equal(pet.Location, pet.Home);
}
// Load never runs the Issue phase. Home (field 12) is read before ControlOrder (18); with an AI
// present, a setter-routed load would re-anchor Home to the restored Location.
[Fact]
public void ControlOrder_RoundTrips_AndLoadDoesNotRunIssue()
{
var post = new Point3D(1001, 1000, 0);
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
pet.ControlOrder = OrderType.Stay; // Home = post
pet.MoveToWorld(new Point3D(1020, 1000, 0), pet.Map); // displaced: Home != Location
Assert.Equal(post, pet.Home);
var writer = new BufferWriter(true);
pet.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new PetTestStub(World.NewMobile);
_created.Add(copy);
copy.ChangeAIType(AIType.AI_Animal); // the Issue gate is ai != null
copy.Deserialize(new BufferReader(buffer));
Assert.Equal(OrderType.Stay, copy.ControlOrder);
Assert.Equal(new Point3D(1020, 1000, 0), copy.Location);
Assert.Equal(post, copy.Home); // not re-anchored
}
[Fact]
public void SpeechCommand_WithoutThePetsName_IsIgnored()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.ControlOrder = OrderType.Stay;
// bare "come" (keyword 0x155) with no name: not for this pet
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Come, pet.ControlOrder);
}
[Fact]
public void AllCommand_IssuesOnce()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.ControlOrder = OrderType.Follow;
var post = pet.Location;
// The client emits both 0x170 ("all stay") and 0x16F ("*stay") for "all stay".
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x170, 0x16F]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(post, pet.Home);
// The named keyword alone must be ignored: the speech starts with "all", not the name.
pet.MoveToWorld(new Point3D(1010, 1010, 0), pet.Map);
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "all stay", MessageType.Regular, 0x3B2, [0x16F]));
Assert.Equal(post, pet.Home);
}
[Fact]
public void SpeechCommand_FromAFriend_CannotComeGuardOrDrop()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var friend = SpawnPlayer(new Point3D(1002, 1000, 0));
pet.AddPetFriend(friend);
pet.ControlOrder = OrderType.Stay;
var post = pet.Home;
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex come", MessageType.Regular, 0x3B2, [0x155]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex guard", MessageType.Regular, 0x3B2, [0x15C]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
pet.IsBonded = true; // CanDrop
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex drop", MessageType.Regular, 0x3B2, [0x156]));
Assert.Equal(OrderType.Stay, pet.ControlOrder);
Assert.Equal(post, pet.Home); // never re-issued
// The friend can still Stay/Follow/Stop.
pet.AIObject.OnSpeech(new SpeechEventArgs(friend, "Rex follow me", MessageType.Regular, 0x3B2, [0x163]));
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Same(friend, pet.ControlTarget);
}
[Fact]
public void GMObey_TakesControlOfACommandablePet()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var gm = SpawnPlayer(new Point3D(1002, 1000, 0));
gm.AccessLevel = AccessLevel.GameMaster;
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, []));
Assert.Same(gm, pet.ControlMaster);
}
[Fact]
public void GMAllObey_DoesNotTakeControlledPets()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
var gm = SpawnPlayer(new Point3D(1002, 1000, 0));
gm.AccessLevel = AccessLevel.GameMaster;
// The mass form is for wild creatures; a controlled pet must be named.
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "all obey", MessageType.Regular, 0x3B2, []));
Assert.Same(master, pet.ControlMaster);
pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, []));
Assert.Same(gm, pet.ControlMaster);
}
// Release is relinquishing control, not exerting it: no roll, so no loyalty either way.
[Fact]
public void MenuRelease_TouchesNoLoyalty()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Loyalty = 50;
new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); // Release
Assert.Equal(50, pet.Loyalty); // no roll: neither the +1 for passing nor the -3 for failing
}
[Fact]
public void SpeechRelease_TouchesNoLoyalty()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Name = "Rex";
pet.Loyalty = 50;
pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex release", MessageType.Regular, 0x3B2, [0x16D]));
Assert.Equal(50, pet.Loyalty);
}
// A creature nobody can command is not released either.
[Fact]
public void MenuRelease_OnAnUncontrollablePet_IsRefusedWithoutCost()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.MinTameSkill = 120.0; // control chance at or below zero
master.Skills.AnimalTaming.Base = 0;
master.Skills.AnimalLore.Base = 0;
pet.Loyalty = 50;
new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet);
Assert.Equal(50, pet.Loyalty); // refused, but never punished
Assert.True(pet.Controlled);
}
}

View file

@ -54,9 +54,126 @@ public class PetPacingTests : IDisposable
Assert.Equal(0.2, pet.CurrentSpeed);
}
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
// The follow pace caps the step delay and leaves the think clock on the active value.
[Fact]
public void FollowMaster_ObeySprints()
public void FollowMaster_PacesStepsWithoutInflatingTheThinkClock()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace
Assert.Equal(0.1, pet.CurrentMoveSpeed); // capped at the follow pace
}
// A creature configured faster than the follow pace keeps its own.
[Fact]
public void FollowMaster_KeepsAFasterConfiguredPace()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.05, 0.9);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.05, pet.CurrentMoveSpeed);
}
// The move-clock override survives the order: it is capped while following, not overwritten.
[Fact]
public void FollowMaster_LeavesTheConfiguredMoveClockAlone()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
pet.AIObject.Obey();
pet.ControlOrder = OrderType.Stay;
Assert.Equal(0.3, pet.ActiveMoveSpeed);
Assert.Equal(0.9, pet.PassiveMoveSpeed);
Assert.Equal(0.9, pet.CurrentMoveSpeed); // resting on its own passive pace again
}
// Pre-AOS pets follow at their own pace; nothing caps them.
[Fact]
public void FollowMaster_PreAOS_KeepsItsOwnPace()
{
var previous = Core.Expansion;
try
{
Core.Expansion = Expansion.UOR;
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed);
}
finally
{
Core.Expansion = previous;
}
}
private sealed class SprintingPet : PetTestStub
{
public override double FollowMoveSpeed => 0.125;
}
// A shard paces follows in any era by overriding the property, not by patching the AI.
[Fact]
public void FollowMoveSpeedOverride_PacesFollowsInAnyEra()
{
var previous = Core.Expansion;
try
{
Core.Expansion = Expansion.UOR;
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
_created.Add(master);
var pet = new SprintingPet();
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
pet.SetControlMaster(master);
_created.Add(pet);
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.125, pet.CurrentMoveSpeed);
}
finally
{
Core.Expansion = previous;
}
}
// A guarding pet outside guard range closes at the follow pace, thinking on its active clock.
[Fact]
public void GuardReturn_PacesStepsWithoutInflatingTheThinkClock()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1006, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlOrder = OrderType.Guard;
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}
// Obeying the follow order must not write the pace into either clock.
[Fact]
public void FollowMaster_ObeyKeepsTheThinkClockActive()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
@ -66,7 +183,7 @@ public class PetPacingTests : IDisposable
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
pet.AIObject.Obey();
Assert.Equal(0.1, pet.CurrentSpeed);
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}

View file

@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Publish 51 (26 March 2008): a pet told to follow, come, stay or stop "will not attack
// anything, even if it is attacked". Guard and attack are unaffected.
[Collection("Sequential UOContent Tests")]
public class PetRetaliationTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private sealed class StandDownPet : PetTestStub
{
public override bool StandsDownOnCommand => true;
}
private sealed class FightBackPet : PetTestStub
{
public override bool StandsDownOnCommand => false;
}
private (PlayerMobile master, T pet) Spawn<T>() where T : BaseCreature, new()
{
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
_created.Add(master);
var pet = new T();
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
pet.SetControlMaster(master);
pet.AIObject.AITimer?.Stop();
_created.Add(pet);
return (master, pet);
}
private void Order(BaseCreature pet, Mobile master, OrderType order)
{
if (order == OrderType.None) // Publish 51's "stop": stops, may wander, will not attack
{
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
pet.ControlOrder = OrderType.Stop;
return;
}
pet.ControlTarget = master;
pet.ControlOrder = order;
}
private BaseCreature Attack(BaseCreature pet)
{
var attacker = new PetTestStub();
attacker.MoveToWorld(new Point3D(1002, 1000, 0), pet.Map);
_created.Add(attacker);
pet.AIObject.AITimer?.Stop();
attacker.Combatant = pet; // a mob starts attacking the pet
return attacker;
}
[Theory]
[InlineData(OrderType.Follow)]
[InlineData(OrderType.Come)]
[InlineData(OrderType.Stay)]
[InlineData(OrderType.None)] // stopped
public void StandDownOrder_IgnoresTheAttacker(OrderType order)
{
var (master, pet) = Spawn<StandDownPet>();
Order(pet, master, order);
var resting = pet.ControlOrder;
Attack(pet);
Assert.Equal(resting, pet.ControlOrder); // never converts to Attack
Assert.Null(pet.Combatant);
Assert.False(pet.Warmode);
}
[Theory]
[InlineData(OrderType.Follow)]
[InlineData(OrderType.Come)]
[InlineData(OrderType.Stay)]
[InlineData(OrderType.None)]
public void WithoutStandDown_TheSameOrdersRetaliate(OrderType order)
{
var (master, pet) = Spawn<FightBackPet>();
Order(pet, master, order);
var attacker = Attack(pet);
Assert.Equal(OrderType.Attack, pet.ControlOrder);
Assert.Same(attacker, pet.Combatant);
}
// No damage callback may put a stand-down pet back into combat behind the policy's back.
[Theory]
[InlineData(Expansion.AOS, false)]
[InlineData(Expansion.AOS, true)]
[InlineData(Expansion.ML, false)]
[InlineData(Expansion.ML, true)]
public void StandDownPet_StaysDown_ThroughRepeatedDamage(Expansion era, bool spellDamage)
{
var previous = Core.Expansion;
try
{
Core.Expansion = era;
var (master, pet) = Spawn<StandDownPet>();
Order(pet, master, OrderType.Follow);
var attacker = Attack(pet);
Assert.Equal(OrderType.Follow, pet.ControlOrder); // the initial aggression stood down
for (var i = 0; i < 500 && pet.ControlOrder == OrderType.Follow; i++)
{
if (spellDamage)
{
pet.OnDamagedBySpell(attacker, 1);
}
else
{
pet.OnDamage(1, attacker, false);
}
}
Assert.Equal(OrderType.Follow, pet.ControlOrder);
Assert.Null(pet.Combatant);
}
finally
{
Core.Expansion = previous;
}
}
// "Guard: the pet should guard as it does currently."
[Fact]
public void GuardingPet_StillFights_UnderStandDown()
{
var (master, pet) = Spawn<StandDownPet>();
Order(pet, master, OrderType.Guard);
var attacker = Attack(pet);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
Assert.Same(attacker, pet.Combatant);
Assert.True(pet.Warmode);
}
}

View file

@ -23,6 +23,23 @@ public class PetTestStub : BaseCreature
passiveSpeed = 0.4;
}
// Effective (value-changing, non-null) Combatant writes; a re-issue on the same target must not add one.
public int CombatantSets { get; private set; }
public override Mobile Combatant
{
get => base.Combatant;
set
{
if (value != null && base.Combatant != value)
{
CombatantSets++;
}
base.Combatant = value;
}
}
public override bool CheckIdle() => ForceIdle || base.CheckIdle();
public PetTestStub(Serial serial) : base(serial)

View file

@ -0,0 +1,455 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Items;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles;
// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new
// SaveFlag format round-trips both a default and a fully-populated creature with exact
// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a
// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old
// Serialize) loads through the legacy path with the table-speed migration applied.
[Collection("Sequential UOContent Tests")]
public class BaseCreatureSerializationTests : IDisposable
{
private readonly List<Mobile> _created = new();
private readonly List<Item> _createdItems = new();
public void Dispose()
{
for (var i = 0; i < _created.Count; i++)
{
_created[i].Delete();
}
for (var i = 0; i < _createdItems.Count; i++)
{
_createdItems[i].Delete();
}
}
private class CreatureStub : BaseCreature
{
public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
public DateTime SummonEndValue => SummonEnd;
// Stands in for the npc-speeds table (unconfigured in the test fixture).
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
{
activeMoveSpeed = 0.6;
passiveMoveSpeed = 1.2;
}
}
private CreatureStub NewCreature()
{
var bc = new CreatureStub();
_created.Add(bc);
return bc;
}
// ReadEntity resolves references through the world table, so the master must be registered.
private PlayerMobile NewMaster()
{
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master);
_created.Add(master);
return master;
}
private static byte[] Snapshot(Mobile m)
{
var writer = new BufferWriter(true);
m.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
return buffer;
}
private CreatureStub Load(byte[] buffer)
{
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position); // exact consumption
return copy;
}
[Fact]
public void DefaultCreature_RoundTrips_AndElidesEverything()
{
var bc = NewCreature();
var buffer = Snapshot(bc);
var copy = Load(buffer);
Assert.Equal(AIType.AI_Melee, copy.AI);
Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
Assert.Equal(0.3, copy.ActiveSpeed);
Assert.Equal(0.6, copy.PassiveSpeed);
Assert.Equal(0.6, copy.CurrentSpeed);
Assert.Equal(0.6, copy.ActiveMoveSpeed); // pulled from the table, not the wire
Assert.Equal(1.2, copy.PassiveMoveSpeed);
Assert.Equal(100, copy.PhysicalDamage);
Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
Assert.Equal(1, copy.ControlSlots);
Assert.NotNull(copy.Owners);
Assert.Empty(copy.Owners);
}
[Fact]
public void BackToBackSaves_AreByteIdentical()
{
var bc = NewCreature();
bc.SetDamage(5, 10);
bc.PhysicalResistanceSeed = 25;
Assert.Equal(Snapshot(bc), Snapshot(bc));
}
[Fact]
public void PopulatedCreature_RoundTrips()
{
var bc = NewCreature();
var master = NewMaster();
bc.Tamable = true;
bc.MinTameSkill = 47.1;
bc.SetControlMaster(master);
bc.Owners.Add(master);
bc.ControlOrder = OrderType.Guard;
bc.SetDamage(11, 17);
bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
bc.SetMoveSpeed(0.25, 0.5);
bc.PhysicalResistanceSeed = 40;
bc.EnergyResistSeed = 15;
bc.FireDamage = 25;
bc.PhysicalDamage = 75;
bc.HitsMaxSeed = 250;
bc.Loyalty = 55;
bc.Home = new Point3D(1000, 1100, 5);
bc.RangeHome = 4;
bc.Team = 3;
bc.IsBonded = true;
bc.BondingBegin = Core.Now;
bc.RemoveIfUntamed = true;
bc.RemoveStep = 2;
bc.CorpseNameOverride = "a test corpse";
var copy = Load(Snapshot(bc));
Assert.True(copy.Controlled);
Assert.Equal(master, copy.ControlMaster);
Assert.Equal(OrderType.Guard, copy.ControlOrder);
Assert.True(copy.Tamable);
Assert.Equal(47.1, copy.MinTameSkill);
Assert.Equal(11, copy.DamageMin);
Assert.Equal(17, copy.DamageMax);
Assert.Equal(0.2, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.25, copy.ActiveMoveSpeed);
Assert.Equal(0.5, copy.PassiveMoveSpeed);
Assert.Equal(40, copy.PhysicalResistanceSeed);
Assert.Equal(15, copy.EnergyResistSeed);
Assert.Equal(25, copy.FireDamage);
Assert.Equal(75, copy.PhysicalDamage);
Assert.Equal(250, copy.HitsMaxSeed);
Assert.Equal(55, copy.Loyalty);
Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
Assert.Equal(4, copy.RangeHome);
Assert.Equal(3, copy.Team);
Assert.True(copy.IsBonded);
Assert.Equal(bc.BondingBegin, copy.BondingBegin);
Assert.True(copy.RemoveIfUntamed);
Assert.Equal(2, copy.RemoveStep);
Assert.Equal("a test corpse", copy.CorpseNameOverride);
Assert.Equal(master, copy.LastOwner);
}
[Fact]
public void UncontrolledSummon_KeepsItsSummonMaster()
{
var bc = NewCreature();
var master = NewMaster();
// Energy vortex-style: summoned with a master, never controlled.
bc.Summoned = true;
bc.SummonMaster = master;
var copy = Load(Snapshot(bc));
Assert.True(copy.Summoned);
Assert.False(copy.Controlled);
Assert.Equal(master, copy.SummonMaster);
Assert.Null(copy.ControlMaster);
}
[Fact]
public void ReferenceFields_RoundTrip()
{
var bc = NewCreature();
var friend = NewMaster();
var wayPoint = new WayPoint();
_createdItems.Add(wayPoint);
bc.AddPetFriend(friend);
bc.CurrentWayPoint = wayPoint;
bc.HomeMap = Map.Felucca;
var copy = Load(Snapshot(bc));
Assert.Equal(friend, Assert.Single(copy.Friends));
Assert.Equal(wayPoint, copy.CurrentWayPoint);
Assert.Equal(Map.Felucca, copy.HomeMap);
}
[Fact]
public void RunningDeleteTimer_RoundTrips()
{
var bc = NewCreature();
bc.BeginDeleteTimer();
Assert.True(bc.DeleteTimeLeft > TimeSpan.Zero);
var copy = Load(Snapshot(bc));
// Anchored: the remaining countdown survives, not the absolute deadline.
Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0));
}
private sealed class VendorStub : BaseVendor
{
private static readonly List<SBInfo> _sbInfos = [];
public VendorStub() : base("the stub")
{
}
public VendorStub(Serial serial) : base(serial)
{
}
protected override List<SBInfo> SBInfos => _sbInfos;
public override void InitSBInfo()
{
}
public override void InitOutfit()
{
}
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
// BaseVendor is generated on top of the generated BaseCreature; the chain must write
// and read both sections in order with exact consumption.
[Fact]
public void GeneratedVendorChain_RoundTrips()
{
var vendor = new VendorStub();
_created.Add(vendor);
vendor.Home = new Point3D(1500, 1600, 0);
vendor.RangeHome = 2;
var buffer = Snapshot(vendor);
var copy = new VendorStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(AIType.AI_Vendor, copy.AI);
Assert.Equal(FightMode.None, copy.FightMode);
Assert.Equal(new Point3D(1500, 1600, 0), copy.Home);
Assert.Equal(2, copy.RangeHome);
}
private sealed class MobileStub : Mobile
{
public MobileStub() => Body = 0xC9;
}
// Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy
// upgrade path stays covered without an old save binary. The full stream is a plain
// Mobile section (identical layout for every Mobile subclass) followed by this tail.
private static void WriteLegacyV22Tail(
IGenericWriter writer,
bool controlled = false,
Mobile controlMaster = null,
bool summoned = false,
Mobile summonMaster = null,
DateTime summonEnd = default
)
{
writer.Write(22); // version
writer.Write((int)AIType.AI_Melee); // current AI
writer.Write((int)AIType.AI_Melee); // default AI
writer.Write(10); // RangePerception
writer.Write(1); // RangeFight
writer.Write(0); // Team
writer.Write(0.3); // active (matches the stub table)
writer.Write(0.6); // passive
writer.Write(0.6); // current
writer.Write(2000); // Home X
writer.Write(2100); // Home Y
writer.Write(7); // Home Z
writer.Write(6); // RangeHome
writer.Write((int)FightMode.Closest);
writer.Write(controlled);
writer.Write(controlMaster);
writer.Write((Mobile)null); // control target
writer.Write(Point3D.Zero); // control dest
writer.Write((int)OrderType.None);
writer.Write(0.0); // min tame skill
writer.Write(true); // tamable
writer.Write(summoned);
if (summoned)
{
writer.WriteAnchoredTime(summonEnd);
}
writer.Write(2); // control slots
writer.Write(73); // loyalty
writer.Write((Item)null); // waypoint
writer.Write(summonMaster);
writer.Write(180); // hits seed
writer.Write(-1); // stam seed
writer.Write(-1); // mana seed
writer.Write(7); // damage min
writer.Write(14); // damage max
writer.Write(30); // phys resist
writer.Write(100); // phys damage
writer.Write(10); // fire resist
writer.Write(0); // fire damage
writer.Write(0); // cold resist
writer.Write(0); // cold damage
writer.Write(0); // poison resist
writer.Write(0); // poison damage
writer.Write(0); // energy resist
writer.Write(0); // energy damage
writer.Write(new List<Mobile>()); // owners
writer.Write(false); // dead pet
writer.Write(false); // bonded
writer.Write(DateTime.MinValue); // bonding begin
writer.Write(DateTime.MinValue); // abandon time
writer.Write(true); // has generated loot
writer.Write(false); // paragon
writer.Write(false); // has friends
writer.Write(false); // remove if untamed
writer.Write(0); // remove step
writer.Write(TimeSpan.Zero); // delete time left
writer.Write((string)null); // corpse name override
writer.Write((Map)null); // home map
writer.Write(0.0); // active move speed (v22)
writer.Write(0.0); // passive move speed (v22)
}
private CreatureStub LoadLegacyV22(
bool controlled = false,
Mobile controlMaster = null,
bool summoned = false,
Mobile summonMaster = null,
DateTime summonEnd = default
)
{
// Every serialized BaseCreature starts with the Mobile base section; a plain
// Mobile donor produces a byte-authentic one.
var donor = new MobileStub();
donor.DefaultMobileInit();
_created.Add(donor);
var writer = new BufferWriter(true);
donor.Serialize(writer);
WriteLegacyV22Tail(writer, controlled, controlMaster, summoned, summonMaster, summonEnd);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
return copy;
}
[Fact]
public void LegacyV22Stream_LoadsThroughLegacyPath()
{
var copy = LoadLegacyV22();
Assert.Equal(10, copy.RangePerception);
Assert.Equal(new Point3D(2000, 2100, 7), copy.Home);
Assert.Equal(6, copy.RangeHome);
Assert.True(copy.Tamable);
Assert.Equal(2, copy.ControlSlots);
Assert.Equal(73, copy.Loyalty);
Assert.Equal(180, copy.HitsMaxSeed);
Assert.Equal(7, copy.DamageMin);
Assert.Equal(14, copy.DamageMax);
Assert.Equal(30, copy.PhysicalResistanceSeed);
Assert.Equal(10, copy.FireResistSeed);
Assert.Equal(0.3, copy.ActiveSpeed);
// v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved
// pace falls back to the think clock.
Assert.Equal(0, copy.ActiveMoveSpeed);
Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting
}
// ControlMaster and SummonMaster are independent references: a summon master can
// exist without Summoned (EnragedCreature), and a controlled summon carries both.
[Theory]
[InlineData(true, true, false, false)] // controlled pet
[InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire
[InlineData(false, false, false, true)] // EnragedCreature shape: SummonMaster only
public void LegacyV22Stream_KeepsBothMasterReferences(
bool controlled,
bool hasControlMaster,
bool summoned,
bool hasSummonMaster
)
{
var master = NewMaster();
var summonEnd = Core.Now + TimeSpan.FromMinutes(5);
var copy = LoadLegacyV22(
controlled,
hasControlMaster ? master : null,
summoned,
hasSummonMaster ? master : null,
summonEnd
);
Assert.Equal(controlled, copy.Controlled);
Assert.Equal(summoned, copy.Summoned);
Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster);
Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster);
if (summoned)
{
Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
}
}
}

View file

@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Server.Items;
using Server.Regions;
using Xunit;
namespace UOContent.Tests.Mobiles;
[Collection("Sequential UOContent Tests")]
public class MountRegionTests
{
public static IEnumerable<object[]> MountCases()
{
foreach (var era in Enum.GetValues<Expansion>())
{
foreach (var allowed in new[] { false, true })
{
yield return new object[] { era, allowed, false };
yield return new object[] { era, allowed, true };
}
}
}
[Theory]
[MemberData(nameof(MountCases))]
public void Mounting_RespectsActiveRegion(Expansion era, bool allowed, bool ethereal)
{
var previous = Core.Expansion;
var region = new MountTestRegion(allowed);
PlayerMobile player = null;
TestHorse horse = null;
EtherealHorse statue = null;
try
{
Core.Expansion = era;
region.Register();
player = new PlayerMobile(World.NewMobile);
player.DefaultMobileInit();
player.Race = Race.Human;
player.AddItem(new Backpack());
player.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
Assert.Same(region, player.Region);
if (ethereal)
{
statue = new EtherealHorse { IsRewardItem = false };
player.Backpack.DropItem(statue);
Assert.Same(player.Backpack, statue.Parent);
Assert.Equal(allowed, statue.Validate(player));
}
else
{
horse = new TestHorse();
horse.MoveToWorld(player.Location, player.Map);
horse.SetControlMaster(player);
horse.OnDoubleClick(player);
Assert.Equal(allowed, horse.Rider == player);
Assert.Equal(allowed, player.Mounted);
}
}
finally
{
horse?.Delete();
statue?.Delete();
player?.Delete();
region.Unregister();
Core.Expansion = previous;
}
}
private class MountTestRegion : BaseRegion
{
private readonly bool _allowed;
public MountTestRegion(bool allowed)
: base("MountRegionTest", Map.Felucca, 100,
new Rectangle3D(990, 990, -128, 20, 20, 256))
{
_allowed = allowed;
}
public override bool MountsAllowed => _allowed;
}
// The test fixture does not configure NPCSpeeds.
private class TestHorse : Horse
{
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.2;
passiveSpeed = 0.4;
}
}
}

View file

@ -0,0 +1,42 @@
using System;
using Server;
using Server.Mobiles;
using Server.Multis;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class HouseDecayPurityTests
{
[Fact]
public void ReadingDecayLevelDoesNotStampLastRefreshed()
{
var decayEnabled = BaseHouse.DecayEnabled;
BaseHouse.DecayEnabled = false; // every house is Ageless, the branch that used to restamp on read
var owner = new PlayerMobile(World.NewMobile);
owner.DefaultMobileInit();
owner.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
var house = new SmallOldHouse(owner, 0x64);
try
{
house.MoveToWorld(new Point3D(1400, 1400, 0), Map.Felucca);
var placed = Core.Now - TimeSpan.FromDays(2);
house.LastRefreshed = placed;
Assert.False(house.CanDecay);
Assert.Equal(DecayLevel.Ageless, house.DecayLevel);
Assert.Equal(placed, house.LastRefreshed);
}
finally
{
house.Delete();
owner.Delete();
BaseHouse.DecayEnabled = decayEnabled;
}
}
}

View file

@ -0,0 +1,124 @@
using Server;
using Server.Misc;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class SkillEventsTests
{
private sealed class Recorder
{
public Mobile From;
public Skill Skill;
public bool Success;
public int Calls;
public void Handle(Mobile from, Skill skill, bool success)
{
From = from;
Skill = skill;
Success = success;
Calls++;
}
}
[Fact]
public void DirectTarget_RolledAttempt_RaisesOnceWithTheReturnedOutcome()
{
var from = new Mobile();
var skill = from.Skills[SkillName.Mining];
var recorder = new Recorder();
SkillEvents.SkillUsed += recorder.Handle;
try
{
var rolled = SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.5);
Assert.Equal(1, recorder.Calls);
Assert.Same(from, recorder.From);
Assert.Same(skill, recorder.Skill);
Assert.Equal(rolled, recorder.Success);
Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.0));
Assert.Equal(2, recorder.Calls);
Assert.False(recorder.Success);
}
finally
{
SkillEvents.SkillUsed -= recorder.Handle;
from.Delete();
}
}
[Fact]
public void ShortCircuits_StillRaise_WithTheHandlerOutcome()
{
var from = new Mobile();
var recorder = new Recorder();
SkillEvents.SkillUsed += recorder.Handle;
try
{
Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0));
Assert.Equal(1, recorder.Calls);
Assert.True(recorder.Success);
Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, -0.1));
Assert.Equal(2, recorder.Calls);
Assert.False(recorder.Success);
Assert.False(SkillCheck.Mobile_SkillCheckLocation(from, SkillName.Mining, 50.0, 100.0));
Assert.Equal(3, recorder.Calls);
Assert.False(recorder.Success);
Assert.True(SkillCheck.Mobile_SkillCheckTarget(from, SkillName.Mining, null, 0.0, 0.0));
Assert.Equal(4, recorder.Calls);
Assert.True(recorder.Success);
}
finally
{
SkillEvents.SkillUsed -= recorder.Handle;
from.Delete();
}
}
[Fact]
public void CheckSkill_Direct_DoesNotRaise()
{
var from = new Mobile();
var skill = from.Skills[SkillName.Mining];
var recorder = new Recorder();
SkillEvents.SkillUsed += recorder.Handle;
try
{
SkillCheck.CheckSkill(from, skill, null, 1.0);
Assert.Equal(0, recorder.Calls);
}
finally
{
SkillEvents.SkillUsed -= recorder.Handle;
from.Delete();
}
}
[Fact]
public void NoSubscriber_DoesNotThrow()
{
var from = new Mobile();
var recorder = new Recorder();
try
{
SkillEvents.SkillUsed += recorder.Handle;
SkillEvents.SkillUsed -= recorder.Handle;
Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0));
Assert.Equal(0, recorder.Calls);
}
finally
{
from.Delete();
}
}
}

View file

@ -121,7 +121,7 @@ public class BloodOathSpellTests
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
Assert.Null(BloodOathSpell.GetBloodOath(target));
Assert.False(BloodOathSpell.RemoveCurse(caster));

View file

@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Commands;
using Server.Items;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class SaveStabilityTests
{
// Serializes the current time, the classic way a record drifts between saves with no mutation.
private sealed class ClockStampedItem : Item
{
public ClockStampedItem() : base(0x1F03)
{
}
public ClockStampedItem(Serial serial) : base(serial)
{
}
public override void Serialize(IGenericWriter writer)
{
base.Serialize(writer);
writer.Write(Core.Now);
}
public override void Deserialize(IGenericReader reader)
{
base.Deserialize(reader);
reader.ReadDateTime();
}
}
private sealed class CreatureStub : BaseCreature
{
public CreatureStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1)
{
Body = 0xEE; // rat
}
public CreatureStub(Serial serial) : base(serial)
{
}
// NPCSpeeds is not configured in the test fixture; the AIType ctor would hit the empty table.
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.2;
passiveSpeed = 0.4;
}
}
[Fact]
public void StableEntitiesHashIdenticallyAfterTimePasses()
{
var bag = new Bag();
bag.DropItem(new Gold(100));
bag.DropItem(new Dagger());
bag.MoveToWorld(new Point3D(1450, 1450, 0), Map.Felucca);
var creature = new CreatureStub();
creature.MoveToWorld(new Point3D(1451, 1450, 0), Map.Felucca);
var entities = new List<ISerializable> { bag, creature };
entities.AddRange(bag.Items);
var now = Core._now;
try
{
var captured = SaveStability.CaptureHashes(entities);
Core._now = now + TimeSpan.FromMinutes(5);
var report = SaveStability.Compare(entities, captured);
Assert.Equal(entities.Count, report.Checked);
Assert.Equal(0, report.Changed);
}
finally
{
Core._now = now;
bag.Delete();
creature.Delete();
}
}
[Fact]
public void ClockDerivedRecordIsReportedByType()
{
var item = new ClockStampedItem();
item.MoveToWorld(new Point3D(1452, 1450, 0), Map.Felucca);
var now = Core._now;
try
{
var entities = new List<ISerializable> { item };
var captured = SaveStability.CaptureHashes(entities);
// Same tick: the drift is invisible, which is why the command waits in real time.
Assert.Equal(0, SaveStability.Compare(entities, captured).Changed);
Core._now = now + TimeSpan.FromMinutes(1);
var report = SaveStability.Compare(entities, captured);
Assert.Equal(1, report.Changed);
Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Changed);
Assert.Equal(1, report.ByType[typeof(ClockStampedItem)].Total);
}
finally
{
Core._now = now;
item.Delete();
}
}
[Fact]
public void SnapshotCoversEveryRegisteredEntityPersistence()
{
var item = new Bag();
item.MoveToWorld(new Point3D(1453, 1450, 0), Map.Felucca);
var mobile = new CreatureStub();
mobile.MoveToWorld(new Point3D(1454, 1450, 0), Map.Felucca);
try
{
var snapshot = SaveStability.SnapshotEntities();
Assert.Contains(item, snapshot);
Assert.Contains(mobile, snapshot);
var names = new List<string>();
foreach (var persistence in Persistence.EntityPersistences)
{
names.Add(persistence.Name);
}
Assert.Contains("Items", names);
Assert.Contains("Mobiles", names);
Assert.Contains("Guilds", names);
}
finally
{
item.Delete();
mobile.Delete();
}
}
}

View file

@ -4,18 +4,23 @@
<Configurations>Debug;Release;Analyze</Configurations>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.85" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.1.0" PrivateAssets="all" />
<ProjectReference Include="..\Server\Server.csproj" />
<ProjectReference Include="..\UOContent\UOContent.csproj" />
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
</ItemGroup>
<ItemGroup>
<None Include="Tests/Engines/Spawners/Fixtures/*.bin" CopyToOutputDirectory="PreserveNewest" />
<AdditionalFiles Include="Migrations/*.v*.json" />
</ItemGroup>
<Target Name="CopyData" AfterTargets="AfterBuild">
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
</Target>

View file

@ -95,11 +95,9 @@ namespace Server.Commands.Generic
parsed.Sort((a, b) => a.Order - b.Order);
AssemblyEmitter emitter = null;
foreach (var update in parsed)
{
update.Optimize(from, baseType, ref emitter);
update.Optimize(from, baseType);
}
if (size != args.Length)
@ -129,7 +127,7 @@ namespace Server.Commands.Generic
public int Order => Info.Order;
public virtual void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly)
public virtual void Optimize(Mobile from, Type baseType)
{
}

View file

@ -1,7 +1,6 @@
using System;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using System.Reflection.Emit;
namespace Server.Commands.Generic
{
@ -12,209 +11,17 @@ namespace Server.Commands.Generic
public interface ICondition
{
// Invoked during the constructor
void Construct(TypeBuilder typeBuilder, ILGenerator il, int index);
// Target object will be loaded on the stack
void Compile(MethodEmitter emitter);
// `target` is the object under test, already cast to the conditional's type (so it is
// null when the cast failed -- TypeCondition, always first, is what rejects that).
Expression Build(ParameterExpression target);
}
public sealed class TypeCondition : ICondition
{
public static TypeCondition Default = new();
void ICondition.Construct(TypeBuilder typeBuilder, ILGenerator il, int index)
{
}
void ICondition.Compile(MethodEmitter emitter)
{
// The object was safely cast to be the conditionals type
// If it's null, then the type cast didn't work...
emitter.LoadNull();
emitter.Compare(OpCodes.Ceq);
emitter.LogicalNot();
}
}
public sealed class PropertyValue
{
public PropertyValue(Type type, object value)
{
Type = type;
Value = value;
}
public Type Type { get; }
public object Value { get; private set; }
public FieldInfo Field { get; private set; }
public bool HasField => Field != null;
public void Load(MethodEmitter method)
{
if (Field != null)
{
method.LoadArgument(0);
method.LoadField(Field);
}
else if (Value == null)
{
method.LoadNull(Type);
}
else
{
if (Value is int i)
{
method.Load(i);
}
else if (Value is long l)
{
method.Load(l);
}
else if (Value is float f)
{
method.Load(f);
}
else if (Value is double d)
{
method.Load(d);
}
else if (Value is char c)
{
method.Load(c);
}
else if (Value is bool b)
{
method.Load(b);
}
else if (Value is string s)
{
method.Load(s);
}
else if (Value is Enum e)
{
method.Load(e);
}
else
{
throw new InvalidOperationException("Unrecognized comparison value.");
}
}
}
public void Acquire(TypeBuilder typeBuilder, ILGenerator il, string fieldName)
{
if (Value is not string toParse)
{
return;
}
if (!Type.IsValueType && toParse == "null")
{
Value = null;
}
else if (Type == typeof(string))
{
if (toParse == @"@""null""")
{
toParse = "null";
}
Value = toParse;
}
else if (Type.IsEnum)
{
Value = Enum.Parse(Type, toParse, true);
}
else if (Type == typeof(bool))
{
Value = bool.Parse(toParse);
}
else
{
MethodInfo parseMethod;
object[] parseArgs;
var parseNumber = Type.GetMethod(
"Parse",
BindingFlags.Public | BindingFlags.Static,
null,
Types.ParseStringNumericParamTypes,
null
);
if (parseNumber != null)
{
var style = NumberStyles.Integer;
if (toParse.InsensitiveStartsWith("0x"))
{
style = NumberStyles.HexNumber;
toParse = toParse[2..];
}
parseMethod = parseNumber;
parseArgs = new object[] { toParse, style };
}
else
{
var parseGeneral = Type.GetMethod(
"Parse",
BindingFlags.Public | BindingFlags.Static,
null,
Types.ParseStringParamTypes,
null
);
parseMethod = parseGeneral;
parseArgs = new object[] { toParse, null };
}
if (parseMethod != null)
{
Value = parseMethod.Invoke(null, parseArgs);
if (!Type.IsPrimitive)
{
Field = typeBuilder.DefineField(
fieldName,
Type,
FieldAttributes.Private | FieldAttributes.InitOnly
);
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldstr, toParse);
if (parseArgs.Length == 2) // dirty evil hack :-(
{
if (parseArgs[1]?.GetType() == typeof(NumberStyles))
{
il.Emit(OpCodes.Ldc_I4, (int)parseArgs[1]);
}
else
{
// IFormatProvider for `IParsable<T>.Parse()` method.
il.Emit(OpCodes.Ldnull);
}
}
il.Emit(OpCodes.Call, parseMethod);
il.Emit(OpCodes.Stfld, Field);
}
}
else
{
throw new InvalidOperationException(
$"Unable to convert string \"{Value}\" into type '{Type}'."
);
}
}
}
Expression ICondition.Build(ParameterExpression target) =>
Expression.ReferenceNotEqual(target, Expression.Constant(null, target.Type));
}
public abstract class PropertyCondition : ICondition
@ -228,9 +35,22 @@ namespace Server.Commands.Generic
m_Not = not;
}
public abstract void Construct(TypeBuilder typeBuilder, ILGenerator il, int index);
public abstract Expression Build(ParameterExpression target);
public abstract void Compile(MethodEmitter emitter);
// A binding like Message.Number dereferences Message first, and Message is null on most
// objects a sweep walks. That is "no match" rather than a crash -- and it stays "no match"
// under negation, so the guard wraps the test after `not` has been applied to it.
protected Expression Guarded(ParameterExpression target, Func<Expression, Expression> test) =>
PropertyExpressions.Chain(
target,
m_Property,
value =>
{
var result = test(value);
return m_Not ? Expression.Not(result) : result;
},
Expression.Constant(false)
);
}
public enum StringOperator
@ -247,125 +67,62 @@ namespace Server.Commands.Generic
{
private readonly bool m_IgnoreCase;
private readonly StringOperator m_Operator;
private readonly PropertyValue m_Value;
private readonly object m_Value;
public StringCondition(Property property, bool not, StringOperator op, object value, bool ignoreCase)
: base(property, not)
{
m_Operator = op;
m_Value = new PropertyValue(property.Type, value);
m_Value = value;
m_IgnoreCase = ignoreCase;
}
public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index)
public override Expression Build(ParameterExpression target)
{
m_Value.Acquire(typeBuilder, il, $"v{index}");
}
public override void Compile(MethodEmitter emitter)
{
var inverse = false;
var type = m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers);
string methodName;
switch (m_Operator)
if (m_Property.Type != typeof(string))
{
case StringOperator.NotEqual:
{
inverse = true;
goto case StringOperator.Equal;
}
case StringOperator.Equal:
{
methodName = m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal";
break;
}
case StringOperator.Contains:
{
methodName = m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal";
break;
}
case StringOperator.StartsWith:
{
methodName = m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal";
break;
}
case StringOperator.EndsWith:
{
methodName = m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal";
break;
}
default:
{
throw new InvalidOperationException("Invalid string comparison operator.");
}
throw new InvalidOperationException("String operators require a string property.");
}
if (m_Operator is StringOperator.Equal or StringOperator.NotEqual)
var inverse = m_Operator == StringOperator.NotEqual;
var methodName = m_Operator switch
{
emitter.BeginCall(
type.GetMethod(
methodName,
BindingFlags.Public | BindingFlags.Static,
null,
[typeof(string), typeof(string)],
null
)
);
StringOperator.Equal or StringOperator.NotEqual => m_IgnoreCase ? "InsensitiveEquals" : "EqualsOrdinal",
StringOperator.Contains => m_IgnoreCase ? "InsensitiveContains" : "ContainsOrdinal",
StringOperator.StartsWith => m_IgnoreCase ? "InsensitiveStartsWith" : "StartsWithOrdinal",
StringOperator.EndsWith => m_IgnoreCase ? "InsensitiveEndsWith" : "EndsWithOrdinal",
_ => throw new InvalidOperationException("Invalid string comparison operator.")
};
emitter.Chain(m_Property);
m_Value.Load(emitter);
var helper = (m_IgnoreCase ? typeof(InsensitiveStringHelpers) : typeof(OrdinalStringHelpers)).GetMethod(
methodName,
BindingFlags.Public | BindingFlags.Static,
null,
[typeof(string), typeof(string)],
null
);
emitter.FinishCall();
}
else
{
var notNull = emitter.CreateLabel();
var moveOn = emitter.CreateLabel();
var constant = PropertyExpressions.Constant(typeof(string), m_Value);
var temp = emitter.AcquireTemp(m_Property.Type);
return Guarded(
target,
value =>
{
Expression test = Expression.Call(helper, value, constant);
emitter.Chain(m_Property);
// The equality helpers handle a null of their own; the rest need the guard.
if (m_Operator is not (StringOperator.Equal or StringOperator.NotEqual))
{
test = Expression.AndAlso(
Expression.ReferenceNotEqual(value, Expression.Constant(null, typeof(string))),
test
);
}
emitter.StoreLocal(temp);
emitter.LoadLocal(temp);
emitter.BranchIfTrue(notNull);
emitter.Load(false);
emitter.Pop();
emitter.Branch(moveOn);
emitter.MarkLabel(notNull);
emitter.LoadLocal(temp);
emitter.BeginCall(
type.GetMethod(
methodName,
BindingFlags.Public | BindingFlags.Static,
null,
[typeof(string), typeof(string)],
null
)
);
m_Value.Load(emitter);
emitter.FinishCall();
emitter.MarkLabel(moveOn);
}
if (m_Not != inverse)
{
emitter.LogicalNot();
}
return inverse ? Expression.Not(test) : test;
}
);
}
}
@ -382,219 +139,110 @@ namespace Server.Commands.Generic
public sealed class ComparisonCondition : PropertyCondition
{
private readonly ComparisonOperator m_Operator;
private readonly PropertyValue m_Value;
private readonly object m_Value;
public ComparisonCondition(Property property, bool not, ComparisonOperator op, object value)
: base(property, not)
{
m_Operator = op;
m_Value = new PropertyValue(property.Type, value);
m_Value = value;
}
public override void Construct(TypeBuilder typeBuilder, ILGenerator il, int index)
public override Expression Build(ParameterExpression target)
{
m_Value.Acquire(typeBuilder, il, $"v{index}");
}
var constant = PropertyExpressions.Constant(m_Property.Type, m_Value);
public override void Compile(MethodEmitter emitter)
{
emitter.Chain(m_Property);
var inverse = false;
var couldCompare =
emitter.CompareTo(1, () => { m_Value.Load(emitter); });
if (couldCompare)
{
emitter.Load(0);
switch (m_Operator)
return Guarded(
target,
value =>
{
case ComparisonOperator.Equal:
{
emitter.Compare(OpCodes.Ceq);
break;
}
if (PropertyExpressions.TryRelational(value, constant, m_Operator, out var test))
{
return test;
}
case ComparisonOperator.NotEqual:
{
emitter.Compare(OpCodes.Ceq);
inverse = true;
break;
}
case ComparisonOperator.Greater:
{
emitter.Compare(OpCodes.Cgt);
break;
}
case ComparisonOperator.GreaterEqual:
{
emitter.Compare(OpCodes.Clt);
inverse = true;
break;
}
case ComparisonOperator.Lesser:
{
emitter.Compare(OpCodes.Clt);
break;
}
case ComparisonOperator.LesserEqual:
{
emitter.Compare(OpCodes.Cgt);
inverse = true;
break;
}
default:
{
throw new InvalidOperationException("Invalid comparison operator.");
}
// This type is -not- comparable. We can only support == and != operations.
return m_Operator switch
{
ComparisonOperator.Equal => PropertyExpressions.ValueEquals(value, constant),
ComparisonOperator.NotEqual => Expression.Not(PropertyExpressions.ValueEquals(value, constant)),
ComparisonOperator.Greater or ComparisonOperator.GreaterEqual
or ComparisonOperator.Lesser or ComparisonOperator.LesserEqual =>
throw new InvalidOperationException("Property does not support relational comparisons."),
_ => throw new InvalidOperationException("Invalid operator.")
};
}
}
else
{
// This type is -not- comparable
// We can only support == and != operations
m_Value.Load(emitter);
switch (m_Operator)
{
case ComparisonOperator.Equal:
{
emitter.Compare(OpCodes.Ceq);
break;
}
case ComparisonOperator.NotEqual:
{
emitter.Compare(OpCodes.Ceq);
inverse = true;
break;
}
case ComparisonOperator.Greater:
case ComparisonOperator.GreaterEqual:
case ComparisonOperator.Lesser:
case ComparisonOperator.LesserEqual:
{
throw new InvalidOperationException("Property does not support relational comparisons.");
}
default:
{
throw new InvalidOperationException("Invalid operator.");
}
}
}
if (m_Not != inverse)
{
emitter.LogicalNot();
}
);
}
}
public static class ConditionalCompiler
{
public static IConditional Compile(AssemblyEmitter assembly, Type objectType, ICondition[] conditions, int index)
private sealed class CompiledConditional : IConditional
{
var typeBuilder = assembly.DefineType(
$"__conditional{index}",
TypeAttributes.Public,
typeof(object)
);
{
var ctor = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
Type.EmptyTypes
);
private readonly Func<object, bool> _verify;
var il = ctor.GetILGenerator();
public CompiledConditional(Func<object, bool> verify) => _verify = verify;
// : base()
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes));
public bool Verify(object obj) => _verify(obj);
}
for (var i = 0; i < conditions.Length; ++i)
/// <summary>
/// Compiles a conjunction of conditions over <paramref name="objectType" /> into a single
/// delegate. The conditions short-circuit left to right, so <see cref="TypeCondition" />
/// comes first and the rest can assume a non-null, correctly typed target.
/// </summary>
public static IConditional Compile(Type objectType, ICondition[] conditions) =>
new CompiledConditional(Build(objectType, conditions).Compile());
public static Expression<Func<object, bool>> Build(Type objectType, ICondition[] conditions) =>
Lambda(objectType, target => Conjunction(target, conditions));
/// <summary>
/// A disjunction of conjunctions -- <c>(a and b) or (c and d)</c> -- as one lambda, for
/// callers that would otherwise compile every group separately and loop over them.
/// </summary>
public static Expression<Func<object, bool>> Build(Type objectType, ICondition[][] groups) =>
Lambda(
objectType,
target =>
{
conditions[i].Construct(typeBuilder, il, i);
}
Expression body = groups.Length > 0 ? Conjunction(target, groups[0]) : Expression.Constant(false);
// return;
il.Emit(OpCodes.Ret);
}
typeBuilder.AddInterfaceImplementation(typeof(IConditional));
MethodBuilder compareMethod;
{
var emitter = new MethodEmitter(typeBuilder);
emitter.Define(
/* name */ "Verify",
/* attr */
MethodAttributes.Public | MethodAttributes.Virtual,
/* return */
typeof(bool),
/* params */
new[] { typeof(object) }
);
var obj = emitter.CreateLocal(objectType);
var eq = emitter.CreateLocal(typeof(bool));
emitter.LoadArgument(1);
emitter.CastAs(objectType);
emitter.StoreLocal(obj);
var done = emitter.CreateLabel();
for (var i = 0; i < conditions.Length; ++i)
{
if (i > 0)
for (var i = 1; i < groups.Length; ++i)
{
emitter.LoadLocal(eq);
emitter.BranchIfFalse(done);
body = Expression.OrElse(body, Conjunction(target, groups[i]));
}
emitter.LoadLocal(obj);
conditions[i].Compile(emitter);
emitter.StoreLocal(eq);
return body;
}
);
emitter.MarkLabel(done);
private static Expression Conjunction(ParameterExpression target, ICondition[] conditions)
{
Expression body = conditions.Length > 0 ? conditions[0].Build(target) : Expression.Constant(true);
emitter.LoadLocal(eq);
emitter.Return();
typeBuilder.DefineMethodOverride(
emitter.Method,
typeof(IConditional).GetMethod(
"Verify",
new[]
{
typeof(object)
}
)
);
compareMethod = emitter.Method;
for (var i = 1; i < conditions.Length; ++i)
{
body = Expression.AndAlso(body, conditions[i].Build(target));
}
var conditionalType = typeBuilder.CreateType();
return body;
}
return conditionalType.CreateInstance<IConditional>();
private static Expression<Func<object, bool>> Lambda(Type objectType, Func<ParameterExpression, Expression> body)
{
var obj = Expression.Parameter(typeof(object), "obj");
var target = Expression.Variable(objectType, "target");
return Expression.Lambda<Func<object, bool>>(
Expression.Block(
[target],
Expression.Assign(target, Expression.TypeAs(obj, objectType)),
body(target)
),
obj
);
}
}
}

View file

@ -1,252 +1,95 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using System.Linq.Expressions;
namespace Server.Commands.Generic
{
public static class DistinctCompiler
{
public static IComparer<T> Compile<T>(AssemblyEmitter assembly, Type objectType, Property[] props)
private sealed class DistinctComparer<T> : IComparer<T>, IEqualityComparer<T>
{
var typeBuilder = assembly.DefineType(
"__distinct",
TypeAttributes.Public,
typeof(object)
private readonly Comparison<T> _compare;
private readonly Func<T, int> _hash;
public DistinctComparer(Comparison<T> compare, Func<T, int> hash)
{
_compare = compare;
_hash = hash;
}
public int Compare(T x, T y) => _compare(x, y);
public bool Equals(T x, T y) => _compare(x, y) == 0;
public int GetHashCode(T obj) => _hash(obj);
}
/// <summary>
/// A comparer that treats two objects as the same when every one of
/// <paramref name="props" /> reads equal on both. Ordering is the sort compiler's, all
/// ascending, so the result doubles as an <see cref="IEqualityComparer{T}" />.
/// </summary>
public static IComparer<T> Compile<T>(Type objectType, Property[] props)
{
var signs = new int[props.Length];
Array.Fill(signs, 1);
return new DistinctComparer<T>(
SortCompiler.Build<T>(objectType, props, signs).Compile(),
BuildHash<T>(objectType, props).Compile()
);
}
// XOR of each property's hash; a null reference hashes to 0 and an int hashes to itself.
public static Expression<Func<T, int>> BuildHash<T>(Type objectType, Property[] props)
{
var arg = Expression.Parameter(typeof(T), "obj");
var target = Expression.Variable(objectType, "target");
Expression hash = Expression.Constant(0);
for (var i = 0; i < props.Length; ++i)
{
var ctor = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
Type.EmptyTypes
);
var part = HashOf(target, props[i]);
var il = ctor.GetILGenerator();
// : base()
il.Emit(OpCodes.Ldarg_0);
il.Emit(
OpCodes.Call,
typeof(T).GetConstructor(Type.EmptyTypes) ??
throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}")
);
// return;
il.Emit(OpCodes.Ret);
hash = i == 0 ? part : Expression.ExclusiveOr(hash, part);
}
typeBuilder.AddInterfaceImplementation(typeof(IComparer<T>));
return Expression.Lambda<Func<T, int>>(
Expression.Block(
[target],
Expression.Assign(target, Expression.TypeAs(arg, objectType)),
hash
),
arg
);
}
MethodBuilder compareMethod;
private static Expression HashOf(Expression target, Property prop)
{
var read = PropertyExpressions.ChainOrDefault(target, prop);
var type = prop.Type;
if (type == typeof(int))
{
var emitter = new MethodEmitter(typeBuilder);
emitter.Define(
/* name */ "Compare",
/* attr */
MethodAttributes.Public | MethodAttributes.Virtual,
/* return */
typeof(int),
/* params */
new[] { typeof(T), typeof(T) }
);
var a = emitter.CreateLocal(objectType);
var b = emitter.CreateLocal(objectType);
var v = emitter.CreateLocal(typeof(int));
emitter.LoadArgument(1);
emitter.CastAs(objectType);
emitter.StoreLocal(a);
emitter.LoadArgument(2);
emitter.CastAs(objectType);
emitter.StoreLocal(b);
emitter.Load(0);
emitter.StoreLocal(v);
var end = emitter.CreateLabel();
for (var i = 0; i < props.Length; ++i)
{
if (i > 0)
{
emitter.LoadLocal(v);
emitter.BranchIfTrue(end);
}
var prop = props[i];
emitter.LoadLocal(a);
emitter.Chain(prop);
var couldCompare =
emitter.CompareTo(
1,
() =>
{
emitter.LoadLocal(b);
emitter.Chain(prop);
}
);
if (!couldCompare)
{
throw new InvalidOperationException("Property is not comparable.");
}
emitter.StoreLocal(v);
}
emitter.MarkLabel(end);
emitter.LoadLocal(v);
emitter.Return();
typeBuilder.DefineMethodOverride(
emitter.Method,
typeof(IComparer<T>).GetMethod(
"Compare",
new[]
{
typeof(T),
typeof(T)
}
) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}")
);
compareMethod = emitter.Method;
return read;
}
typeBuilder.AddInterfaceImplementation(typeof(IEqualityComparer<T>));
var value = Expression.Variable(type, prop.Binding);
var getHashCode = type.GetMethod("GetHashCode", Type.EmptyTypes) ?? typeof(object).GetMethod("GetHashCode", Type.EmptyTypes)!;
Expression hash = Expression.Call(value, getHashCode);
if (!type.IsValueType)
{
var emitter = new MethodEmitter(typeBuilder);
emitter.Define(
/* name */ "Equals",
/* attr */
MethodAttributes.Public | MethodAttributes.Virtual,
/* return */
typeof(bool),
/* params */
new[] { typeof(T), typeof(T) }
);
emitter.Generator.Emit(OpCodes.Ldarg_0);
emitter.Generator.Emit(OpCodes.Ldarg_1);
emitter.Generator.Emit(OpCodes.Ldarg_2);
emitter.Generator.Emit(OpCodes.Call, compareMethod);
emitter.Generator.Emit(OpCodes.Ldc_I4_0);
emitter.Generator.Emit(OpCodes.Ceq);
emitter.Generator.Emit(OpCodes.Ret);
typeBuilder.DefineMethodOverride(
emitter.Method,
typeof(IEqualityComparer<T>).GetMethod(
"Equals",
new[]
{
typeof(T),
typeof(T)
}
) ?? throw new Exception($"No Equals method found for type {typeof(T).FullName}")
hash = Expression.Condition(
Expression.ReferenceNotEqual(value, Expression.Constant(null, type)),
hash,
Expression.Constant(0)
);
}
{
var emitter = new MethodEmitter(typeBuilder);
emitter.Define(
/* name */ "GetHashCode",
/* attr */
MethodAttributes.Public | MethodAttributes.Virtual,
/* return */
typeof(int),
/* params */
new[] { typeof(T) }
);
var obj = emitter.CreateLocal(objectType);
emitter.LoadArgument(1);
emitter.CastAs(objectType);
emitter.StoreLocal(obj);
for (var i = 0; i < props.Length; ++i)
{
var prop = props[i];
emitter.LoadLocal(obj);
emitter.Chain(prop);
var active = emitter.Active;
var getHashCode = active.GetMethod("GetHashCode", Type.EmptyTypes)
?? typeof(T).GetMethod("GetHashCode", Type.EmptyTypes);
if (active != typeof(int))
{
if (!active.IsValueType)
{
var value = emitter.AcquireTemp(active);
var valueNotNull = emitter.CreateLabel();
var done = emitter.CreateLabel();
emitter.StoreLocal(value);
emitter.LoadLocal(value);
emitter.BranchIfTrue(valueNotNull);
emitter.Load(0);
emitter.Pop(typeof(int));
emitter.Branch(done);
emitter.MarkLabel(valueNotNull);
emitter.LoadLocal(value);
emitter.Call(getHashCode);
emitter.ReleaseTemp(value);
emitter.MarkLabel(done);
}
else
{
emitter.Call(getHashCode);
}
}
if (i > 0)
{
emitter.Xor();
}
}
emitter.Return();
typeBuilder.DefineMethodOverride(
emitter.Method,
typeof(IEqualityComparer<T>).GetMethod(
"GetHashCode",
new[]
{
typeof(T)
}
) ?? throw new Exception($"No GetHashCode method found for type {typeof(T).FullName}")
);
}
var comparerType = typeBuilder.CreateType();
return comparerType.CreateInstance<IComparer<T>>();
return Expression.Block([value], Expression.Assign(value, read), hash);
}
}
}

View file

@ -0,0 +1,355 @@
using System;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
namespace Server.Commands.Generic;
/// <summary>
/// Expression-tree fragments over a bound <see cref="Property" /> chain, shared by the
/// conditional, sort and distinct compilers. Everything here builds an <see cref="Expression" />;
/// the compilers assemble those into a lambda and hand <c>Compile()</c> the codegen.
/// </summary>
public static class PropertyExpressions
{
private static readonly MethodInfo _objectEquals = typeof(object).GetMethod(
nameof(object.Equals),
BindingFlags.Public | BindingFlags.Static,
[typeof(object), typeof(object)]
)!;
/// <summary>
/// Walks a property binding. A binding of more than one property (<c>Message.Number</c>)
/// dereferences each link in turn, and any link but the last can legitimately be null -- an
/// unset TextDefinition, an unparented item. Each reference-typed intermediate link is stored
/// once and null-checked; a null there yields <paramref name="whenUnreadable" /> in place of
/// whatever <paramref name="onValue" /> would have built from the final link.
/// </summary>
public static Expression Chain(
Expression target,
Property prop,
Func<Expression, Expression> onValue,
Expression whenUnreadable
) => ChainFrom(target, prop.Chain, 0, onValue, whenUnreadable);
private static Expression ChainFrom(
Expression current,
PropertyInfo[] chain,
int index,
Func<Expression, Expression> onValue,
Expression whenUnreadable
)
{
var link = Expression.Property(current, chain[index]);
// The last link is the value being tested, so a null there is the caller's business.
if (index == chain.Length - 1)
{
return onValue(link);
}
if (link.Type.IsValueType)
{
return ChainFrom(link, chain, index + 1, onValue, whenUnreadable);
}
var local = Expression.Variable(link.Type, chain[index].Name);
return Expression.Block(
[local],
Expression.Assign(local, link),
Expression.Condition(
Expression.ReferenceNotEqual(local, Expression.Constant(null, local.Type)),
ChainFrom(local, chain, index + 1, onValue, whenUnreadable),
whenUnreadable
)
);
}
/// <summary>
/// Walks a binding for a caller that has no way to express "no match" -- ordering and
/// grouping, where the value itself is the answer rather than a yes or no. An unreadable
/// link yields <c>default(T)</c>, which is what a null link along the way amounts to; the
/// comparers these feed already handle a null value.
/// </summary>
public static Expression ChainOrDefault(Expression target, Property prop) =>
Chain(target, prop, static value => value, Expression.Default(prop.Type));
/// <summary>
/// Equality for the "not comparable" path, which supports only == and !=. Reference equality
/// would miss a type whose equality is by value -- <see cref="TextDefinition" /> among them --
/// so this is static <c>object.Equals</c>, which honors the override and is null-safe on
/// either side. Value types box; they only reach here when they have no <c>CompareTo</c>.
/// </summary>
public static Expression ValueEquals(Expression a, Expression b) =>
Expression.Call(_objectEquals, Box(a), Box(b));
private static Expression Box(Expression e) =>
e.Type == typeof(object) ? e : Expression.Convert(e, typeof(object));
/// <summary>
/// A boolean test of <paramref name="a" /> against <paramref name="b" />. Integral primitives
/// and enums compare with the operator itself -- on the unsigned types as unsigned; nothing
/// here widens to a signed type. Everything else goes through <see cref="TryCompare" /> and
/// tests the sign of the result. <c>Nullable&lt;T&gt;</c> is lifted either way, as C# lifts it:
/// two nulls are equal, a null and a value are unequal, and a null satisfies no relation.
/// (A null <em>reference</em> keeps the ordering <see cref="TryCompare" /> gives it.) False
/// when the type has no <c>CompareTo</c> at all, in which case only equality is meaningful.
/// </summary>
public static bool TryRelational(Expression a, Expression b, ComparisonOperator op, out Expression test)
{
var type = a.Type;
var underlying = Nullable.GetUnderlyingType(type);
var nonNullable = underlying ?? type;
if (underlying != null && !underlying.IsEnum && !IsIntegral(underlying))
{
return TryLiftedRelational(a, b, op, out test);
}
if (nonNullable.IsEnum)
{
// Equal/NotEqual are defined on enums; the relational operators are not, so those
// read the underlying integer. Convert lifts over Nullable<E> on its own.
if (op is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual))
{
var integer = Enum.GetUnderlyingType(nonNullable);
if (underlying != null)
{
integer = typeof(Nullable<>).MakeGenericType(integer);
}
a = Expression.Convert(a, integer);
b = Expression.Convert(b, integer);
}
test = Relational(a, b, op);
return true;
}
if (IsIntegral(nonNullable))
{
test = Relational(a, b, op);
return true;
}
if (!TryCompare(a, b, 1, out var comparison))
{
test = null;
return false;
}
test = Relational(comparison, Expression.Constant(0), op);
return true;
}
// Nullable<T> over a type that compares through CompareTo: the values compare when both are
// present, and the HasValue flags decide otherwise, the way the language lifts an operator.
private static bool TryLiftedRelational(Expression a, Expression b, ComparisonOperator op, out Expression test)
{
var left = Expression.Variable(a.Type, "left");
var right = Expression.Variable(a.Type, "right");
var couldCompare = TryCompareValues(
Expression.Property(left, "Value"),
Expression.Property(right, "Value"),
1,
out var comparison
);
if (!couldCompare)
{
test = null;
return false;
}
var leftHasValue = Expression.Property(left, "HasValue");
var rightHasValue = Expression.Property(right, "HasValue");
var both = Expression.AndAlso(leftHasValue, rightHasValue);
var relation = Relational(comparison, Expression.Constant(0), op);
Expression lifted = op switch
{
ComparisonOperator.Equal => Expression.Condition(both, relation, Expression.Equal(leftHasValue, rightHasValue)),
ComparisonOperator.NotEqual => Expression.Condition(both, relation, Expression.NotEqual(leftHasValue, rightHasValue)),
_ => Expression.AndAlso(both, relation)
};
test = Expression.Block(
[left, right],
Expression.Assign(left, a),
Expression.Assign(right, b),
lifted
);
return true;
}
private static Expression Relational(Expression a, Expression b, ComparisonOperator op) =>
op switch
{
ComparisonOperator.Equal => Expression.Equal(a, b),
ComparisonOperator.NotEqual => Expression.NotEqual(a, b),
ComparisonOperator.Greater => Expression.GreaterThan(a, b),
ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(a, b),
ComparisonOperator.Lesser => Expression.LessThan(a, b),
ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(a, b),
_ => throw new InvalidOperationException("Invalid comparison operator.")
};
private static bool IsIntegral(Type type) =>
type == typeof(int) || type == typeof(long) || type == typeof(uint) || type == typeof(ulong)
|| type == typeof(short) || type == typeof(ushort) || type == typeof(byte) || type == typeof(sbyte);
/// <summary>
/// An <c>int</c>-valued comparison of <paramref name="a" /> against <paramref name="b" />
/// with <c>CompareTo</c> semantics, multiplied by <paramref name="sign" />. A null on either
/// side of a reference or nullable type is handled here rather than in the callee:
/// <c>null.CompareTo(null) = 0</c>, <c>real.CompareTo(null) = -sign</c>,
/// <c>null.CompareTo(real) = +sign</c>. False when the type has no <c>CompareTo</c>.
/// </summary>
public static bool TryCompare(Expression a, Expression b, int sign, out Expression comparison)
{
var type = a.Type;
// Both sides are read more than once below; pin them so a chained binding is walked once.
var left = Expression.Variable(type, "left");
var right = Expression.Variable(type, "right");
if (!TryCompareValues(left, right, sign, out var body))
{
comparison = null;
return false;
}
comparison = Expression.Block(
[left, right],
Expression.Assign(left, a),
Expression.Assign(right, b),
body
);
return true;
}
private static bool TryCompareValues(Expression a, Expression b, int sign, out Expression comparison)
{
var type = a.Type;
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
{
if (!TryCompareValues(Expression.Property(a, "Value"), Expression.Property(b, "Value"), sign, out var inner))
{
comparison = null;
return false;
}
comparison = NullAware(Expression.Property(a, "HasValue"), Expression.Property(b, "HasValue"), inner, sign);
return true;
}
if (type.IsEnum)
{
var integer = Enum.GetUnderlyingType(type);
return TryCompareValues(Expression.Convert(a, integer), Expression.Convert(b, integer), sign, out comparison);
}
var compareTo = FindCompareTo(type);
if (compareTo == null)
{
comparison = null;
return false;
}
var parameterType = compareTo.GetParameters()[0].ParameterType;
var argument = parameterType == type ? b : Expression.Convert(b, parameterType);
Expression call = Expression.Call(a, compareTo, argument);
if (sign == -1)
{
call = Expression.Negate(call);
}
if (type.IsValueType)
{
comparison = call;
return true;
}
var nil = Expression.Constant(null, type);
comparison = NullAware(Expression.ReferenceNotEqual(a, nil), Expression.ReferenceNotEqual(b, nil), call, sign);
return true;
}
private static Expression NullAware(Expression aHasValue, Expression bHasValue, Expression compare, int sign) =>
Expression.Condition(
aHasValue,
Expression.Condition(bHasValue, compare, Expression.Constant(-sign)),
Expression.Condition(bHasValue, Expression.Constant(sign), Expression.Constant(0))
);
private static MethodInfo FindCompareTo(Type type)
{
var compareTo = type.GetMethod("CompareTo", [type]);
if (compareTo != null)
{
return compareTo;
}
/* There's a scenario where we might be trying to use CompareTo on an interface
* which, while it doesn't explicitly implement CompareTo itself, is said to
* extend IComparable indirectly. The implementation is implicitly passed off
* to implementers, so the interface's own GetMethod("CompareTo") returns null.
*/
var ifaces = type.FindInterfaces(
static (iface, _) => iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IComparable<>),
null
);
for (var i = 0; i < ifaces.Length; ++i)
{
if (ifaces[i].GetGenericArguments()[0].IsAssignableFrom(type))
{
return ifaces[i].GetMethod("CompareTo", [type]);
}
}
return typeof(IComparable).IsAssignableFrom(type)
? typeof(IComparable).GetMethod("CompareTo", [typeof(object)])
: null;
}
/// <summary>
/// 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)
{
if (value is string text)
{
value = Parse(type, text);
}
return Expression.Constant(value, type);
}
private static object Parse(Type type, string text)
{
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;
}
return Types.ParseOrThrow(underlying ?? type, text);
}
}

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using System.Linq.Expressions;
namespace Server.Commands.Generic
{
@ -47,120 +46,83 @@ namespace Server.Commands.Generic
public static class SortCompiler
{
public static IComparer<T> Compile<T>(AssemblyEmitter assembly, Type objectType, OrderInfo[] orders)
public static IComparer<T> Compile<T>(Type objectType, OrderInfo[] orders)
{
var typeBuilder = assembly.DefineType(
"__sort",
TypeAttributes.Public,
typeof(T)
var properties = new Property[orders.Length];
var signs = new int[orders.Length];
for (var i = 0; i < orders.Length; ++i)
{
properties[i] = orders[i].Property;
signs[i] = orders[i].Sign;
}
return Comparer<T>.Create(Build<T>(objectType, properties, signs).Compile());
}
/// <summary>
/// A <see cref="Comparison{T}" /> over <paramref name="properties" />, taken in order: the
/// first property that orders the two objects decides, each multiplied by its sign. Both
/// arguments are cast to <paramref name="objectType" /> first; the bindings are read from
/// that.
/// </summary>
public static Expression<Comparison<T>> Build<T>(Type objectType, Property[] properties, int[] signs)
{
var x = Expression.Parameter(typeof(T), "x");
var y = Expression.Parameter(typeof(T), "y");
var a = Expression.Variable(objectType, "a");
var b = Expression.Variable(objectType, "b");
return Expression.Lambda<Comparison<T>>(
Expression.Block(
[a, b],
Expression.Assign(a, Expression.TypeAs(x, objectType)),
Expression.Assign(b, Expression.TypeAs(y, objectType)),
Ordered(a, b, properties, signs, 0)
),
x,
y
);
}
private static Expression Ordered(Expression a, Expression b, Property[] properties, int[] signs, int index)
{
if (index >= properties.Length)
{
var ctor = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
Type.EmptyTypes
);
var il = ctor.GetILGenerator();
// : base()
il.Emit(OpCodes.Ldarg_0);
il.Emit(
OpCodes.Call,
typeof(T).GetConstructor(Type.EmptyTypes) ??
throw new Exception($"Could not find empty constructor for type {typeof(T).FullName}")
);
// return;
il.Emit(OpCodes.Ret);
return Expression.Constant(0);
}
typeBuilder.AddInterfaceImplementation(typeof(IComparer<T>));
var prop = properties[index];
var couldCompare = PropertyExpressions.TryCompare(
PropertyExpressions.ChainOrDefault(a, prop),
PropertyExpressions.ChainOrDefault(b, prop),
signs[index],
out var comparison
);
if (!couldCompare)
{
var emitter = new MethodEmitter(typeBuilder);
emitter.Define(
/* name */ "Compare",
/* attr */
MethodAttributes.Public | MethodAttributes.Virtual,
/* return */
typeof(int),
/* params */
new[] { typeof(T), typeof(T) }
);
var a = emitter.CreateLocal(objectType);
var b = emitter.CreateLocal(objectType);
var v = emitter.CreateLocal(typeof(int));
emitter.LoadArgument(1);
emitter.CastAs(objectType);
emitter.StoreLocal(a);
emitter.LoadArgument(2);
emitter.CastAs(objectType);
emitter.StoreLocal(b);
emitter.Load(0);
emitter.StoreLocal(v);
var end = emitter.CreateLabel();
for (var i = 0; i < orders.Length; ++i)
{
if (i > 0)
{
emitter.LoadLocal(v);
emitter.BranchIfTrue(end);
}
var orderInfo = orders[i];
var prop = orderInfo.Property;
var sign = orderInfo.Sign;
emitter.LoadLocal(a);
emitter.Chain(prop);
var couldCompare =
emitter.CompareTo(
sign,
() =>
{
emitter.LoadLocal(b);
emitter.Chain(prop);
}
);
if (!couldCompare)
{
throw new InvalidOperationException("Property is not comparable.");
}
emitter.StoreLocal(v);
}
emitter.MarkLabel(end);
emitter.LoadLocal(v);
emitter.Return();
typeBuilder.DefineMethodOverride(
emitter.Method,
typeof(IComparer<T>).GetMethod(
"Compare",
new[]
{
typeof(T),
typeof(T)
}
) ?? throw new Exception($"No Compare method found for type {typeof(T).FullName}")
);
throw new InvalidOperationException("Property is not comparable.");
}
var comparerType = typeBuilder.CreateType();
return comparerType.CreateInstance<IComparer<T>>();
if (index == properties.Length - 1)
{
return comparison;
}
var v = Expression.Variable(typeof(int), "v");
return Expression.Block(
[v],
Expression.Assign(v, comparison),
Expression.Condition(
Expression.NotEqual(v, Expression.Constant(0)),
v,
Ordered(a, b, properties, signs, index + 1)
)
);
}
}
}

View file

@ -21,7 +21,7 @@ namespace Server.Commands.Generic
ExtensionInfo.Register(ExtInfo);
}
public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly)
public override void Optimize(Mobile from, Type baseType)
{
if (baseType == null)
{
@ -34,9 +34,7 @@ namespace Server.Commands.Generic
prop.CheckAccess(from);
}
assembly ??= new AssemblyEmitter("__dynamic");
m_Comparer = DistinctCompiler.Compile<object>(assembly, baseType, m_Properties.ToArray());
m_Comparer = DistinctCompiler.Compile<object>(baseType, m_Properties.ToArray());
}
public override void Parse(Mobile from, string[] arguments, int offset, int size)

View file

@ -20,7 +20,7 @@ namespace Server.Commands.Generic
ExtensionInfo.Register(ExtInfo);
}
public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly)
public override void Optimize(Mobile from, Type baseType)
{
if (baseType == null)
{
@ -33,9 +33,7 @@ namespace Server.Commands.Generic
order.Property.CheckAccess(from);
}
assembly ??= new AssemblyEmitter("__dynamic");
m_Comparer = SortCompiler.Compile<object>(assembly, baseType, m_Orders.ToArray());
m_Comparer = SortCompiler.Compile<object>(baseType, m_Orders.ToArray());
}
public override void Parse(Mobile from, string[] arguments, int offset, int size)

View file

@ -15,14 +15,14 @@ namespace Server.Commands.Generic
ExtensionInfo.Register(ExtInfo);
}
public override void Optimize(Mobile from, Type baseType, ref AssemblyEmitter assembly)
public override void Optimize(Mobile from, Type baseType)
{
if (baseType == null)
{
throw new InvalidOperationException("Insanity.");
}
Conditional.Compile(ref assembly);
Conditional.Compile();
}
public override void Parse(Mobile from, string[] arguments, int offset, int size)

View file

@ -27,15 +27,13 @@ namespace Server.Commands.Generic
public bool HasCompiled => m_Conditionals != null;
public void Compile(ref AssemblyEmitter emitter)
public void Compile()
{
emitter ??= new AssemblyEmitter("__dynamic");
m_Conditionals = new IConditional[m_Conditions.Length];
for (var i = 0; i < m_Conditionals.Length; ++i)
{
m_Conditionals[i] = ConditionalCompiler.Compile(emitter, Type, m_Conditions[i], i);
m_Conditionals[i] = ConditionalCompiler.Compile(Type, m_Conditions[i]);
}
}
@ -48,9 +46,7 @@ namespace Server.Commands.Generic
if (!HasCompiled)
{
AssemblyEmitter emitter = null;
Compile(ref emitter);
Compile();
}
for (var i = 0; i < m_Conditionals.Length; ++i)

View file

@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using Server.Logging;
namespace Server.Commands;
/// <summary>
/// Measures whether entities serialize to the same bytes over time when nothing changed them.
/// Hashes every entity of every registered entity persistence, waits, hashes again, and reports
/// per type how many records changed. Both passes are chunked across ticks so the loop never
/// stalls, and the wait spans real time so anything derived from <see cref="Core.Now" /> (which
/// is frozen within a tick) shows up. On an idle shard the only legitimate churn is NPC movement
/// and regeneration; a static type with a high changed fraction is a serialization bug.
/// </summary>
public static class SaveStability
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(SaveStability));
private const int DefaultDelaySeconds = 60;
private static readonly TimeSpan TickBudget = TimeSpan.FromMilliseconds(20);
private static Run _current;
public sealed class TypeStats
{
public int Total;
public int Changed;
}
public sealed record SaveStabilityReport(
int Checked,
int Changed,
Dictionary<Type, TypeStats> ByType
);
public static void Configure()
{
CommandSystem.Register("SaveStability", AccessLevel.Administrator, SaveStability_OnCommand);
}
[Usage("SaveStability [delaySeconds=60] [sampleStride=1] | SaveStability cancel")]
[Description("Hashes every entity, waits, hashes again, and reports the types whose serialized bytes changed.")]
private static void SaveStability_OnCommand(CommandEventArgs e)
{
var from = e.Mobile;
if (e.Length > 0 && e.GetString(0).InsensitiveEquals("cancel"))
{
if (_current != null)
{
_current.Cancel();
_current = null;
from.SendMessage("Save stability check cancelled.");
}
else
{
from.SendMessage("No save stability check is running.");
}
return;
}
if (_current != null)
{
from.SendMessage("A save stability check is already running. Use [SaveStability cancel to stop it.");
return;
}
var delaySeconds = e.Length > 0 ? e.GetInt32(0) : DefaultDelaySeconds;
var stride = e.Length > 1 ? e.GetInt32(1) : 1;
if (delaySeconds < 1 || stride < 1)
{
from.SendMessage("Usage: [SaveStability [delaySeconds] [sampleStride]");
return;
}
_current = new Run(from, TimeSpan.FromSeconds(delaySeconds), stride);
_current.Start();
}
/// <summary>Snapshots every entity of every registered entity persistence, taking every Nth.</summary>
public static List<ISerializable> SnapshotEntities(int stride = 1)
{
var list = new List<ISerializable>();
var i = 0;
foreach (var persistence in Persistence.EntityPersistences)
{
foreach (var entity in persistence.EnumerateEntities())
{
if (i++ % stride == 0)
{
list.Add(entity);
}
}
}
return list;
}
/// <summary>Hashes the serialized bytes of every entity in order; deleted entities hash to 0.</summary>
public static ulong[] CaptureHashes(List<ISerializable> entities)
{
var hashes = new ulong[entities.Count];
var writer = new BufferWriter(true);
for (var i = 0; i < entities.Count; i++)
{
hashes[i] = Hash(entities[i], writer);
}
writer.Close();
return hashes;
}
/// <summary>Re-hashes every entity and reports, per type, how many differ from the captured hashes.</summary>
public static SaveStabilityReport Compare(List<ISerializable> entities, ulong[] captured)
{
var writer = new BufferWriter(true);
var report = new SaveStabilityReport(0, 0, new Dictionary<Type, TypeStats>());
var checkedCount = 0;
var changed = 0;
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity.Deleted || captured[i] == 0)
{
continue;
}
checkedCount++;
var type = entity.GetType();
if (!report.ByType.TryGetValue(type, out var stats))
{
report.ByType[type] = stats = new TypeStats();
}
stats.Total++;
if (Hash(entity, writer) != captured[i])
{
stats.Changed++;
changed++;
}
}
writer.Close();
return report with { Checked = checkedCount, Changed = changed };
}
private static ulong Hash(ISerializable entity, BufferWriter writer)
{
if (entity.Deleted)
{
return 0;
}
writer.Seek(0, SeekOrigin.Begin);
entity.Serialize(writer);
return HashUtility.ComputeHash64(writer.Buffer.AsSpan(0, (int)writer.Position));
}
// Drives capture -> wait -> compare across ticks under a fixed time budget per tick.
private sealed class Run
{
private readonly Mobile _from;
private readonly TimeSpan _delay;
private readonly int _stride;
private readonly BufferWriter _writer = new(true);
private List<ISerializable> _entities;
private ulong[] _hashes;
private SaveStabilityReport _report;
private int _index;
private bool _comparing;
private Timer _timer;
public Run(Mobile from, TimeSpan delay, int stride)
{
_from = from;
_delay = delay;
_stride = stride;
}
public void Start()
{
_entities = SnapshotEntities(_stride);
_hashes = new ulong[_entities.Count];
_from.SendMessage($"Save stability: hashing {_entities.Count} entities across ticks...");
_timer = Timer.DelayCall(TimeSpan.Zero, TimeSpan.Zero, Step);
}
public void Cancel()
{
_timer?.Stop();
_timer = null;
_writer.Close();
}
private void Step()
{
var started = Stopwatch.GetTimestamp();
while (_index < _entities.Count)
{
var entity = _entities[_index];
if (_comparing)
{
CompareOne(entity, _index);
}
else
{
_hashes[_index] = Hash(entity, _writer);
}
_index++;
if (Stopwatch.GetElapsedTime(started) >= TickBudget)
{
return;
}
}
_timer.Stop();
_timer = null;
if (!_comparing)
{
_from.SendMessage($"Save stability: captured. Comparing again in {_delay.TotalSeconds:F0}s.");
_comparing = true;
_index = 0;
_report = new SaveStabilityReport(0, 0, new Dictionary<Type, TypeStats>());
_timer = Timer.DelayCall(_delay, TimeSpan.Zero, Step);
return;
}
Finish();
}
private void CompareOne(ISerializable entity, int index)
{
if (entity.Deleted || _hashes[index] == 0)
{
return;
}
var type = entity.GetType();
if (!_report.ByType.TryGetValue(type, out var stats))
{
_report.ByType[type] = stats = new TypeStats();
}
stats.Total++;
if (Hash(entity, _writer) != _hashes[index])
{
stats.Changed++;
}
}
private void Finish()
{
_writer.Close();
_current = null;
var checkedCount = 0;
var changed = 0;
var ranked = new List<KeyValuePair<Type, TypeStats>>();
foreach (var pair in _report.ByType)
{
checkedCount += pair.Value.Total;
changed += pair.Value.Changed;
if (pair.Value.Changed > 0)
{
ranked.Add(pair);
}
}
ranked.Sort(static (a, b) => b.Value.Changed.CompareTo(a.Value.Changed));
_from.SendMessage($"Save stability: {changed} of {checkedCount} entities changed over {_delay.TotalSeconds:F0}s.");
logger.Information(
"Save stability: {Changed} of {Checked} entities changed over {Delay}s ({Types} types)",
changed,
checkedCount,
_delay.TotalSeconds,
ranked.Count
);
var shown = 0;
foreach (var (type, stats) in ranked)
{
var percent = stats.Changed * 100.0 / stats.Total;
logger.Information(" {Type}: {Changed}/{Total} ({Percent:F1}%)", type.FullName, stats.Changed, stats.Total, percent);
if (shown++ < 25)
{
_from.SendMessage($" {type.Name}: {stats.Changed}/{stats.Total} ({percent:F1}%)");
}
}
if (ranked.Count > 25)
{
_from.SendMessage($" ...{ranked.Count - 25} more types in the log.");
}
}
}
}

View file

@ -0,0 +1,432 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using Server.Commands.Generic;
namespace Server.Engines.AdvancedSearch;
/// <summary>
/// Turns a property-test string from the Advanced Search gump into a compiled predicate. The
/// grammar is the gump's own -- <c>~</c> negates a leaf, <c>@</c> is AND, <c>|</c> is OR and binds
/// looser, and the string operators double up (<c>&gt;</c> is "starts with" on a string) -- but
/// each leaf becomes the same <see cref="ICondition" /> a <c>where</c> clause compiles, so there is
/// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match".
/// </summary>
/// <remarks>
/// Runs on the search workers, off the game loop: binding is reflection, compiling is
/// <c>Expression.Compile</c>, and neither touches game state. The one exception is a value that
/// names an entity by serial, which <see cref="Types.TryParse" /> resolves through the world --
/// the same read the previous per-entity evaluator made, now made once per type instead.
/// </remarks>
public static class AdvancedSearchConditions
{
// Runtime type -> its public readable instance properties, for the case-insensitive name scan.
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _properties = new();
private static readonly Func<object, bool> _never = static _ => false;
/// <summary>
/// Per-search memo shared by every worker. Predicates are keyed twice: by the runtime type
/// seen, and by the type the predicate was actually compiled for -- the most derived type
/// that declares one of the properties -- so every subclass of Item that does not hide
/// <c>Hue</c> shares one compiled <c>Hue = 5</c>.
/// </summary>
public sealed class Cache
{
internal readonly ConcurrentDictionary<Type, Func<object, bool>> ByRuntimeType = new();
internal readonly ConcurrentDictionary<Type, Func<object, bool>> ByCompiledType = new();
}
public static Func<object, bool> GetPredicate(Cache cache, Type runtimeType, string propertyTest) =>
cache.ByRuntimeType.GetOrAdd(
runtimeType,
static (type, state) => Build(state.cache, type, state.propertyTest),
(cache, propertyTest)
);
/// <summary>Compiles without a cache. Test seam and one-off use.</summary>
public static Func<object, bool> Compile(Type runtimeType, string propertyTest) =>
Build(new Cache(), runtimeType, propertyTest);
private static Func<object, bool> Build(Cache cache, Type runtimeType, string propertyTest)
{
var groups = Parse(runtimeType, propertyTest, out var compiledType);
if (groups == null)
{
return _never;
}
return cache.ByCompiledType.GetOrAdd(
compiledType,
static (type, groups) =>
{
try
{
return ConditionalCompiler.Build(type, groups).Compile();
}
catch (Exception)
{
return _never;
}
},
groups
);
}
// OR of ANDs, which is what splitting on '|' and then on '@' yields. A group with a dead leaf
// is dropped; no live group left means nothing can match, reported as null.
private static ICondition[][] Parse(Type runtimeType, string propertyTest, out Type compiledType)
{
Type mostDerived = null;
var groups = new List<ICondition[]>();
foreach (var orPart in propertyTest.Split('|'))
{
var group = new List<ICondition> { TypeCondition.Default };
var alive = true;
foreach (var andPart in orPart.Split('@'))
{
var leaf = Leaf(runtimeType, andPart, out var declaringType);
if (leaf == null)
{
alive = false;
break;
}
group.Add(leaf);
// Every declaring type is an ancestor of the runtime type (or the type itself), so
// they nest; the predicate is compiled for the most derived one any leaf needs and
// serves every runtime type that resolves the same properties.
if (mostDerived == null || declaringType.IsAssignableTo(mostDerived))
{
mostDerived = declaringType;
}
}
if (alive)
{
groups.Add(group.ToArray());
}
}
compiledType = mostDerived ?? runtimeType;
return groups.Count > 0 ? groups.ToArray() : null;
}
private static ICondition Leaf(Type runtimeType, ReadOnlySpan<char> expression, out Type declaringType)
{
declaringType = runtimeType;
expression = expression.Trim();
if (expression.Length == 0)
{
return null;
}
var negate = false;
if (expression[0] == '~')
{
negate = true;
expression = expression[1..];
}
var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex);
if (operatorSpan.Length == 0)
{
return null;
}
var propertyName = expression[..operatorIndex].Trim();
var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim();
if (valuePart.Length == 0)
{
return null;
}
var chain = Resolve(runtimeType, propertyName);
if (chain == null)
{
return null;
}
declaringType = chain[0].DeclaringType!;
var property = new Property(chain);
var type = property.Type;
var op = operatorSpan.ToString();
var value = valuePart.ToString();
ICondition condition;
if (type == typeof(string))
{
condition = StringLeaf(property, negate, op, value);
}
else if (type == typeof(double) || type == typeof(float))
{
condition = EpsilonLeaf(property, negate, op, value);
}
else
{
condition = ComparisonLeaf(property, negate, op, value);
}
return condition != null && Probe(condition, runtimeType) ? condition : null;
}
// A leaf the compiler rejects -- a relational operator on a type with no CompareTo -- is
// "no match" for that leaf, not an error for the whole search.
private static bool Probe(ICondition condition, Type runtimeType)
{
try
{
condition.Build(Expression.Parameter(runtimeType, "probe"));
return true;
}
catch (Exception)
{
return false;
}
}
private static ICondition StringLeaf(Property property, bool negate, string op, string value)
{
var (stringOp, ignoreCase) = op switch
{
"=" or "==" => (StringOperator.Equal, false),
"!" or "!=" => (StringOperator.NotEqual, false),
">" => (StringOperator.StartsWith, false),
"<" => (StringOperator.EndsWith, false),
"~" => (StringOperator.Contains, false),
"~>" => (StringOperator.StartsWith, true),
"~<" => (StringOperator.EndsWith, true),
"~~" => (StringOperator.Contains, true),
"~=" => (StringOperator.Equal, true),
"~!" => (StringOperator.NotEqual, true),
_ => ((StringOperator?)null, false)
};
if (stringOp == null)
{
return null;
}
// `null` is the null string for equality, as it is in a where clause; for the substring
// operators, where a null needle means nothing, it is the four-letter word.
if (value == "null" && stringOp is not (StringOperator.Equal or StringOperator.NotEqual))
{
value = @"@""null""";
}
return new StringCondition(property, negate, stringOp.Value, value, ignoreCase);
}
private static ICondition EpsilonLeaf(Property property, bool negate, string op, string value)
{
var comparison = MapOperator(op);
if (comparison == null)
{
return null;
}
// A float property parses its value as a float first, so the widened constant carries the
// same rounding the property's own value does.
double parsed;
if (property.Type == typeof(float))
{
if (!float.TryParse(value, null, out var f))
{
return null;
}
parsed = f;
}
else if (!double.TryParse(value, null, out parsed))
{
return null;
}
return new EpsilonCondition(property, negate, comparison.Value, parsed, AdvancedSearchUtilities.CalculateEpsilon(value));
}
private static ICondition ComparisonLeaf(Property property, bool negate, string op, string value)
{
var comparison = MapOperator(op);
if (comparison == null)
{
return null;
}
var type = property.Type;
var underlying = Nullable.GetUnderlyingType(type);
object parsed;
if (value == "null" && (underlying != null || !type.IsValueType))
{
parsed = null;
}
else if (underlying == null && type == typeof(bool))
{
// The gump accepts the switch words as well as the literals.
if (comparison is not (ComparisonOperator.Equal or ComparisonOperator.NotEqual))
{
return null;
}
parsed = value.ToLowerInvariant() switch
{
"true" or "1" or "enabled" or "on" => true,
"false" or "0" or "disabled" or "off" => false,
_ => null
};
if (parsed == null)
{
return null;
}
}
else if (Types.TryParse(underlying ?? type, value, out parsed) != null)
{
return null;
}
return new ComparisonCondition(property, negate, comparison.Value, parsed);
}
private static ComparisonOperator? MapOperator(string op) =>
op switch
{
"=" or "==" => ComparisonOperator.Equal,
"!" or "!=" => ComparisonOperator.NotEqual,
">" => ComparisonOperator.Greater,
"<" => ComparisonOperator.Lesser,
">=" => ComparisonOperator.GreaterEqual,
"<=" => ComparisonOperator.LesserEqual,
_ => null
};
// Case-insensitive, first readable match per link, the way the gump has always resolved a
// name. A dotted name walks into the property's type.
private static PropertyInfo[] Resolve(Type type, ReadOnlySpan<char> name)
{
var count = name.Count('.') + 1;
var chain = new PropertyInfo[count];
for (var i = 0; i < count; ++i)
{
var dot = name.IndexOf('.');
var segment = dot == -1 ? name : name[..dot];
name = dot == -1 ? default : name[(dot + 1)..];
var found = Find(type, segment.Trim());
if (found == null)
{
return null;
}
chain[i] = found;
type = found.PropertyType;
}
return chain;
}
private static PropertyInfo Find(Type type, ReadOnlySpan<char> name)
{
var properties = _properties.GetOrAdd(type, static t => Readable(t));
for (var i = 0; i < properties.Length; ++i)
{
if (name.InsensitiveEquals(properties[i].Name))
{
return properties[i];
}
}
return null;
}
private static PropertyInfo[] Readable(Type type)
{
var all = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var readable = new List<PropertyInfo>(all.Length);
for (var i = 0; i < all.Length; ++i)
{
if (all[i].CanRead && all[i].GetIndexParameters().Length == 0)
{
readable.Add(all[i]);
}
}
return readable.ToArray();
}
/// <summary>
/// A floating-point comparison with the tolerance the gump derives from the typed value: a
/// value with no decimal point compares to within 1E-10, one with ten or more decimals to
/// within the last digit typed.
/// </summary>
private sealed class EpsilonCondition : ICondition
{
private static readonly MethodInfo _abs = typeof(Math).GetMethod(nameof(Math.Abs), [typeof(double)])!;
private readonly Property _property;
private readonly bool _not;
private readonly ComparisonOperator _operator;
private readonly double _value;
private readonly double _epsilon;
public EpsilonCondition(Property property, bool not, ComparisonOperator op, double value, double epsilon)
{
_property = property;
_not = not;
_operator = op;
_value = value;
_epsilon = epsilon;
}
public Expression Build(ParameterExpression target) =>
PropertyExpressions.Chain(
target,
_property,
read =>
{
var value = read.Type == typeof(double) ? read : Expression.Convert(read, typeof(double));
var constant = Expression.Constant(_value);
var epsilon = Expression.Constant(_epsilon);
var distance = Expression.Call(_abs, Expression.Subtract(value, constant));
Expression test = _operator switch
{
ComparisonOperator.Equal => Expression.LessThan(distance, epsilon),
ComparisonOperator.NotEqual => Expression.GreaterThanOrEqual(distance, epsilon),
ComparisonOperator.Greater => Expression.GreaterThan(value, Expression.Add(constant, epsilon)),
ComparisonOperator.Lesser => Expression.LessThan(value, Expression.Subtract(constant, epsilon)),
ComparisonOperator.GreaterEqual => Expression.GreaterThanOrEqual(value, Expression.Subtract(constant, epsilon)),
ComparisonOperator.LesserEqual => Expression.LessThanOrEqual(value, Expression.Add(constant, epsilon)),
_ => throw new InvalidOperationException("Invalid comparison operator.")
};
return _not ? Expression.Not(test) : test;
},
Expression.Constant(false)
);
}
}

View file

@ -767,11 +767,12 @@ public class AdvancedSearchGump : Gump
var ignoreQueue = new ConcurrentQueue<IEntity>();
var results = new ConcurrentQueue<AdvancedSearchResult>();
var predicates = new AdvancedSearchConditions.Cache();
var worldLocation = new WorldLocation(from.Location, from.Map);
for (var i = 0; i < _threadWorkers.Length; i++)
{
(_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue);
(_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue, predicates);
}
var type = Filter.FilterType ? Filter.Type : null;
@ -818,6 +819,8 @@ public class AdvancedSearchGump : Gump
}
}
resultsList.Sort(AdvancedSearchResultSerialComparer.Instance);
SearchResults = resultsList.ToArray();
// Force the GC to collect the results

View file

@ -2,86 +2,118 @@ using System.Collections.Generic;
namespace Server.Engines.AdvancedSearch;
public class AdvancedSearchResultTypeComparer : IComparer<AdvancedSearchResult>
// Results arrive in worker-finish order, so every sort ends in the same serial tie-break.
public abstract class AdvancedSearchResultComparer : IComparer<AdvancedSearchResult>
{
public static readonly AdvancedSearchResultTypeComparer Instance = new();
public static readonly AdvancedSearchResultTypeComparer InstanceReverse = new(true);
protected readonly bool Reverse;
private readonly bool _reverse;
public AdvancedSearchResultTypeComparer(bool reverse = false) => _reverse = reverse;
protected AdvancedSearchResultComparer(bool reverse) => Reverse = reverse;
public int Compare(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x?.Entity?.GetType().Name;
var b = y?.Entity?.GetType().Name;
return _reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b);
}
}
public class AdvancedSearchResultNameComparer : IComparer<AdvancedSearchResult>
{
public static readonly AdvancedSearchResultNameComparer Instance = new();
public static readonly AdvancedSearchResultNameComparer InstanceReverse = new(true);
private readonly bool _reverse;
public AdvancedSearchResultNameComparer(bool reverse = false) => _reverse = reverse;
public int Compare(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x?.Name;
var b = y?.Name;
return _reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b);
}
}
public class AdvancedSearchResultMapComparer : IComparer<AdvancedSearchResult>
{
public static readonly AdvancedSearchResultMapComparer Instance = new();
public static readonly AdvancedSearchResultMapComparer InstanceReverse = new(true);
private readonly bool _reverse;
public AdvancedSearchResultMapComparer(bool reverse = false) => _reverse = reverse;
public int Compare(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x?.Map?.MapID ?? -1;
var b = y?.Map?.MapID ?? -1;
return _reverse ? b.CompareTo(a) : a.CompareTo(b);
}
}
public class AdvancedSearchRangeComparer : IComparer<AdvancedSearchResult>
{
private readonly bool _reverse;
private readonly Mobile _from;
public AdvancedSearchRangeComparer(Mobile from, bool reverse = false)
{
_from = from;
_reverse = reverse;
}
public int Compare(AdvancedSearchResult x, AdvancedSearchResult y)
{
if (_from == null || x == null && y == null)
if (ReferenceEquals(x, y))
{
return 0;
}
if (x == null)
{
return _reverse ? 1 : -1;
return -1;
}
if (y == null)
{
return _reverse ? -1 : 1;
return 1;
}
var c = CompareKey(x, y);
return c != 0 ? c : CompareSerial(x, y);
}
protected abstract int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y);
// Always ascending; the direction applies to the key only.
public static int CompareSerial(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x.Entity?.Serial ?? Serial.Zero;
var b = y.Entity?.Serial ?? Serial.Zero;
return a.CompareTo(b);
}
}
public sealed class AdvancedSearchResultSerialComparer : AdvancedSearchResultComparer
{
public static readonly AdvancedSearchResultSerialComparer Instance = new();
private AdvancedSearchResultSerialComparer() : base(false)
{
}
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) => 0;
}
public sealed class AdvancedSearchResultTypeComparer : AdvancedSearchResultComparer
{
public static readonly AdvancedSearchResultTypeComparer Instance = new();
public static readonly AdvancedSearchResultTypeComparer InstanceReverse = new(true);
public AdvancedSearchResultTypeComparer(bool reverse = false) : base(reverse)
{
}
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x.Entity?.GetType().Name;
var b = y.Entity?.GetType().Name;
return Reverse ? b.InsensitiveCompare(a) : a.InsensitiveCompare(b);
}
}
public sealed class AdvancedSearchResultNameComparer : AdvancedSearchResultComparer
{
public static readonly AdvancedSearchResultNameComparer Instance = new();
public static readonly AdvancedSearchResultNameComparer InstanceReverse = new(true);
public AdvancedSearchResultNameComparer(bool reverse = false) : base(reverse)
{
}
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) =>
Reverse ? y.Name.InsensitiveCompare(x.Name) : x.Name.InsensitiveCompare(y.Name);
}
public sealed class AdvancedSearchResultMapComparer : AdvancedSearchResultComparer
{
public static readonly AdvancedSearchResultMapComparer Instance = new();
public static readonly AdvancedSearchResultMapComparer InstanceReverse = new(true);
public AdvancedSearchResultMapComparer(bool reverse = false) : base(reverse)
{
}
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y)
{
var a = x.Map?.MapID ?? -1;
var b = y.Map?.MapID ?? -1;
return Reverse ? b.CompareTo(a) : a.CompareTo(b);
}
}
public sealed class AdvancedSearchRangeComparer : AdvancedSearchResultComparer
{
private readonly Mobile _from;
public AdvancedSearchRangeComparer(Mobile from, bool reverse = false) : base(reverse) => _from = from;
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y)
{
if (_from == null)
{
return 0;
}
var fromMap = _from.Map;
@ -93,36 +125,31 @@ public class AdvancedSearchRangeComparer : IComparer<AdvancedSearchResult>
if (x.Map == fromMap && y.Map != fromMap)
{
return _reverse ? 1 : -1;
return Reverse ? 1 : -1;
}
if (x.Map != fromMap && y.Map == fromMap)
{
return _reverse ? -1 : 1;
return Reverse ? -1 : 1;
}
var xDist = _from.GetDistanceToSqrt(x.Location);
var yDist = _from.GetDistanceToSqrt(y.Location);
return _reverse ? yDist.CompareTo(xDist) : xDist.CompareTo(yDist);
return Reverse ? yDist.CompareTo(xDist) : xDist.CompareTo(yDist);
}
}
public class AdvancedSearchResultSelectedComparer : IComparer<AdvancedSearchResult>
public sealed class AdvancedSearchResultSelectedComparer : AdvancedSearchResultComparer
{
public static readonly AdvancedSearchResultSelectedComparer Instance = new();
public static readonly AdvancedSearchResultSelectedComparer InstanceReverse = new(true);
private readonly bool _reverse;
public AdvancedSearchResultSelectedComparer(bool reverse = false) => _reverse = reverse;
public int Compare(AdvancedSearchResult x, AdvancedSearchResult y)
public AdvancedSearchResultSelectedComparer(bool reverse = false) : base(reverse)
{
var a = x?.Selected ?? false;
var b = y?.Selected ?? false;
// True then false, which is 1 then 0, so the comparison is reverse of integers
return _reverse ? a.CompareTo(b) : b.CompareTo(a);
}
// True then false, which is 1 then 0, so the comparison is reverse of integers
protected override int CompareKey(AdvancedSearchResult x, AdvancedSearchResult y) =>
Reverse ? x.Selected.CompareTo(y.Selected) : y.Selected.CompareTo(x.Selected);
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Reflection;
using System.Threading;
using Server.Items;
using Server.Logging;
@ -21,7 +20,6 @@ namespace Server.Engines.AdvancedSearch;
public class AdvancedSearchThreadWorker
{
private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker));
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> _propCache = new();
private readonly Thread _thread;
private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working
@ -33,6 +31,7 @@ public class AdvancedSearchThreadWorker
private ConcurrentQueue<IEntity> _ignoreQueue;
private WorldLocation _worldLocation;
private AdvancedSearchFilter _filter;
private AdvancedSearchConditions.Cache _predicates;
public AdvancedSearchThreadWorker()
{
@ -46,17 +45,23 @@ public class AdvancedSearchThreadWorker
_thread.Start(this);
}
/// <param name="predicates">
/// Compiled property-test memo for this search. Shared across the workers so a type is
/// compiled once per search rather than once per worker; a lone worker may leave it null.
/// </param>
public void Wake(
WorldLocation worldLocation,
AdvancedSearchFilter filter,
ConcurrentQueue<AdvancedSearchResult> results,
ConcurrentQueue<IEntity> ignoreQueue
ConcurrentQueue<IEntity> ignoreQueue,
AdvancedSearchConditions.Cache predicates = null
)
{
_worldLocation = worldLocation;
_filter = filter;
_ignoreQueue = ignoreQueue;
_results = results;
_predicates = predicates ?? new AdvancedSearchConditions.Cache();
_startEvent.Set();
}
@ -108,6 +113,7 @@ public class AdvancedSearchThreadWorker
{
worker._results = null;
worker._filter = null;
worker._predicates = null; // a compiled constant may pin an entity resolved by serial
break;
}
else
@ -208,6 +214,38 @@ public class AdvancedSearchThreadWorker
return null;
}
// The map boxes are independent checks, so several can be ticked at once: an entity passes when
// its map is any of them. With none ticked there is no map constraint.
private bool OnASelectedMap(Map map)
{
var f = _filter;
var anySelected = f.FilterFelucca || f.FilterTrammel || f.FilterIlshenar || f.FilterMalas ||
f.FilterTokuno || f.FilterTerMur || f.FilterInternalMap || f.FilterNullMap;
if (!anySelected)
{
return true;
}
if (map == null)
{
return f.FilterNullMap;
}
if (map == Map.Internal)
{
return f.FilterInternalMap;
}
return map == Map.Felucca && f.FilterFelucca ||
map == Map.Trammel && f.FilterTrammel ||
map == Map.Ilshenar && f.FilterIlshenar ||
map == Map.Malas && f.FilterMalas ||
map == Map.Tokuno && f.FilterTokuno ||
map == Map.TerMur && f.FilterTerMur;
}
private static bool IsValidInternal(Item item)
{
if (item.Parent != null || item.HeldBy != null)
@ -249,8 +287,7 @@ public class AdvancedSearchThreadWorker
return null;
}
if (_filter.FilterPropertyTest &&
(string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(item, _filter.PropertyTest)))
if (_filter.FilterPropertyTest && !PassesPropertyTest(item))
{
return null;
}
@ -273,8 +310,7 @@ public class AdvancedSearchThreadWorker
return null;
}
if (_filter.FilterPropertyTest &&
(string.IsNullOrWhiteSpace(_filter.PropertyTest) || !EvaluateRecursive(mobile, _filter.PropertyTest)))
if (_filter.FilterPropertyTest && !PassesPropertyTest(mobile))
{
return null;
}
@ -353,58 +389,13 @@ public class AdvancedSearchThreadWorker
}
}
private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan<char> span) =>
AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf));
private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan<char> expression)
// The test is compiled once per runtime type for the search and memoized; after that each
// entity costs a dictionary lookup and a delegate call.
private bool PassesPropertyTest(IEntity entity)
{
expression = expression.Trim();
if (expression.Length == 0)
{
return false;
}
var test = _filter.PropertyTest;
var negate = false;
if (expression[0] == '~')
{
negate = true;
expression = expression[1..];
}
var operatorSpan = AdvancedSearchUtilities.FindOperatorIndex(expression, out var operatorIndex);
if (operatorSpan.Length == 0)
{
return false;
}
var propertyName = expression[..operatorIndex].Trim();
var valuePart = expression[(operatorIndex + operatorSpan.Length)..].Trim();
if (valuePart.Length == 0)
{
return false;
}
var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties());
PropertyInfo property = null;
for (var i = 0; i < properties.Length; ++i)
{
var p = properties[i];
if (p.CanRead && p.Name.InsensitiveEquals(propertyName))
{
property = p;
break;
}
}
if (property == null)
{
return false;
}
var propertyValue = property.GetValue(entity);
var result = AdvancedSearchUtilities.CompareValues(property.PropertyType, propertyValue, valuePart, operatorSpan);
return negate ? !result : result;
return !string.IsNullOrWhiteSpace(test) &&
AdvancedSearchConditions.GetPredicate(_predicates, entity.GetType(), test)(entity);
}
}

View file

@ -1,8 +1,5 @@
using System;
using System.Buffers;
using System.Globalization;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace Server.Engines.AdvancedSearch;
@ -36,139 +33,6 @@ public static class AdvancedSearchUtilities
return expression.Slice(index, 1);
}
public static bool CompareValues(Type propertyType, object propertyValue, ReadOnlySpan<char> valuePart, ReadOnlySpan<char> operatorSpan)
{
// TODO: Add support for implicit conversion types like Serial -> uint
if (propertyType == typeof(long))
{
return TryParseValue<long>(valuePart, out var parsedValue) &&
CompareNumeric((long)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(ulong))
{
return TryParseValue<ulong>(valuePart, out var parsedValue) &&
CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(int))
{
return TryParseValue<int>(valuePart, out var parsedValue) &&
CompareNumeric((int)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(uint))
{
return TryParseValue<uint>(valuePart, out var parsedValue) &&
CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(short))
{
return TryParseValue<short>(valuePart, out var parsedValue) &&
CompareNumeric((short)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(ushort))
{
return TryParseValue<ushort>(valuePart, out var parsedValue) &&
CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(sbyte))
{
return TryParseValue<sbyte>(valuePart, out var parsedValue) &&
CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(byte))
{
return TryParseValue<byte>(valuePart, out var parsedValue) &&
CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(float))
{
return TryParseValue<float>(valuePart, out var parsedValue) &&
Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan);
}
if (propertyType == typeof(double))
{
return TryParseValue<double>(valuePart, out var parsedValue) &&
Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan);
}
if (propertyType == typeof(string))
{
return TryParseValue<string>(valuePart, out var parsedValue) &&
Compare((string)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(TimeSpan))
{
return TryParseValue<TimeSpan>(valuePart, out var parsedValue) &&
Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(DateTime))
{
return TryParseValue<DateTime>(valuePart, out var parsedValue) &&
Compare((DateTime)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType == typeof(bool))
{
return TryParseValue<bool>(valuePart, out var parsedValue) &&
Compare((bool)propertyValue!, parsedValue, operatorSpan);
}
if (propertyType.IsEnum)
{
if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null)
{
return false;
}
return GetEnumSize(propertyType) switch
{
1 => CompareNumeric((byte)propertyValue!, (byte)valueEnum, operatorSpan),
2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan),
4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan),
8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan),
_ => false
};
}
// Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity
// properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy
// RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared,
// thread-safe Types converter so the target is parsed into the property's real type, then
// compare by value. A string is allocated here, but this is the uncommon path; the common
// types never reach it. Types returns a non-null message when it can't parse -> no match.
return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null &&
CompareReference(propertyValue!, parsed, operatorSpan);
}
public static bool CompareNumeric<T>(T propertyValue, T parsedValue, ReadOnlySpan<char> operatorSpan) where T : INumber<T> =>
operatorSpan switch
{
"=" or "==" => propertyValue == parsedValue,
"!" or "!=" => propertyValue != parsedValue,
">" => propertyValue > parsedValue,
"<" => propertyValue < parsedValue,
">=" => propertyValue >= parsedValue,
"<=" => propertyValue <= parsedValue,
_ => false
};
public static bool Compare(
double propertyValue,
double parsedValue,
ReadOnlySpan<char> originalValue,
ReadOnlySpan<char> operatorSpan
)
{
var epsilon = CalculateEpsilon(originalValue);
return operatorSpan switch
{
"=" or "==" => Math.Abs(propertyValue - parsedValue) < epsilon,
"!" or "!=" => Math.Abs(propertyValue - parsedValue) >= epsilon,
">" => propertyValue > parsedValue + epsilon,
"<" => propertyValue < parsedValue - epsilon,
">=" => propertyValue >= parsedValue - epsilon,
"<=" => propertyValue <= parsedValue + epsilon,
_ => throw new ArgumentException("Invalid operator")
};
}
public static double CalculateEpsilon(ReadOnlySpan<char> value)
{
var decimalPlace = value.IndexOf('.');
@ -191,247 +55,4 @@ public static class AdvancedSearchUtilities
_ => 1E-16
};
}
public static bool Compare(string propertyValue, string parsedValue, ReadOnlySpan<char> operatorSpan) =>
operatorSpan switch
{
"=" or "==" => propertyValue.EqualsOrdinal(parsedValue),
"!" or "!=" => !propertyValue.EqualsOrdinal(parsedValue),
">" => propertyValue.StartsWithOrdinal(parsedValue),
"<" => propertyValue.EndsWithOrdinal(parsedValue),
"~" => propertyValue.Contains(parsedValue),
"~<" => propertyValue.InsensitiveEndsWith(parsedValue),
"~>" => propertyValue.InsensitiveStartsWith(parsedValue),
"~~" => propertyValue.InsensitiveContains(parsedValue),
"~=" => propertyValue.InsensitiveEquals(parsedValue),
"~!" => !propertyValue.InsensitiveEquals(parsedValue),
_ => false
};
public static bool Compare(TimeSpan propertyValue, TimeSpan parsedValue, ReadOnlySpan<char> operatorSpan) =>
operatorSpan switch
{
"=" or "==" => propertyValue == parsedValue,
"!" or "!=" => propertyValue != parsedValue,
">" => propertyValue > parsedValue,
"<" => propertyValue < parsedValue,
">=" => propertyValue >= parsedValue,
"<=" => propertyValue <= parsedValue,
_ => false
};
public static bool Compare(DateTime propertyValue, DateTime parsedValue, ReadOnlySpan<char> operatorSpan) =>
operatorSpan switch
{
"=" or "==" => propertyValue == parsedValue,
"!" or "!=" => propertyValue != parsedValue,
">" => propertyValue > parsedValue,
"<" => propertyValue < parsedValue,
">=" => propertyValue >= parsedValue,
"<=" => propertyValue <= parsedValue,
_ => false
};
public static bool Compare(bool propertyValue, bool parsedValue, ReadOnlySpan<char> operatorSpan) =>
operatorSpan switch
{
"=" or "==" => propertyValue == parsedValue,
"!" or "!=" => propertyValue != parsedValue,
_ => false
};
public static bool CompareReference<T>(T propertyValue, T parsedValue, ReadOnlySpan<char> operatorSpan)
{
switch (operatorSpan)
{
case "=":
case "==": return Equals(propertyValue, parsedValue);
case "!":
case "!=": return !Equals(propertyValue, parsedValue);
}
if (propertyValue is IComparable cmp && parsedValue != null)
{
try
{
var c = cmp.CompareTo(parsedValue);
return operatorSpan switch
{
">" => c > 0,
"<" => c < 0,
">=" => c >= 0,
"<=" => c <= 0,
_ => false
};
}
catch
{
return false;
}
}
return false;
}
internal static bool TryParseValue<T>(ReadOnlySpan<char> valuePart, out T value)
{
// Special handling for boolean and hexadecimal values
if (typeof(T) == typeof(bool))
{
var val = valuePart.ToString().ToLower();
if (val is "true" or "1" or "enabled" or "on")
{
value = (T)(object)true;
return true;
}
if (val is "false" or "0" or "disabled" or "off")
{
value = (T)(object)false;
return true;
}
value = default;
return false;
}
if (typeof(T) == typeof(long))
{
return TryParseNumericValue<long, T>(valuePart, out value);
}
if (typeof(T) == typeof(ulong))
{
return TryParseNumericValue<ulong, T>(valuePart, out value);
}
if (typeof(T) == typeof(int))
{
return TryParseNumericValue<int, T>(valuePart, out value);
}
if (typeof(T) == typeof(uint))
{
return TryParseNumericValue<uint, T>(valuePart, out value);
}
if (typeof(T) == typeof(short))
{
return TryParseNumericValue<short, T>(valuePart, out value);
}
if (typeof(T) == typeof(ushort))
{
return TryParseNumericValue<ushort, T>(valuePart, out value);
}
if (typeof(T) == typeof(sbyte))
{
return TryParseNumericValue<sbyte, T>(valuePart, out value);
}
if (typeof(T) == typeof(byte))
{
return TryParseNumericValue<byte, T>(valuePart, out value);
}
if (typeof(T) == typeof(float))
{
return TryParseNumericValue<float, T>(valuePart, out value);
}
if (typeof(T) == typeof(double))
{
return TryParseNumericValue<double, T>(valuePart, out value);
}
// string needs no parsing — the span itself is the value.
if (typeof(T) == typeof(string))
{
value = (T)(object)valuePart.ToString();
return true;
}
// Remaining supported types (TimeSpan, DateTime) parse straight from the span via
// ISpanParsable<T> — no allocation, no reflection, and unlike Convert.ChangeType it handles
// TimeSpan, which is not IConvertible and previously failed silently.
if (typeof(T) == typeof(TimeSpan))
{
return TryParseSpanParsable<TimeSpan, T>(valuePart, out value);
}
if (typeof(T) == typeof(DateTime))
{
return TryParseSpanParsable<DateTime, T>(valuePart, out value);
}
value = default;
return false;
}
// Parses U (a value type exposing ISpanParsable<U>) from the span and reinterprets it as T. The
// two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at
// every call site and the (T)(object) cast is always valid.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryParseSpanParsable<U, T>(ReadOnlySpan<char> valuePart, out T value) where U : ISpanParsable<U>
{
if (U.TryParse(valuePart, null, out var parsed))
{
value = (T)(object)parsed;
return true;
}
value = default;
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryParseNumericValue<T, R>(ReadOnlySpan<char> valuePart, out R value) where T : INumber<T>
{
var ok = valuePart.StartsWith("0x")
? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed)
: T.TryParse(valuePart, null, out parsed);
if (ok)
{
value = (R)(object)parsed;
return true;
}
value = default;
return false;
}
// Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required
// because ReadOnlySpan<char> cannot be a Func<> type argument; passing state avoids a per-call
// capturing closure, so the recursion allocates neither a string nor a closure.
internal delegate bool LeafEvaluator<in TState>(TState state, ReadOnlySpan<char> leaf);
// OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND.
internal static bool EvaluateBoolean<TState>(ReadOnlySpan<char> expr, TState state, LeafEvaluator<TState> evalLeaf)
{
var orIndex = expr.IndexOf('|');
if (orIndex != -1)
{
return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf);
}
var andIndex = expr.IndexOf('@');
if (andIndex != -1)
{
return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf);
}
return evalLeaf(state, expr.Trim());
}
private static int GetEnumSize(Type enumType) =>
Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch
{
TypeCode.Byte or TypeCode.SByte => sizeof(byte),
TypeCode.Int16 or TypeCode.UInt16 => sizeof(ushort),
TypeCode.Int32 or TypeCode.UInt32 => sizeof(uint),
TypeCode.Int64 or TypeCode.UInt64 => sizeof(ulong),
_ => 4
};
}

View file

@ -5,6 +5,11 @@ namespace Server.Engines.BulkOrders;
[SerializationGenerator(2)]
public partial class BOBFilter
{
[DirtyTrackingEntity]
private IEntity _owner;
public BOBFilter(IEntity owner) => _owner = owner;
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeType))]
private int _type;

View file

@ -26,7 +26,7 @@ public partial class BOBLargeEntry : BaseBOBEntry
for (var i = 0; i < _entries.Length; ++i)
{
_entries[i] = new BOBLargeSubEntry(bod.Entries[i]);
_entries[i] = new BOBLargeSubEntry(this, bod.Entries[i]);
}
}
@ -76,7 +76,7 @@ public partial class BOBLargeEntry : BaseBOBEntry
for (var i = 0; i < Entries.Length; ++i)
{
_entries[i] = new BOBLargeSubEntry();
_entries[i] = new BOBLargeSubEntry(this);
_entries[i].Deserialize(reader);
}
}

View file

@ -6,6 +6,9 @@ namespace Server.Engines.BulkOrders;
[SerializationGenerator(0)]
public partial class BOBLargeSubEntry
{
[DirtyTrackingEntity]
private BOBLargeEntry _parent;
[SerializableField(0, setter: "private")]
private Type _itemType;
@ -21,12 +24,11 @@ public partial class BOBLargeSubEntry
[SerializableField(3, setter: "private")]
private int _graphic;
public BOBLargeSubEntry()
{
}
public BOBLargeSubEntry(BOBLargeEntry parent) => _parent = parent;
public BOBLargeSubEntry(LargeBulkEntry lbe)
public BOBLargeSubEntry(BOBLargeEntry parent, LargeBulkEntry lbe)
{
_parent = parent;
_itemType = lbe.Details.Type;
_amountCur = lbe.Amount;
_number = lbe.Details.Number;

View file

@ -43,7 +43,7 @@ public partial class BulkOrderBook : Item, ISecurable
LootType = LootType.Blessed;
_entries = [];
_filter = new BOBFilter();
_filter = new BOBFilter(this);
_level = SecureLevel.CoOwners;
}
@ -224,7 +224,7 @@ public partial class BulkOrderBook : Item, ISecurable
_bookName = reader.ReadString();
_filter = new BOBFilter();
_filter = new BOBFilter(this);
_filter.Deserialize(reader);
var count = reader.ReadEncodedInt();

View file

@ -1,11 +1,23 @@
using System;
using ModernUO.Serialization;
using Server.Mobiles;
namespace Server.Engines.CannedEvil;
[SerializationGenerator(0)]
public partial class ChampionTitle
{
[DirtyTrackingEntity]
private PlayerMobile _player;
public ChampionTitle(ChampionTitleContext context) : this(context?.Player)
{
}
// The generator resolves the deserialization constructor against the owning type, but emits the
// owner's own dirty-tracking reference (the player) at the call site, so both overloads exist.
public ChampionTitle(PlayerMobile player) => _player = player;
[EncodedInt]
[SerializableField(0)]
private int _value;

View file

@ -16,6 +16,7 @@ public partial class ChampionTitleContext
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _harrower;
[DirtyTrackingEntity]
private PlayerMobile _player;
public PlayerMobile Player => _player;
@ -45,7 +46,7 @@ public partial class ChampionTitleContext
throw new NotImplementedException($"Cannot find ChampionSpawnType value {type}.");
}
title = new ChampionTitle();
title = new ChampionTitle(this);
title.Deserialize(reader);
}
}
@ -290,7 +291,7 @@ public partial class ChampionTitleContext
return null;
}
return title ??= new ChampionTitle();
return title ??= new ChampionTitle(this);
}
public void SetValue(ChampionSpawnType type, int value)
@ -313,7 +314,7 @@ public partial class ChampionTitleContext
}
else
{
title = new ChampionTitle();
title = new ChampionTitle(this);
}
title.Value = value;

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.CodeGeneratedEvents;
using Server.Collections;
using Server.Mobiles;
namespace Server.Engines.CannedEvil;

View file

@ -61,7 +61,7 @@ public class HorseBreederGump : FactionGump
if (m_From.Followers + horse.ControlSlots > m_From.FollowersMax)
{
// TODO: Message?
m_From.SendLocalizedMessage(1049607); // You have too many followers to control that creature.
horse.Delete();
}
else
@ -79,9 +79,7 @@ public class HorseBreederGump : FactionGump
else if (pack.ConsumeTotal(typeof(Silver), FactionWarHorse.SilverPrice) &&
pack.ConsumeTotal(typeof(Gold), FactionWarHorse.GoldPrice))
{
horse.Controlled = true;
horse.ControlMaster = m_From;
horse.SetControlMaster(m_From);
horse.ControlOrder = OrderType.Follow;
horse.ControlTarget = m_From;

View file

@ -23,11 +23,19 @@ namespace Server.Items
[SerializationGenerator(1)]
public partial class PuzzleChestSolution
{
[DirtyTrackingEntity]
private PuzzleChest _chest;
[SerializableField(0)]
private PuzzleChestCylinder[] _cylinders;
public const int Length = 5;
// Declared first: the generator picks the first matching constructor, and a deserialized
// solution must know its chest to mark it dirty.
public PuzzleChestSolution(PuzzleChest chest) : this() => _chest = chest;
// Transient solutions (player guesses being edited in a gump) have no owning chest.
public PuzzleChestSolution() =>
_cylinders = [RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder(), RandomCylinder()];
@ -42,6 +50,9 @@ namespace Server.Items
solution.Cylinders.AsSpan().CopyTo(Cylinders);
}
protected PuzzleChestSolution(PuzzleChest chest, PuzzleChestSolution solution) : this(solution) =>
_chest = chest;
private void Deserialize(IGenericReader reader, int version)
{
var length = reader.ReadEncodedInt();
@ -174,10 +185,11 @@ namespace Server.Items
[SerializableField(0)]
private DateTime _when;
public PuzzleChestSolutionAndTime(DateTime when, PuzzleChestSolution solution) : base(solution) => _when = when;
public PuzzleChestSolutionAndTime(PuzzleChest chest, DateTime when, PuzzleChestSolution solution)
: base(chest, solution) => _when = when;
// For serialization
public PuzzleChestSolutionAndTime()
// The generator deserializes guesses through this constructor so each one knows its chest.
public PuzzleChestSolutionAndTime(PuzzleChest chest) : base(chest)
{
}
}
@ -214,7 +226,7 @@ namespace Server.Items
private void Deserialize(IGenericReader reader, int version)
{
_solution = new PuzzleChestSolution();
_solution = new PuzzleChestSolution(this);
_solution.Deserialize(reader);
var length = reader.ReadEncodedInt();
@ -238,7 +250,7 @@ namespace Server.Items
for (var i = 0; i < guessCount; i++)
{
var m = reader.ReadEntity<Mobile>();
(_guesses[m] = new PuzzleChestSolutionAndTime()).Deserialize(reader);
(_guesses[m] = new PuzzleChestSolutionAndTime(this)).Deserialize(reader);
}
}
@ -329,7 +341,7 @@ namespace Server.Items
}
else
{
(_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(Core.Now, solution));
(_guesses ??= []).Add(m, new PuzzleChestSolutionAndTime(this, Core.Now, solution));
StartCleanupTimer();
m.SendGump(new StatusGump(correctCylinders, correctColors));
@ -525,7 +537,7 @@ namespace Server.Items
}
}
Solution = new PuzzleChestSolution();
Solution = new PuzzleChestSolution(this);
}
private void StartCleanupTimer()

View file

@ -73,7 +73,15 @@ public partial class PlantItem : Item, ISecurable
{
get
{
InitializePropertyList(_oldClientPropertyList ??= new ObjectPropertyList(this));
// Build once, like Item.PropertyList. Initializing on every read appended another copy
// of every property to the same list. InvalidateProperties rebuilds it, Reset first.
if (_oldClientPropertyList == null)
{
var list = new ObjectPropertyList(this);
_oldClientPropertyList = list;
InitializePropertyList(list);
}
return _oldClientPropertyList;
}
}
@ -96,6 +104,7 @@ public partial class PlantItem : Item, ISecurable
var ratio = PlantSystem != null ? (double)PlantSystem.Hits / PlantSystem.MaxHits : 1.0;
_plantStatus = value;
this.MarkDirty();
if (_plantStatus >= PlantStatus.DecorativePlant)
{

View file

@ -58,6 +58,7 @@ public partial class MurderContext
_lastMurderTime = Core.Now;
}
[DirtyTrackingEntity]
public PlayerMobile _player;
public PlayerMobile Player => _player;

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using ModernUO.CodeGeneratedEvents;
using Server.Collections;
using Server.Logging;
using Server.Misc;
using Server.Mobiles;

View file

@ -42,18 +42,14 @@ public abstract partial class BaseSpawner
_spawnPositionMode = dto.SpawnPositionMode;
_maxSpawnAttempts = dto.MaxSpawnAttempts;
if (dto.Entries != null)
if (dto.EntryView != null)
{
for (var i = 0; i < dto.Entries.Count; i++)
{
var entry = dto.Entries[i];
AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters);
}
AdoptEntries(dto.EntryView);
}
}
/// <summary>The square spawn bounds a homeRange radius represents (centered on the location).</summary>
private protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange)
protected static Rectangle3D BoundsFromHomeRange(Point3D location, int homeRange)
{
int z;
int depth;
@ -78,7 +74,7 @@ public abstract partial class BaseSpawner
);
}
private protected string DtoName
protected string DtoName
{
get
{
@ -91,17 +87,17 @@ public abstract partial class BaseSpawner
// MaxDelay/Team/SpawnLocationIsHome match their property and are referenced directly in ToDto.)
// Raw field; the public WalkingRange is computed (falls back to HomeRange).
private protected int DtoWalkingRange => _walkingRange;
protected int DtoWalkingRange => _walkingRange;
// Abandoned is a transient runtime state, not persisted -> map to Automatic (omitted).
private protected SpawnPositionMode DtoSpawnPositionMode =>
protected SpawnPositionMode DtoSpawnPositionMode =>
_spawnPositionMode == SpawnPositionMode.Abandoned ? SpawnPositionMode.Automatic : _spawnPositionMode;
// Runtime treats 0 as DefaultMaxSpawnAttempts -> map the default to 0 (omitted).
private protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts;
protected int DtoMaxSpawnAttempts => _maxSpawnAttempts == DefaultMaxSpawnAttempts ? 0 : _maxSpawnAttempts;
// The radius if SpawnBounds is exactly what it reconstructs (lossless square); otherwise -1.
private protected int DtoHomeRange
protected int DtoHomeRange
{
get
{

View file

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
namespace Server.Engines.Spawners;
public abstract partial class BaseSpawner
{
/// <summary>
/// The entries this spawner cycles through, owned by the concrete subclass so it can use its own
/// entry type. Cold read-only view; loops inside BaseSpawner use <see cref="EntrySpan"/>.
/// </summary>
[IgnoreDupe]
public abstract IReadOnlyList<SpawnerEntry> Entries { get; }
/// <summary>Zero-cost span over the owner's list for hot loops (no interface dispatch, no allocation).</summary>
protected abstract ReadOnlySpan<SpawnerEntry> EntrySpan { get; }
/// <summary>Creates an entry of the owner's entry type, parented to this spawner. Not added.</summary>
protected abstract SpawnerEntry CreateEntry(
string name,
int probability,
int maxCount,
string properties,
string parameters
);
protected abstract void AddEntryCore(SpawnerEntry entry);
protected abstract bool RemoveEntryCore(SpawnerEntry entry);
protected abstract void ClearEntriesCore();
/// <summary>
/// Takes ownership of entries built elsewhere (a legacy save, a DTO import). The owner stores
/// them, re-parents them, and converts foreign entry types if it must. Replaces the current list.
/// </summary>
/// <remarks>
/// An implementer that converts a foreign entry into its own entry type must carry the live spawns
/// across with <see cref="TransferSpawned"/>; <see cref="CloneEntry"/> deliberately does not copy
/// them, so a conversion that only clones orphans every creature the adopted entry owns.
/// </remarks>
protected abstract void AdoptEntries(IReadOnlyList<SpawnerEntry> entries);
/// <summary>
/// Moves the live spawns of <paramref name="source"/> onto <paramref name="target"/>. Use when an owner converts
/// an adopted entry into its own entry type; <see cref="CloneEntry"/> deliberately does not copy spawns.
/// </summary>
protected static void TransferSpawned(SpawnerEntry source, SpawnerEntry target)
{
var spawned = source.Spawned;
for (var i = 0; i < spawned.Count; i++)
{
target.AddToSpawned(spawned[i]);
}
source.ClearSpawned();
}
/// <summary>Deep-copies an entry into this spawner's entry type. Override to carry subtype fields.</summary>
protected virtual SpawnerEntry CloneEntry(SpawnerEntry source)
{
var entry = CreateEntry(
source.SpawnedName,
source.SpawnedProbability,
source.SpawnedMaxCount,
source.Properties,
source.Parameters
);
entry.Disabled = source.Disabled;
return entry;
}
public SpawnerEntry AddEntry(
string creaturename,
int probability = 100,
int amount = 1,
bool dotimer = true,
string properties = null,
string parameters = null
)
{
var entry = CreateEntry(creaturename, probability, amount, properties, parameters);
AddEntryCore(entry);
if (dotimer)
{
DoTimer(TimeSpan.FromSeconds(1));
}
return entry;
}
public void RemoveEntry(SpawnerEntry entry)
{
Defrag();
if (!RemoveEntryCore(entry))
{
return;
}
RemoveSpawn(entry);
if (_running && !IsFull && _timer?.Running != true)
{
DoTimer();
}
InvalidateProperties();
}
/// <summary>
/// Deletes every live spawn and removes every entry. Named for the deletion: before entry ownership
/// moved to the owner, the generator emitted a <c>ClearEntries()</c> here that only emptied the list.
/// </summary>
public void RemoveAllEntries()
{
RemoveSpawns();
ClearEntriesCore();
InvalidateProperties();
}
/// <summary>Replaces <paramref name="target"/>'s entries with clones of this spawner's entries.</summary>
public void CopyEntriesTo(BaseSpawner target)
{
// A self-copy would clear the source.
if (ReferenceEquals(target, this))
{
return;
}
target.RemoveAllEntries();
var entries = EntrySpan;
for (var i = 0; i < entries.Length; i++)
{
target.AddEntryCore(target.CloneEntry(entries[i]));
}
target.InvalidateProperties();
}
/// <summary>
/// Rebuilds the entity -> entry registry from the owner's entries and re-arms the timer.
/// The owner calls this from its own [AfterDeserialization] once its list is loaded; the base
/// hook runs before derived fields exist and must not touch entries.
/// </summary>
/// <remarks>
/// <see cref="Spawner"/> calls this for its own entry list only. A subclass that owns a different
/// list (its own entry type, or an extra list) must call it again from its own
/// <c>[AfterDeserialization]</c>: the base class's runs before the derived fields have been read,
/// so the load would otherwise finish with an empty <see cref="Spawned"/> registry even though the
/// entries themselves carry their spawns.
/// </remarks>
protected void RebuildSpawned()
{
Spawned = new Dictionary<ISpawnable, SpawnerEntry>();
var entries = EntrySpan;
for (var i = 0; i < entries.Length; i++)
{
var entry = entries[i];
var spawned = entry.Spawned;
for (var j = 0; j < spawned.Count; j++)
{
Spawned.TryAdd(spawned[j], entry);
}
}
DoTimer(_end - Core.Now);
}
}

View file

@ -0,0 +1,53 @@
namespace Server.Engines.Spawners;
public abstract partial class BaseSpawner
{
/// <summary>
/// Called after the timer starts (Start(), Running = true, NextSpawn on a stopped spawner).
/// Not called for construction (<c>InitSpawn</c>) or deserialization; subclasses initialise
/// run state in their constructor and <c>[AfterDeserialization]</c>.
/// </summary>
protected virtual void OnStarted()
{
}
/// <summary>Called after the timer stops (Stop(), Running = false), and only if it was running.</summary>
protected virtual void OnStopped()
{
}
/// <summary>Veto point before an entry's entity is constructed. Return false to skip this attempt.</summary>
protected virtual bool OnBeforeSpawn(SpawnerEntry entry) => true;
/// <summary>
/// Runs after property application and before positioning, so computed properties apply first.
/// The entity is not yet in <see cref="Spawned"/>, has no <c>Spawner</c> set, and is still on
/// the internal map.
/// </summary>
protected virtual void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned)
{
}
/// <summary>Entry-aware positioning. Default delegates to the entry-agnostic overload.</summary>
protected virtual Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) =>
GetSpawnPosition(spawned, map);
/// <summary>Runs after the entity is in the world and linked to this spawner.</summary>
protected virtual void OnSpawned(SpawnerEntry entry, ISpawnable spawned)
{
}
/// <summary>A spawned creature died (before base death deletes it and unlinks the spawner).</summary>
protected virtual void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer)
{
}
/// <summary>Entry point for BaseCreature.OnDeath. Resolves the entry and dispatches the hook.</summary>
public void NotifySpawnedDeath(ISpawnable spawned, Mobile killer)
{
if (spawned != null && Spawned != null && Spawned.TryGetValue(spawned, out var entry))
{
OnSpawnedDeath(entry, spawned, killer);
}
}
}

View file

@ -13,7 +13,7 @@ public abstract partial class BaseSpawner
{
_guid = content.Guid;
_returnOnDeactivate = content.ReturnOnDeactivate;
_entries = content.Entries;
AdoptEntries(content.Entries ?? []);
_walkingRange = content.WalkingRange;
_wayPoint = content.WayPoint;
_group = content.Group;
@ -41,7 +41,7 @@ public abstract partial class BaseSpawner
{
_guid = content.Guid;
_returnOnDeactivate = content.ReturnOnDeactivate;
_entries = content.Entries;
AdoptEntries(content.Entries ?? []);
_walkingRange = content.WalkingRange;
_wayPoint = content.WayPoint;
_group = content.Group;
@ -65,21 +65,44 @@ public abstract partial class BaseSpawner
_maxSpawnAttempts = DefaultMaxSpawnAttempts;
}
private void MigrateFrom(V12Content content)
{
_guid = content.Guid;
_returnOnDeactivate = content.ReturnOnDeactivate;
_walkingRange = content.WalkingRange;
_wayPoint = content.WayPoint;
_group = content.Group;
_minDelay = content.MinDelay ?? DefaultMinDelay;
_maxDelay = content.MaxDelay ?? DefaultMaxDelay;
_count = content.Count;
_team = content.Team ?? 0;
_running = content.Running;
_spawnLocationIsHome = content.SpawnLocationIsHome;
// End is unsaved when default; a running spawner re-arms immediately either way (DoTimer clamps).
_end = _running ? content.End ?? Core.Now : Core.Now;
_spawnPositionMode = content.SpawnPositionMode ?? SpawnPositionMode.Automatic;
_maxSpawnAttempts = content.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts;
AdoptEntries(content.Entries ?? []);
}
private void Deserialize(IGenericReader reader, int version)
{
_guid = reader.ReadGuid();
_returnOnDeactivate = reader.ReadBool();
var count = reader.ReadInt();
_entries = new List<SpawnerEntry>(count);
var entries = new List<SpawnerEntry>(count);
for (var i = 0; i < count; ++i)
{
var entry = new SpawnerEntry(this);
entry.Deserialize(reader);
_entries.Add(entry);
entries.Add(entry);
}
AdoptEntries(entries);
_walkingRange = reader.ReadInt();
_wayPoint = reader.ReadEntity<WayPoint>();
_group = reader.ReadBool();

View file

@ -40,7 +40,7 @@ public enum SpawnPositionMode : byte
Abandoned = 3
}
[SerializationGenerator(12, false)]
[SerializationGenerator(13, false)]
public abstract partial class BaseSpawner : Item, ISpawner
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseSpawner));
@ -61,15 +61,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _returnOnDeactivate;
[SerializedIgnoreDupe]
[SerializableField(2, setter: "private")]
private List<SpawnerEntry> _entries;
private int _walkingRange = -1;
private bool ShouldSerializeWayPoint() => _wayPoint != null;
[SerializableField(4)]
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeWayPoint))]
[SerializedCommandProperty(AccessLevel.Developer)]
private WayPoint _wayPoint;
@ -77,7 +73,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private bool ShouldSerializeGroup() => _group;
[InvalidateProperties]
[SerializableField(5)]
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeGroup))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _group;
@ -87,7 +83,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private TimeSpan MinDelayDefault() => DefaultMinDelay;
[InvalidateProperties]
[SerializableField(6)]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _minDelay;
@ -97,7 +93,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
[InvalidateProperties]
[SerializableField(7)]
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _maxDelay;
@ -105,7 +101,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private bool ShouldSerializeTeam() => _team != 0;
[InvalidateProperties]
[SerializableField(9)]
[SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeTeam))]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _team;
@ -126,14 +122,14 @@ public abstract partial class BaseSpawner : Item, ISpawner
private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome;
[InvalidateProperties]
[SerializableField(11)]
[SerializableField(10)]
[SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _spawnLocationIsHome;
private bool ShouldSerializeEnd() => _end != default;
[SerializableField(12)]
[SerializableField(11)]
[SaveFlag(nameof(ShouldSerializeEnd))]
[SerializedCommandProperty(AccessLevel.Developer)]
private DateTime _end;
@ -144,7 +140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private bool ShouldSerializeSpawnPositionMode() =>
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
[SerializableField(13)]
[SerializableField(12)]
[SaveFlag(nameof(ShouldSerializeSpawnPositionMode))]
[SerializedCommandProperty(AccessLevel.Developer)]
private SpawnPositionMode _spawnPositionMode;
@ -158,7 +154,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
[SerializableField(14)]
[SerializableField(13)]
[SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _maxSpawnAttempts;
@ -300,7 +296,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
public Dictionary<ISpawnable, SpawnerEntry> Spawned { get; private set; }
[CommandProperty(AccessLevel.Developer)]
[SerializableProperty(3, nameof(_walkingRange))]
[SerializableProperty(2, nameof(_walkingRange))]
public int WalkingRange
{
get => _walkingRange > 0 ? _walkingRange : HomeRange;
@ -308,10 +304,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
_walkingRange = value;
InvalidateProperties();
this.MarkDirty();
}
}
[SerializableField(8, fieldChanged: nameof(OnCountChanged))]
[SerializableField(7, fieldChanged: nameof(OnCountChanged))]
[SerializedCommandProperty(AccessLevel.Developer)]
[InvalidateProperties]
private int _count;
@ -328,7 +325,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
}
[SerializableProperty(10)]
[SerializableProperty(9)]
[CommandProperty(AccessLevel.Developer)]
public bool Running
{
@ -355,10 +352,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
get => _running && _timer?.Running == true ? End - Core.Now : TimeSpan.Zero;
set
{
if (!_running && Entries.Count > 0)
if (BeginStart())
{
_running = true;
DoTimer(value);
OnStarted();
}
}
}
@ -640,20 +637,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
newSpawner._guid = Guid.NewGuid();
newSpawner.Spawned = new Dictionary<ISpawnable, SpawnerEntry>();
newSpawner.Entries = [];
for (var i = 0; i < Entries.Count; i++)
{
var entry = Entries[i];
newSpawner.AddEntry(
entry.SpawnedName,
entry.SpawnedProbability,
entry.SpawnedMaxCount,
false,
entry.Properties,
entry.Parameters
);
}
CopyEntriesTo(newSpawner);
}
}
@ -697,25 +681,6 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
}
public SpawnerEntry AddEntry(
string creaturename,
int probability = 100,
int amount = 1,
bool dotimer = true,
string properties = null,
string parameters = null
)
{
var entry = new SpawnerEntry(this, creaturename, probability, amount, properties, parameters);
AddToEntries(entry);
if (dotimer)
{
DoTimer(TimeSpan.FromSeconds(1));
}
return entry;
}
public void InitSpawn(int amount, TimeSpan minDelay, TimeSpan maxDelay, int team = 0, Rectangle3D spawnBounds = default)
{
Visible = false;
@ -736,7 +701,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
HomeRange = 4;
}
Entries = [];
ClearEntriesCore();
Spawned = new Dictionary<ISpawnable, SpawnerEntry>();
DoTimer(TimeSpan.FromSeconds(1));
@ -803,26 +768,42 @@ public abstract partial class BaseSpawner : Item, ISpawner
public void Start()
{
if (!_running && Entries.Count > 0)
if (BeginStart())
{
_running = true;
DoTimer();
OnStarted();
}
}
/// <summary>Guards and flips <see cref="Running"/>. Callers arm the timer and fire <see cref="OnStarted"/>.</summary>
private bool BeginStart()
{
if (_running || Entries.Count == 0)
{
return false;
}
_running = true;
return true;
}
public void Stop()
{
var wasRunning = _running;
_timer?.Stop();
_running = false;
if (wasRunning)
{
OnStopped();
}
}
public void Defrag()
{
Entries ??= [];
for (var i = 0; i < Entries.Count; ++i)
var entries = EntrySpan;
for (var i = 0; i < entries.Length; i++)
{
Entries[i].Defrag(this);
entries[i].Defrag(this);
}
}
@ -866,16 +847,18 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
Defrag();
if (Entries.Count <= 0 || IsFull)
var entries = EntrySpan;
if (entries.Length <= 0 || IsFull)
{
return;
}
var probsum = 0;
foreach (var spawnerEntry in Entries)
for (var i = 0; i < entries.Length; i++)
{
if (!spawnerEntry.IsFull)
var spawnerEntry = entries[i];
if (!spawnerEntry.IsFull && !spawnerEntry.Disabled)
{
probsum += spawnerEntry.SpawnedProbability;
}
@ -888,10 +871,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
var rand = Utility.RandomMinMax(1, probsum);
for (var i = 0; i < Entries.Count; i++)
for (var i = 0; i < entries.Length; i++)
{
var entry = Entries[i];
if (entry.IsFull)
var entry = entries[i];
if (entry.IsFull || entry.Disabled)
{
continue;
}
@ -1009,6 +992,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
return false;
}
if (!OnBeforeSpawn(entry))
{
return false;
}
try
{
IEntity entity = null;
@ -1094,6 +1082,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
}
if (entity is ISpawnable configured)
{
OnConfigureSpawned(entry, configured);
}
if (entity is Mobile m)
{
Spawned.Add(m, entry);
@ -1101,7 +1094,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
// var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map);
var spawnLocation = GetSpawnPosition(m, map);
var spawnLocation = GetSpawnPosition(entry, m, map);
m.OnBeforeSpawn(spawnLocation, map);
m.MoveToWorld(spawnLocation, map);
@ -1132,13 +1125,14 @@ public abstract partial class BaseSpawner : Item, ISpawner
m.Spawner = this;
m.OnAfterSpawn();
OnSpawned(entry, m);
}
else if (entity is Item item)
{
Spawned.Add(item, entry);
entry.AddToSpawned(item);
var loc = GetSpawnPosition(item, map);
var loc = GetSpawnPosition(entry, item, map);
item.OnBeforeSpawn(loc, map);
@ -1146,6 +1140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner
item.Spawner = this;
item.OnAfterSpawn();
OnSpawned(entry, item);
}
else
{
@ -1220,27 +1215,6 @@ public abstract partial class BaseSpawner : Item, ISpawner
return entry.Spawned.Count;
}
public void RemoveEntry(SpawnerEntry entry)
{
Defrag();
for (var i = entry.Spawned.Count - 1; i >= 0; i--)
{
var e = entry.Spawned[i];
entry.Spawned.RemoveAt(i);
e?.Delete();
}
Entries.Remove(entry);
if (_running && !IsFull && _timer?.Running != true)
{
DoTimer();
}
InvalidateProperties();
}
public void RemoveSpawn(int index) // Entry
{
if (index >= 0 && index < Entries.Count)
@ -1268,9 +1242,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
{
Defrag();
for (var i = 0; i < Entries.Count; i++)
var entries = EntrySpan;
for (var i = 0; i < entries.Length; i++)
{
var entry = Entries[i];
var entry = entries[i];
for (var j = entry.Spawned.Count - 1; j >= 0; j--)
{
@ -1326,18 +1301,6 @@ public abstract partial class BaseSpawner : Item, ISpawner
256
);
}
Spawned = new Dictionary<ISpawnable, SpawnerEntry>();
foreach (var entry in Entries)
{
foreach (var spawned in entry.Spawned)
{
Spawned.Add(spawned, entry);
}
}
DoTimer(_end - Core.Now);
}
private class InternalTimer : Timer

View file

@ -105,8 +105,10 @@ public class EditSpawnCommand : BaseCommand
public static void UpdateSpawner(BaseSpawner spawner, string name, string arguments, string properties, string find = null)
{
foreach (var entry in spawner.Entries)
for (var i = 0; i < spawner.Entries.Count; i++)
{
var entry = spawner.Entries[i];
// TODO: Should cache spawn type on the entry
if (!entry.SpawnedName.InsensitiveEquals(name))
{

View file

@ -74,11 +74,6 @@ public abstract record SpawnerDto
[JsonPropertyOrder(9)]
public int WalkingRange { get; init; }
[JsonPropertyName("entries")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<SpawnerEntry> Entries { get; init; }
[JsonPropertyName("spawnLocationIsHome")]
[JsonPropertyOrder(11)]
public bool SpawnLocationIsHome { get; init; }
@ -97,6 +92,10 @@ public abstract record SpawnerDto
[JsonPropertyOrder(8)]
public int HomeRange { get; init; } = -1;
/// <summary>The entries carried by the concrete record, in its own entry type. Never serialized directly.</summary>
[JsonIgnore]
public abstract IReadOnlyList<SpawnerEntry> EntryView { get; }
/// <summary>Constructs the empty concrete spawner Item for this DTO.</summary>
protected abstract BaseSpawner CreateEmpty();
@ -125,6 +124,14 @@ public sealed record SpawnerDataDto : SpawnerDto
[JsonPropertyOrder(8)]
public Rectangle3D SpawnBounds { get; init; }
[JsonPropertyName("entries")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<SpawnerEntry> Entries { get; init; }
[JsonIgnore]
public override IReadOnlyList<SpawnerEntry> EntryView => Entries;
protected override BaseSpawner CreateEmpty() => new Spawner();
public override BaseSpawner ToSpawner()
@ -154,6 +161,14 @@ public sealed record RegionSpawnerDto : SpawnerDto
[JsonPropertyOrder(8)]
public string Region { get; init; }
[JsonPropertyName("entries")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<SpawnerEntry> Entries { get; init; }
[JsonIgnore]
public override IReadOnlyList<SpawnerEntry> EntryView => Entries;
protected override BaseSpawner CreateEmpty() => new RegionSpawner();
public override BaseSpawner ToSpawner()
@ -191,6 +206,14 @@ public sealed record ProximitySpawnerDto : SpawnerDto
[JsonPropertyOrder(16)]
public bool Instant { get; init; }
[JsonPropertyName("entries")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public List<SpawnerEntry> Entries { get; init; }
[JsonIgnore]
public override IReadOnlyList<SpawnerEntry> EntryView => Entries;
protected override BaseSpawner CreateEmpty() => new ProximitySpawner();
public override BaseSpawner ToSpawner()

View file

@ -31,7 +31,7 @@ public partial class ProximitySpawner
MaxDelay = MaxDelay,
Team = Team,
WalkingRange = DtoWalkingRange,
Entries = Entries,
Entries = EntryList ?? [],
SpawnLocationIsHome = SpawnLocationIsHome,
SpawnPositionMode = DtoSpawnPositionMode,
MaxSpawnAttempts = DtoMaxSpawnAttempts,

View file

@ -29,7 +29,7 @@ public partial class RegionSpawner
MaxDelay = MaxDelay,
Team = Team,
WalkingRange = DtoWalkingRange,
Entries = Entries,
Entries = EntryList ?? [],
SpawnLocationIsHome = SpawnLocationIsHome,
SpawnPositionMode = DtoSpawnPositionMode,
MaxSpawnAttempts = DtoMaxSpawnAttempts,

Some files were not shown because too many files have changed in this diff Show more