#W# Merge: Resolved Merge Conflit from myChanges.
This commit is contained in:
commit
275519b1ff
189 changed files with 11049 additions and 4330 deletions
|
|
@ -3,7 +3,7 @@
|
|||
"isRoot": true,
|
||||
"tools": {
|
||||
"modernuoschemagenerator": {
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"commands": [
|
||||
"ModernUOSchemaGenerator"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
43
Projects/Server.Tests/Helpers/MockAccount.cs
Normal file
43
Projects/Server.Tests/Helpers/MockAccount.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
272
Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs
Normal file
272
Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// Commands.Split strips quotes before any parser runs, so "0" and 0 arrive identical -- the
|
||||
// markers are the only way to say which was meant. See dev-docs/generic-commands.md.
|
||||
public class TextDefinitionParsingTests
|
||||
{
|
||||
private static TextDefinition Parse(string value)
|
||||
{
|
||||
Assert.Null(Types.TryParse(typeof(TextDefinition), value, out var parsed));
|
||||
return parsed as TextDefinition;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1060847", 1060847)]
|
||||
[InlineData("#1060847", 1060847)]
|
||||
public void ClilocsParseToNumber(string entered, int expected)
|
||||
{
|
||||
var td = Parse(entered);
|
||||
|
||||
Assert.NotNull(td);
|
||||
Assert.Equal(expected, td.Number);
|
||||
Assert.Null(td.String);
|
||||
}
|
||||
|
||||
// ToString() already emits '#', so this closes the round trip.
|
||||
[Fact]
|
||||
public void ClilocMarkerRoundTripsThroughToString()
|
||||
{
|
||||
var td = TextDefinition.Of(1060847);
|
||||
|
||||
Assert.Equal("#1060847", td.ToString());
|
||||
Assert.Equal(td, Parse(td.ToString()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello", "hello")]
|
||||
[InlineData(@"@""0""", "0")]
|
||||
[InlineData(@"@""1060847""", "1060847")]
|
||||
[InlineData(@"@""null""", "null")]
|
||||
[InlineData(@"@""#5""", "#5")]
|
||||
public void QuotedLiteralsForceAString(string entered, string expected)
|
||||
{
|
||||
var td = Parse(entered);
|
||||
|
||||
Assert.NotNull(td);
|
||||
Assert.Equal(0, td.Number);
|
||||
Assert.Equal(expected, td.String);
|
||||
}
|
||||
|
||||
// Bare integers stay clilocs, so existing content is unaffected.
|
||||
[Fact]
|
||||
public void BareZeroStaysAnEmptyCliloc()
|
||||
{
|
||||
var td = Parse("0");
|
||||
|
||||
Assert.NotNull(td);
|
||||
Assert.Equal(0, td.Number);
|
||||
Assert.Null(td.String);
|
||||
}
|
||||
|
||||
// GetValue() fills the props-gump edit box; an ambiguous string must come out quoted.
|
||||
[Theory]
|
||||
[InlineData("1060847")]
|
||||
[InlineData("0")]
|
||||
[InlineData("#5")]
|
||||
public void AmbiguousStringsAreEmittedQuotedSoTheyReadBack(string text)
|
||||
{
|
||||
var round = Parse(TextDefinition.Of(text).GetValue());
|
||||
|
||||
Assert.NotNull(round);
|
||||
Assert.Equal(0, round.Number);
|
||||
Assert.Equal(text, round.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnambiguousValuesAreEmittedPlain()
|
||||
{
|
||||
Assert.Equal("Hail, traveller.", TextDefinition.Of("Hail, traveller.").GetValue());
|
||||
Assert.Equal("1060847", TextDefinition.Of(1060847).GetValue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullSentinelClearsTheValue()
|
||||
{
|
||||
Assert.Null(Types.TryParse(typeof(TextDefinition), "(-null-)", out var parsed));
|
||||
Assert.Null(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// [get emits @"null" for the literal string "null"; [set has to decode it to round trip.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class StringEscapeRoundTripTests
|
||||
{
|
||||
[Fact]
|
||||
public void QuotedNullSetsTheLiteralStringNull()
|
||||
{
|
||||
Assert.Null(Types.TryParse(typeof(string), @"@""null""", out var parsed));
|
||||
|
||||
Assert.Equal("null", parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BareNullSentinelStillClearsTheValue()
|
||||
{
|
||||
Assert.Null(Types.TryParse(typeof(string), "(-null-)", out var parsed));
|
||||
|
||||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOutputPastesBackIntoSet()
|
||||
{
|
||||
var item = new Static(0x1F13) { Name = "null" };
|
||||
|
||||
try
|
||||
{
|
||||
var from = new Mobile { AccessLevel = AccessLevel.Developer };
|
||||
var shown = Properties.GetValue(from, item, "Name");
|
||||
|
||||
// "Name = @"null"" -> the value half is what a GM copies.
|
||||
var value = shown[(shown.IndexOf('=') + 2)..];
|
||||
Assert.Equal(@"@""null""", value);
|
||||
|
||||
item.Name = "changed";
|
||||
Properties.SetValue(from, item, "Name", value);
|
||||
|
||||
Assert.Equal("null", item.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
item.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Commands;
|
||||
using Server.Commands.Generic;
|
||||
using Server.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Commands;
|
||||
|
||||
// `where` constants resolve through Types.TryParse like every other command. Type- and
|
||||
// entity-valued properties have no static Parse, so the old local parser threw on them.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class WhereConstantParsingTests : IDisposable
|
||||
{
|
||||
private readonly List<IEntity> _entities = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
for (var i = 0; i < _entities.Count; i++)
|
||||
{
|
||||
_entities[i].Delete();
|
||||
}
|
||||
|
||||
_entities.Clear();
|
||||
}
|
||||
|
||||
public class Subject
|
||||
{
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Type Kind { get; set; } = typeof(Static);
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Mobile Owner { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Item Thing { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public string Label { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public TextDefinition Message { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Map Facet { get; set; } = Map.Felucca;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int Count { get; set; } = 0x1F13;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public SkillName Craft { get; set; } = SkillName.Magery;
|
||||
}
|
||||
|
||||
private static bool Check(object target, string binding, string value,
|
||||
ComparisonOperator op = ComparisonOperator.Equal)
|
||||
{
|
||||
var prop = new Property(binding);
|
||||
prop.BindTo(typeof(Subject), PropertyAccess.Read);
|
||||
|
||||
var compiled = ConditionalCompiler.Compile(
|
||||
typeof(Subject),
|
||||
[TypeCondition.Default, new ComparisonCondition(prop, false, op, value)]
|
||||
);
|
||||
|
||||
return compiled.Verify(target);
|
||||
}
|
||||
|
||||
private T Track<T>(T entity) where T : IEntity
|
||||
{
|
||||
_entities.Add(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TypeValuedPropertiesResolveByName()
|
||||
{
|
||||
var s = new Subject();
|
||||
|
||||
Assert.True(Check(s, "Kind", "Static"));
|
||||
Assert.False(Check(s, "Kind", "Container"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityValuedPropertiesResolveBySerial()
|
||||
{
|
||||
var mob = Track(new Mobile());
|
||||
var item = Track(new Static(0x1F13));
|
||||
var s = new Subject { Owner = mob, Thing = item };
|
||||
|
||||
Assert.True(Check(s, "Owner", mob.Serial.ToString()));
|
||||
Assert.True(Check(s, "Thing", item.Serial.ToString()));
|
||||
Assert.False(Check(s, "Thing", (item.Serial + 1).ToString()));
|
||||
}
|
||||
|
||||
// `where` spells null as a bare `null`, not [set's (-null-); every existing clause relies on it.
|
||||
[Fact]
|
||||
public void BareNullStillMeansNull()
|
||||
{
|
||||
var s = new Subject();
|
||||
|
||||
Assert.True(Check(s, "Label", "null"));
|
||||
Assert.True(Check(s, "Owner", "null"));
|
||||
|
||||
s.Label = "set";
|
||||
s.Owner = Track(new Mobile());
|
||||
|
||||
Assert.False(Check(s, "Label", "null"));
|
||||
Assert.False(Check(s, "Owner", "null"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotedNullStillMeansTheLiteralString()
|
||||
{
|
||||
var s = new Subject { Label = "null" };
|
||||
|
||||
Assert.True(Check(s, "Label", @"@""null"""));
|
||||
Assert.False(Check(s, "Label", "null"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Facet", "Felucca", true)]
|
||||
[InlineData("Facet", "Trammel", false)]
|
||||
[InlineData("Count", "0x1F13", true)]
|
||||
[InlineData("Count", "7955", true)]
|
||||
[InlineData("Count", "0x1F14", false)]
|
||||
[InlineData("Craft", "Magery", true)]
|
||||
[InlineData("Craft", "Anatomy", false)]
|
||||
public void ExistingConstantFormsKeepWorking(string binding, string value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, Check(new Subject(), binding, value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#1060847", true)]
|
||||
[InlineData("#1060848", false)]
|
||||
public void TextDefinitionMarkersReachTheWhereClause(string value, bool expected)
|
||||
{
|
||||
var s = new Subject { Message = TextDefinition.Of(1060847) };
|
||||
|
||||
Assert.Equal(expected, Check(s, "Message", value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotedLiteralsReachTextDefinitionsInWhereToo()
|
||||
{
|
||||
var s = new Subject { Message = TextDefinition.Of("1060847") };
|
||||
|
||||
Assert.True(Check(s, "Message", @"@""1060847"""));
|
||||
Assert.False(Check(s, "Message", "1060847"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnresolvableConstantsStillReportAnError()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => Check(new Subject(), "Kind", "NoSuchTypeAnywhere")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "="));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
164
Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs
Normal file
164
Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
96
Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs
Normal file
96
Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs
Normal file
124
Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
152
Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
Normal file
152
Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
318
Projects/UOContent/Commands/SaveStabilityCommand.cs
Normal file
318
Projects/UOContent/Commands/SaveStabilityCommand.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>></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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ public partial class MurderContext
|
|||
_lastMurderTime = Core.Now;
|
||||
}
|
||||
|
||||
[DirtyTrackingEntity]
|
||||
public PlayerMobile _player;
|
||||
|
||||
public PlayerMobile Player => _player;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
172
Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs
Normal file
172
Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
53
Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs
Normal file
53
Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public partial class ProximitySpawner
|
|||
MaxDelay = MaxDelay,
|
||||
Team = Team,
|
||||
WalkingRange = DtoWalkingRange,
|
||||
Entries = Entries,
|
||||
Entries = EntryList ?? [],
|
||||
SpawnLocationIsHome = SpawnLocationIsHome,
|
||||
SpawnPositionMode = DtoSpawnPositionMode,
|
||||
MaxSpawnAttempts = DtoMaxSpawnAttempts,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue