diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f9f5fbe15..3e22e2e66 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "4.0.0", + "version": "4.1.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..ba7b3f5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/Directory.Build.props b/Directory.Build.props index c832f1006..ad8c7515a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -75,7 +75,7 @@ - 3.10.91 + 3.10.94 all diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs new file mode 100644 index 000000000..8d8973d86 --- /dev/null +++ b/Projects/Server.Tests/Helpers/MockAccount.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Server.Accounting; + +namespace Server.Tests.Network; + +/// +/// Minimal IAccount so a test NetState looks authenticated. +/// +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 _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(); +} diff --git a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs index 5c6c793d4..48d344d39 100644 --- a/Projects/Server.Tests/Helpers/PacketTestUtilities.cs +++ b/Projects/Server.Tests/Helpers/PacketTestUtilities.cs @@ -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). /// - public static NetState CreateTestNetState() + public static NetState CreateTestNetState() => CreateTestNetState(out _); + + /// + /// As , also handing back the peer socket so a test can read what the + /// server delivered. + /// + 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 diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 4bae245cb..b1462ba37 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,9 +5,9 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs new file mode 100644 index 000000000..b8b4fd419 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs @@ -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; + } + } +} diff --git a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs index bfbc18e94..629ec3961 100644 --- a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs @@ -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 _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 diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs new file mode 100644 index 000000000..adc513e2c --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Diagnostics; +using System.Net.Sockets; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Send-side behaviour of a NetState after its disconnect is handed to the socket. +/// +[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(); + } + } +} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs new file mode 100644 index 000000000..19a8704b7 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs @@ -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(); + var withoutArgument = new HashSet(); + + 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))); + } +} diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index f2114a427..48ba16e55 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -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); } } + + /// + /// 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. + /// + [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(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(); + } + } + } + + /// + /// 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. + /// + [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(() => persistence.WriteSnapshot(dir)); + persistence.PostWorldSave(); + } + finally + { + persistence.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + Directory.Delete(dir, true); + } + } } diff --git a/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs new file mode 100644 index 000000000..2b1b3aff0 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/StagedSavePublishTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using Xunit; + +namespace Server.Tests; + +/// +/// 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. +/// +[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)); + } +} diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index 06c676b25..18aa27be5 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -153,6 +153,27 @@ public partial class Container : Item public virtual bool IsDecoContainer => !Movable && !IsLockedDown && !IsSecure && Parent == null && !LiftOverride; + /// + /// 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. + /// + public virtual bool ContentsDecay => IsLockedDown && !IsSecure; + + /// + /// Re-evaluates decay registration for every direct child. Call when + /// may have changed. + /// + 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; diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 13f249f2f..03ff9d000 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -426,8 +426,14 @@ public partial class Item : IHued, IComparable, 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, 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, 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 { diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index f69f92076..5bedb504c 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -61,6 +61,9 @@ public partial class NetState /// public static IIORingGroup Ring => _socketManager?.Ring; + // Test hook + internal static RingSocketManager SocketManager => _socketManager; + /// /// 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); + } } } diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..e161462fc 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -36,6 +36,7 @@ public partial class NetState : IComparable, IValueLinkListNode _flushPending = new(2048); private static readonly Queue _pendingDisconnects = new(256); // Processed AFTER flush @@ -54,7 +55,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode= 0; --i) { - if (Trades != null) + // RemoveTrade() nulls the list once empty + if (Trades == null) { break; } @@ -489,6 +493,12 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode 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, IValueLinkListNode= 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 buffer) @@ -1123,8 +1160,8 @@ public partial class NetState : IComparable, IValueLinkListNode - /// 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). /// public void Disconnect(string reason) { diff --git a/Projects/Server/Network/Packets/OutgoingPackets.cs b/Projects/Server/Network/Packets/OutgoingPackets.cs index ce2cce0f4..23b8f15c6 100644 --- a/Projects/Server/Network/Packets/OutgoingPackets.cs +++ b/Projects/Server/Network/Packets/OutgoingPackets.cs @@ -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; } diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 58b247990..d1955ad2d 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -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) diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index c7323329f..70e9c6aab 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -28,7 +28,17 @@ namespace Server; public interface IGenericEntityPersistence { + string Name { get; } + + int EntityCount { get; } + void DeserializeIndexes(string savePath, Dictionary typesDb); + + /// + /// 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. + /// + IEnumerable EnumerateEntities(); } public class GenericEntityPersistence : GenericPersistence, IGenericEntityPersistence, ISlotRangeSource @@ -85,6 +95,16 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer public Dictionary EntitiesBySerial { get; } = new(); + public int EntityCount => EntitiesBySerial.Count; + + public IEnumerable 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 : GenericPersistence, IGenericEntityPer _selfPosition, _selfLength ); + throw; } binPosition += _selfLength; @@ -185,6 +206,7 @@ public class GenericEntityPersistence : 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 : GenericPersistence, IGenericEntityPer segment.HeapStart, segment.RecordCount ); + throw; } } } @@ -298,9 +321,7 @@ public class GenericEntityPersistence : 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( diff --git a/Projects/Server/Serialization/Persistence.cs b/Projects/Server/Serialization/Persistence.cs index 35d1d7514..edd1b519c 100644 --- a/Projects/Server/Serialization/Persistence.cs +++ b/Projects/Server/Serialization/Persistence.cs @@ -34,6 +34,21 @@ public abstract class Persistence public bool Register() => _registry.Add(this); + /// Every registered entity persistence (Items, Mobiles, Guilds, Accounts, ...), in priority order. + public static IEnumerable 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) diff --git a/Projects/Server/Serialization/SerializationThreadWorker.cs b/Projects/Server/Serialization/SerializationThreadWorker.cs index 5a9b5ed1c..74973a80c 100644 --- a/Projects/Server/Serialization/SerializationThreadWorker.cs +++ b/Projects/Server/Serialization/SerializationThreadWorker.cs @@ -78,6 +78,12 @@ public class SerializationThreadWorker internal List Lengths => _lengths; internal List BufferEntities => _bufferEntities; + /// + /// First serializer exception during the drain. The drain continues so the handshake + /// completes; the loop fails the save once every worker has paused. + /// + public Exception Error { get; private set; } + /// /// 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 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; diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index fd69557b7..030197955 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,13 +34,13 @@ - + - + - - + + @@ -50,5 +50,8 @@ <_Parameter1>UOContent.Tests + + <_Parameter1>ModernSpawner.Tests + diff --git a/Projects/Server/Text/TextDefinition.cs b/Projects/Server/Text/TextDefinition.cs index 4951c2290..0dfcc7ba8 100644 --- a/Projects/Server/Text/TextDefinition.cs +++ b/Projects/Server/Text/TextDefinition.cs @@ -68,7 +68,26 @@ public class TextDefinition : IEquatable, IEquatable, IS Number > 0 ? $"{Number} (0x{Number:X})" : String != null ? $"\"{String}\"" : null; - public string GetValue() => Number > 0 ? Number.ToString() : String ?? ""; + /// + /// The editable text form. Quotes a string that TryParse would not read back unchanged; + /// the check is a real round trip so it cannot drift from the parser. + /// + 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, IEquatable, 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, IEquatable, IS public static TextDefinition Parse(ReadOnlySpan 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; } + /// + /// #1234 or a bare 1234/0x4D2 is a cliloc; @"1234" is the literal + /// text. Always succeeds -- anything that is not a cliloc is a string. + /// See dev-docs/generic-commands.md. + /// public static bool TryParse(ReadOnlySpan 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, IEquatable, IS result = Of(s); return true; } + + private static bool TryGetQuotedLiteral(ReadOnlySpan s, out ReadOnlySpan literal) + { + if (s.Length >= 3 && s[0] == '@' && s[1] == '"' && s[^1] == '"') + { + literal = s[2..^1]; + return true; + } + + literal = default; + return false; + } } diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index c00fc85f6..7ce1b52f2 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -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); } + /// + /// A complete snapshot is staged here before the previous save is touched; a staged + /// directory is always a complete save newer than . + /// + 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); + } + + /// + /// Finishes an interrupted publish. Runs at boot (before load) and before every save; + /// whatever is at Saves/ is set aside, never deleted. + /// + 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; diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 05101226d..033553924 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -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) diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs new file mode 100644 index 000000000..a7c266589 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -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 _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 Sorter(string binding) => + SortCompiler.Compile( + typeof(SkillTeleporter), + [new OrderInfo(Bind(binding), true)] + ); + + private static IComparer Distincter(string binding) => + DistinctCompiler.Compile( + 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)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs new file mode 100644 index 000000000..f430973a9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -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 _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( + 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( + 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(() => Check(op, "(1, 2)+(3, 4)")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs new file mode 100644 index 000000000..e1068f6e2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -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 _items = []; + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } + + private SkillTeleporter Teleporter(Action setup = null) + { + var tp = new SkillTeleporter(); + setup?.Invoke(tp); + _items.Add(tp); + return tp; + } + + private static IEqualityComparer 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)DistinctCompiler.Compile(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)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs new file mode 100644 index 000000000..997d8b79f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -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." + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs new file mode 100644 index 000000000..b1f34b2f3 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/NullablePropertyConditionTests.cs @@ -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 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(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())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs new file mode 100644 index 000000000..3d43eca42 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -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 _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")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs new file mode 100644 index 000000000..f8502e193 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/TextDefinitionCommandTests.cs @@ -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(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs new file mode 100644 index 000000000..34d0ca482 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/WhereConstantParsingTests.cs @@ -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 _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 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( + () => Check(new Subject(), "Kind", "NoSuchTypeAnywhere") + ); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs new file mode 100644 index 000000000..df795a440 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -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("Namemoon", true)] + [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())); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs new file mode 100644 index 000000000..f6921de9e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchResultOrderTests.cs @@ -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 _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 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 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 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); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index ea14f3cf2..36f97eaf0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -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); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs deleted file mode 100644 index 9176fae4a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs +++ /dev/null @@ -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 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", "=")); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs index 2bfe94af8..bd96a8586 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs @@ -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(); + var ignore = new ConcurrentQueue(); + 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(); + 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(); + var ignore = new ConcurrentQueue(); + 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(); + 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() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs new file mode 100644 index 000000000..b8a4f9d4b --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Plants/PlantItemPropertyListTests.cs @@ -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(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin new file mode 100644 index 000000000..29db020bc Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin new file mode 100644 index 000000000..4e5fa2145 Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin new file mode 100644 index 000000000..a8653ab9b Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs index 5f8e80f92..689420c93 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs @@ -11,12 +11,16 @@ public class SpawnerDiscoveryValidationTests [JsonDiscoverableType("dup")] private sealed record DupA : SpawnerDto { + public override IReadOnlyList EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } [JsonDiscoverableType("dup")] private sealed record DupB : SpawnerDto { + public override IReadOnlyList EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs new file mode 100644 index 000000000..8a25c4b0f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs @@ -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 { 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>(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; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs new file mode 100644 index 000000000..767a3289d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -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(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); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs new file mode 100644 index 000000000..d3f48792a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs @@ -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(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs new file mode 100644 index 000000000..4450e1dea --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs @@ -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 _testEntries; + + public List Log { get; } = []; + public bool VetoNext { get; set; } + + public HookRecordingSpawner() + { + } + + public HookRecordingSpawner(Serial serial) : base(serial) + { + } + + public override IReadOnlyList Entries => _testEntries ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => + ReadOnlySpan.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 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); + } + } + + /// Test hook: adopt entries built elsewhere, exercising the conversion path. + public void AdoptForTest(IReadOnlyList entries) => AdoptEntries(entries); + + /// Test hook: rebuild the Spawned registry without a full save round trip. + 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(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(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(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); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs new file mode 100644 index 000000000..870ae1161 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs @@ -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(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(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(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(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(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(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs new file mode 100644 index 000000000..f6dac1546 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/PropsGumpTextDefinitionTests.cs @@ -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 _items = []; + private readonly List _mobiles = []; + private readonly List _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.Empty, ReadOnlySpan.Empty, ReadOnlySpan.Empty, ReadOnlySpan.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.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()); + } + + // 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()); + } + + // 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(); + 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(); + 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(); + 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 list, int page) : base(m, o, null, list, page) + { + } + + public List List => m_List; + } +} diff --git a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs new file mode 100644 index 000000000..f596282f6 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs @@ -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(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs new file mode 100644 index 000000000..fc3f82aa8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AIDeactivationTests.cs @@ -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(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index db5225359..6765b07f9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -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); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs index 7d9243a29..8637c5e59 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -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); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index e0fe22a39..91acee5d9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -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); + } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 217049b3d..12d978e6d 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -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); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs new file mode 100644 index 000000000..00316a351 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -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 _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() 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(); + 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(); + 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(); + 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(); + Order(pet, master, OrderType.Guard); + + var attacker = Attack(pet); + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Same(attacker, pet.Combatant); + Assert.True(pet.Warmode); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index a759cc024..d15228590 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -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) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs new file mode 100644 index 000000000..51a4de573 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -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 _created = new(); + private readonly List _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 _sbInfos = []; + + public VendorStub() : base("the stub") + { + } + + public VendorStub(Serial serial) : base(serial) + { + } + + protected override List 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()); // 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)); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs new file mode 100644 index 000000000..6369ddda9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/MountRegionTests.cs @@ -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 MountCases() + { + foreach (var era in Enum.GetValues()) + { + 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; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs new file mode 100644 index 000000000..f8e1cb3f2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HouseDecayPurityTests.cs @@ -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; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs new file mode 100644 index 000000000..91fba77bc --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs @@ -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(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs index 0264a282e..08658b1a1 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -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)); diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs new file mode 100644 index 000000000..978adcc3f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/WorldSaves/SaveStabilityTests.cs @@ -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 { 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 { 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(); + 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(); + } + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1c73f6eb4..1dc4440ae 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,18 +4,23 @@ Debug;Release;Analyze - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + diff --git a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs index 8709f0d14..7a37cd4f9 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs @@ -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) { } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs index 6f5af48d4..fecf0ee2e 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/ConditionalCompiler.cs @@ -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.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 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 _verify; - var il = ctor.GetILGenerator(); + public CompiledConditional(Func 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) + /// + /// Compiles a conjunction of conditions over into a single + /// delegate. The conditions short-circuit left to right, so + /// comes first and the rest can assume a non-null, correctly typed target. + /// + public static IConditional Compile(Type objectType, ICondition[] conditions) => + new CompiledConditional(Build(objectType, conditions).Compile()); + + public static Expression> Build(Type objectType, ICondition[] conditions) => + Lambda(objectType, target => Conjunction(target, conditions)); + + /// + /// A disjunction of conjunctions -- (a and b) or (c and d) -- as one lambda, for + /// callers that would otherwise compile every group separately and loop over them. + /// + public static Expression> 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(); + private static Expression> Lambda(Type objectType, Func body) + { + var obj = Expression.Parameter(typeof(object), "obj"); + var target = Expression.Variable(objectType, "target"); + + return Expression.Lambda>( + Expression.Block( + [target], + Expression.Assign(target, Expression.TypeAs(obj, objectType)), + body(target) + ), + obj + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs index 9e96ff2f7..7d80199b7 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/DistinctCompiler.cs @@ -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 Compile(AssemblyEmitter assembly, Type objectType, Property[] props) + private sealed class DistinctComparer : IComparer, IEqualityComparer { - var typeBuilder = assembly.DefineType( - "__distinct", - TypeAttributes.Public, - typeof(object) + private readonly Comparison _compare; + private readonly Func _hash; + + public DistinctComparer(Comparison compare, Func 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); + } + + /// + /// A comparer that treats two objects as the same when every one of + /// reads equal on both. Ordering is the sort compiler's, all + /// ascending, so the result doubles as an . + /// + public static IComparer Compile(Type objectType, Property[] props) + { + var signs = new int[props.Length]; + Array.Fill(signs, 1); + + return new DistinctComparer( + SortCompiler.Build(objectType, props, signs).Compile(), + BuildHash(objectType, props).Compile() ); + } + + // XOR of each property's hash; a null reference hashes to 0 and an int hashes to itself. + public static Expression> BuildHash(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)); + return Expression.Lambda>( + 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).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)); + 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).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).GetMethod( - "GetHashCode", - new[] - { - typeof(T) - } - ) ?? throw new Exception($"No GetHashCode method found for type {typeof(T).FullName}") - ); - } - - var comparerType = typeBuilder.CreateType(); - - return comparerType.CreateInstance>(); + return Expression.Block([value], Expression.Assign(value, read), hash); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs new file mode 100644 index 000000000..0689d130a --- /dev/null +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/PropertyExpressions.cs @@ -0,0 +1,355 @@ +using System; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; + +namespace Server.Commands.Generic; + +/// +/// Expression-tree fragments over a bound chain, shared by the +/// conditional, sort and distinct compilers. Everything here builds an ; +/// the compilers assemble those into a lambda and hand Compile() the codegen. +/// +public static class PropertyExpressions +{ + private static readonly MethodInfo _objectEquals = typeof(object).GetMethod( + nameof(object.Equals), + BindingFlags.Public | BindingFlags.Static, + [typeof(object), typeof(object)] + )!; + + /// + /// Walks a property binding. A binding of more than one property (Message.Number) + /// 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 in place of + /// whatever would have built from the final link. + /// + public static Expression Chain( + Expression target, + Property prop, + Func onValue, + Expression whenUnreadable + ) => ChainFrom(target, prop.Chain, 0, onValue, whenUnreadable); + + private static Expression ChainFrom( + Expression current, + PropertyInfo[] chain, + int index, + Func 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 + ) + ); + } + + /// + /// 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 default(T), which is what a null link along the way amounts to; the + /// comparers these feed already handle a null value. + /// + public static Expression ChainOrDefault(Expression target, Property prop) => + Chain(target, prop, static value => value, Expression.Default(prop.Type)); + + /// + /// Equality for the "not comparable" path, which supports only == and !=. Reference equality + /// would miss a type whose equality is by value -- among them -- + /// so this is static object.Equals, which honors the override and is null-safe on + /// either side. Value types box; they only reach here when they have no CompareTo. + /// + 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)); + + /// + /// A boolean test of against . 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 and + /// tests the sign of the result. Nullable<T> 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 reference keeps the ordering gives it.) False + /// when the type has no CompareTo at all, in which case only equality is meaningful. + /// + 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 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 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); + + /// + /// An int-valued comparison of against + /// with CompareTo semantics, multiplied by . A null on either + /// side of a reference or nullable type is handled here rather than in the callee: + /// null.CompareTo(null) = 0, real.CompareTo(null) = -sign, + /// null.CompareTo(real) = +sign. False when the type has no CompareTo. + /// + 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; + } + + /// + /// The right-hand side of a condition as a typed constant, resolved by the same parser behind + /// [set and [add. See dev-docs/generic-commands.md. + /// + 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); + } +} diff --git a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs index a597ece98..812a6726a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/Compilers/SortCompiler.cs @@ -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 Compile(AssemblyEmitter assembly, Type objectType, OrderInfo[] orders) + public static IComparer Compile(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.Create(Build(objectType, properties, signs).Compile()); + } + + /// + /// A over , taken in order: the + /// first property that orders the two objects decides, each multiplied by its sign. Both + /// arguments are cast to first; the bindings are read from + /// that. + /// + public static Expression> Build(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>( + 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)); + 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).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>(); + 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) + ) + ); } } } diff --git a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs index 2ed85ec1b..bbcb4215a 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/DistinctExtension.cs @@ -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(assembly, baseType, m_Properties.ToArray()); + m_Comparer = DistinctCompiler.Compile(baseType, m_Properties.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs index c81f8f160..adfbcfae8 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/SortExtension.cs @@ -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(assembly, baseType, m_Orders.ToArray()); + m_Comparer = SortCompiler.Compile(baseType, m_Orders.ToArray()); } public override void Parse(Mobile from, string[] arguments, int offset, int size) diff --git a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs index 47d8cd9a2..78f206483 100644 --- a/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs +++ b/Projects/UOContent/Commands/Generic/Extensions/WhereExtension.cs @@ -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) diff --git a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs index bbd64e822..750df728d 100644 --- a/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs +++ b/Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs @@ -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) diff --git a/Projects/UOContent/Commands/SaveStabilityCommand.cs b/Projects/UOContent/Commands/SaveStabilityCommand.cs new file mode 100644 index 000000000..0a0cd6d0f --- /dev/null +++ b/Projects/UOContent/Commands/SaveStabilityCommand.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Server.Logging; + +namespace Server.Commands; + +/// +/// 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 (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. +/// +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 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(); + } + + /// Snapshots every entity of every registered entity persistence, taking every Nth. + public static List SnapshotEntities(int stride = 1) + { + var list = new List(); + var i = 0; + + foreach (var persistence in Persistence.EntityPersistences) + { + foreach (var entity in persistence.EnumerateEntities()) + { + if (i++ % stride == 0) + { + list.Add(entity); + } + } + } + + return list; + } + + /// Hashes the serialized bytes of every entity in order; deleted entities hash to 0. + public static ulong[] CaptureHashes(List 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; + } + + /// Re-hashes every entity and reports, per type, how many differ from the captured hashes. + public static SaveStabilityReport Compare(List entities, ulong[] captured) + { + var writer = new BufferWriter(true); + var report = new SaveStabilityReport(0, 0, new Dictionary()); + 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 _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()); + _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>(); + + 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."); + } + } + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs new file mode 100644 index 000000000..5eb42ac9e --- /dev/null +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchConditions.cs @@ -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; + +/// +/// Turns a property-test string from the Advanced Search gump into a compiled predicate. The +/// grammar is the gump's own -- ~ negates a leaf, @ is AND, | is OR and binds +/// looser, and the string operators double up (> is "starts with" on a string) -- but +/// each leaf becomes the same a where clause compiles, so there is +/// one comparison engine. A leaf that cannot be resolved or parsed is simply "no match". +/// +/// +/// Runs on the search workers, off the game loop: binding is reflection, compiling is +/// Expression.Compile, and neither touches game state. The one exception is a value that +/// names an entity by serial, which resolves through the world -- +/// the same read the previous per-entity evaluator made, now made once per type instead. +/// +public static class AdvancedSearchConditions +{ + // Runtime type -> its public readable instance properties, for the case-insensitive name scan. + private static readonly ConcurrentDictionary _properties = new(); + + private static readonly Func _never = static _ => false; + + /// + /// 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 + /// Hue shares one compiled Hue = 5. + /// + public sealed class Cache + { + internal readonly ConcurrentDictionary> ByRuntimeType = new(); + internal readonly ConcurrentDictionary> ByCompiledType = new(); + } + + public static Func GetPredicate(Cache cache, Type runtimeType, string propertyTest) => + cache.ByRuntimeType.GetOrAdd( + runtimeType, + static (type, state) => Build(state.cache, type, state.propertyTest), + (cache, propertyTest) + ); + + /// Compiles without a cache. Test seam and one-off use. + public static Func Compile(Type runtimeType, string propertyTest) => + Build(new Cache(), runtimeType, propertyTest); + + private static Func 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(); + + foreach (var orPart in propertyTest.Split('|')) + { + var group = new List { 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 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 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 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(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(); + } + + /// + /// 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. + /// + 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) + ); + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index c6b2bc5f4..95b1a1040 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -767,11 +767,12 @@ public class AdvancedSearchGump : Gump var ignoreQueue = new ConcurrentQueue(); var results = new ConcurrentQueue(); + 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 diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs index 8842b8f80..1a455064d 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchResultComparers.cs @@ -2,86 +2,118 @@ using System.Collections.Generic; namespace Server.Engines.AdvancedSearch; -public class AdvancedSearchResultTypeComparer : IComparer +// Results arrive in worker-finish order, so every sort ends in the same serial tie-break. +public abstract class AdvancedSearchResultComparer : IComparer { - 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 -{ - 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 -{ - 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 -{ - 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 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 +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); } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index 48b1f602b..1b44c789a 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -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 _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 _ignoreQueue; private WorldLocation _worldLocation; private AdvancedSearchFilter _filter; + private AdvancedSearchConditions.Cache _predicates; public AdvancedSearchThreadWorker() { @@ -46,17 +45,23 @@ public class AdvancedSearchThreadWorker _thread.Start(this); } + /// + /// 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. + /// public void Wake( WorldLocation worldLocation, AdvancedSearchFilter filter, ConcurrentQueue results, - ConcurrentQueue ignoreQueue + ConcurrentQueue 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 span) => - AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); - - private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan 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); } } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 6775dcaef..f84cef41c 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -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 valuePart, ReadOnlySpan operatorSpan) - { - // TODO: Add support for implicit conversion types like Serial -> uint - - if (propertyType == typeof(long)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ulong)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(int)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(uint)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(short)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(ushort)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(sbyte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(byte)) - { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(float)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(double)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); - } - if (propertyType == typeof(string)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((string)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(TimeSpan)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(DateTime)) - { - return TryParseValue(valuePart, out var parsedValue) && - Compare((DateTime)propertyValue!, parsedValue, operatorSpan); - } - if (propertyType == typeof(bool)) - { - return TryParseValue(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 propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => - 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 originalValue, - ReadOnlySpan 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 value) { var decimalPlace = value.IndexOf('.'); @@ -191,247 +55,4 @@ public static class AdvancedSearchUtilities _ => 1E-16 }; } - - public static bool Compare(string propertyValue, string parsedValue, ReadOnlySpan 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 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 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 operatorSpan) => - operatorSpan switch - { - "=" or "==" => propertyValue == parsedValue, - "!" or "!=" => propertyValue != parsedValue, - _ => false - }; - - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan 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(ReadOnlySpan 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(valuePart, out value); - } - - if (typeof(T) == typeof(ulong)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(int)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(uint)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(short)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(ushort)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(sbyte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(byte)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(float)) - { - return TryParseNumericValue(valuePart, out value); - } - - if (typeof(T) == typeof(double)) - { - return TryParseNumericValue(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 — 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(valuePart, out value); - } - - if (typeof(T) == typeof(DateTime)) - { - return TryParseSpanParsable(valuePart, out value); - } - - value = default; - return false; - } - - // Parses U (a value type exposing ISpanParsable) 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(ReadOnlySpan valuePart, out T value) where U : ISpanParsable - { - 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(ReadOnlySpan valuePart, out R value) where T : INumber - { - 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 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(TState state, ReadOnlySpan leaf); - - // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. - internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator 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 - }; } diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index f63e4f8d4..5b72a3c54 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -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; diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs index 44f1e727c..b2d8873d7 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeEntry.cs @@ -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); } } diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs index 8a4a91637..8337dc91b 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBLargeSubEntry.cs @@ -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; diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs index a0c17c575..e4d788a7c 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BulkOrderBook.cs @@ -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(); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs index c1970a449..dca633bb7 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitle.cs @@ -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; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index 644e2b965..f610e485f 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -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; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index 6160c4595..4c74ee832 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -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; diff --git a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs index 7cd4edc92..8e8bf9308 100644 --- a/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs +++ b/Projects/UOContent/Engines/Factions/Gumps/HorseBreederGump.cs @@ -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; diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 53932c8f0..2da3b198b 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -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(); - (_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() diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 5f66b1a7a..5633db48c 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -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) { diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index 384c91c98..9be182300 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -58,6 +58,7 @@ public partial class MurderContext _lastMurderTime = Core.Now; } + [DirtyTrackingEntity] public PlayerMobile _player; public PlayerMobile Player => _player; diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index d1be82aa9..80ba553fc 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -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; diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs index 5c0917086..aab965232 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -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); } } /// The square spawn bounds a homeRange radius represents (centered on the location). - 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 { diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs new file mode 100644 index 000000000..83e31c179 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; + +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// 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 . + /// + [IgnoreDupe] + public abstract IReadOnlyList Entries { get; } + + /// Zero-cost span over the owner's list for hot loops (no interface dispatch, no allocation). + protected abstract ReadOnlySpan EntrySpan { get; } + + /// Creates an entry of the owner's entry type, parented to this spawner. Not added. + 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(); + + /// + /// 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. + /// + /// + /// An implementer that converts a foreign entry into its own entry type must carry the live spawns + /// across with ; deliberately does not copy + /// them, so a conversion that only clones orphans every creature the adopted entry owns. + /// + protected abstract void AdoptEntries(IReadOnlyList entries); + + /// + /// Moves the live spawns of onto . Use when an owner converts + /// an adopted entry into its own entry type; deliberately does not copy spawns. + /// + 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(); + } + + /// Deep-copies an entry into this spawner's entry type. Override to carry subtype fields. + 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(); + } + + /// + /// Deletes every live spawn and removes every entry. Named for the deletion: before entry ownership + /// moved to the owner, the generator emitted a ClearEntries() here that only emptied the list. + /// + public void RemoveAllEntries() + { + RemoveSpawns(); + ClearEntriesCore(); + InvalidateProperties(); + } + + /// Replaces 's entries with clones of this spawner's entries. + 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(); + } + + /// + /// 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. + /// + /// + /// 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 + /// [AfterDeserialization]: the base class's runs before the derived fields have been read, + /// so the load would otherwise finish with an empty registry even though the + /// entries themselves carry their spawns. + /// + protected void RebuildSpawned() + { + Spawned = new Dictionary(); + + 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); + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs new file mode 100644 index 000000000..ccc2a226d --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs @@ -0,0 +1,53 @@ +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// Called after the timer starts (Start(), Running = true, NextSpawn on a stopped spawner). + /// Not called for construction (InitSpawn) or deserialization; subclasses initialise + /// run state in their constructor and [AfterDeserialization]. + /// + protected virtual void OnStarted() + { + } + + /// Called after the timer stops (Stop(), Running = false), and only if it was running. + protected virtual void OnStopped() + { + } + + /// Veto point before an entry's entity is constructed. Return false to skip this attempt. + protected virtual bool OnBeforeSpawn(SpawnerEntry entry) => true; + + /// + /// Runs after property application and before positioning, so computed properties apply first. + /// The entity is not yet in , has no Spawner set, and is still on + /// the internal map. + /// + protected virtual void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// Entry-aware positioning. Default delegates to the entry-agnostic overload. + protected virtual Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) => + GetSpawnPosition(spawned, map); + + /// Runs after the entity is in the world and linked to this spawner. + protected virtual void OnSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// A spawned creature died (before base death deletes it and unlinks the spawner). + protected virtual void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) + { + } + + /// Entry point for BaseCreature.OnDeath. Resolves the entry and dispatches the hook. + public void NotifySpawnedDeath(ISpawnable spawned, Mobile killer) + { + if (spawned != null && Spawned != null && Spawned.TryGetValue(spawned, out var entry)) + { + OnSpawnedDeath(entry, spawned, killer); + } + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs index 9c5c4f002..35d9c2433 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -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(count); + var entries = new List(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(); _group = reader.ReadBool(); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 8efc54b8c..3a78f3b4a 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -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 _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 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(); - 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(); 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(); } } + /// Guards and flips . Callers arm the timer and fire . + 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(); - - foreach (var entry in Entries) - { - foreach (var spawned in entry.Spawned) - { - Spawned.Add(spawned, entry); - } - } - - DoTimer(_end - Core.Now); } private class InternalTimer : Timer diff --git a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs index 067a7f762..4efc99585 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs @@ -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)) { diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs index dc5ef63f4..e6bc92808 100644 --- a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -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 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; + /// The entries carried by the concrete record, in its own entry type. Never serialized directly. + [JsonIgnore] + public abstract IReadOnlyList EntryView { get; } + /// Constructs the empty concrete spawner Item for this DTO. 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 Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList 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 Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList 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 Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new ProximitySpawner(); public override BaseSpawner ToSpawner() diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs index 219e91943..d71676815 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs @@ -31,7 +31,7 @@ public partial class ProximitySpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs index 3d9614247..d6c343071 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs @@ -29,7 +29,7 @@ public partial class RegionSpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs index a291a4854..4b9e9f495 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs @@ -31,7 +31,7 @@ public partial class Spawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index db25cab55..184558e79 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; using ModernUO.Serialization; namespace Server.Engines.Spawners; -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Spawner : BaseSpawner { /// @@ -33,6 +35,11 @@ public partial class Spawner : BaseSpawner } } + // Owned by the concrete class so subclasses can store their own entry type; null until the first entry. + [SerializedIgnoreDupe] + [SerializableField(2, getter: "protected", setter: "private")] + private List _entryList; + [Constructible(AccessLevel.Developer)] public Spawner() { @@ -63,8 +70,73 @@ public partial class Spawner : BaseSpawner protected override ReadOnlySpan GetAllSpawnBounds() => new(ref _spawnBounds); + public override IReadOnlyList Entries => _entryList ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => CollectionsMarshal.AsSpan(_entryList); + + protected override SpawnerEntry CreateEntry( + string name, + int probability, + int maxCount, + string properties, + string parameters + ) => new(this, name, probability, maxCount, properties, parameters); + + protected override void AddEntryCore(SpawnerEntry entry) + { + EntryList ??= []; + AddToEntryList(entry); + } + + protected override bool RemoveEntryCore(SpawnerEntry entry) + { + if (_entryList?.Contains(entry) != true) + { + return false; + } + + RemoveFromEntryList(entry); + return true; + } + + protected override void ClearEntriesCore() + { + if (_entryList?.Count > 0) + { + ClearEntryList(); + } + } + + protected override void AdoptEntries(IReadOnlyList entries) + { + if (entries.Count == 0) + { + EntryList = null; + return; + } + + // Copy, never alias the caller's list. + var list = new List(entries); + for (var i = 0; i < list.Count; i++) + { + list[i].SetParent(this); + } + + EntryList = list; + } + private void MigrateFrom(V0Content content) { - // V0 had no fields in Spawner, new v1 field _useSpiralScan defaults to false + // v0 had no fields. } + + private void MigrateFrom(V1Content content) + { + _useSpiralScan = content.UseSpiralScan; + _spawnBounds = content.SpawnBounds ?? default; + // _entryList was already adopted by BaseSpawner.MigrateFrom(V12Content). + } + + [AfterDeserialization] + private void AfterDeserialization() => RebuildSpawned(); } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs index 787ef9ab4..ee5c44586 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs @@ -447,8 +447,9 @@ public class SpawnerControllerGump : DynamicGump private static bool SearchSpawnerCreatures(BaseSpawner spawner, string searchPattern) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; if (entry.SpawnedName?.InsensitiveContains(searchPattern) == true) { return true; @@ -486,17 +487,9 @@ public class SpawnerControllerGump : DynamicGump public static void CopyEntry(BaseSpawner spawner, BaseSpawner target) { - if (spawner.Entries?.Count > 0) + if (spawner.Entries.Count > 0) { - target.Entries?.Clear(); - - for (var i = 0; i < spawner.Entries.Count; i++) - { - var item = spawner.Entries[i]; - var targetEntry = target.AddEntry(item.SpawnedName, item.SpawnedProbability, item.SpawnedMaxCount); - targetEntry.Properties = item.Properties; - targetEntry.Parameters = item.Parameters; - } + spawner.CopyEntriesTo(target); } } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs index d364b3916..524da1b6f 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs @@ -5,7 +5,7 @@ using Server.Json; namespace Server.Engines.Spawners; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class SpawnerEntry { [DirtyTrackingEntity] @@ -36,6 +36,48 @@ public partial class SpawnerEntry [SerializableField(5)] private List _spawned; + private bool ShouldSerializeDisabled() => _disabled; + + /// + /// Locked entries are skipped by weighted selection; live spawns are untouched. + /// Stored inverted so the common (enabled) case writes nothing. + /// + [SaveFlag(nameof(ShouldSerializeDisabled))] + [SerializableField(6)] + [SerializedJsonPropertyName("disabled")] + private bool _disabled; + + [JsonIgnore] + public bool Enabled + { + get => !_disabled; + set => Disabled = !value; + } + + /// + /// The spawner that owns this entry. Derived entry types declare it as their + /// [DirtyTrackingEntity]; the generator only inspects the type it is generating. + /// + protected BaseSpawner Parent => _parent; + + /// Re-parents this entry. Public so out-of-tree owners can call it from AdoptEntries. + public void SetParent(BaseSpawner parent) + { + _parent = parent; + _spawned ??= []; + } + + private void MigrateFrom(V1Content content) + { + _spawnedName = content.SpawnedName; + _spawnedProbability = content.SpawnedProbability; + _spawnedMaxCount = content.SpawnedMaxCount; + _properties = content.Properties; + _parameters = content.Parameters; + _spawned = content.Spawned ?? []; + _disabled = false; + } + public SpawnerEntry(BaseSpawner parent) { _parent = parent; diff --git a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs index e22782228..ac14a9c5d 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs @@ -19,12 +19,12 @@ public class SpawnerGump : Gump AddPage(0); - AddBackground(0, 0, 346, 400 + (_entry != null ? 44 : 0), 5054); - AddAlphaRegion(0, 0, 346, 400 + (_entry != null ? 44 : 0)); + AddBackground(0, 0, 369, 400 + (_entry != null ? 44 : 0), 5054); + AddAlphaRegion(0, 0, 369, 400 + (_entry != null ? 44 : 0)); - AddHtml(240, 1, 250, 20, "#"); - AddHtml(271, 1, 250, 20, "Max"); - AddHtml(311, 1, 250, 20, "Prb"); + AddHtml(263, 1, 250, 20, "#"); + AddHtml(294, 1, 250, 20, "Max"); + AddHtml(334, 1, 250, 20, "Prb"); // AddLabel( 95, 1, 0, "Creatures List" ); @@ -45,7 +45,7 @@ public class SpawnerGump : Gump if (entry == null || _entry != entry) { AddButton( - 5, + 28, 22 * i + 21 + offset, entry != null ? 0xFBA : 0xFA5, entry != null ? 0xFBC : 0xFA7, @@ -55,7 +55,7 @@ public class SpawnerGump : Gump else { AddButton( - 5, + 28, 22 * i + 21 + offset, 0xFBB, 0xFBC, @@ -63,19 +63,35 @@ public class SpawnerGump : Gump ); // Unexpand } - AddButton(38, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + AddButton(61, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete - AddImageTiled(71, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box - AddImageTiled(72, 22 * i + 21 + offset, 159, 21, 0xBBC); // creature text box + if (entry != null) + { + if (entry.Disabled) + { + AddButton(9, 22 * i + 24 + offset, 0x82C, 0x82C, GetButtonID(3, i)); // Locked, press to unlock + } + else + { + AddButton(7, 22 * i + 24 + offset, 0x2C88, 0x2C89, GetButtonID(3, i)); // Unlocked, press to lock + } + } - AddImageTiled(235, 22 * i + 20 + offset, 35, 23, 0xA40); // count html label - AddImageTiled(236, 22 * i + 21 + offset, 33, 21, 0xE14); // count html label + // Locked entries render on a grey tile as labels instead of text entries, so they can't be edited + var locked = entry?.Disabled == true; + var fieldTile = locked ? 0x23F4 : 0xBBC; - AddImageTiled(267, 22 * i + 20 + offset, 35, 23, 0xA40); // maxcount text box - AddImageTiled(268, 22 * i + 21 + offset, 33, 21, 0xBBC); // maxcount text box + AddImageTiled(94, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box + AddImageTiled(95, 22 * i + 21 + offset, 159, 21, fieldTile); // creature text box - AddImageTiled(305, 22 * i + 20 + offset, 35, 23, 0xA40); // probability text box - AddImageTiled(306, 22 * i + 21 + offset, 33, 21, 0xBBC); // probability text box + AddImageTiled(258, 22 * i + 20 + offset, 35, 23, 0xA40); // count html label + AddImageTiled(259, 22 * i + 21 + offset, 33, 21, 0xE14); // count html label + + AddImageTiled(290, 22 * i + 20 + offset, 35, 23, 0xA40); // maxcount text box + AddImageTiled(291, 22 * i + 21 + offset, 33, 21, fieldTile); // maxcount text box + + AddImageTiled(328, 22 * i + 20 + offset, 35, 23, 0xA40); // probability text box + AddImageTiled(329, 22 * i + 21 + offset, 33, 21, fieldTile); // probability text box string name; string probability; @@ -91,7 +107,7 @@ public class SpawnerGump : Gump var count = spawner.CountSpawns(entry); - AddHtml(235, 22 * i + 20 + offset + 1, 35, 15, $"
{count}/
"); + AddHtml(258, 22 * i + 20 + offset + 1, 35, 15, $"
{count}/
"); } else { @@ -100,47 +116,44 @@ public class SpawnerGump : Gump maxCount = ""; } - // creature - AddTextEntry( - 75, - 22 * i + 21 + offset, - 156, - 21, - (flags & EntryFlags.InvalidType) != 0 ? 33 : 0, - textIndex, - name - ); - AddTextEntry(270, 22 * i + 21 + offset, 30, 21, 0, textIndex + 1, maxCount); // max count - AddTextEntry(308, 22 * i + 21 + offset, 30, 21, 0, textIndex + 2, probability); // probability + var nameHue = (flags & EntryFlags.InvalidType) != 0 ? 33 : 0; + + if (locked) + { + AddLabelCropped(98, 22 * i + 21 + offset, 156, 21, nameHue, name); // creature + AddLabelCropped(293, 22 * i + 21 + offset, 30, 21, 0, maxCount); // max count + AddLabelCropped(331, 22 * i + 21 + offset, 30, 21, 0, probability); // probability + } + else + { + AddTextEntry(98, 22 * i + 21 + offset, 156, 21, nameHue, textIndex, name); // creature + AddTextEntry(293, 22 * i + 21 + offset, 30, 21, 0, textIndex + 1, maxCount); // max count + AddTextEntry(331, 22 * i + 21 + offset, 30, 21, 0, textIndex + 2, probability); // probability + } if (entry != null && _entry == entry) { AddLabel(5, 22 * i + 42, 0x384, "Params"); - AddImageTiled(55, 22 * i + 42, 253, 23, 0xA40); // Parameters - AddImageTiled(56, 22 * i + 43, 251, 21, 0xBBC); // Parameters + AddImageTiled(55, 22 * i + 42, 276, 23, 0xA40); // Parameters + AddImageTiled(56, 22 * i + 43, 274, 21, fieldTile); // Parameters AddLabel(5, 22 * i + 64, 0x384, "Props"); - AddImageTiled(55, 22 * i + 64, 253, 23, 0xA40); // Properties - AddImageTiled(56, 22 * i + 65, 251, 21, 0xBBC); // Properties + AddImageTiled(55, 22 * i + 64, 276, 23, 0xA40); // Properties + AddImageTiled(56, 22 * i + 65, 274, 21, fieldTile); // Properties - AddTextEntry( - 59, - 22 * i + 42, - 248, - 21, - (flags & EntryFlags.InvalidParams) != 0 ? 33 : 0, - textIndex + 3, - entry.Parameters - ); // parameters - AddTextEntry( - 59, - 22 * i + 62, - 248, - 21, - (flags & EntryFlags.InvalidProps) != 0 ? 33 : 0, - textIndex + 4, - entry.Properties - ); // properties + var paramsHue = (flags & EntryFlags.InvalidParams) != 0 ? 33 : 0; + var propsHue = (flags & EntryFlags.InvalidProps) != 0 ? 33 : 0; + + if (locked) + { + AddLabelCropped(59, 22 * i + 42, 271, 21, paramsHue, entry.Parameters); // parameters + AddLabelCropped(59, 22 * i + 62, 271, 21, propsHue, entry.Properties); // properties + } + else + { + AddTextEntry(59, 22 * i + 42, 271, 21, paramsHue, textIndex + 3, entry.Parameters); // parameters + AddTextEntry(59, 22 * i + 62, 271, 21, propsHue, textIndex + 4, entry.Properties); // properties + } offset += 44; } @@ -158,16 +171,24 @@ public class SpawnerGump : Gump } var totalSpawned = 0; + var totalMax = 0; var totalWeight = 0; - foreach (var spawnerEntry in _spawner.Entries) + for (var i = 0; i < _spawner.Entries.Count; i++) { + var spawnerEntry = _spawner.Entries[i]; totalSpawned += spawner.CountSpawns(spawnerEntry); - totalWeight += spawnerEntry.SpawnedProbability; + + if (!spawnerEntry.Disabled) + { + totalMax += spawnerEntry.SpawnedMaxCount; + totalWeight += spawnerEntry.SpawnedProbability; + } } - AddHtml(232, 308 + offset, 35, 20, Html.Center($"{totalSpawned}", 0xF4F4F4)); - AddHtml(270, 308 + offset, 35, 20, Html.Center($"{totalWeight}", 0xF4F4F4)); + AddHtml(258, 308 + offset, 35, 20, Html.Center($"{totalSpawned}", 0xF4F4F4)); + AddHtml(290, 308 + offset, 35, 20, Html.Center($"{totalMax}", 0xF4F4F4)); + AddHtml(328, 308 + offset, 35, 20, Html.Center($"{totalWeight}", 0xF4F4F4)); AddHtml(5, 1, 161, 20, $"{spawner.Name} ({totalSpawned}/{spawner.Count})"); @@ -186,28 +207,28 @@ public class SpawnerGump : Gump AddButton(90, 369 + offset, 0xFA8, 0xFAA, GetButtonID(1, 4)); AddLabel(123, 369 + offset, 0x384, "Total Respawn"); - AddButton(260, 347 + offset, 0xFB7, 0xFB9, GetButtonID(1, 5)); - AddLabel(293, 347 + offset, 0x384, "Save"); + AddButton(283, 347 + offset, 0xFB7, 0xFB9, GetButtonID(1, 5)); + AddLabel(316, 347 + offset, 0x384, "Save"); - AddButton(260, 369 + offset, 0xFB1, 0xFB3, 0); - AddLabel(293, 369 + offset, 0x384, "Cancel"); + AddButton(283, 369 + offset, 0xFB1, 0xFB3, 0); + AddLabel(316, 369 + offset, 0x384, "Cancel"); if (_page > 0) { - AddButton(200, 308 + offset, 0x15E3, 0x15E7, GetButtonID(1, 0)); + AddButton(223, 308 + offset, 0x15E3, 0x15E7, GetButtonID(1, 0)); } else { - AddImage(200, 308 + offset, 0x25EA); + AddImage(223, 308 + offset, 0x25EA); } if ((_page + 1) * 13 <= _spawner.Entries.Count) { - AddButton(217, 308 + offset, 0x15E1, 0x15E5, GetButtonID(1, 1)); + AddButton(240, 308 + offset, 0x15E1, 0x15E5, GetButtonID(1, 1)); } else { - AddImage(217, 308 + offset, 0x25E6); + AddImage(240, 308 + offset, 0x25E6); } } @@ -419,12 +440,24 @@ public class SpawnerGump : Gump } } + CreateArray(info, state.Mobile, _spawner); + break; + } + case 3: // Enable/disable entry + { + var entryIndex = index + _page * 13; + if (entryIndex >= 0 && entryIndex < _spawner.Entries.Count) + { + var entry = _spawner.Entries[entryIndex]; + entry.Disabled = !entry.Disabled; + } + CreateArray(info, state.Mobile, _spawner); break; } } - if (_entry != null && _spawner.Entries?.Contains(_entry) == true) + if (_entry != null && HasEntry(_spawner, _entry)) { state.Mobile.SendGump(new SpawnerGump(_spawner, _entry, _page)); } @@ -433,4 +466,19 @@ public class SpawnerGump : Gump state.Mobile.SendGump(new SpawnerGump(_spawner, null, _page)); } } + + private static bool HasEntry(BaseSpawner spawner, SpawnerEntry entry) + { + var entries = spawner.Entries; + + for (var i = 0; i < entries.Count; i++) + { + if (entries[i] == entry) + { + return true; + } + } + + return false; + } } diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 7524f77ed..e12d803f6 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -8,6 +8,13 @@ namespace Server.Engines.Virtues; [SerializationGenerator(1)] public partial class VirtueContext { + [DirtyTrackingEntity] + private PlayerMobile _player; + + public PlayerMobile Player => _player; + + public VirtueContext(PlayerMobile player) => _player = player; + private void MigrateFrom(V0Content content) { // Save-flagged values arrive as nullables; unset flags fall back to the same diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index e57a2d386..9a0fd7148 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; @@ -99,7 +98,7 @@ public class VirtueSystem : GenericPersistence for (var i = 0; i < contextCount; i++) { var player = reader.ReadEntity(); - var virtues = new VirtueContext(); + var virtues = new VirtueContext(player); virtues.Deserialize(reader); if (player != null && virtues.IsUsed()) @@ -122,7 +121,7 @@ public class VirtueSystem : GenericPersistence ref var context = ref CollectionsMarshal.GetValueRefOrAddDefault(_playerVirtues, from, out var exists); if (!exists) { - context = new VirtueContext(); + context = new VirtueContext(from); } return context; diff --git a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs index 1863881a1..aa3bdc4bc 100644 --- a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs +++ b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs @@ -42,7 +42,6 @@ public class ConfirmReleaseGump : StaticGump return; } - _pet.ControlTarget = null; - _pet.ControlOrder = OrderType.Release; + _pet.IssueOrder(OrderType.Release, _from); } } diff --git a/Projects/UOContent/Gumps/Props/PropsGump.cs b/Projects/UOContent/Gumps/Props/PropsGump.cs index eda11e287..00fd2557d 100644 --- a/Projects/UOContent/Gumps/Props/PropsGump.cs +++ b/Projects/UOContent/Gumps/Props/PropsGump.cs @@ -305,6 +305,12 @@ namespace Server.Gumps from.SendGump(new PropertiesGump(from, mobile, m_Stack, m_List, m_Page)); from.SendGump(new SkillsGump(from, mobile)); } + // Must stay ahead of [PropertyObject]: TextDefinition carries that + // attribute, but Number and String are get-only so drilling in is a dead end. + else if (IsType(type, OfText)) + { + from.SendGump(new SetGump(prop, from, m_Object, this)); + } else if (HasAttribute(type, OfPropertyObject, true)) { from.SendGump( diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 054fea588..39134e523 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -13,6 +13,8 @@ namespace Server.Items [SerializationGenerator(4, false)] public partial class Aquarium : BaseAddonContainer { + public override bool ContentsDecay => false; // fish and decorations are part of the aquarium + public static readonly TimeSpan EvaluationInterval = TimeSpan.FromDays(1); private static readonly Type[] m_Decorations = diff --git a/Projects/UOContent/Items/Aquarium/FishBowl.cs b/Projects/UOContent/Items/Aquarium/FishBowl.cs index 9d8375551..1cf2b7507 100644 --- a/Projects/UOContent/Items/Aquarium/FishBowl.cs +++ b/Projects/UOContent/Items/Aquarium/FishBowl.cs @@ -8,6 +8,8 @@ namespace Server.Items [SerializationGenerator(0, false)] public partial class FishBowl : BaseContainer { + public override bool ContentsDecay => false; // the fish is part of the bowl + [Constructible] public FishBowl() : base(0x241C) { diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index f4062326d..a55400e5a 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -195,6 +195,7 @@ namespace Server.Items { UnscaleDurability(); _quality = value; + this.MarkDirty(); ScaleDurability(); } } @@ -213,6 +214,7 @@ namespace Server.Items { UnscaleDurability(); _durability = value; + this.MarkDirty(); ScaleDurability(); } } @@ -246,6 +248,7 @@ namespace Server.Items UnscaleDurability(); _resource = value; + this.MarkDirty(); if (CraftItem.RetainsColor(GetType())) { diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index da4d8b77f..60ca33cb2 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -53,6 +53,7 @@ public abstract partial class FillableContainer : LockableContainer ClearContents(); _contentType = value; + this.MarkDirty(); Respawn(); } } diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 477be0bc1..26b919076 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -350,6 +350,7 @@ public abstract partial class BaseBeverage : Item, IHasQuantity set { _quantity = Math.Clamp(value, 0, MaxQuantity); + this.MarkDirty(); InvalidateProperties(); diff --git a/Projects/UOContent/Items/Games/BaseBoard.cs b/Projects/UOContent/Items/Games/BaseBoard.cs index b13d7dde2..be437f9d9 100644 --- a/Projects/UOContent/Items/Games/BaseBoard.cs +++ b/Projects/UOContent/Items/Games/BaseBoard.cs @@ -11,6 +11,8 @@ namespace Server.Items; [SerializationGenerator(2, false)] public abstract partial class BaseBoard : Container, ISecurable { + public override bool ContentsDecay => false; // pieces are part of the board + [SerializedIgnoreDupe] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs index ad854cef6..4d6a70dd5 100644 --- a/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs +++ b/Projects/UOContent/Items/Minor Artifacts/ML/BloodwoodSpirit.cs @@ -13,7 +13,7 @@ public partial class BloodwoodSpirit : BaseTalisman Removal = TalismanRemoval.Damage; Blessed = GetRandomBlessed(); - Protection = GetRandomProtection(false); + Protection = GetRandomProtection(this, false); SkillBonuses.SetValues(0, SkillName.SpiritSpeak, 10.0); SkillBonuses.SetValues(1, SkillName.Necromancy, 5.0); diff --git a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs index 546f906c4..885130daf 100644 --- a/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs +++ b/Projects/UOContent/Items/Minor Artifacts/ML/TotemOfVoid.cs @@ -14,7 +14,7 @@ public partial class TotemOfVoid : BaseTalisman MaxChargeTime = 1800; Blessed = GetRandomBlessed(); - Protection = GetRandomProtection(false); + Protection = GetRandomProtection(this, false); Attributes.RegenHits = 2; Attributes.LowerManaCost = 10; diff --git a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs index 0dcd418aa..1c193c32d 100644 --- a/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs +++ b/Projects/UOContent/Items/Misc/PlayerBulletinBoards.cs @@ -74,13 +74,13 @@ public abstract partial class BasePlayerBB : Item, ISecurable if (_greeting != null) { - board.Greeting = new PlayerBBMessage(_greeting.Time, _greeting.Poster, _greeting.Message); + board.Greeting = new PlayerBBMessage(board, _greeting.Time, _greeting.Poster, _greeting.Message); } for (var i = 0; i < _messages.Count; i++) { var message = _messages[i]; - board.AddToMessages(new PlayerBBMessage(message.Time, message.Poster, message.Message)); + board.AddToMessages(new PlayerBBMessage(board, message.Time, message.Poster, message.Message)); } } @@ -176,7 +176,7 @@ public abstract partial class BasePlayerBB : Item, ISecurable if (text.Length > 0) { - var message = new PlayerBBMessage(Core.Now, from, text); + var message = new PlayerBBMessage(board, Core.Now, from, text); if (_greeting) { @@ -265,6 +265,9 @@ public abstract partial class BasePlayerBB : Item, ISecurable [SerializationGenerator(0)] public partial class PlayerBBMessage { + [DirtyTrackingEntity] + private BasePlayerBB _board; + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _time; @@ -277,12 +280,11 @@ public partial class PlayerBBMessage [SerializedCommandProperty(AccessLevel.GameMaster)] private string _message; - public PlayerBBMessage() - { - } + public PlayerBBMessage(BasePlayerBB board) => _board = board; - public PlayerBBMessage(DateTime time, Mobile poster, string message) + public PlayerBBMessage(BasePlayerBB board, DateTime time, Mobile poster, string message) { + _board = board; _time = time; _poster = poster; _message = message; diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index 03e93d0ea..71e339074 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Network; namespace Server.Items; diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 970434c5f..b7f277d1a 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -42,6 +42,7 @@ public partial class RecallRune : Item set { _house = value; + this.MarkDirty(); CalculateHue(); InvalidateProperties(); } diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index f7716a4a1..5adff2ce7 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -92,6 +92,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer { UnscaleUses(); _quality = value; + this.MarkDirty(); InvalidateProperties(); ScaleUses(); } @@ -109,6 +110,7 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer set { _usesRemaining = value; + this.MarkDirty(); InvalidateProperties(); } } diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index 59e2b8277..21b0e5eb2 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -16,6 +16,9 @@ namespace Server.Items; [SerializationGenerator(0)] public partial class RaffleEntry { + [DirtyTrackingEntity] + private HouseRaffleStone _stone; + [SerializableField(0, setter: "private")] private Mobile _from; @@ -25,15 +28,17 @@ public partial class RaffleEntry [SerializableField(2, setter: "private")] private DateTime _date; - public RaffleEntry(Mobile from) + public RaffleEntry(HouseRaffleStone stone, Mobile from) { + _stone = stone; _from = from; _address = from?.NetState?.Address ?? IPAddress.None; _date = Core.Now; } - public RaffleEntry() + public RaffleEntry(HouseRaffleStone stone) { + _stone = stone; _from = null; _address = null; _date = Core.Now; @@ -454,7 +459,7 @@ public partial class HouseRaffleStone : Item if (_ticketPrice == 0 || from.Backpack?.ConsumeTotal(typeof(Gold), _ticketPrice) == true || Banker.Withdraw(from, _ticketPrice)) { - AddToEntries(new RaffleEntry(from)); + AddToEntries(new RaffleEntry(this, from)); from.SendMessage(MessageHue, "You have successfully entered the plot's raffle."); } @@ -539,7 +544,7 @@ public partial class HouseRaffleStone : Item for (var i = 0; i < entryCount; i++) { - var entry = new RaffleEntry(); + var entry = new RaffleEntry(this); entry.Deserialize(reader); if (entry.From == null) diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..bf0953041 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -228,7 +228,7 @@ public partial class BallOfSummoning : Item, TranslocationItem { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 33459f5b7..4db6965b9 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -53,16 +53,15 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem [CommandProperty(AccessLevel.GameMaster)] public BraceletOfBinding Bound { - get + get => _bound?.Deleted != false ? null : _bound; + set { - if (_bound?.Deleted == true) + if (_bound != value) { - _bound = null; + _bound = value; + this.MarkDirty(); } - - return _bound; } - set => _bound = value; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs index 85d41c852..c2da33683 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.Migrations.cs @@ -26,19 +26,19 @@ public partial class BaseTalisman BlessedFor = reader.ReadEntity(); } - _protection = new TalismanAttribute(); + _protection = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Protection)) { _protection.Deserialize(reader); } - _killer = new TalismanAttribute(); + _killer = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Killer)) { _killer.Deserialize(reader); } - _summoner = new TalismanAttribute(); + _summoner = new TalismanAttribute(this); if (GetOldSaveFlag(flags, OldSaveFlag.Summoner)) { _summoner.Deserialize(reader); diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index b3f0c2ed4..5f594b399 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -155,7 +155,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeProtection() => !_protection.IsEmpty; - private TalismanAttribute ProtectionDefaultValue() => new(); + private TalismanAttribute ProtectionDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] @@ -166,7 +166,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeKiller() => !_killer.IsEmpty; - private TalismanAttribute KillerDefaultValue() => new(); + private TalismanAttribute KillerDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] @@ -177,7 +177,7 @@ public partial class BaseTalisman : Item, IAosItem public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - private TalismanAttribute SummonerDefaultValue() => new(); + private TalismanAttribute SummonerDefaultValue() => new(this); [InvalidateProperties] [SerializableField(5)] @@ -267,9 +267,9 @@ public partial class BaseTalisman : Item, IAosItem { Layer = Layer.Talisman; - _protection = new TalismanAttribute(); - _killer = new TalismanAttribute(); - _summoner = new TalismanAttribute(); + _protection = new TalismanAttribute(this); + _killer = new TalismanAttribute(this); + _summoner = new TalismanAttribute(this); Attributes = new AosAttributes(this); SkillBonuses = new AosSkillBonuses(this); } @@ -319,9 +319,9 @@ public partial class BaseTalisman : Item, IAosItem return; } - talisman._summoner = new TalismanAttribute(_summoner); - talisman._protection = new TalismanAttribute(_protection); - talisman._killer = new TalismanAttribute(_killer); + talisman._summoner = new TalismanAttribute(talisman, _summoner); + talisman._protection = new TalismanAttribute(talisman, _protection); + talisman._killer = new TalismanAttribute(talisman, _killer); talisman.Attributes = new AosAttributes(newItem, Attributes); talisman.SkillBonuses = new AosSkillBonuses(newItem, SkillBonuses); } @@ -502,7 +502,7 @@ public partial class BaseTalisman : Item, IAosItem ); mob.Summoned = false; - mob.ControlOrder = OrderType.Friend; + mob.IssueOrder(OrderType.Follow, null, from); _creature = mob; } @@ -660,17 +660,17 @@ public partial class BaseTalisman : Item, IAosItem public virtual void SetSummoner(Type type, TextDefinition name) { - _summoner = new TalismanAttribute(type, name); + _summoner = new TalismanAttribute(this, type, name); } public virtual void SetProtection(Type type, TextDefinition name, int amount) { - _protection = new TalismanAttribute(type, name, amount); + _protection = new TalismanAttribute(this, type, name, amount); } public virtual void SetKiller(Type type, TextDefinition name, int amount) { - _killer = new TalismanAttribute(type, name, amount); + _killer = new TalismanAttribute(this, type, name, amount); } public virtual void StartTimer() @@ -712,18 +712,18 @@ public partial class BaseTalisman : Item, IAosItem public static Type GetRandomSummonType() => _summons.RandomElement(); - public static TalismanAttribute GetRandomSummoner() + public static TalismanAttribute GetRandomSummoner(BaseTalisman owner) { if (Utility.RandomDouble() < 0.975) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_summons.Length); return num > 14 - ? new TalismanAttribute(_summons[num], _summonLabels[num], 10) - : new TalismanAttribute(_summons[num], _summonLabels[num]); + ? new TalismanAttribute(owner, _summons[num], _summonLabels[num], 10) + : new TalismanAttribute(owner, _summons[num], _summonLabels[num]); } public static TalismanRemoval GetRandomRemoval() @@ -736,32 +736,32 @@ public partial class BaseTalisman : Item, IAosItem return TalismanRemoval.None; } - public static TalismanAttribute GetRandomKiller() => GetRandomKiller(true); + public static TalismanAttribute GetRandomKiller(BaseTalisman owner) => GetRandomKiller(owner, true); - public static TalismanAttribute GetRandomKiller(bool includingNone) + public static TalismanAttribute GetRandomKiller(BaseTalisman owner, bool includingNone) { if (includingNone && Utility.RandomBool()) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_killers.Length); - return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100)); + return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(10, 100)); } - public static TalismanAttribute GetRandomProtection() => GetRandomProtection(true); + public static TalismanAttribute GetRandomProtection(BaseTalisman owner) => GetRandomProtection(owner, true); - public static TalismanAttribute GetRandomProtection(bool includingNone) + public static TalismanAttribute GetRandomProtection(BaseTalisman owner, bool includingNone) { if (includingNone && Utility.RandomBool()) { - return new TalismanAttribute(); + return new TalismanAttribute(owner); } var num = Utility.Random(_killers.Length); - return new TalismanAttribute(_killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60)); + return new TalismanAttribute(owner, _killers[num], _killerLabels[num], Utility.RandomMinMax(5, 60)); } public static SkillName GetRandomSkill() => _skills.RandomElement(); diff --git a/Projects/UOContent/Items/Talismans/RandomTalisman.cs b/Projects/UOContent/Items/Talismans/RandomTalisman.cs index 5235c6f20..60529c203 100644 --- a/Projects/UOContent/Items/Talismans/RandomTalisman.cs +++ b/Projects/UOContent/Items/Talismans/RandomTalisman.cs @@ -8,7 +8,7 @@ public partial class RandomTalisman : BaseTalisman [Constructible] public RandomTalisman() : base(GetRandomItemID()) { - Summoner = GetRandomSummoner(); + Summoner = GetRandomSummoner(this); if (Summoner.IsEmpty) { @@ -36,8 +36,8 @@ public partial class RandomTalisman : BaseTalisman Blessed = GetRandomBlessed(); Slayer = GetRandomSlayer(); - Protection = GetRandomProtection(); - Killer = GetRandomKiller(); + Protection = GetRandomProtection(this); + Killer = GetRandomKiller(this); Skill = GetRandomSkill(); ExceptionalBonus = GetRandomExceptional(); SuccessBonus = GetRandomSuccessful(); diff --git a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs index e1a7ad6ec..153951081 100644 --- a/Projects/UOContent/Items/Talismans/TalismanAttribute.cs +++ b/Projects/UOContent/Items/Talismans/TalismanAttribute.cs @@ -7,6 +7,9 @@ namespace Server.Items; [SerializationGenerator(1, false)] public partial class TalismanAttribute { + [DirtyTrackingEntity] + private BaseTalisman _owner; + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Type _type; @@ -19,12 +22,12 @@ public partial class TalismanAttribute [SerializedCommandProperty(AccessLevel.GameMaster)] private int _amount; - public TalismanAttribute() : this(null, null) - { - } + public TalismanAttribute(BaseTalisman owner) => _owner = owner; - public TalismanAttribute(TalismanAttribute copy) + public TalismanAttribute(BaseTalisman owner, TalismanAttribute copy) { + _owner = owner; + if (copy != null) { _type = copy.Type; @@ -33,8 +36,9 @@ public partial class TalismanAttribute } } - public TalismanAttribute(Type type, TextDefinition name, int amount = 0) + public TalismanAttribute(BaseTalisman owner, Type type, TextDefinition name, int amount = 0) { + _owner = owner; _type = type; _name = name; _amount = amount; diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs index 3385598d9..78937f06f 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -1,5 +1,4 @@ using System; -using Server.Engines.Craft; namespace Server.Items; diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json index ec12a5845..43bc92702 100644 --- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json +++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BOBLargeEntry.v1.json @@ -9,7 +9,7 @@ "ruleArguments": [ "Server.Engines.BulkOrders.BOBLargeSubEntry", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json index 37899091a..74f878450 100644 --- a/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json +++ b/Projects/UOContent/Migrations/Server.Engines.BulkOrders.BulkOrderBook.v3.json @@ -28,7 +28,7 @@ "type": "Server.Engines.BulkOrders.BOBFilter", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json index af6658d06..c2811c097 100644 --- a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionTitleContext.v1.json @@ -16,7 +16,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -25,7 +25,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -34,7 +34,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -43,7 +43,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -52,7 +52,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -61,7 +61,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -70,7 +70,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -79,7 +79,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -88,7 +88,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json new file mode 100644 index 000000000..eab4d5279 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json @@ -0,0 +1,113 @@ +{ + "version": 13, + "type": "Server.Engines.Spawners.BaseSpawner", + "properties": [ + { + "name": "Guid", + "type": "System.Guid", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ReturnOnDeactivate", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WalkingRange", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Group", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MaxDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Count", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Running", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnLocationIsHome", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "End", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnPositionMode", + "type": "Server.Engines.Spawners.SpawnPositionMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MaxSpawnAttempts", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json new file mode 100644 index 000000000..8127420e2 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.Spawner", + "properties": [ + { + "name": "UseSpiralScan", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnBounds", + "type": "Server.Rectangle3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect3D" + ] + }, + { + "name": "EntryList", + "type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Engines.Spawners.SpawnerEntry", + "RawSerializableMigrationRule", + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json new file mode 100644 index 000000000..481b3b243 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json @@ -0,0 +1,65 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.SpawnerEntry", + "properties": [ + { + "name": "SpawnedName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedProbability", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedMaxCount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Properties", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Parameters", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Spawned", + "type": "System.Collections.Generic.List\u003CServer.ISpawnable\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.ISpawnable", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Disabled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json index 2d19ebd1a..09f2db9d1 100644 --- a/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json +++ b/Projects/UOContent/Migrations/Server.Items.BasePlayerBB.v0.json @@ -20,7 +20,7 @@ "type": "Server.Items.PlayerBBMessage", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "", + "DeserializationRequiresParent", "@CanBeNull" ] }, @@ -31,7 +31,7 @@ "ruleArguments": [ "Server.Items.PlayerBBMessage", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json index 958652db9..2d997d4a5 100644 --- a/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json +++ b/Projects/UOContent/Migrations/Server.Items.BaseTalisman.v1.json @@ -26,7 +26,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -35,7 +35,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -44,7 +44,7 @@ "usesSaveFlag": true, "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { diff --git a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json index b2d79f227..351cb26dc 100644 --- a/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json +++ b/Projects/UOContent/Migrations/Server.Items.HouseRaffleStone.v4.json @@ -66,7 +66,7 @@ "ruleArguments": [ "Server.Items.RaffleEntry", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json index 508755f69..fb3360ba2 100644 --- a/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChest.v1.json @@ -7,7 +7,7 @@ "type": "Server.Items.PuzzleChestSolution", "rule": "RawSerializableMigrationRule", "ruleArguments": [ - "" + "DeserializationRequiresParent" ] }, { @@ -32,7 +32,7 @@ "Server.Items.PuzzleChestSolutionAndTime", "RawSerializableMigrationRule", "2", - "", + "DeserializationRequiresParent", "@CanBeNull" ] } diff --git a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json index 46345e368..e8611014a 100644 --- a/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json +++ b/Projects/UOContent/Migrations/Server.Misc.ShardPoller.v1.json @@ -38,7 +38,7 @@ "ruleArguments": [ "Server.Misc.ShardPollOption", "RawSerializableMigrationRule", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json new file mode 100644 index 000000000..b700154ab --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json @@ -0,0 +1,471 @@ +{ + "version": 23, + "type": "Server.Mobiles.BaseCreature", + "properties": [ + { + "name": "DefaultAI", + "type": "Server.Mobiles.AIType", + "rule": "EnumMigrationRule" + }, + { + "name": "CurrentAI", + "type": "Server.Mobiles.AIType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "RangePerception", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeFight", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeHome", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FightMode", + "type": "Server.Mobiles.FightMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ActiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "CurrentSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Home", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "HomeMap", + "type": "Server.Map", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Controlled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ControlMaster", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlTarget", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlDest", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "ControlOrder", + "type": "Server.Mobiles.OrderType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MinTameSkill", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Tamable", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Summoned", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SummonEnd", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "SummonMaster", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlSlots", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Loyalty", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "CurrentWayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HitsMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StamMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ManaMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMin", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMax", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalResistanceSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Owners", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "IsDeadPet", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsBonded", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BondingBegin", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "OwnerAbandonTime", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HasGeneratedLoot", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsParagon", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Friends", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RemoveIfUntamed", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RemoveStep", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PendingDeleteTimer", + "type": "Server.Timer", + "usesSaveFlag": true, + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "CorpseNameOverride", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json new file mode 100644 index 000000000..887fd9bdf --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseVendor.v2.json @@ -0,0 +1,4 @@ +{ + "version": 2, + "type": "Server.Mobiles.BaseVendor" +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json index aee2b1da6..407b93cfc 100644 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v3.json @@ -55,7 +55,7 @@ "Server.Mobiles.VendorItem", "RawSerializableMigrationRule", "1", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json index 6b9c9eb1c..a2c354874 100644 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -55,7 +55,7 @@ "Server.Mobiles.VendorItem", "RawSerializableMigrationRule", "1", - "" + "DeserializationRequiresParent" ] } ] diff --git a/Projects/UOContent/Misc/Emitter.cs b/Projects/UOContent/Misc/Emitter.cs deleted file mode 100644 index 61ba4cb7f..000000000 --- a/Projects/UOContent/Misc/Emitter.cs +++ /dev/null @@ -1,727 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Reflection.Emit; - -namespace Server -{ - public class AssemblyEmitter - { - private readonly ModuleBuilder m_ModuleBuilder; - - public AssemblyEmitter(string assemblyName) - { - var assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName(assemblyName), - AssemblyBuilderAccess.Run - ); - - m_ModuleBuilder = assemblyBuilder.DefineDynamicModule(assemblyName); - } - - public TypeBuilder DefineType(string typeName, TypeAttributes attrs, Type parentType) => - m_ModuleBuilder.DefineType(typeName, attrs, parentType); - } - - public class MethodEmitter - { - public delegate void Callback(); - - private readonly Stack m_Calls; - - private readonly Stack m_Stack; - - private readonly Dictionary> m_Temps; - private Type[] m_ArgumentTypes; - - public MethodEmitter(TypeBuilder typeBuilder) - { - Type = typeBuilder; - - m_Temps = new Dictionary>(); - - m_Stack = new Stack(); - m_Calls = new Stack(); - } - - public TypeBuilder Type { get; } - - public ILGenerator Generator { get; private set; } - - public MethodBuilder Method { get; private set; } - - public Type Active => m_Stack.Peek(); - - public void Define(string name, MethodAttributes attr, Type returnType, Type[] parms) - { - Method = Type.DefineMethod(name, attr, returnType, parms); - Generator = Method.GetILGenerator(); - - m_ArgumentTypes = parms; - } - - public LocalBuilder CreateLocal(Type localType) => Generator.DeclareLocal(localType); - - public LocalBuilder AcquireTemp(Type localType) - { - if (!m_Temps.TryGetValue(localType, out var list)) - { - m_Temps[localType] = list = new Queue(); - } - - return list.Count > 0 ? list.Dequeue() : CreateLocal(localType); - } - - public void ReleaseTemp(LocalBuilder local) - { - if (local.LocalType == null) - { - return; - } - - if (!m_Temps.TryGetValue(local.LocalType, out var list)) - { - m_Temps[local.LocalType] = list = new Queue(); - } - - list.Enqueue(local); - } - - public void Branch(Label label) - { - Generator.Emit(OpCodes.Br, label); - } - - public void BranchIfFalse(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brfalse, label); - } - - public void BranchIfTrue(Label label) - { - Pop(typeof(object)); - - Generator.Emit(OpCodes.Brtrue, label); - } - - public Label CreateLabel() => Generator.DefineLabel(); - - public void MarkLabel(Label label) - { - Generator.MarkLabel(label); - } - - public void Pop() - { - m_Stack.Pop(); - } - - public void Pop(Type expected) - { - if (expected == null) - { - throw new InvalidOperationException("Expected type cannot be null."); - } - - var onStack = m_Stack.Pop(); - - if (expected == typeof(bool)) - { - expected = typeof(int); - } - - if (onStack == typeof(bool)) - { - onStack = typeof(int); - } - - if (!expected.IsAssignableFrom(onStack)) - { - throw new InvalidOperationException("Unexpected stack state."); - } - } - - public void Push(Type type) - { - m_Stack.Push(type); - } - - public void Return() - { - if (m_Stack.Count != (Method.ReturnType == typeof(void) ? 0 : 1)) - { - throw new InvalidOperationException("Stack return mismatch."); - } - - Generator.Emit(OpCodes.Ret); - } - - public void LoadNull() - { - LoadNull(typeof(object)); - } - - public void LoadNull(Type type) - { - Push(type); - - Generator.Emit(OpCodes.Ldnull); - } - - public void Load(string value) - { - Push(typeof(string)); - - if (value != null) - { - Generator.Emit(OpCodes.Ldstr, value); - } - else - { - Generator.Emit(OpCodes.Ldnull); - } - } - - public void Load(Enum value) - { - var toLoad = ((IConvertible)value).ToInt32(null); - Load(toLoad); - - Pop(); - Push(value.GetType()); - } - - public void Load(long value) - { - Push(typeof(long)); - - Generator.Emit(OpCodes.Ldc_I8, value); - } - - public void Load(float value) - { - Push(typeof(float)); - - Generator.Emit(OpCodes.Ldc_R4, value); - } - - public void Load(double value) - { - Push(typeof(double)); - - Generator.Emit(OpCodes.Ldc_R8, value); - } - - public void Load(char value) - { - Load((int)value); - - Pop(); - Push(typeof(char)); - } - - public void Load(bool value) - { - Push(typeof(bool)); - - if (value) - { - Generator.Emit(OpCodes.Ldc_I4_1); - } - else - { - Generator.Emit(OpCodes.Ldc_I4_0); - } - } - - public void Load(int value) - { - Push(typeof(int)); - - switch (value) - { - case -1: - Generator.Emit(OpCodes.Ldc_I4_M1); - break; - - case 0: - Generator.Emit(OpCodes.Ldc_I4_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldc_I4_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldc_I4_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldc_I4_3); - break; - - case 4: - Generator.Emit(OpCodes.Ldc_I4_4); - break; - - case 5: - Generator.Emit(OpCodes.Ldc_I4_5); - break; - - case 6: - Generator.Emit(OpCodes.Ldc_I4_6); - break; - - case 7: - Generator.Emit(OpCodes.Ldc_I4_7); - break; - - case 8: - Generator.Emit(OpCodes.Ldc_I4_8); - break; - - default: - if (value >= sbyte.MinValue && value <= sbyte.MaxValue) - { - Generator.Emit(OpCodes.Ldc_I4_S, (sbyte)value); - } - else - { - Generator.Emit(OpCodes.Ldc_I4, value); - } - - break; - } - } - - public void LoadField(FieldInfo field) - { - Pop(field.DeclaringType); - - Push(field.FieldType); - - Generator.Emit(OpCodes.Ldfld, field); - } - - public void LoadLocal(LocalBuilder local) - { - Push(local.LocalType); - - var index = local.LocalIndex; - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldloc_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldloc_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldloc_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldloc_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MinValue) - { - Generator.Emit(OpCodes.Ldloc_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldloc, (short)index); - } - - break; - } - } - - public void StoreLocal(LocalBuilder local) - { - Pop(local.LocalType); - - Generator.Emit(OpCodes.Stloc, local); - } - - public void LoadArgument(int index) - { - if (index > 0) - { - Push(m_ArgumentTypes[index - 1]); - } - else - { - Push(Type); - } - - switch (index) - { - case 0: - Generator.Emit(OpCodes.Ldarg_0); - break; - - case 1: - Generator.Emit(OpCodes.Ldarg_1); - break; - - case 2: - Generator.Emit(OpCodes.Ldarg_2); - break; - - case 3: - Generator.Emit(OpCodes.Ldarg_3); - break; - - default: - if (index >= byte.MinValue && index <= byte.MaxValue) - { - Generator.Emit(OpCodes.Ldarg_S, (byte)index); - } - else - { - Generator.Emit(OpCodes.Ldarg, (short)index); - } - - break; - } - } - - public void CastAs(Type type) - { - Pop(typeof(object)); - Push(type); - - Generator.Emit(OpCodes.Isinst, type); - } - - public void Neg() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Neg); - } - - public void Compare(OpCode opCode) - { - Pop(); - Pop(); - - Push(typeof(int)); - - Generator.Emit(opCode); - } - - public void LogicalNot() - { - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Ldc_I4_0); - Generator.Emit(OpCodes.Ceq); - } - - public void Xor() - { - Pop(typeof(int)); - Pop(typeof(int)); - - Push(typeof(int)); - - Generator.Emit(OpCodes.Xor); - } - - public void Chain(Property prop) - { - for (var i = 0; i < prop.Chain.Length; ++i) - { - Call(prop.Chain[i].GetGetMethod()); - } - } - - public void Call(MethodInfo method) - { - BeginCall(method); - - var call = m_Calls.Peek(); - - if (call.parms.Length > 0) - { - throw new InvalidOperationException("Method requires parameters."); - } - - FinishCall(); - } - - public bool CompareTo(int sign, Callback argGenerator) - { - var active = Active; - - var compareTo = active.GetMethod("CompareTo", new[] { active }); - - if (compareTo == null) - { - /* This gets a little tricky... - * - * 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... - * - * interface ISomeInterface : IComparable - * { - * void SomeMethod(); - * } - * - * class SomeClass : ISomeInterface - * { - * void SomeMethod() { ... } - * int CompareTo( object other ) { ... } - * } - * - * In this case, calling ISomeInterface.GetMethod( "CompareTo" ) will return null. - * - * Bleh. - */ - - var ifaces = active.FindInterfaces( - (type, obj) => type.IsGenericType - && type.GetGenericTypeDefinition() == typeof(IComparable<>) - && type.GetGenericArguments()[0].IsAssignableFrom(active), - null - ); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - else - { - ifaces = active.FindInterfaces((type, obj) => type == typeof(IComparable), null); - - if (ifaces.Length > 0) - { - compareTo = ifaces[0].GetMethod("CompareTo", new[] { active }); - } - } - } - - if (compareTo == null) - { - return false; - } - - if (!active.IsValueType) - { - /* This object is a reference type, so we have to make it behave - * - * null.CompareTo( null ) = 0 - * real.CompareTo( null ) = -1 - * null.CompareTo( real ) = +1 - * - */ - - var aValue = AcquireTemp(active); - var bValue = AcquireTemp(active); - - StoreLocal(aValue); - - argGenerator(); - - StoreLocal(bValue); - - /* if (aValue == null) - * { - * if (bValue == null) - * v = 0; - * else - * v = +1; - * } - * else if (bValue == null) - * { - * v = -1; - * } - * else - * { - * v = aValue.CompareTo( bValue ); - * } - */ - - var store = CreateLabel(); - - var aNotNull = CreateLabel(); - - LoadLocal(aValue); - BranchIfTrue(aNotNull); - // if (aValue == null) - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // if (bValue == null) - { - Load(0); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - Load(sign); - Pop(typeof(int)); - Branch(store); - } - } - MarkLabel(aNotNull); - // else - { - var bNotNull = CreateLabel(); - - LoadLocal(bValue); - BranchIfTrue(bNotNull); - // bValue == null - { - Load(-sign); - Pop(typeof(int)); - Branch(store); - } - MarkLabel(bNotNull); - // else - { - LoadLocal(aValue); - BeginCall(compareTo); - - LoadLocal(bValue); - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - } - - MarkLabel(store); - - ReleaseTemp(aValue); - ReleaseTemp(bValue); - } - else - { - BeginCall(compareTo); - - argGenerator(); - - ArgumentPushed(); - - FinishCall(); - - if (sign == -1) - { - Neg(); - } - } - - return true; - } - - public void BeginCall(MethodInfo method) - { - var type = (method.CallingConvention & CallingConventions.HasThis) != 0 ? m_Stack.Peek() : method.DeclaringType; - - m_Calls.Push(new CallInfo(type, method)); - - if (type!.IsValueType) - { - var temp = AcquireTemp(type); - - Generator.Emit(OpCodes.Stloc, temp); - Generator.Emit(OpCodes.Ldloca, temp); - - ReleaseTemp(temp); - } - } - - public void FinishCall() - { - var call = m_Calls.Pop(); - - if ((call.type.IsValueType || call.type.IsByRef) && call.method.DeclaringType != call.type) - { - Generator.Emit(OpCodes.Constrained, call.type); - } - - if (call.method.DeclaringType?.IsValueType == true || call.method.IsStatic) - { - Generator.Emit(OpCodes.Call, call.method); - } - else - { - Generator.Emit(OpCodes.Callvirt, call.method); - } - - for (var i = call.parms.Length - 1; i >= 0; --i) - { - Pop(call.parms[i].ParameterType); - } - - if ((call.method.CallingConvention & CallingConventions.HasThis) != 0) - { - Pop(call.method.DeclaringType); - } - - if (call.method.ReturnType != typeof(void)) - { - Push(call.method.ReturnType); - } - } - - public void ArgumentPushed() - { - var call = m_Calls.Peek(); - - var parm = call.parms[call.index++]; - - var argumentType = m_Stack.Peek(); - - if (!parm.ParameterType.IsAssignableFrom(argumentType)) - { - throw new InvalidOperationException("Parameter type mismatch."); - } - - if (argumentType.IsValueType && !parm.ParameterType.IsValueType) - { - Generator.Emit(OpCodes.Box, argumentType); - } - } - - private class CallInfo - { - public readonly MethodInfo method; - public readonly ParameterInfo[] parms; - public readonly Type type; - - public int index; - - public CallInfo(Type type, MethodInfo method) - { - this.type = type; - this.method = method; - - parms = method.GetParameters(); - } - } - } -} diff --git a/Projects/UOContent/Misc/Guild.cs b/Projects/UOContent/Misc/Guild.cs index 88ec44773..b2ce6d428 100644 --- a/Projects/UOContent/Misc/Guild.cs +++ b/Projects/UOContent/Misc/Guild.cs @@ -498,25 +498,22 @@ namespace Server.Guilds } } - public class WarTimer : Timer + public class GuildMaintenanceTimer : Timer { - public WarTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0)) + public GuildMaintenanceTimer() : base(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0)) { } public static void Initialize() { - if (Guild.NewGuildSystem) - { - new WarTimer().Start(); - } + new GuildMaintenanceTimer().Start(); } protected override void OnTick() { foreach (var g in World.Guilds.Values) { - (g as Guild)?.CheckExpiredWars(); + (g as Guild)?.RunMaintenance(); } } } @@ -1100,8 +1097,18 @@ namespace Server.Guilds list.TrimExcess(); } - public override void Serialize(IGenericWriter writer) + /// + /// Periodic bookkeeping that used to ride on every world save: the daily fealty + /// recalculation, war expiry, and the alliance leadership check. Saves must be pure, + /// so this runs from instead. + /// + public void RunMaintenance() { + if (Disbanded) + { + return; + } + if (LastFealty + TimeSpan.FromDays(1.0) < Core.Now) { CalculateGuildmaster(); @@ -1110,7 +1117,10 @@ namespace Server.Guilds CheckExpiredWars(); Alliance?.CheckLeader(); + } + public override void Serialize(IGenericWriter writer) + { writer.Write(5); // version writer.Write(PendingWars.Count); diff --git a/Projects/UOContent/Misc/Loot.cs b/Projects/UOContent/Misc/Loot.cs index 2477c25b5..808364298 100644 --- a/Projects/UOContent/Misc/Loot.cs +++ b/Projects/UOContent/Misc/Loot.cs @@ -738,7 +738,7 @@ namespace Server { var talisman = new BaseTalisman(BaseTalisman.GetRandomItemID()); - talisman.Summoner = BaseTalisman.GetRandomSummoner(); + talisman.Summoner = BaseTalisman.GetRandomSummoner(talisman); if (talisman.Summoner.IsEmpty) { @@ -758,8 +758,8 @@ namespace Server talisman.Blessed = BaseTalisman.GetRandomBlessed(); talisman.Slayer = BaseTalisman.GetRandomSlayer(); - talisman.Protection = BaseTalisman.GetRandomProtection(); - talisman.Killer = BaseTalisman.GetRandomKiller(); + talisman.Protection = BaseTalisman.GetRandomProtection(talisman); + talisman.Killer = BaseTalisman.GetRandomKiller(talisman); talisman.Skill = BaseTalisman.GetRandomSkill(); talisman.ExceptionalBonus = BaseTalisman.GetRandomExceptional(); talisman.SuccessBonus = BaseTalisman.GetRandomSuccessful(); diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index cb9b1ddcb..4416b09b5 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -228,7 +228,7 @@ namespace Server.Misc } if (bcTarg?.Controlled == true - || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster.Player) + || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true) { return false; // Cannot harm other controlled mobiles from players } diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 8516e6678..643657c1f 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -187,7 +187,7 @@ public partial class ShardPoller : Item for (var i = 0; i < _options.Length; ++i) { - var option = _options[i] = new ShardPollOption(); + var option = _options[i] = new ShardPollOption(this); option.Deserialize(reader); } } @@ -212,15 +212,23 @@ public partial class ShardPoller : Item [SerializationGenerator(1, false)] public partial class ShardPollOption { + [DirtyTrackingEntity] + private ShardPoller _poller; + private int _lineBreaks = -1; [SerializableField(1)] private IPAddress[] _voters; - public ShardPollOption() => _voters = []; - - public ShardPollOption(string title) + public ShardPollOption(ShardPoller poller) { + _poller = poller; + _voters = []; + } + + public ShardPollOption(ShardPoller poller, string title) + { + _poller = poller; _title = title; _voters = []; } @@ -600,7 +608,7 @@ public partial class ShardPollPrompt : Prompt if (_option == null) { - _poller.AddOption(new ShardPollOption(text)); + _poller.AddOption(new ShardPollOption(_poller, text)); } else { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 8b2a16cf2..381958d68 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -612,6 +612,24 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // Following its master, or guarding from outside guard range. FollowMoveSpeed caps the step + // delay while this holds. + public bool IsPacingToMaster() + { + if (!Mobile.Controlled || Mobile.Combatant != null) + { + return false; + } + + return Mobile.ControlOrder switch + { + OrderType.Follow => Mobile.ControlTarget == Mobile.ControlMaster, + OrderType.Guard => Mobile.ControlMaster?.Deleted == false && + (int)Mobile.GetDistanceToSqrt(Mobile.ControlMaster) > GuardRange, + _ => false + }; + } + // A pet executing a movement order outside combat; its order handler owns its speed. [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool IsObeyingMoveOrder() => diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..e762c5c61 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,6 +26,9 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // How far a guarding pet may drift from its master before it closes the gap. + internal const int GuardRange = 3; + // Last-known-position tracking: recorded while the combatant is in LOS; drives the // guard-time investigation and the instant re-engage. private const int GuardGraceDuration = 10_000; @@ -35,7 +38,6 @@ public abstract partial class BaseAI private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; - public Mobile _commandIssuer; private Mobile _lkpTarget; private Point3D _lkpLocation; @@ -160,15 +162,7 @@ public abstract partial class BaseAI if (Mobile.CheckControlChance(from)) { - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; - - if (order == OrderType.Attack) - { - Mobile.FocusMob = target; - Mobile.Combatant = target; - Action = ActionType.Combat; - } + Mobile.IssueOrder(order, from, target); } } @@ -188,7 +182,7 @@ public abstract partial class BaseAI return false; } - if (isFriend && order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop)) + if (isFriend && !IsFriendOrder(order)) { return false; } @@ -1108,7 +1102,7 @@ public abstract partial class BaseAI public virtual void Deactivate() { - if (Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active) + if (Mobile.Map == null || Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active) { AITimer.Stop(); } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs index 9bee2edff..d245f91b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/InternalEntry.cs @@ -47,7 +47,7 @@ internal sealed class InternalEntry : ContextMenuEntry return from.CheckAlive() && bc != null && !bc.Deleted && bc.Controlled; } - private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && _order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + private bool IsInvalidOrderForDeadPet(BaseCreature bc) => bc.IsDeadPet && BaseAI.IsDeadPetOrder(_order); private static bool IsOwnerOrFriend(Mobile from, BaseCreature bc, out bool isFriend) { @@ -56,7 +56,7 @@ internal sealed class InternalEntry : ContextMenuEntry return isOwner || isFriend; } - private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && _order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop); + private bool IsInvalidOrderForFriend(bool isFriend) => isFriend && !BaseAI.IsFriendOrder(_order); private void HandleOrder(Mobile from, BaseCreature bc) { @@ -99,9 +99,16 @@ internal sealed class InternalEntry : ContextMenuEntry private void HandleReleaseOrder(Mobile from, BaseCreature bc) { + // No roll: a refused one would only drain loyalty toward the involuntary release the + // drain performs anyway. Whoever can command the creature may dismiss it. + if (!bc.CanBeControlledBy(from)) + { + return; + } + if (bc.Summoned) { - HandleDefaultOrder(from, bc); + bc.IssueOrder(OrderType.Release, from); return; } @@ -112,8 +119,7 @@ internal sealed class InternalEntry : ContextMenuEntry { if (bc.CheckControlChance(from)) { - bc.ControlTarget = null; - bc.ControlOrder = _order; + bc.IssueOrder(_order, from); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index 8ffd39b42..7ce0994f6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -48,16 +48,23 @@ public abstract partial class BaseAI } } - if (Mobile.Controlled && Mobile.Commandable) + // Staff first, so " obey" reaches a controlled pet. + if (e.Mobile.AccessLevel >= AccessLevel.GameMaster && HandleGMCommands(e)) { - AllOnSpeechPet(e); - NamedOnSpeechPet(e); return; } - if (e.Mobile.AccessLevel >= AccessLevel.GameMaster) + if (Mobile.Controlled && Mobile.Commandable) { - HandleGMCommands(e); + // Exactly one handler per utterance: named addresses this pet, "all" every pet in range. + if (WasNamed(e.Speech)) + { + NamedOnSpeechPet(e); + } + else + { + AllOnSpeechPet(e); + } } } @@ -158,7 +165,7 @@ public abstract partial class BaseAI { case 0x164: // all come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x165: // all follow @@ -169,7 +176,7 @@ public abstract partial class BaseAI case 0x166: // all guard case 0x16B: // all guard me { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x167: // all stop @@ -180,7 +187,7 @@ public abstract partial class BaseAI case 0x168: // all kill case 0x169: // all attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x16C: // all follow me @@ -230,12 +237,12 @@ public abstract partial class BaseAI { case 0x155: // *come { - HandleComeCommand(e.Mobile, true); + HandleComeCommand(e.Mobile, isOwner); break; } case 0x156: // *drop { - HandleDropCommand(e.Mobile, true, e.Speech); + HandleDropCommand(e.Mobile, isOwner); break; } case 0x15A: // *follow @@ -245,18 +252,18 @@ public abstract partial class BaseAI } case 0x15B: // *friend { - HandleFriendCommand(e.Mobile, true, e.Speech); + HandleFriendCommand(e.Mobile, isOwner); break; } case 0x15C: // *guard { - HandleGuardCommand(e.Mobile, true); + HandleGuardCommand(e.Mobile, isOwner); break; } case 0x15D: // *kill case 0x15E: // *attack { - HandleAttackCommand(e.Mobile, true); + HandleAttackCommand(e.Mobile, isOwner); break; } case 0x161: // *stop @@ -271,12 +278,12 @@ public abstract partial class BaseAI } case 0x16D: // *release { - HandleReleaseCommand(e.Mobile, true, e.Speech); + HandleReleaseCommand(e.Mobile, isOwner); break; } case 0x16E: // *transfer { - HandleTransferCommand(e.Mobile, true, e.Speech); + HandleTransferCommand(e.Mobile, isOwner); break; } case 0x16F: // *stay @@ -330,9 +337,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Come; + Mobile.IssueOrder(OrderType.Come, from); } } @@ -340,9 +345,7 @@ public abstract partial class BaseAI { if (isOwner && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Guard; + Mobile.IssueOrder(OrderType.Guard, from); } } @@ -350,9 +353,7 @@ public abstract partial class BaseAI { if (Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = target; - Mobile.ControlOrder = order; + Mobile.IssueOrder(order, from, target); } } @@ -360,25 +361,21 @@ public abstract partial class BaseAI { if (isOwner) { - _commandIssuer = from; BeginPickTarget(from, OrderType.Attack); } } - private void HandleDropCommand(Mobile from, bool isOwner, string speech) + private void HandleDropCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && WasNamed(speech) - && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && !Mobile.Summoned && Mobile.CheckControlChance(from)) { - _commandIssuer = from; - Mobile.ControlTarget = null; - Mobile.ControlOrder = OrderType.Drop; + Mobile.IssueOrder(OrderType.Drop, from); } } - private void HandleFriendCommand(Mobile from, bool isOwner, string speech) + private void HandleFriendCommand(Mobile from, bool isOwner) { - if (isOwner && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -398,29 +395,31 @@ public abstract partial class BaseAI } } - private void HandleReleaseCommand(Mobile from, bool isOwner, string speech) + private void HandleReleaseCommand(Mobile from, bool isOwner) { if (!isOwner) { return; } - if (WasNamed(speech) && Mobile.CheckControlChance(from)) + // No control roll: see InternalEntry.HandleReleaseOrder. + if (!Mobile.CanBeControlledBy(from)) { - if (!Mobile.Summoned) - { - from.SendGump(new ConfirmReleaseGump(from, Mobile)); - } - else - { - Mobile.ControlOrder = OrderType.Release; - } + return; } + + if (Mobile.Summoned) + { + Mobile.IssueOrder(OrderType.Release, from); + return; + } + + from.SendGump(new ConfirmReleaseGump(from, Mobile)); } - private void HandleTransferCommand(Mobile from, bool isOwner, string speech) + private void HandleTransferCommand(Mobile from, bool isOwner) { - if (isOwner && !Mobile.IsDeadPet && WasNamed(speech) && Mobile.CheckControlChance(from)) + if (isOwner && !Mobile.IsDeadPet && Mobile.CheckControlChance(from)) { if (Mobile.Summoned || Mobile is GrizzledMare) { @@ -440,18 +439,23 @@ public abstract partial class BaseAI } } - private void HandleGMCommands(SpeechEventArgs e) + private bool HandleGMCommands(SpeechEventArgs e) { this.DebugSayFormatted($"Command is from GM: {e.Mobile.Name}, Target: {Mobile.ControlTarget?.Name ?? "None or Unknown"}"); - if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey")) + // "all obey" is for wild creatures; a controlled pet must be named. + if (!Mobile.FindMyName(e.Speech, !Mobile.Controlled) || !e.Speech.InsensitiveContains("obey")) { - Mobile.SetControlMaster(e.Mobile); - - if (Mobile.Summoned) - { - Mobile.SummonMaster = e.Mobile; - } + return false; } + + Mobile.SetControlMaster(e.Mobile); + + if (Mobile.SummonMaster != null) + { + Mobile.SummonMaster = e.Mobile; + } + + return true; } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs index 446b25b21..16ca9648a 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs @@ -25,9 +25,8 @@ public static class PetLoginHandler [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] public static void OnLogin(PlayerMobile pm) => DeriveFollowerOrders(pm); - // The persistent command is runtime-only and reset to None on load. When the master logs - // in we give each controlled pet that still has no standing command a sane one, inferred - // from proximity: near master -> Follow, otherwise Stay. + // PersistentOrder is runtime-only; ControlOrder and Home are saved. A pet saved mid-transient + // gets Follow near the master, else Stay. public static void DeriveFollowerOrders(PlayerMobile master) { if (master?.AllFollowers == null) @@ -37,14 +36,30 @@ public static class PetLoginHandler foreach (var follower in master.AllFollowers) { - if (follower is BaseCreature { Controlled: true, Deleted: false } bc - && bc.ControlMaster == master - && bc.AIObject is { } ai - && ai.PersistentOrder == OrderType.None) + if (follower is not BaseCreature { Controlled: true, Deleted: false } bc + || bc.ControlMaster != master + || bc.AIObject is not { } ai + || ai.PersistentOrder != OrderType.None) { - var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; - ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay); + continue; } + + var restored = bc.ControlOrder; + + if (restored is OrderType.Stay or OrderType.Follow or OrderType.Guard) + { + ai.RestorePersistentOrder(restored); + continue; + } + + var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange; + var derived = near ? OrderType.Follow : OrderType.Stay; + + // ControlTarget first: SetPersistentOrder records it as the Follow target, and a + // mid-Attack save still holds the victim. + bc.ControlTarget = near ? master : null; + ai.SetPersistentOrder(derived); + bc.SetControlOrder(derived, null, true); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs deleted file mode 100644 index 862c78649..000000000 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ /dev/null @@ -1,287 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PetOrderHandlers.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - ************************************************************************/ - -using System; - -namespace Server.Mobiles; - -public abstract partial class BaseAI -{ - public virtual void OnCurrentOrderChanged(OrderType previous) - { - if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false) - { - return; - } - - AITimer.Prod(); - - switch (Mobile.ControlOrder) - { - case OrderType.None: - { - HandleNoOrder(); - break; - } - case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } - case OrderType.Drop: - case OrderType.Friend: - case OrderType.Unfriend: - { - break; - } - case OrderType.Release: - { - HandleReleaseOrder(); - break; - } - case OrderType.Stop: - { - // Stop is resolved into another order; it never rests as the active order. - ResolveStop(previous); - return; - } - case OrderType.Transfer: - { - HandleTransferOrder(); - break; - } - case OrderType.Stay: - { - HandleStayOrder(); - break; - } - case OrderType.Guard: - { - HandleGuardOrder(); - break; - } - case OrderType.Attack: - { - HandleAttackOrder(); - break; - } - case OrderType.Follow: - { - HandleFollowOrder(); - break; - } - case OrderType.Rename: - { - HandleRenameOrder(); - break; - } - } - - // A freshly issued standing command becomes the persistent fallback and (re)anchors - // Home. Skipped while resuming a fallback so a resume never re-anchors. See - // ResumePersistentOrder. - if (!_resolvingOrder && Mobile.ControlOrder is OrderType.Stay or OrderType.Follow or OrderType.Guard) - { - SetPersistentOrder(Mobile.ControlOrder); - } - } - - // "Stop" cancels the active order, mapping to a resting order based on what the pet was - // doing: Attack/Come/etc. -> resume the persistent command; Follow/Guard -> cancel to idle - // (None) where it stands; Stay -> remain staying at its post. - private void ResolveStop(OrderType previous) - { - _commandIssuer?.RevealingAction(); - _commandIssuer = null; - Mobile.ControlTarget = null; - - switch (previous) - { - case OrderType.Stay: - { - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.Stay; // remain staying; anchor untouched - _resolvingOrder = false; - break; - } - case OrderType.Follow: - case OrderType.Guard: - { - SetPersistentOrder(OrderType.None); // cancel standing order; anchor = current - _resolvingOrder = true; - Mobile.ControlOrder = OrderType.None; // idle - _resolvingOrder = false; - break; - } - default: // Attack / Come / Drop / None / etc. -> resume the standing order - { - ResumePersistentOrder(); - break; - } - } - } - - private void HandleNoOrder() - { - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - } - - private void HandleTransferOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleGuardOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - - // Resuming the persistent order must not replay the flourish. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - - _commandIssuer = null; - } - - private void HandleAttackOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - - if (Mobile.ControlTarget != null && - !Mobile.ControlTarget.Deleted && - Mobile.ControlTarget.Alive) - { - Mobile.FocusMob = Mobile.ControlTarget; - Mobile.Combatant = Mobile.ControlTarget; - } - else - { - Mobile.FocusMob = null; - Mobile.Combatant = null; - } - - Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetAttackSound()); - _commandIssuer = null; - } - - private void HandleFollowOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - } - - private void HandleStayOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - _commandIssuer?.RevealingAction(); - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); - Mobile.PlaySound(Mobile.GetIdleSound()); - _commandIssuer = null; - // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. - } - - private void HandleReleaseOrder() - { - if (Mobile.ControlMaster?.Alive != true) - { - return; - } - - if (Mobile.Summoned) - { - Mobile.Kill(); - return; - } - - if (!string.IsNullOrEmpty(Mobile.Name)) - { - Mobile.Name = null; - } - - _commandIssuer?.RevealingAction(); - Mobile.ControlTarget = null; - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; - Mobile.PlaySound(Mobile.GetIdleSound()); - Mobile.BondingBegin = DateTime.MinValue; - Mobile.OwnerAbandonTime = DateTime.MinValue; - Mobile.IsBonded = false; - Mobile.SetControlMaster(null); - _commandIssuer = null; - } - - public virtual void HandleRenameOrder() - { - if (Mobile.Summoned) - { - Mobile.ControlMaster?.SendMessage("You cannot rename a summoned creature."); - } - else - { - Mobile.ControlMaster?.SendMessage("Change name on pet health bar."); - } - } -} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e6c48850d..1e751e9ab 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -13,51 +13,586 @@ * along with this program. If not, see . * ************************************************************************/ +using System; + namespace Server.Mobiles; public abstract partial class BaseAI { - // The standing command a pet falls back to when a transient order (Attack/Come/Drop) - // completes: None, Stay, Follow, or Guard. Runtime-only (not serialized); reset to None - // on load and derived from master proximity on login. See PetLoginHandler. + // Runtime-only: None after a load until PetLoginHandler derives it. internal OrderType PersistentOrder { get; private set; } = OrderType.None; - // Guards anchor/persistent derivation while we resume a fallback order, so a resume - // never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged. - private bool _resolvingOrder; + public static bool IsRestableOrder(OrderType order) => + order is OrderType.None or OrderType.Come or OrderType.Guard or OrderType.Attack or OrderType.Stay + or OrderType.Follow; + + public static bool IsFriendOrder(OrderType order) => + order is OrderType.Follow or OrderType.Stay or OrderType.Stop; + + // Everything else stands the pet down before it runs. + private static bool KeepsCombatPosture(OrderType order) => + order is OrderType.Attack or OrderType.Guard or OrderType.Drop or OrderType.Friend + or OrderType.Unfriend or OrderType.Rename; + + // Publish 51: told any of these, a pet "will not attack anything, even if it is attacked". + // Stop resolves to None, which is its resting form ("and may wander"). + public static bool IsStandDownOrder(OrderType order) => + order is OrderType.Follow or OrderType.Come or OrderType.Stay or OrderType.None; + + // Orders a dead bonded pet refuses. + public static bool IsDeadPetOrder(OrderType order) => + order is OrderType.Guard or OrderType.Attack or OrderType.Transfer or OrderType.Drop; + + // A targeted command overwrites ControlTarget; a resumed Follow restores it from here. + private Mobile _persistentTarget; // The controlled-pet wander anchor (Home) is a pure function of the persistent command. internal void SetPersistentOrder(OrderType order) { PersistentOrder = order; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location; } - // Resume the persistent command without re-deriving the persistent order or anchor. - private void ResumePersistentOrder() + // Adopt a saved standing order; Home and ControlTarget were saved with it. + internal void RestorePersistentOrder(OrderType order) { - _resolvingOrder = true; - Mobile.ControlOrder = PersistentOrder; - _resolvingOrder = false; + PersistentOrder = order; + _persistentTarget = order == OrderType.Follow ? Mobile.ControlTarget : null; } - public virtual bool Obey() => - !Mobile.Deleted && Mobile.ControlOrder switch + // The standing order is the fallback only for an interrupted order that cannot resume: a + // transient, or an attack whose target is gone. + private OrderType ResumeInterrupted(OrderType previous, Mobile interruptedTarget) + { + if (!IsRestableOrder(previous) || + previous == OrderType.Attack && IsInvalidControlTarget(interruptedTarget)) { - OrderType.None => DoOrderNone(), - OrderType.Come => DoOrderCome(), - OrderType.Drop => DoOrderDrop(), - OrderType.Friend => DoOrderFriend(), - OrderType.Unfriend => DoOrderUnfriend(), - OrderType.Guard => DoOrderGuard(), - OrderType.Attack => DoOrderAttack(), - OrderType.Release => DoOrderRelease(), - OrderType.Stay => DoOrderStay(), - OrderType.Stop => DoOrderStop(), - OrderType.Follow => DoOrderFollow(), - OrderType.Transfer => DoOrderTransfer(), - _ => false + return PersistentOrder; + } + + Mobile.ControlTarget = interruptedTarget; + return previous; + } + + // Resume the standing command without re-deriving it or re-anchoring Home. + private void ResumePersistentOrder() => Mobile.SetControlOrder(PersistentOrder, null, true); + + /// + /// Issue phase. is the only mobile revealed (null = system-issued); + /// marks a fallback to the standing order. Returns the order to rest in. + /// + public virtual OrderType IssueOrder( + OrderType order, OrderType previous, Mobile issuer, bool resuming, Mobile interruptedTarget + ) + { + if (Mobile.Deleted) + { + return order; + } + + AITimer.Prod(); + + issuer?.RevealingAction(); + + // Dropping Warmode nulls Combatant through the Mobile setter, which would turn Attack's + // single Combatant write into a re-write (DoHarmful again) and flap Guard's war stance. + Mobile.FocusMob = null; + + if (!KeepsCombatPosture(order)) + { + Mobile.Warmode = false; // also nulls Combatant via the setter + Mobile.Combatant = null; + } + + return order switch + { + OrderType.None => IssueNone(), + OrderType.Come => IssueCome(), + OrderType.Drop => IssueDrop(previous, interruptedTarget), + OrderType.Friend => IssueFriend(previous, interruptedTarget), + OrderType.Unfriend => IssueUnfriend(previous, interruptedTarget), + OrderType.Guard => IssueGuard(resuming), + OrderType.Attack => IssueAttack(resuming), + OrderType.Release => IssueRelease(), + OrderType.Stay => IssueStay(resuming), + OrderType.Stop => IssueStop(previous), + OrderType.Follow => IssueFollow(resuming), + OrderType.Transfer => IssueTransfer(), + OrderType.Rename => IssueRename(issuer, previous, interruptedTarget), + _ => PersistentOrder // Patrol and anything unimplemented }; + } + + private OrderType IssueNone() + { + Mobile.ControlTarget = null; + Mobile.SetCurrentSpeedToPassive(); + return OrderType.None; + } + + private OrderType IssueCome() + { + Mobile.SetCurrentSpeedToActive(); + return OrderType.Come; + } + + private OrderType IssueStay(bool resuming) + { + Mobile.SetCurrentSpeedToPassive(); + + if (resuming) + { + Mobile.ControlTarget = null; // a transient's target does not carry over + } + else + { + SetPersistentOrder(OrderType.Stay); // anchors Home at the post + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Stay; + } + + private OrderType IssueFollow(bool resuming) + { + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + // the standing Follow's target, never a transient's + Mobile.ControlTarget = _persistentTarget?.Deleted == false ? _persistentTarget : Mobile.ControlMaster; + } + else + { + SetPersistentOrder(OrderType.Follow); // Home = Zero, remembers the target + Mobile.PlaySound(Mobile.GetIdleSound()); + } + + return OrderType.Follow; + } + + private OrderType IssueGuard(bool resuming) + { + Mobile.Warmode = true; // the guard order opens in war stance + Mobile.SetCurrentSpeedToActive(); + + if (resuming) + { + Mobile.ControlTarget = null; + } + else + { + SetPersistentOrder(OrderType.Guard); + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + + return OrderType.Guard; + } + + private OrderType IssueAttack(bool resuming) + { + var target = Mobile.ControlTarget; + var valid = target?.Deleted == false && target.Alive; + + Mobile.FocusMob = valid ? target : null; + Mobile.Combatant = valid ? target : null; // the one Combatant write of the Attack command + + if (valid) + { + Action = ActionType.Combat; + } + + Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); + + // A resumed attack is not a new command: no bark. The Combatant write above is + // idempotent (the setter early-outs unchanged), so its aggression is not repeated. + if (!resuming) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + } + + return OrderType.Attack; + } + + // Stop: Follow/Guard -> idle here; Stay -> keep the post; anything transient -> the standing order. + private OrderType IssueStop(OrderType previous) + { + Mobile.ControlTarget = null; + + switch (previous) + { + case OrderType.Stay: + { + return OrderType.Stay; // resumed: anchor untouched + } + case OrderType.Follow: + case OrderType.Guard: + { + SetPersistentOrder(OrderType.None); // cancel the standing order; idle anchor = here + return OrderType.None; + } + default: + { + // No standing order: idle here, anchored (a Zero Home wanders without bounds). + if (PersistentOrder == OrderType.None) + { + SetPersistentOrder(OrderType.None); + } + + return PersistentOrder; + } + } + } + + private OrderType IssueDrop(OrderType previous, Mobile interruptedTarget) + { + if (!Mobile.IsDeadPet && Mobile.CanDrop) + { + this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); + DropItems(); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + private void DropItems() + { + var pack = Mobile.Backpack; + + if (pack == null) + { + return; + } + + var items = pack.Items; + + for (var i = items.Count - 1; i >= 0; --i) + { + if (i < items.Count) + { + items[i].MoveToWorld(Mobile.Location, Mobile.Map); + } + } + } + + private OrderType IssueFriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!from.CanBeBeneficial(to, true)) + { + return ResumeInterrupted(previous, interruptedTarget); + } + + if (from.HasTrade || to.HasTrade) + { + (from.HasTrade ? from : to).SendLocalizedMessage(1070947); + // You cannot friend a pet with a trade pending + return ResumeInterrupted(previous, interruptedTarget); + } + + if (Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1049691); + // That person is already a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.AllowNewPetFriend) + { + from.SendLocalizedMessage(1005482); + // Your pet does not seem to be interested in making new friends right now. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will now accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will now consider you as a friend. + + Mobile.AddPetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueUnfriend(OrderType previous, Mobile interruptedTarget) + { + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); + // *looks confused* + return ResumeInterrupted(previous, interruptedTarget); + } + + if (!Mobile.IsPetFriend(to)) + { + from.SendLocalizedMessage(1070953); + // That person is not a friend. + return ResumeInterrupted(previous, interruptedTarget); + } + + from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); + // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. + + to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); + // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. + // This creature will no longer consider you as a friend. + + Mobile.RemovePetFriend(to); + + return ResumeInterrupted(previous, interruptedTarget); + } + + private OrderType IssueTransfer() + { + if (Mobile.IsDeadPet) + { + return PersistentOrder; + } + + var from = Mobile.ControlMaster; + var to = Mobile.ControlTarget; + + if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) + { + return PersistentOrder; + } + + this.DebugSayFormatted($"Beginning transfer with {to.Name}"); + + var youngFrom = from is PlayerMobile { Young: true }; + var youngTo = to is PlayerMobile { Young: true }; + + if (youngFrom && !youngTo) + { + from.SendLocalizedMessage(502040); + // As a young player, you may not friend pets to older players. + return PersistentOrder; + } + + if (!youngFrom && youngTo) + { + from.SendLocalizedMessage(502041); + // As an older player, you may not friend pets to young players. + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(to)) + { + SendTransferRefusalMessages(from, to, 1043248, 1043249); + // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ + // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ + return PersistentOrder; + } + + if (!Mobile.CanBeControlledBy(from)) + { + SendTransferRefusalMessages(from, to, 1043250, 1043251); + // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ + // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ + return PersistentOrder; + } + + // The stand-down already cleared Combatant; the aggressor lists and the combat cooldown gate this. + if (Mobile.Aggressors.Count > 0 || Mobile.Aggressed.Count > 0 || Core.TickCount - Mobile.NextCombatTime < 0) + { + from.SendMessage("You can not transfer a pet while in combat."); + to.SendMessage("You can not transfer a pet while in combat."); + return PersistentOrder; + } + + var fromState = from.NetState; + var toState = to.NetState; + + if (fromState == null || toState == null) + { + return PersistentOrder; + } + + if (from.HasTrade || to.HasTrade) + { + from.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + to.SendLocalizedMessage(1010507); + // You cannot transfer a pet with a trade pending + return PersistentOrder; + } + + var container = fromState.AddTrade(toState); + container.DropItem(new TransferItem(Mobile)); + + // Hold position while the trade window is open. + Mobile.PlaySound(Mobile.GetIdleSound()); + Mobile.SetCurrentSpeedToPassive(); + SetPersistentOrder(OrderType.Stay); + return OrderType.Stay; + } + + private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) + { + var args = $"{to.Name}\t{from.Name}\t "; + + from.SendLocalizedMessage(fromMessage, args); + to.SendLocalizedMessage(toMessage, args); + } + + // SetControlMaster(null) assigns ControlOrder = None underneath; the funnel keeps that write. + private OrderType IssueRelease() + { + if (Mobile.Summoned) + { + Mobile.Kill(); + + // A vetoed death leaves the summon controlled; it keeps its standing order. + return Mobile.Deleted || !Mobile.Alive ? OrderType.None : PersistentOrder; + } + + DebugSay("I have been released to the wild."); + + if (!string.IsNullOrEmpty(Mobile.Name)) + { + Mobile.Name = null; + } + + Mobile.PlaySound(Mobile.GetIdleSound()); + + Mobile.ControlTarget = null; + Mobile.BondingBegin = DateTime.MinValue; + Mobile.OwnerAbandonTime = DateTime.MinValue; + Mobile.IsBonded = false; + // Nothing of the old master survives a re-tame. + Mobile.ClearPetFriends(); + PersistentOrder = OrderType.None; + _persistentTarget = null; + Mobile.SetControlMaster(null); + + var spawner = Mobile.Spawner; + + if (spawner != null) + { + Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); + Mobile.RangeHome = spawner.WalkingRange; + } + else + { + // No spawner: anchor here rather than path toward a stale stay anchor. + Mobile.Home = Mobile.Location; + Action = ActionType.Wander; + } + + if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) + { + Mobile.Delete(); + } + else + { + Mobile.BeginDeleteTimer(); + + if (Mobile.CanDrop) + { + Mobile.DropBackpack(); + } + } + + return OrderType.None; + } + + protected virtual OrderType IssueRename(Mobile issuer, OrderType previous, Mobile interruptedTarget) + { + var to = issuer ?? Mobile.ControlMaster; + + if (Mobile.Summoned) + { + to?.SendMessage("You cannot rename a summoned creature."); + } + else + { + to?.SendMessage("Change name on pet health bar."); + } + + return ResumeInterrupted(previous, interruptedTarget); + } + + // Only restable orders arrive here; anything else is a pre-refactor save and resumes the standing order. + public virtual bool Obey() + { + if (Mobile.Deleted) + { + return false; + } + + switch (Mobile.ControlOrder) + { + case OrderType.None: + { + return DoOrderNone(); + } + case OrderType.Come: + { + return DoOrderCome(); + } + case OrderType.Guard: + { + return DoOrderGuard(); + } + case OrderType.Attack: + { + return DoOrderAttack(); + } + case OrderType.Stay: + { + return DoOrderStay(); + } + case OrderType.Follow: + { + return DoOrderFollow(); + } + default: + { + ResumePersistentOrder(); + return true; + } + } + } public virtual bool DoOrderNone() { @@ -65,8 +600,7 @@ public abstract partial class BaseAI Mobile.Warmode = IsValidCombatant(Mobile.Combatant); - // Pure idle: gently wander near the anchor, with CheckIdle rest periods. Pets resume - // a standing order via ResumePersistentOrder, not by re-deriving it here. + // A standing order is resumed through ResumePersistentOrder, never re-derived here. WalkRandomIdle(); return true; } @@ -128,166 +662,12 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); - // AOS: sprint after the master (bespoke 0.1 paces both clocks). - if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) - { - Mobile.CurrentSpeed = 0.1; - } - if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } - public virtual bool DoOrderDrop() - { - if (Mobile.IsDeadPet || !Mobile.CanDrop) - { - return true; - } - - this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}."); - - DropItems(); - ResumePersistentOrder(); - return true; - } - - private void DropItems() - { - var pack = Mobile.Backpack; - - if (pack == null) - { - return; - } - - var items = pack.Items; - - for (var i = items.Count - 1; i >= 0; --i) - { - if (i < items.Count) - { - items[i].MoveToWorld(Mobile.Location, Mobile.Map); - } - } - } - - public virtual bool DoOrderFriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleFriendRequest(from, to); - return true; - } - - private void HandleFriendRequest(Mobile from, Mobile to) - { - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - return; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - return; - } - - if (!from.CanBeBeneficial(to, true)) - { - return; - } - - if (to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (from.HasTrade || to.HasTrade) - { - (from.HasTrade ? from : to).SendLocalizedMessage(1070947); - // You cannot friend a pet with a trade pending - return; - } - - if (Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1049691); - // That person is already a friend. - ResumePersistentOrder(); - return; - } - - if (!Mobile.AllowNewPetFriend) - { - from.SendLocalizedMessage(1005482); - // Your pet does not seem to be interested in making new friends right now. - return; - } - - from.SendLocalizedMessage(1049676, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will now accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1043246, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will now consider you as a friend. - - Mobile.AddPetFriend(to); - - Mobile.ControlTarget = to; - Mobile.ControlOrder = OrderType.Follow; - } - - public virtual bool DoOrderUnfriend() - { - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - HandleUnfriendRequest(from, to); - return true; - } - - private void HandleUnfriendRequest(Mobile from, Mobile to) - { - if (from?.Deleted != false || to?.Deleted != false || from == to || !to.Player) - { - Mobile.PublicOverheadMessage(MessageType.Regular, 0x3B2, 502039); - // *looks confused* - return; - } - - if (!Mobile.IsPetFriend(to)) - { - from.SendLocalizedMessage(1070953); - // That person is not a friend. - ResumePersistentOrder(); - return; - } - - from.SendLocalizedMessage(1070951, $"{Mobile.Name}\t{to.Name}"); - // ~1_NAME~ will no longer accept movement commands from ~2_NAME~. - - to.SendLocalizedMessage(1070952, $"{from.Name}\t{Mobile.Name}"); - // ~1_NAME~ has no longer granted you the ability to give orders to their pet ~2_PET_NAME~. - // This creature will no longer consider you as a friend. - - Mobile.RemovePetFriend(to); - - Mobile.ControlTarget = from; - Mobile.ControlOrder = OrderType.Follow; - } - public virtual bool DoOrderGuard() { var controlMaster = Mobile.ControlMaster; @@ -321,23 +701,15 @@ public abstract partial class BaseAI var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); - if (distance > 3) - { - // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. - if (Core.AOS) - { - Mobile.CurrentSpeed = 0.1; - } - else - { - Mobile.SetCurrentSpeedToActive(); - } + // Alert either way; FollowMoveSpeed caps the steps of the return itself. + Mobile.SetCurrentSpeedToActive(); - WalkMobileRange(controlMaster, 1, 1, 3); + if (distance > GuardRange) + { + WalkMobileRange(controlMaster, 1, 1, GuardRange); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -358,10 +730,14 @@ public abstract partial class BaseAI } else { - Mobile.Combatant = Mobile.ControlTarget; - this.DebugSayFormatted($"Attacking target: {Mobile.ControlTarget?.Name}"); + // OnAggressiveAction can swap Combatant; the commanded target wins. + if (Mobile.Combatant != Mobile.ControlTarget) + { + Mobile.Combatant = Mobile.ControlTarget; + } + Think(); } @@ -375,7 +751,6 @@ public abstract partial class BaseAI { DebugSay("Target is either dead, hidden, or out of range."); - Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. @@ -389,9 +764,7 @@ public abstract partial class BaseAI if (next != null) { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; + Mobile.IssueOrder(OrderType.Attack, null, next); this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); @@ -457,42 +830,6 @@ public abstract partial class BaseAI return best; } - public virtual bool DoOrderRelease() - { - DebugSay("I have been released to the wild."); - - var spawner = Mobile.Spawner; - - if (spawner != null) - { - Mobile.Home = spawner.GetSpawnPosition(Mobile, spawner.Map); - Mobile.RangeHome = spawner.WalkingRange; - } - else - { - // No spawner to return to: anchor where it stands so it idle-wanders here - // instead of pathing toward a stale (e.g. former stay) anchor. - Mobile.Home = Mobile.Location; - Action = ActionType.Wander; - } - - if (Mobile.DeleteOnRelease || Mobile.IsDeadPet) - { - Mobile.Delete(); - } - else - { - Mobile.BeginDeleteTimer(); - - if (Mobile.CanDrop) - { - Mobile.DropBackpack(); - } - } - - return true; - } - public virtual bool DoOrderStay() { if (CheckHerding()) @@ -513,103 +850,4 @@ public abstract partial class BaseAI return true; } - - // Stop is resolved into another order in OnCurrentOrderChanged and never rests as the - // active order; this is a defensive no-op. - public virtual bool DoOrderStop() => true; - - public virtual bool DoOrderTransfer() - { - if (Mobile.IsDeadPet) - { - return true; - } - - var from = Mobile.ControlMaster; - var to = Mobile.ControlTarget; - - if (from?.Deleted == false && to?.Deleted == false && from != to && to.Player) - { - this.DebugSayFormatted($"Beginning transfer with {to.Name}"); - - var youngFrom = from is PlayerMobile mobile && mobile.Young; - var youngTo = to is PlayerMobile playerMobile && playerMobile.Young; - - if (youngFrom && !youngTo) - { - from.SendLocalizedMessage(502040); - // As a young player, you may not friend pets to older players. - ResumePersistentOrder(); - return true; - } - - if (!youngFrom && youngTo) - { - from.SendLocalizedMessage(502041); - // As an older player, you may not friend pets to young players. - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(to)) - { - SendTransferRefusalMessages(from, to, 1043248, 1043249); - // 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~ - // 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (!Mobile.CanBeControlledBy(from)) - { - SendTransferRefusalMessages(from, to, 1043250, 1043251); - // 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~ - // 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~ - ResumePersistentOrder(); - return true; - } - - if (Mobile.Combatant != null || Mobile.Aggressors.Count > 0 || - Mobile.Aggressed.Count > 0 || Core.TickCount < Mobile.NextCombatTime) - { - from.SendMessage("You can not transfer a pet while in combat."); - to.SendMessage("You can not transfer a pet while in combat."); - ResumePersistentOrder(); - return true; - } - - var fromState = from.NetState; - var toState = to.NetState; - - if (fromState == null || toState == null) - { - ResumePersistentOrder(); - return true; - } - - if (from.HasTrade || to.HasTrade) - { - from.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - to.SendLocalizedMessage(1010507); - // You cannot transfer a pet with a trade pending - ResumePersistentOrder(); - return true; - } - - var container = fromState.AddTrade(toState); - container.DropItem(new TransferItem(Mobile)); - } - - Mobile.ControlOrder = OrderType.Stay; - return true; - } - - private static void SendTransferRefusalMessages(Mobile from, Mobile to, int fromMessage, int toMessage) - { - var args = $"{to.Name}\t{from.Name}\t "; - - from.SendLocalizedMessage(fromMessage, args); - to.SendLocalizedMessage(toMessage, args); - } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 9efad5a5e..7eac17ae5 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -156,17 +156,16 @@ internal sealed partial class TransferItem : Item private void TransferPetOwnership(Mobile from, Mobile to) { - if (_creature.Summoned) + if (_creature.SummonMaster != null) { _creature.SummonMaster = to; } - _creature.ControlTarget = to; - _creature.ControlOrder = OrderType.Follow; + _creature.ClearPetFriends(); + _creature.IssueOrder(OrderType.Follow, null, to); _creature.BondingBegin = DateTime.MinValue; _creature.OwnerAbandonTime = DateTime.MinValue; _creature.IsBonded = false; - _creature.PlaySound(_creature.GetIdleSound()); var args = $"{from.Name}\t{_creature.Name}\t{to.Name}"; from.SendLocalizedMessage(1043253, args); diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index fc227507e..75c6fc3dc 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -99,8 +99,8 @@ public abstract class MonsterAbility { } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void InvalidateNextAbilityTriggers(BaseCreature source) { var abilities = source.GetMonsterAbilities(); diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs index 243299af9..17d1ba616 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs @@ -3,6 +3,7 @@ using System; using Server.Items; using Server.Misc; using Server.Multis; +using Server.Regions; using Server.Targeting; using Server.Regions; @@ -287,7 +288,6 @@ public abstract partial class BaseMount : BaseCreature, IMount if (mob is PlayerMobile mobile) { - if (mobile.Region is BaseRegion { MountsAllowed: false}) { mobile.SendLocalizedMessage(1042317); // You may not ride at this time diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index a7538a252..bf3edafee 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.ConPVP; @@ -13,6 +14,7 @@ using Server.Engines.Virtues; using Server.Ethics; using Server.Factions; using Server.Items; +using Server.Logging; using Server.Misc; using Server.Multis; using Server.Network; @@ -133,8 +135,17 @@ namespace Server.Mobiles public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage); } + [SerializationGenerator(23, false)] public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature)); + + // Medium bucket; used when an elided load finds no table entry (a 0-delay AI timer would spin). + private const double FallbackActiveSpeed = 0.25; + private const double FallbackPassiveSpeed = 0.5; + + private static bool _loggedMissingSpeeds; + public enum Allegiance { None, @@ -160,7 +171,7 @@ namespace Server.Mobiles public const int DefaultRangePerception = 16; - private const double ChanceToRummage = 0.5; // 50% + private const double ChanceToRummage = 0.5; private const double MinutesToNextRummageMin = 1.0; private const double MinutesToNextRummageMax = 4.0; @@ -227,7 +238,7 @@ namespace Server.Mobiles private static readonly Type[] _gold = { - // white wyrms eat gold.. + // White wyrms eat gold. typeof(Gold) }; @@ -250,56 +261,540 @@ namespace Server.Mobiles typeof(AncientSmithyHammer), typeof(Scorp) }; - private bool _summoned; private bool _isLosHidden; private bool m_bTamable; private int m_ColdResistance; - private bool _controlled; // Is controlled - private Mobile m_ControlMaster; // My master - private OrderType m_ControlOrder; // My order + [SerializableField(0, setter: "private")] + private AIType _defaultAI; - private AIType m_CurrentAI; // The current AI + [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeCurrentAI), nameof(CurrentAIDefaultValue))] + private AIType _currentAI; + + private bool ShouldSerializeCurrentAI() => _currentAI != _defaultAI; + + private AIType CurrentAIDefaultValue() => _defaultAI; + + [EncodedInt] + [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeRangePerception), nameof(RangePerceptionDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangePerception; + + private bool ShouldSerializeRangePerception() => _rangePerception != DefaultRangePerception; + + private int RangePerceptionDefaultValue() => DefaultRangePerception; + + [EncodedInt] + [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeRangeFight), nameof(RangeFightDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeFight; + + private bool ShouldSerializeRangeFight() => _rangeFight != 1; + + private int RangeFightDefaultValue() => 1; + + [EncodedInt] + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeRangeHome), nameof(RangeHomeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeHome = 10; + + private bool ShouldSerializeRangeHome() => _rangeHome != 10; + + private int RangeHomeDefaultValue() => 10; + + [EncodedInt] + [SerializableField(5, fieldChanged: nameof(OnTeamChange))] + [SaveFlag(nameof(ShouldSerializeTeam))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _team; + + private bool ShouldSerializeTeam() => _team != 0; + + private void OnTeamChange(int oldValue, int newValue) => OnTeamChange(); + + [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeFightMode), nameof(FightModeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private FightMode _fightMode; + + private bool ShouldSerializeFightMode() => _fightMode != FightMode.Closest; + + private FightMode FightModeDefaultValue() => FightMode.Closest; + + /// Seconds per AI decision while engaged; see for movement pace. + [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeSpeed; + + private bool ShouldSerializeActiveSpeed() + { + GetSpeeds(out var activeSpeed, out _); + return _activeSpeed != activeSpeed; + } + + private double ActiveSpeedDefaultValue() + { + GetSpeeds(out var activeSpeed, out _); + return activeSpeed; + } + + /// Seconds per AI decision while idle; see for movement pace. + [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveSpeed; + + private bool ShouldSerializePassiveSpeed() + { + GetSpeeds(out _, out var passiveSpeed); + return _passiveSpeed != passiveSpeed; + } + + private double PassiveSpeedDefaultValue() + { + GetSpeeds(out _, out var passiveSpeed); + return passiveSpeed; + } + + [SerializableField(9, fieldChanged: nameof(OnCurrentSpeedChange))] + [SaveFlag(nameof(ShouldSerializeCurrentSpeed), nameof(CurrentSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _currentSpeed; - // Movement clock (seconds per step); 0 = inherit the matching think value. + private bool ShouldSerializeCurrentSpeed() => _currentSpeed != _passiveSpeed; + + private double CurrentSpeedDefaultValue() => _passiveSpeed; + + private void OnCurrentSpeedChange(double oldValue, double newValue) => AIObject?.OnCurrentSpeedChanged(); + + /// + /// Movement clock (seconds per step) while engaged; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(10, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeMoveSpeed; + + /// + /// Movement clock (seconds per step) while idle; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveMoveSpeed; + private bool CoerceMoveSpeed(ref double value) + { + value = Math.Max(0, value); // anything non-positive means "inherit" + return true; + } + + private bool ShouldSerializeActiveMoveSpeed() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return _activeMoveSpeed != activeMoveSpeed; + } + + private double ActiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return activeMoveSpeed; + } + + private bool ShouldSerializePassiveMoveSpeed() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return _passiveMoveSpeed != passiveMoveSpeed; + } + + private double PassiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return passiveMoveSpeed; + } + + [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeHome))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _home; + + private bool ShouldSerializeHome() => _home != Point3D.Zero; + + [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeHomeMap))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Map _homeMap; + + private bool ShouldSerializeHomeMap() => _homeMap != null; + + [SerializableField(14, fieldChanged: nameof(OnControlledChange))] + [SaveFlag(nameof(ShouldSerializeControlled))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _controlled; + + private bool ShouldSerializeControlled() => _controlled; + + private void OnControlledChange(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _controlMaster; + + private bool ShouldSerializeControlMaster() => _controlMaster != null; + + [SerializableField(16)] + [SaveFlag(nameof(ShouldSerializeControlTarget))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Mobile _controlTarget; + + private bool ShouldSerializeControlTarget() => _controlTarget != null; + + [SerializableField(17)] + [SaveFlag(nameof(ShouldSerializeControlDest))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _controlDest; + + private bool ShouldSerializeControlDest() => _controlDest != Point3D.Zero; + + // Order logic must run on equal re-assignment, so the property is hand-written. + private OrderType _controlOrder; + + private bool ShouldSerializeControlOrder() => _controlOrder != OrderType.None; + + [SerializableField(19)] + [SaveFlag(nameof(ShouldSerializeMinTameSkill))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private double _minTameSkill; + + private bool ShouldSerializeMinTameSkill() => _minTameSkill != 0; + + // The getter masks paragons, so the property is hand-written. + private bool _tamable; + + private bool ShouldSerializeTamable() => _tamable; + + [SerializableField(21, fieldChanged: nameof(OnSummonedChange))] + [SaveFlag(nameof(ShouldSerializeSummoned))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private bool _summoned; + + private bool ShouldSerializeSummoned() => _summoned; + + private void OnSummonedChange(bool oldValue, bool newValue) + { + NextReacquireTime = Core.TickCount; + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + [AnchoredDateTime] + [SerializableField(22, getter: "protected", setter: "protected")] + [SaveFlag(nameof(ShouldSerializeSummonEnd))] + private DateTime _summonEnd; + + private bool ShouldSerializeSummonEnd() => _summoned; + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _summonMaster; + + private bool ShouldSerializeSummonMaster() => _summonMaster != null; + + [EncodedInt] + [SerializableField(24)] + [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private int _controlSlots = 1; + + private bool ShouldSerializeControlSlots() => _controlSlots != 1; + + private int ControlSlotsDefaultValue() => 1; + + [EncodedInt] + [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))] + [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _loyalty; + + private bool ShouldSerializeLoyalty() => _loyalty != MaxLoyalty; + + private int LoyaltyDefaultValue() => MaxLoyalty; + + private bool ClampLoyalty(ref int value) + { + value = Math.Clamp(value, 0, MaxLoyalty); + return true; + } + + [SerializableField(26)] + [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private WayPoint _currentWayPoint; + + private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null; + + [EncodedInt] + [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _hitsMaxSeed = -1; + + private bool ShouldSerializeHitsMaxSeed() => _hitsMaxSeed != -1; + + private int HitsMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(28)] + [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _stamMaxSeed = -1; + + private bool ShouldSerializeStamMaxSeed() => _stamMaxSeed != -1; + + private int StamMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _manaMaxSeed = -1; + + private bool ShouldSerializeManaMaxSeed() => _manaMaxSeed != -1; + + private int ManaMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(30)] + [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMin = -1; + + private bool ShouldSerializeDamageMin() => _damageMin != -1; + + private int DamageMinDefaultValue() => -1; + + [EncodedInt] + [SerializableField(31)] + [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMax = -1; + + private bool ShouldSerializeDamageMax() => _damageMax != -1; + + private int DamageMaxDefaultValue() => -1; + + [EncodedInt] + [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalResistanceSeed; + + private bool ShouldSerializePhysicalResistanceSeed() => _physicalResistanceSeed != 0; + + private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances(); + + [EncodedInt] + [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeFireResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireResistSeed; + + private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0; + + [EncodedInt] + [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeColdResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldResistSeed; + + private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0; + + [EncodedInt] + [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePoisonResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonResistSeed; + + private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0; + + [EncodedInt] + [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyResistSeed; + + private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0; + + [EncodedInt] + [SerializableField(37)] + [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalDamage = 100; + + private bool ShouldSerializePhysicalDamage() => _physicalDamage != 100; + + private int PhysicalDamageDefaultValue() => 100; + + [EncodedInt] + [SerializableField(38)] + [SaveFlag(nameof(ShouldSerializeFireDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireDamage; + + private bool ShouldSerializeFireDamage() => _fireDamage != 0; + + [EncodedInt] + [SerializableField(39)] + [SaveFlag(nameof(ShouldSerializeColdDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldDamage; + + private bool ShouldSerializeColdDamage() => _coldDamage != 0; + + [EncodedInt] + [SerializableField(40)] + [SaveFlag(nameof(ShouldSerializePoisonDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonDamage; + + private bool ShouldSerializePoisonDamage() => _poisonDamage != 0; + + [EncodedInt] + [SerializableField(41)] + [SaveFlag(nameof(ShouldSerializeEnergyDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyDamage; + + private bool ShouldSerializeEnergyDamage() => _energyDamage != 0; + + [Tidy] + [SerializableField(42, setter: "private")] + [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))] + private List _owners; + + private bool ShouldSerializeOwners() + { + _owners?.Tidy(); + return _owners?.Count > 0; + } + + private List OwnersDefaultValue() => new(); + + [SerializableField(43)] + [SaveFlag(nameof(ShouldSerializeIsDeadPet))] + private bool _isDeadPet; + + private bool ShouldSerializeIsDeadPet() => _isDeadPet; + + [SerializableField(44, fieldChanged: nameof(OnBondedChange))] + [SaveFlag(nameof(ShouldSerializeIsBonded))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _isBonded; + + private bool ShouldSerializeIsBonded() => _isBonded; + + private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties(); + + [SerializableField(45)] + [SaveFlag(nameof(ShouldSerializeBondingBegin))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _bondingBegin; + + private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue; + + [SerializableField(46)] + [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _ownerAbandonTime; + + private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue; + + [SerializableField(47)] + [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))] + private bool _hasGeneratedLoot; + + private bool ShouldSerializeHasGeneratedLoot() => _hasGeneratedLoot; + + // The setter converts the creature, which must not run at load, so the property is hand-written. + private bool _isParagon; + + private bool ShouldSerializeIsParagon() => _isParagon; + + [Tidy] + [SerializableField(49, setter: "private")] + [SaveFlag(nameof(ShouldSerializeFriends))] + private List _friends; + + private bool ShouldSerializeFriends() + { + _friends?.Tidy(); + return _friends?.Count > 0; + } + + [SerializableField(50)] + [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _removeIfUntamed; + + private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed; + + [EncodedInt] + [SerializableField(51)] + [SaveFlag(nameof(ShouldSerializeRemoveStep))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _removeStep; + + private bool ShouldSerializeRemoveStep() => _removeStep != 0; + + [SerializableField(52, setter: "private")] + [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))] + [DeserializeTimer(nameof(DeserializePendingDeleteTimer))] + private Timer _pendingDeleteTimer; + + // Stabled and controlled pets never resume a delete countdown. + private bool ShouldSerializePendingDeleteTimer() => + _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null); + + private void DeserializePendingDeleteTimer(TimeSpan delay) + { + _pendingDeleteTimer = new DeleteTimer(this, delay); + _pendingDeleteTimer.Start(); + } + + [SerializableField(53)] + [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _corpseNameOverride; + + private bool ShouldSerializeCorpseNameOverride() => _corpseNameOverride != null; + + // --- Non-serialized state ------------------------------------------------------- + // Herding - forces the mob to walk to a specific location, paced by the movement // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; - private int m_DamageMax = -1; - - private int m_DamageMin = -1; - private AIType m_DefaultAI; // The default AI - - private DeleteTimer m_DeleteTimer; - private int m_EnergyResistance; - private int m_FailedReturnHome; /* return to home failure counter */ - private int m_FireResistance; - private bool m_HasGeneratedLoot; // have we generated our loot yet? private TimerExecutionToken _healTimerToken; - private Point3D m_Home; // The home position of the creature, used by some AI - private DateTime m_IdleReleaseTime; - private bool m_IsBonded; - private bool m_IsStabled; protected int m_KillersLuck; - private int m_Loyalty; - private DateTime m_MLNextShout; private List m_MLQuests; @@ -312,24 +807,13 @@ namespace Server.Mobiles private long m_NextRummageTime; - private bool m_Paragon; - - private int m_PhysicalResistance; - private int m_PoisonResistance; - - /* until we are sure about who should be getting deleted, move them instead */ - /* On OSI, they despawn */ - + // On OSI these despawn; we queue a return home instead of deleting. private bool m_ReturnQueued; protected bool m_Spawning; - private Mobile m_SummonMaster; - private SkillName m_Teaching = (SkillName)(-1); - private int m_Team; // Monster Team - public BaseCreature( AIType ai, FightMode mode = FightMode.Closest, @@ -337,10 +821,10 @@ namespace Server.Mobiles int iRangeFight = 1 ) { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; - m_CurrentAI = ai; - m_DefaultAI = ai; + _currentAI = ai; + _defaultAI = ai; RangePerception = iRangePerception; RangeFight = iRangeFight; @@ -350,20 +834,28 @@ namespace Server.Mobiles GetSpeeds(out var activeSpeed, out var passiveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + if (activeSpeed <= 0 || passiveSpeed <= 0) + { + // A 0-delay creature would spin its AI timer; only construction refuses. + throw new InvalidOperationException( + $"{GetType()} constructed without speeds - is Data/npc-speeds.json missing?" + ); + } + ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; CurrentSpeed = passiveSpeed; - m_Team = 0; + _team = 0; Debug = false; _controlled = false; - m_ControlMaster = null; + _controlMaster = null; ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlOrder = OrderType.None; - m_bTamable = false; + _tamable = false; Owners = new List(); @@ -408,14 +900,10 @@ namespace Server.Mobiles public virtual InhumanSpeech SpeechType => null; - /* Do not serialize this till the code is finalized */ - + // Deliberately not serialized until the feature is finalized. [CommandProperty(AccessLevel.GameMaster)] public bool SeeksHome { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public string CorpseNameOverride { get; set; } - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool IsStabled { @@ -438,20 +926,20 @@ namespace Server.Mobiles public virtual bool FollowsAcquireRules => true; - protected DateTime SummonEnd { get; set; } - public virtual Faction FactionAllegiance => null; public virtual int FactionSilverWorth => 30; public virtual double WeaponAbilityChance => 0.4; + [SerializableProperty(48, useField: nameof(_isParagon))] + [SaveFlag(nameof(ShouldSerializeIsParagon))] [CommandProperty(AccessLevel.GameMaster)] public bool IsParagon { - get => m_Paragon; + get => _isParagon; set { - if (m_Paragon == value) + if (_isParagon == value) { return; } @@ -465,9 +953,10 @@ namespace Server.Mobiles Paragon.UnConvert(this); } - m_Paragon = value; + _isParagon = value; InvalidateProperties(); + this.MarkDirty(); } } @@ -476,8 +965,6 @@ namespace Server.Mobiles public virtual FoodType FavoriteFood => FoodType.Meat; public virtual PackInstinct PackInstinct => PackInstinct.None; - public List Owners { get; private set; } - public virtual bool AllowMaleTamer => true; public virtual bool AllowFemaleTamer => true; public virtual bool SubdueBeforeTame => false; @@ -501,11 +988,11 @@ namespace Server.Mobiles public virtual bool DeathAdderCharmable => false; - // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course. - // at this skill level we dispel 50% chance + //TODO Apply the pub 31 DispelDifficulty tweaks + // Skill level at which dispel succeeds 50% of the time. public virtual double DispelDifficulty => 0.0; - // at difficulty - focus we have 0%, at difficulty + focus we have 100% + // 0% at difficulty - focus, 100% at difficulty + focus. public virtual double DispelFocus => 20.0; public virtual bool DisplayWeight => Backpack is StrongBackpack; @@ -541,21 +1028,11 @@ namespace Server.Mobiles } public virtual bool IsNecroFamiliar => - Summoned && m_ControlMaster != null && - SummonFamiliarSpell.Table.TryGetValue(m_ControlMaster, out var bc) && bc == this; + Summoned && _controlMaster != null && + SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this; public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned; - [CommandProperty(AccessLevel.GameMaster)] - public int Loyalty - { - get => m_Loyalty; - set => m_Loyalty = Math.Clamp(value, 0, MaxLoyalty); - } - - [CommandProperty(AccessLevel.GameMaster)] - public WayPoint CurrentWayPoint { get; set; } - public virtual Mobile ConstantFocus => null; public virtual bool DisallowAllMoves => false; @@ -568,59 +1045,24 @@ namespace Server.Mobiles public virtual bool AlwaysAttackable => false; - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMin - { - get => m_DamageMin; - set => m_DamageMin = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMax - { - get => m_DamageMax; - set => m_DamageMax = value; - } - [CommandProperty(AccessLevel.GameMaster)] public override int HitsMax => HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int HitsMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int StamMax => StamMaxSeed <= 0 ? Dex : Math.Clamp(StamMaxSeed + GetStatOffset(StatType.Dex), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int StamMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int ManaMax => ManaMaxSeed <= 0 ? Int : Math.Clamp(ManaMaxSeed + GetStatOffset(StatType.Int), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int ManaMaxSeed { get; set; } = -1; - public virtual bool CanOpenDoors => !Body.IsAnimal && !Body.IsSea; public virtual bool CanMoveOverObstacles => Core.AOS || Body.IsMonster; public virtual bool CanDestroyObstacles => false; - /* - Seems this actually was removed on OSI somewhere between the original bug report and now. - We will call it ML, until we can get better information. I suspect it was on the OSI TC when - originally it taken out of RunUO, and not implemented on OSIs production shards until more - recently. Either way, this is, or was, accurate OSI behavior, and just entirely - removing it was incorrect. OSI followers were distracted by being attacked well into - AoS, at very least. - - */ - - public virtual bool CanBeDistracted => !Core.ML; - public override bool ShouldCheckStatTimers => false; public virtual bool CanAngerOnTame => false; @@ -630,43 +1072,27 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public AIType AI { - get => m_CurrentAI; + get => _currentAI; set { - m_CurrentAI = value; + _currentAI = value; - if (m_CurrentAI == AIType.AI_Use_Default) + if (_currentAI == AIType.AI_Use_Default) { - m_CurrentAI = m_DefaultAI; + _currentAI = _defaultAI; } - ChangeAIType(m_CurrentAI); + this.MarkDirty(); + ChangeAIType(_currentAI); } } [CommandProperty(AccessLevel.Administrator)] public bool Debug { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public int Team - { - get => m_Team; - set - { - m_Team = value; - OnTeamChange(); - } - } - [CommandProperty(AccessLevel.GameMaster)] public Mobile FocusMob { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public FightMode FightMode { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangePerception { get; set; } - /// /// How far a chase may stretch before the creature gives up its combatant. Between /// RangePerception and this leash it keeps chasing but may switch to closer targets. @@ -674,61 +1100,17 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public virtual int ChaseLeashRange => RangePerception * 2; - [CommandProperty(AccessLevel.GameMaster)] - public int RangeFight { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangeHome { get; set; } = 10; - - /// Seconds per AI decision while engaged; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveSpeed - { - get => _activeSpeed; - set - { - if (Math.Abs(_activeSpeed - value) > .0001) - { - _activeSpeed = value; - } - } - } - - /// Seconds per AI decision while idle; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveSpeed - { - get => _passiveSpeed; - set - { - _passiveSpeed = value; - if (Math.Abs(_passiveSpeed - value) > .0001) - { - _passiveSpeed = value; - } - } - } - - /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveMoveSpeed - { - get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; - set => _activeMoveSpeed = value > 0 ? value : 0; - } - - /// Seconds per step while idle. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveMoveSpeed - { - get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; - set => _passiveMoveSpeed = value > 0 ? value : 0; - } - // Herded creatures walk at a fixed standard pace regardless of their own speed // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; + /// + /// Seconds per step while closing on the master under a standing order. A cap, not an + /// override: a creature configured faster keeps its own pace. 0 disables it. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual double FollowMoveSpeed => Core.AOS ? 0.1 : 0; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { @@ -736,25 +1118,11 @@ namespace Server.Mobiles set => _targetLocation = value; } - [CommandProperty(AccessLevel.GameMaster)] - public double CurrentSpeed - { - get => _currentSpeed; - set - { - if (Math.Abs(_currentSpeed - value) > 0.0001) - { - _currentSpeed = value; - AIObject?.OnCurrentSpeedChanged(); - } - } - } - /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) - /// stays fused to both clocks. A herded creature is always driven at - /// . + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. A + /// herded creature is always driven at , and a pet + /// closing on its master is capped at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -766,105 +1134,145 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } + double speed; - [CommandProperty(AccessLevel.GameMaster)] - public Point3D Home - { - get => m_Home; - set => m_Home = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public Map HomeMap { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public bool Controlled - { - get => _controlled; - set - { - if (_controlled == value) + if (_currentSpeed == _activeSpeed) { - return; + speed = _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + } + else if (_currentSpeed == _passiveSpeed) + { + speed = _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } + else + { + speed = _currentSpeed; } - _controlled = value; - Delta(MobileDelta.Noto); + var followSpeed = FollowMoveSpeed; - InvalidateProperties(); + return followSpeed > 0 && AIObject?.IsPacingToMaster() == true + ? Math.Min(followSpeed, speed) + : speed; } } + [SerializableProperty(15, useField: nameof(_controlMaster))] + [SaveFlag(nameof(ShouldSerializeControlMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile ControlMaster { - get => m_ControlMaster; + get => _controlMaster; set { - if (m_ControlMaster == value || this == value) + if (_controlMaster == value || this == value) { return; } RemoveFollowers(); - m_ControlMaster = value; + _controlMaster = value; AddFollowers(); - if (m_ControlMaster != null) + if (_controlMaster != null) { StopDeleteTimer(); } Delta(MobileDelta.Noto); + this.MarkDirty(); } } + [SerializableProperty(23, useField: nameof(_summonMaster))] + [SaveFlag(nameof(ShouldSerializeSummonMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => m_SummonMaster; + get => _summonMaster; set { - if (m_SummonMaster == value || this == value) + if (_summonMaster == value || this == value) { return; } RemoveFollowers(); - m_SummonMaster = value; + _summonMaster = value; AddFollowers(); Delta(MobileDelta.Noto); + this.MarkDirty(); } } - [CommandProperty(AccessLevel.GameMaster)] - public Mobile ControlTarget { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D ControlDest { get; set; } - - // Fires on every assignment, not only changes: a reissued order is a command - // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. + // Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor). + // A raw assignment is system-issued; player commands go through IssueOrder. + [SerializableProperty(18, useField: nameof(_controlOrder))] + [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { - get => m_ControlOrder; - set + get => _controlOrder; + set => SetControlOrder(value, null, false); + } + + /// + /// Gives this pet a command. (null = system-issued) is the only mobile + /// revealed; replaces first. + /// + public void IssueOrder(OrderType order, Mobile issuer, Mobile target = null) + { + // The interrupted order owns this; BaseAI.ResumeInterrupted hands it back. + var interrupted = ControlTarget; + ControlTarget = target; + SetControlOrder(order, issuer, false, interrupted); + } + + // Loops until the Issue phase returns an order that rests. `resuming` = falling back to + // the standing order: no re-derivation, no flourish. + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming) => + SetControlOrder(order, issuer, resuming, ControlTarget); + + internal void SetControlOrder(OrderType order, Mobile issuer, bool resuming, Mobile interruptedTarget) + { + var ai = AIObject; + var previous = _controlOrder; + _controlOrder = order; + + if (ai != null) { - var previous = m_ControlOrder; - m_ControlOrder = value; + for (var depth = 0; ; depth++) + { + var next = ai.IssueOrder(order, previous, issuer, resuming, interruptedTarget); - AIObject?.OnCurrentOrderChanged(previous); + // A nested assignment (SetControlMaster(null), Kill()) already resolved itself; it wins. + if (_controlOrder != order || next == order) + { + break; + } - InvalidateProperties(); + System.Diagnostics.Debug.Assert(depth < 8, "pet order resolution did not converge"); - m_ControlMaster?.InvalidateProperties(); + if (depth >= 8) + { + // Non-converging override: rest at the standing order. + _controlOrder = ai.PersistentOrder; + break; + } + + previous = order; + order = next; + issuer = null; // chained resolutions reveal nobody + resuming = true; + _controlOrder = order; + } + + System.Diagnostics.Debug.Assert(Deleted || BaseAI.IsRestableOrder(_controlOrder), "a transient pet order rested"); } + + InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } [CommandProperty(AccessLevel.GameMaster)] @@ -882,39 +1290,19 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public DateTime BardEndTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public double MinTameSkill { get; set; } - + [SerializableProperty(20, useField: nameof(_tamable))] + [SaveFlag(nameof(ShouldSerializeTamable))] [CommandProperty(AccessLevel.GameMaster)] public bool Tamable { - get => m_bTamable && !m_Paragon; - set => m_bTamable = value; - } - - [CommandProperty(AccessLevel.Administrator)] - public bool Summoned - { - get => _summoned; + get => _tamable && !_isParagon; set { - if (_summoned == value) - { - return; - } - - NextReacquireTime = Core.TickCount; - - _summoned = value; - Delta(MobileDelta.Noto); - - InvalidateProperties(); + _tamable = value; + this.MarkDirty(); } } - [CommandProperty(AccessLevel.Administrator)] - public int ControlSlots { get; set; } = 1; - public virtual bool NoHouseRestrictions => false; public virtual bool IsHouseSummonable => false; @@ -945,7 +1333,7 @@ namespace Server.Mobiles // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the // next scan to at most this far away. Zero (paragons) scans on the very next // think; larger is dumber; pure ReacquireDelay is the oblivious floor. - public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); + public virtual TimeSpan AcquireOnApproachDelay => _isParagon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); // Reactive range is tighter than the periodic scan's RangePerception: approach // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. @@ -992,13 +1380,6 @@ namespace Server.Mobiles public virtual bool ReturnsToHome => SeeksHome && Home != Point3D.Zero && !m_ReturnQueued && !Controlled && !Summoned; - // used for deleting untamed creatures [in houses] - [CommandProperty(AccessLevel.GameMaster)] - public bool RemoveIfUntamed { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RemoveStep { get; set; } - public virtual bool CanGiveMLQuest => MLQuests.Count != 0; public virtual bool StaticMLQuester => true; @@ -1006,6 +1387,15 @@ namespace Server.Mobiles public static bool BondingEnabled { get; private set; } + /// + /// Publish 51: a pet told to follow, come, stay or stop "will not attack anything, even + /// if it is attacked". Guard and attack are unaffected. The publish has no step of its own + /// on the expansion ladder, so it rides ML and the setting carries the rest. + /// + public static bool PetsStandDownOnCommand { get; private set; } + + public virtual bool StandsDownOnCommand => PetsStandDownOnCommand; + public virtual bool IsBondable => BondingEnabled && !Summoned; public virtual TimeSpan BondingDelay => TimeSpan.FromDays(7.0); public virtual TimeSpan BondingAbandonDelay => TimeSpan.FromDays(1.0); @@ -1033,114 +1423,25 @@ namespace Server.Mobiles } } - [CommandProperty(AccessLevel.GameMaster)] - public bool IsBonded - { - get => m_IsBonded; - set - { - m_IsBonded = value; - InvalidateProperties(); - } - } - - public bool IsDeadPet { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime BondingBegin { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime OwnerAbandonTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public TimeSpan DeleteTimeLeft { get { - if (m_DeleteTimer?.Running == true) + if (_pendingDeleteTimer?.Running == true) { - return m_DeleteTimer.Next - Core.Now; + return _pendingDeleteTimer.Next - Core.Now; } return TimeSpan.Zero; } } - public override int BasePhysicalResistance => m_PhysicalResistance; - public override int BaseFireResistance => m_FireResistance; - public override int BaseColdResistance => m_ColdResistance; - public override int BasePoisonResistance => m_PoisonResistance; - public override int BaseEnergyResistance => m_EnergyResistance; - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalResistanceSeed - { - get => m_PhysicalResistance; - set - { - m_PhysicalResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int FireResistSeed - { - get => m_FireResistance; - set - { - m_FireResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdResistSeed - { - get => m_ColdResistance; - set - { - m_ColdResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonResistSeed - { - get => m_PoisonResistance; - set - { - m_PoisonResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyResistSeed - { - get => m_EnergyResistance; - set - { - m_EnergyResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalDamage { get; set; } = 100; - - [CommandProperty(AccessLevel.GameMaster)] - public int FireDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyDamage { get; set; } + public override int BasePhysicalResistance => _physicalResistanceSeed; + public override int BaseFireResistance => _fireResistSeed; + public override int BaseColdResistance => _coldResistSeed; + public override int BasePoisonResistance => _poisonResistSeed; + public override int BaseEnergyResistance => _energyResistSeed; [CommandProperty(AccessLevel.GameMaster)] public int ChaosDamage { get; set; } @@ -1148,15 +1449,12 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int DirectDamage { get; set; } - // Is immune to breath damages public virtual bool BreathImmune => false; - public virtual bool CanFlee => !m_Paragon; + public virtual bool CanFlee => !_isParagon; public DateTime EndFleeTime { get; set; } - public List Friends { get; private set; } - public virtual bool AllowNewPetFriend => Friends == null || Friends.Count < 5; public virtual Ethic EthicAllegiance => null; @@ -1208,7 +1506,6 @@ namespace Server.Mobiles public HonorContext ReceivedHonorContext { get; set; } public List MLQuests => - // Assign the quests if we don't have one, and if it is still null, return an empty list (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; public virtual MonsterAbility[] GetMonsterAbilities() => null; @@ -1434,7 +1731,7 @@ namespace Server.Mobiles return false; } - if (m_Team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) + if (_team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) { return true; } @@ -1552,7 +1849,7 @@ namespace Server.Mobiles var chance = Math.Clamp(700 + bonus, 220, 990); - chance -= (MaxLoyalty - m_Loyalty) * 10; + chance -= (MaxLoyalty - _loyalty) * 10; return chance / 1000.0; } @@ -1644,7 +1941,7 @@ namespace Server.Mobiles public override bool CheckPoisonImmunity(Mobile from, Poison poison) => base.CheckPoisonImmunity(from, poison) || - (m_Paragon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; + (_isParagon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; public void Unpacify() { @@ -1652,17 +1949,6 @@ namespace Server.Mobiles BardPacified = false; } - public virtual void CheckDistracted(Mobile from) - { - if (Utility.RandomDouble() < .10) - { - ControlTarget = from; - ControlOrder = OrderType.Attack; - Combatant = from; - Warmode = true; - } - } - public override void OnDamage(int amount, Mobile from, bool willKill) { if (BardPacified && (HitsMax - Hits) * 0.001 > Utility.RandomDouble()) @@ -1671,7 +1957,6 @@ namespace Server.Mobiles } int disruptThreshold; - // NPCs can use bandages too! if (!Core.AOS) { disruptThreshold = 0; @@ -1705,21 +1990,11 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetDamaged(from, amount); - if (!willKill && CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - base.OnDamage(amount, from, willKill); } public virtual void OnDamagedBySpell(Mobile from, int damage) { - if (CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - TriggerAbility(MonsterAbilityTrigger.TakeSpellDamage, from); } @@ -1921,163 +2196,28 @@ namespace Server.Mobiles } } - public override void Serialize(IGenericWriter writer) + // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom. + private void Deserialize(IGenericReader reader, int version) { - base.Serialize(writer); + _currentAI = (AIType)reader.ReadInt(); + _defaultAI = (AIType)reader.ReadInt(); - writer.Write(22); // version + _rangePerception = reader.ReadInt(); + _rangeFight = reader.ReadInt(); - writer.Write((int)m_CurrentAI); - writer.Write((int)m_DefaultAI); - - writer.Write(RangePerception); - writer.Write(RangeFight); - - writer.Write(m_Team); - - writer.Write(_activeSpeed); - writer.Write(_passiveSpeed); - writer.Write(_currentSpeed); - - writer.Write(m_Home.X); - writer.Write(m_Home.Y); - writer.Write(m_Home.Z); - - // Version 1 - writer.Write(RangeHome); - - // Version 2 - writer.Write((int)FightMode); - - writer.Write(_controlled); - writer.Write(m_ControlMaster); - writer.Write(ControlTarget); - writer.Write(ControlDest); - writer.Write((int)m_ControlOrder); - writer.Write(MinTameSkill); - // Removed in version 9 - // writer.Write( (double) m_dMaxTameSkill ); - writer.Write(m_bTamable); - writer.Write(_summoned); - - if (_summoned) - { - writer.WriteAnchoredTime(SummonEnd); - } - - writer.Write(ControlSlots); - - // Version 3 - writer.Write(m_Loyalty); - - // Version 4 - writer.Write(CurrentWayPoint); - - // Verison 5 - writer.Write(m_SummonMaster); - - // Version 6 - writer.Write(HitsMaxSeed); - writer.Write(StamMaxSeed); - writer.Write(ManaMaxSeed); - writer.Write(m_DamageMin); - writer.Write(m_DamageMax); - - // Version 7 - writer.Write(m_PhysicalResistance); - writer.Write(PhysicalDamage); - - writer.Write(m_FireResistance); - writer.Write(FireDamage); - - writer.Write(m_ColdResistance); - writer.Write(ColdDamage); - - writer.Write(m_PoisonResistance); - writer.Write(PoisonDamage); - - writer.Write(m_EnergyResistance); - writer.Write(EnergyDamage); - - // Version 8 - Owners.Tidy(); - writer.Write(Owners); - - // Version 10 - writer.Write(IsDeadPet); - writer.Write(m_IsBonded); - writer.Write(BondingBegin); - writer.Write(OwnerAbandonTime); - - // Version 11 - writer.Write(m_HasGeneratedLoot); - - // Version 12 - writer.Write(m_Paragon); - - var hasFriends = Friends?.Count > 0; - - // Version 13 - writer.Write(hasFriends); - - if (hasFriends) - { - Friends.Tidy(); - writer.Write(Friends); - } - - // Version 14 - writer.Write(RemoveIfUntamed); - writer.Write(RemoveStep); - - // Version 17 - if (IsStabled || Controlled && ControlMaster != null) - { - writer.Write(TimeSpan.Zero); - } - else - { - writer.Write(DeleteTimeLeft); - } - - // Version 18 - writer.Write(CorpseNameOverride); - - // Version 19 - writer.Write(HomeMap); - - // Version 22 (0 = inherit the matching think value) - writer.Write(_activeMoveSpeed); - writer.Write(_passiveMoveSpeed); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - NextReacquireTime = Core.TickCount; - - var version = reader.ReadInt(); - - m_CurrentAI = (AIType)reader.ReadInt(); - m_DefaultAI = (AIType)reader.ReadInt(); - - RangePerception = reader.ReadInt(); - RangeFight = reader.ReadInt(); - - m_Team = reader.ReadInt(); + _team = reader.ReadInt(); _activeSpeed = reader.ReadDouble(); _passiveSpeed = reader.ReadDouble(); _currentSpeed = reader.ReadDouble(); - m_Home.X = reader.ReadInt(); - m_Home.Y = reader.ReadInt(); - m_Home.Z = reader.ReadInt(); + _home.X = reader.ReadInt(); + _home.Y = reader.ReadInt(); + _home.Z = reader.ReadInt(); if (version >= 1) { - RangeHome = reader.ReadInt(); + _rangeHome = reader.ReadInt(); if (version < 20) { @@ -2098,121 +2238,121 @@ namespace Server.Mobiles } else { - RangeHome = 0; + _rangeHome = 0; } if (version >= 2) { - FightMode = (FightMode)reader.ReadInt(); + _fightMode = (FightMode)reader.ReadInt(); _controlled = reader.ReadBool(); - m_ControlMaster = reader.ReadEntity(); - ControlTarget = reader.ReadEntity(); - ControlDest = reader.ReadPoint3D(); - m_ControlOrder = (OrderType)reader.ReadInt(); + _controlMaster = reader.ReadEntity(); + _controlTarget = reader.ReadEntity(); + _controlDest = reader.ReadPoint3D(); + _controlOrder = (OrderType)reader.ReadInt(); - MinTameSkill = reader.ReadDouble(); + _minTameSkill = reader.ReadDouble(); if (version < 9) { reader.ReadDouble(); } - m_bTamable = reader.ReadBool(); + _tamable = reader.ReadBool(); _summoned = reader.ReadBool(); if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - new UnsummonTimer(this, SummonEnd - Core.Now).Start(); + // The UnsummonTimer is restarted in AfterDeserialization. + _summonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); } - ControlSlots = reader.ReadInt(); + _controlSlots = reader.ReadInt(); } else { - FightMode = FightMode.Closest; + _fightMode = FightMode.Closest; _controlled = false; - m_ControlMaster = null; - ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlMaster = null; + _controlTarget = null; + _controlOrder = OrderType.None; } if (version >= 3) { - m_Loyalty = reader.ReadInt(); + _loyalty = reader.ReadInt(); } else { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; } if (version >= 4) { - CurrentWayPoint = reader.ReadEntity(); + _currentWayPoint = reader.ReadEntity(); } if (version >= 5) { - m_SummonMaster = reader.ReadEntity(); + _summonMaster = reader.ReadEntity(); } if (version >= 6) { - HitsMaxSeed = reader.ReadInt(); - StamMaxSeed = reader.ReadInt(); - ManaMaxSeed = reader.ReadInt(); - m_DamageMin = reader.ReadInt(); - m_DamageMax = reader.ReadInt(); + _hitsMaxSeed = reader.ReadInt(); + _stamMaxSeed = reader.ReadInt(); + _manaMaxSeed = reader.ReadInt(); + _damageMin = reader.ReadInt(); + _damageMax = reader.ReadInt(); } if (version >= 7) { - m_PhysicalResistance = reader.ReadInt(); - PhysicalDamage = reader.ReadInt(); + _physicalResistanceSeed = reader.ReadInt(); + _physicalDamage = reader.ReadInt(); - m_FireResistance = reader.ReadInt(); - FireDamage = reader.ReadInt(); + _fireResistSeed = reader.ReadInt(); + _fireDamage = reader.ReadInt(); - m_ColdResistance = reader.ReadInt(); - ColdDamage = reader.ReadInt(); + _coldResistSeed = reader.ReadInt(); + _coldDamage = reader.ReadInt(); - m_PoisonResistance = reader.ReadInt(); - PoisonDamage = reader.ReadInt(); + _poisonResistSeed = reader.ReadInt(); + _poisonDamage = reader.ReadInt(); - m_EnergyResistance = reader.ReadInt(); - EnergyDamage = reader.ReadInt(); + _energyResistSeed = reader.ReadInt(); + _energyDamage = reader.ReadInt(); } if (version >= 8) { - Owners = reader.ReadEntityList(); + _owners = reader.ReadEntityList(); } else { - Owners = new List(); + _owners = new List(); } if (version >= 10) { - IsDeadPet = reader.ReadBool(); - m_IsBonded = reader.ReadBool(); - BondingBegin = reader.ReadDateTime(); - OwnerAbandonTime = reader.ReadDateTime(); + _isDeadPet = reader.ReadBool(); + _isBonded = reader.ReadBool(); + _bondingBegin = reader.ReadDateTime(); + _ownerAbandonTime = reader.ReadDateTime(); } - m_HasGeneratedLoot = version < 11 || reader.ReadBool(); + _hasGeneratedLoot = version < 11 || reader.ReadBool(); - m_Paragon = version >= 12 && reader.ReadBool(); + _isParagon = version >= 12 && reader.ReadBool(); if (version >= 13 && reader.ReadBool()) { - Friends = reader.ReadEntityList(); + _friends = reader.ReadEntityList(); } - else if (version < 13 && m_ControlOrder >= OrderType.Unfriend) + else if (version < 13 && _controlOrder >= OrderType.Unfriend) { - ++m_ControlOrder; + ++_controlOrder; } if (version < 16 && Loyalty != MaxLoyalty) @@ -2222,8 +2362,8 @@ namespace Server.Mobiles if (version >= 14) { - RemoveIfUntamed = reader.ReadBool(); - RemoveStep = reader.ReadInt(); + _removeIfUntamed = reader.ReadBool(); + _removeStep = reader.ReadInt(); } var deleteTime = TimeSpan.Zero; @@ -2240,18 +2380,18 @@ namespace Server.Mobiles deleteTime = TimeSpan.FromDays(3.0); } - m_DeleteTimer = new DeleteTimer(this, deleteTime); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, deleteTime); + _pendingDeleteTimer.Start(); } if (version >= 18) { - CorpseNameOverride = reader.ReadString(); + _corpseNameOverride = reader.ReadString(); } if (version >= 19) { - HomeMap = reader.ReadMap(); + _homeMap = reader.ReadMap(); } if (version >= 22) @@ -2264,25 +2404,61 @@ namespace Server.Mobiles MigrateMoveSpeeds(); } - if (version <= 14 && m_Paragon && Hue == 0x31) + if (version <= 14 && _isParagon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. } + } + + [AfterDeserialization] + private void AfterDeserialization() + { + NextReacquireTime = Core.TickCount; + + if (_activeSpeed <= 0 || _passiveSpeed <= 0) + { + if (!_loggedMissingSpeeds) + { + _loggedMissingSpeeds = true; + logger.Error( + "{Type} loaded without speeds - is Data/npc-speeds.json missing or changed? Pacing at {Active}/{Passive}.", + GetType(), + FallbackActiveSpeed, + FallbackPassiveSpeed + ); + } + + _activeSpeed = FallbackActiveSpeed; + _passiveSpeed = FallbackPassiveSpeed; + _currentSpeed = _passiveSpeed; + } if (Core.AOS && NameHue == 0x35) { NameHue = -1; } + if (_summoned) + { + new UnsummonTimer(this, _summonEnd - Core.Now).Start(); + } + + // An abandoned pet with no persisted countdown still despawns. + if (_pendingDeleteTimer == null && LastOwner != null && !_controlled && !IsStabled) + { + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + } + CheckStatTimers(); - ChangeAIType(m_CurrentAI); + ChangeAIType(_currentAI); AddFollowers(); if (IsAnimatedDead) { - AnimateDeadSpell.Register(m_SummonMaster, this); + AnimateDeadSpell.Register(_summonMaster, this); } } @@ -2337,8 +2513,7 @@ namespace Server.Mobiles return true; } - // Note: Yes, this happens for all questers (regardless of type, e.g. escorts), - // even if they can't offer you anything at the moment + // Happens for all questers, even those with nothing to offer right now. if (MLQuestSystem.Enabled && CanGiveMLQuest && from is PlayerMobile mobile) { // You need to mark your quest items so I don't take the wrong object. Then speak to me. @@ -2369,7 +2544,7 @@ namespace Server.Mobiles AIType.AI_Vendor => new VendorAI(this), AIType.AI_Mage => new MageAI(this), AIType.AI_Predator => - // m_AI = new PredatorAI(this); + //TODO Implement PredatorAI new MeleeAI(this), AIType.AI_Thief => new ThiefAI(this), _ => null @@ -2389,7 +2564,7 @@ namespace Server.Mobiles public void RemoveFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers -= Math.Min(ControlSlots, master.Followers); @@ -2403,7 +2578,7 @@ namespace Server.Mobiles public void AddFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers += ControlSlots; @@ -2449,7 +2624,7 @@ namespace Server.Mobiles public virtual void OnGaveMeleeAttack(Mobile defender, int damage) { - var p = m_Paragon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; + var p = _isParagon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; if (p != null && HitPoisonChance >= Utility.RandomDouble()) { @@ -2478,17 +2653,13 @@ namespace Server.Mobiles AIObject = null; } - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); FocusMob = null; if (IsAnimatedDead) { - AnimateDeadSpell.Unregister(m_SummonMaster, this); + AnimateDeadSpell.Unregister(_summonMaster, this); } if (Summoned && SummonMaster != null) @@ -2507,13 +2678,6 @@ namespace Server.Mobiles base.OnAfterDelete(); } - /* - * This function can be overridden.. so a "Strongest" mobile, can have a different definition depending - * on who check for value - * -Could add a FightMode.Preferred - * - */ - public virtual double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) { if (bPlayerOnly && !m.Player) @@ -2529,8 +2693,7 @@ namespace Server.Mobiles }; } - // Turn, - for left, + for right - // Basic for now, needs work + // Turn: negative = left, positive = right. public virtual void Turn(int iTurnSteps) { var v = (int)Direction; @@ -2547,7 +2710,7 @@ namespace Server.Mobiles public bool IsHurt() => Hits != HitsMax; - public double GetHomeDistance() => this.GetDistanceToSqrt(m_Home); + public double GetHomeDistance() => this.GetDistanceToSqrt(_home); public virtual int GetTeamSize(int iRange) { @@ -2574,11 +2737,11 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var ct = m_ControlOrder; + var ct = _controlOrder; if (AIObject != null) { - if (!Core.ML || ct != OrderType.Follow && ct != OrderType.Stop && ct != OrderType.Stay) + if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct)) { AIObject.OnAggressiveAction(aggressor); } @@ -2604,11 +2767,10 @@ namespace Server.Mobiles } } - if (aggressor.ChangingCombatant && (_controlled || _summoned) && - (ct == OrderType.Come || !Core.ML && ct == OrderType.Stay || ct is OrderType.Stop or OrderType.None or OrderType.Follow)) + // Only reachable when the pet does not stand down: the orders above returned early. + if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct)) { - ControlTarget = aggressor; - ControlOrder = OrderType.Attack; + IssueOrder(OrderType.Attack, null, aggressor); } else if (Combatant == null && !BardPacified) { @@ -2648,7 +2810,7 @@ namespace Server.Mobiles AIObject?.GetContextMenuEntries(from, ref list); } - if (m_bTamable && !_controlled && from.Alive) + if (_tamable && !_controlled && from.Alive) { list.Add(new TameEntry(from.Female ? AllowFemaleTamer : AllowMaleTamer)); } @@ -2699,7 +2861,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - (!Controlled || target != m_ControlMaster) && (!Summoned || target != m_SummonMaster) && + (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2709,13 +2871,13 @@ namespace Server.Mobiles if (Controlled || Summoned) { - if (m_ControlMaster?.Player == true) + if (_controlMaster?.Player == true) { - m_ControlMaster.CriminalAction(false); + _controlMaster.CriminalAction(false); } - else if (m_SummonMaster?.Player == true) + else if (_summonMaster?.Player == true) { - m_SummonMaster.CriminalAction(false); + _summonMaster.CriminalAction(false); } } } @@ -2724,7 +2886,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == m_ControlMaster || target == m_SummonMaster || !Controlled && !Summoned) + if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned) { return; } @@ -2774,12 +2936,11 @@ namespace Server.Mobiles { if (Combatant != null) { - return false; // in combat.. not idling + return false; // in combat, not idling } if (m_IdleReleaseTime > DateTime.MinValue) { - // idling... if (Core.Now >= m_IdleReleaseTime) { m_IdleReleaseTime = DateTime.MinValue; @@ -2791,7 +2952,7 @@ namespace Server.Mobiles if (Utility.Random(100) < 95) { - return false; // not idling, but don't want to enter idle state + return false; // chose not to enter the idle state } var idleSeconds = Utility.RandomMinMax(NPCSpeeds.MinIdleSeconds, NPCSpeeds.MaxIdleSeconds); @@ -2831,12 +2992,6 @@ namespace Server.Mobiles return true; // entered idle state } - /* - this way, due to the huge number of locations this will have to be changed - Perhaps we can change this in the future when fixing game play is not the - major issue. - */ - public virtual void CheckedAnimate(int action, int frameCount, int repeatCount, bool forward, bool repeat, int delay) { if (!Mounted) @@ -2899,7 +3054,7 @@ namespace Server.Mobiles SpeechType?.OnMovement(this, m, oldLocation); - /* Begin notice sound */ + // Notice sound if ((!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Player && FightMode != FightMode.Aggressor && FightMode != FightMode.None && Combatant == null && !Controlled && !Summoned && !BardPacified && InRange(m.Location, 18) && !InRange(oldLocation, 18)) @@ -2911,7 +3066,6 @@ namespace Server.Mobiles PlaySound(GetAngerSound()); } - /* End notice sound */ if (MLQuestSystem.Enabled && CanShout && m is PlayerMobile mobile) { @@ -2985,7 +3139,7 @@ namespace Server.Mobiles list.Add(TotalWeight == 1 ? 1072788 : 1072789, TotalWeight); // Weight: ~1_WEIGHT~ stones } - if (m_ControlOrder == OrderType.Guard) + if (_controlOrder == OrderType.Guard) { list.Add(1080078); // guarding } @@ -2997,7 +3151,7 @@ namespace Server.Mobiles } else if (Controlled && Commandable) { - // Intentional difference (showing ONLY bonded when bonded instead of bonded & tame) + // Deliberate: show only (bonded), never (bonded) and (tame) together. if (IsBonded) { list.Add(1049608); // (bonded) @@ -3059,7 +3213,7 @@ namespace Server.Mobiles { if (treasureLevel >= 0) { - if (m_Paragon && Paragon.ChestChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChestChance > Utility.RandomDouble()) { PackItem(new ParagonChest(Name, treasureLevel)); } @@ -3069,7 +3223,7 @@ namespace Server.Mobiles } } - if (m_Paragon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) { switch (Utility.Random(4)) { @@ -3097,9 +3251,10 @@ namespace Server.Mobiles } } - if (!Summoned && !NoKillAwards && !m_HasGeneratedLoot) + if (!Summoned && !NoKillAwards && !_hasGeneratedLoot) { - m_HasGeneratedLoot = true; + _hasGeneratedLoot = true; + this.MarkDirty(); GenerateLoot(false); } @@ -3291,7 +3446,7 @@ namespace Server.Mobiles MondainsLegacy.GiveArtifactTo(mob); } } - else if (m_Paragon) + else if (_isParagon) { if (Paragon.CheckArtifactChance(mob, this)) { @@ -3300,11 +3455,13 @@ namespace Server.Mobiles } } - [GeneratedEvent(nameof(CreatureDeathEvent))] - public static partial void CreatureDeathEvent(BaseCreature bc); - public override void OnDeath(Container c) { + if (Spawner is BaseSpawner spawner) + { + spawner.NotifySpawnedDeath(this, LastKiller); + } + if (IsBonded) { Effects.PlaySound(this, GetDeathSound()); @@ -3319,13 +3476,11 @@ namespace Server.Mobiles Mana = 0; IsDeadPet = true; - ControlTarget = ControlMaster; - ControlOrder = OrderType.Follow; + IssueOrder(OrderType.Follow, null, ControlMaster); ProcessDelta(); SendIncomingPacket(); - // TODO: This can be done in Parallel if there are lots of them. var aggressors = Aggressors; for (var i = 0; i < aggressors.Count; ++i) @@ -3365,7 +3520,7 @@ namespace Server.Mobiles OwnerAbandonTime = DateTime.MinValue; } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); CheckStatTimers(); return; @@ -3399,7 +3554,6 @@ namespace Server.Mobiles if (ds.m_Mobile == killer) { - // If the titles system gets feature flagged, it will be supported titles.Add(ds.m_Mobile); fame.Add(totalFame); karma.Add(totalKarma); @@ -3495,17 +3649,14 @@ namespace Server.Mobiles c.Delete(); } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); } - [GeneratedEvent(nameof(CreatureDeletedEvent))] - public static partial void CreatureDeletedEvent(BaseCreature bc); - public override void OnDelete() { - CreatureDeletedEvent(this); + CreatureEvents.CreatureDeletedEvent(this); - var m = m_ControlMaster; + var m = _controlMaster; SetControlMaster(null); SummonMaster = null; @@ -3578,12 +3729,7 @@ namespace Server.Mobiles ControlTarget = null; ControlOrder = OrderType.Come; - - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); } Guild = null; @@ -3815,7 +3961,7 @@ namespace Server.Mobiles { // *rummages through a corpse and takes an item* PublicOverheadMessage(MessageType.Emote, 0x3B2, 1008086); - // TODO: Instancing of Rummaged stuff. + //TODO Instance rummaged loot return true; } } @@ -3836,14 +3982,14 @@ namespace Server.Mobiles return BardMaster; } - if (_controlled && m_ControlMaster != null) + if (_controlled && _controlMaster != null) { - return m_ControlMaster; + return _controlMaster; } - if (_summoned && m_SummonMaster != null) + if (_summoned && _summonMaster != null) { - return m_SummonMaster; + return _summonMaster; } return base.GetDamageMaster(damagee); @@ -4108,6 +4254,7 @@ namespace Server.Mobiles public static void Configure() { BondingEnabled = ServerConfiguration.GetSetting("taming.enableBonding", Core.LBR); + PetsStandDownOnCommand = ServerConfiguration.GetSetting("taming.petsStandDownOnCommand", Core.ML); } public void BeginDeleteTimer() @@ -4115,19 +4262,13 @@ namespace Server.Mobiles if (this is not BaseEscortable && !Summoned && !Deleted && !IsStabled) { StopDeleteTimer(); - m_DeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + this.MarkDirty(); } } - public void StopDeleteTimer() - { - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } - } + public void StopDeleteTimer() => StopPendingDeleteTimer(); public void SpillAcid(int amount) { @@ -4161,11 +4302,7 @@ namespace Server.Mobiles } } - /* - Solen Style, override me for other mobiles/items: - kappa+acidslime, grizzles+whatever, etc. - */ - + // Solen-style acid; override for other harmful drops (kappa slime, etc.). public virtual Item NewHarmfulItem() => new Acid(TimeSpan.FromSeconds(10), 30, 30); public virtual void StopFlee() @@ -4198,15 +4335,23 @@ namespace Server.Mobiles public virtual void AddPetFriend(Mobile m) { - Friends ??= new List(); - + Friends ??= []; Friends.Add(m); + this.MarkDirty(); } - public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); + public virtual void RemovePetFriend(Mobile m) + { + if (Friends?.Remove(m) == true) + { + this.MarkDirty(); + } + } + + public virtual void ClearPetFriends() => Friends = null; // generated setter marks dirty public virtual bool IsFriend(Mobile m) => - OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && m_Team == c.m_Team + OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team && (_summoned || _controlled) == (c._summoned || c._controlled); public virtual Allegiance GetFactionAllegiance(Mobile mob) @@ -4398,16 +4543,17 @@ namespace Server.Mobiles if (Core.SE) { - m_Loyalty = MaxLoyalty; + _loyalty = MaxLoyalty; + this.MarkDirty(); } - else if (m_Loyalty < MaxLoyalty) + else if (_loyalty < MaxLoyalty) { - // Calculate the loyalty increase var loyaltyIncrease = Utility.CoinFlips(amount, MaxLoyaltyIncrease) * 10; - if (loyaltyIncrease > 0) // Only update if there's an actual increase + if (loyaltyIncrease > 0) { - m_Loyalty = Math.Min(MaxLoyalty, m_Loyalty + loyaltyIncrease); + _loyalty = Math.Min(MaxLoyalty, _loyalty + loyaltyIncrease); + this.MarkDirty(); SayTo(from, 502060); // Your pet looks happier. } } @@ -4423,7 +4569,7 @@ namespace Server.Mobiles if (IsBondable && !IsBonded) { - var master = m_ControlMaster; + var master = _controlMaster; if (master != null && master == from) // So friends can't start the bonding process { @@ -4610,7 +4756,6 @@ namespace Server.Mobiles } } - /* Sanity check */ if (baseToSet > theirSkill.CapFixedPoint || m.Skills.Total - theirSkill.BaseFixedPoint + baseToSet > m.Skills.Cap) { @@ -4721,6 +4866,7 @@ namespace Server.Mobiles { _activeMoveSpeed = 0; _passiveMoveSpeed = 0; + this.MarkDirty(); } /// @@ -4738,6 +4884,8 @@ namespace Server.Mobiles { _passiveMoveSpeed *= scalar; } + + this.MarkDirty(); } /// @@ -4767,6 +4915,8 @@ namespace Server.Mobiles { _passiveMoveSpeed = passiveMoveSpeed; } + + this.MarkDirty(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -4775,16 +4925,13 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToPassive() => CurrentSpeed = PassiveSpeed; - public void SetDamage(int val) - { - m_DamageMin = val; - m_DamageMax = val; - } + public void SetDamage(int val) => SetDamage(val, val); public void SetDamage(int min, int max) { - m_DamageMin = min; - m_DamageMax = max; + _damageMin = min; + _damageMax = max; + this.MarkDirty(); } public void SetHits(int val) @@ -4918,31 +5065,32 @@ namespace Server.Mobiles { case ResistanceType.Physical: { - m_PhysicalResistance = val; + _physicalResistanceSeed = val; break; } case ResistanceType.Fire: { - m_FireResistance = val; + _fireResistSeed = val; break; } case ResistanceType.Cold: { - m_ColdResistance = val; + _coldResistSeed = val; break; } case ResistanceType.Poison: { - m_PoisonResistance = val; + _poisonResistSeed = val; break; } case ResistanceType.Energy: { - m_EnergyResistance = val; + _energyResistSeed = val; break; } } + this.MarkDirty(); UpdateResistances(); } @@ -5077,20 +5225,39 @@ namespace Server.Mobiles // If this needs to be serialized, recommend creating a hash or registry id. Don't serialize strings. public virtual SpeedLevel SpeedClass => SpeedLevel.None; + // Cached: the speed SaveFlags consult this on every save and elided load. + private NPCSpeeds.SpeedClassEntry _speedEntry; + + private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(this); + + // Never throws: the speed SaveFlags call this on every save and elided load. Without a + // table entry the serialized speeds stand; only the constructor refuses. public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { - NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); + var entry = SpeedEntry; + + if (entry == null) + { + activeSpeed = _activeSpeed; + passiveSpeed = _passiveSpeed; + return; + } + + activeSpeed = entry.ActiveSpeed; + passiveSpeed = entry.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates an unloaded table. public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) { - NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + var entry = SpeedEntry; + + activeMoveSpeed = entry?.ActiveMoveSpeed ?? 0; + passiveMoveSpeed = entry?.PassiveMoveSpeed ?? 0; } - // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds - // still match what it would spawn with today was never hand-tuned: adopt today's - // move values so existing worlds (and pets) pick up npc-speeds pacing without a - // respawn. Tuned creatures keep movement inheriting their think clock. + // Pre-v22 saves have no movement clock: untuned creatures adopt the table's move + // values, hand-tuned ones keep inheriting. internal void MigrateMoveSpeeds() { GetSpeeds(out var activeSpeed, out var passiveSpeed); @@ -5139,7 +5306,7 @@ namespace Server.Mobiles GenerateLoot(); - if (m_Paragon) + if (_isParagon) { if (Fame < 1250) { @@ -5539,8 +5706,6 @@ namespace Server.Mobiles var onSelf = patient == this; - // DoBeneficial( patient ); - RevealingAction(); if (!onSelf) @@ -5583,7 +5748,7 @@ namespace Server.Mobiles { patient.SendLocalizedMessage(1010059); // You have been cured of all poisons. - CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); // TODO: Verify formula + CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); //TODO Verify formula CheckSkill(SkillName.Anatomy, 0.0, 100.0); } } @@ -5782,7 +5947,6 @@ namespace Server.Mobiles using var toRelease = PooledRefQueue.Create(); - // added array for wild creatures in house regions to be removed using var toRemove = PooledRefQueue.Create(); foreach (var m in World.Mobiles.Values) @@ -5840,7 +6004,7 @@ namespace Server.Mobiles } } - // added lines to check if a wild creature in a house region has to be removed or not + // Wild creatures squatting in houses are removed outright. if (!c.Controlled && !c.IsStabled && (c.Region.IsPartOf() && c.CanBeDamaged() || c.RemoveIfUntamed && c.Spawner == null)) { @@ -5862,14 +6026,8 @@ namespace Server.Mobiles var c = toRelease.Dequeue(); c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! - c.Loyalty = BaseCreature.MaxLoyalty; // Wonderfully Happy - c.IsBonded = false; - c.BondingBegin = DateTime.MinValue; - c.OwnerAbandonTime = DateTime.MinValue; - c.ControlTarget = null; - // This will prevent no release of creatures left alone with AI disabled (and consequent bug of Followers) - c.AIObject.DoOrderRelease(); - c.DropBackpack(); + c.Loyalty = BaseCreature.MaxLoyalty; + c.ControlOrder = OrderType.Release; } while (toRemove.Count > 0) diff --git a/Projects/UOContent/Mobiles/CreatureEvents.cs b/Projects/UOContent/Mobiles/CreatureEvents.cs new file mode 100644 index 000000000..848e9de2c --- /dev/null +++ b/Projects/UOContent/Mobiles/CreatureEvents.cs @@ -0,0 +1,13 @@ +using ModernUO.CodeGeneratedEvents; + +namespace Server.Mobiles; + +// Hosts BaseCreature's generated events: two generators cannot both emit [GeneratedCode] on one type (CS0579). +public static partial class CreatureEvents +{ + [GeneratedEvent(nameof(CreatureDeathEvent))] + public static partial void CreatureDeathEvent(BaseCreature bc); + + [GeneratedEvent(nameof(CreatureDeletedEvent))] + public static partial void CreatureDeletedEvent(BaseCreature bc); +} diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index 178436fb9..fca41ec25 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -154,7 +154,7 @@ namespace Server.Mobiles public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m); [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] - [OnEvent(nameof(CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] public static void StopEffect(Mobile m, bool message = false) { if (m_Table.Remove(m, out var timer)) diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs index 7706115f5..ff40211e8 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs @@ -93,8 +93,6 @@ namespace Server.Mobiles public override FoodType FavoriteFood => FoodType.None; - public override bool CanBeDistracted => false; - public override string DefaultName => "a golem"; public override bool DeleteOnRelease => true; diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs index 8c7984360..05fc563b7 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeast.cs @@ -21,7 +21,11 @@ namespace Server.Mobiles public int DevourGoal { get => IsParagon ? _devourGoal + 25 : _devourGoal; - set => _devourGoal = value; + set + { + _devourGoal = value; + this.MarkDirty(); + } } [Constructible] diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs index bbcef613e..0fd0c5bb3 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/PlagueBeastLord.cs @@ -52,9 +52,9 @@ namespace Server.Mobiles VirtualArmor = 50; } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(3)] - public Mobile OpenedBy { get; set; } + [SerializedCommandProperty(AccessLevel.GameMaster)] + [SerializableField(3)] + private Mobile _openedBy; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs index 0d63142f2..9c174f07a 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/SerpentineDragon.cs @@ -86,9 +86,7 @@ namespace Server.Mobiles if (!Core.SE && Utility.RandomDouble() < 0.2 && attacker is BaseCreature c && c.Controlled && c.ControlMaster != null) { - c.ControlTarget = c.ControlMaster; - c.ControlOrder = OrderType.Attack; - c.Combatant = c.ControlMaster; + c.IssueOrder(OrderType.Attack, null, c.ControlMaster); } } } diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 8f5e908bb..44489ba6b 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -26,32 +26,16 @@ public static class NPCSpeeds public static int MinIdleSeconds { get; private set; } public static int MaxIdleSeconds { get; private set; } - public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed) + // Null when the table is unloaded (test fixtures). Immutable after Configure, so creatures cache it. + public static SpeedClassEntry FindEntry(BaseCreature bc) { if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && !_speedsByType.TryGetValue(bc.GetType(), out sp)) { - sp = _speedsByLevel[SpeedLevel.Medium]; + _speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp); } - activeSpeed = sp.ActiveSpeed; - passiveSpeed = sp.PassiveSpeed; - } - - // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. - public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) - { - if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && - !_speedsByType.TryGetValue(bc.GetType(), out sp) && - !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) - { - activeMoveSpeed = 0; - passiveMoveSpeed = 0; - return; - } - - activeMoveSpeed = sp.ActiveMoveSpeed; - passiveMoveSpeed = sp.PassiveMoveSpeed; + return sp; } public static void RegisterSpeed(SpeedClassEntry entry) diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 8328415e8..bef4e9ff3 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -199,7 +199,7 @@ namespace Server.Mobiles VisibilityList = new List(); PermaFlags = new List(); - BOBFilter = new BOBFilter(); + BOBFilter = new BOBFilter(this); m_GameTime = TimeSpan.Zero; m_GuildRank = RankDefinition.Lowest; @@ -2958,7 +2958,7 @@ namespace Server.Mobiles case 13: // just removed m_PaidInsurance list case 12: { - BOBFilter = new BOBFilter(); + BOBFilter = new BOBFilter(this); BOBFilter.Deserialize(reader); goto case 11; } @@ -3081,7 +3081,7 @@ namespace Server.Mobiles } PermaFlags ??= new List(); - BOBFilter ??= new BOBFilter(); + BOBFilter ??= new BOBFilter(this); // Default to member if going from older version to new version (only time it should be null) m_GuildRank ??= RankDefinition.Member; @@ -3602,7 +3602,7 @@ namespace Server.Mobiles { pet.SetControlMaster(this); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = this; } diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index 485d56c1d..a6467746b 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.BulkOrders; @@ -24,7 +25,8 @@ namespace Server.Mobiles ThighBoots } - public abstract class BaseVendor : BaseCreature, IVendor + [SerializationGenerator(2, false)] + public abstract partial class BaseVendor : BaseCreature, IVendor { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor)); private const int MaxSell = 500; @@ -119,6 +121,8 @@ namespace Server.Mobiles public virtual bool IsTokunoVendor => Map == Map.Tokuno; + public virtual bool IsTerMurVendor => Map == Map.TerMur; + public virtual VendorShoeType ShoeType => VendorShoeType.Shoes; public DateTime LastRestock { get; set; } @@ -1297,109 +1301,28 @@ namespace Server.Mobiles Region.GetRegion()?.CheckVendorAccess(this, from) != false || Region != from.Region && from.Region.GetRegion()?.CheckVendorAccess(this, from) != false; - public override void Serialize(IGenericWriter writer) + [AfterDeserialization] + private void AfterDeserialization() { - base.Serialize(writer); - - writer.Write(1); // version - - var sbInfos = SBInfos; - - for (var i = 0; i < sbInfos?.Count; ++i) - { - var sbInfo = sbInfos[i]; - var buyInfo = sbInfo.BuyInfo; - - for (var j = 0; j < buyInfo?.Count; ++j) - { - var gbi = buyInfo[j]; - - var maxAmount = gbi.MaxAmount; - - var doubled = maxAmount switch - { - 40 => 1, - 80 => 2, - 160 => 3, - 320 => 4, - 640 => 5, - 999 => 6, - _ => 0 - }; - - if (doubled > 0) - { - writer.WriteEncodedInt(1 + j * sbInfos.Count + i); - writer.WriteEncodedInt(doubled); - } - } - } - - writer.WriteEncodedInt(0); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - var version = reader.ReadInt(); - LoadSBInfo(); - var sbInfos = SBInfos; - - switch (version) - { - case 1: - { - int index; - - while ((index = reader.ReadEncodedInt()) > 0) - { - var doubled = reader.ReadEncodedInt(); - - if (sbInfos != null) - { - index -= 1; - var sbInfoIndex = index % sbInfos.Count; - var buyInfoIndex = index / sbInfos.Count; - - if (sbInfoIndex >= 0 && sbInfoIndex < sbInfos.Count) - { - var sbInfo = sbInfos[sbInfoIndex]; - var buyInfo = sbInfo.BuyInfo; - - if (buyInfo != null && buyInfoIndex >= 0 && buyInfoIndex < buyInfo.Count) - { - var gbi = buyInfo[buyInfoIndex]; - - var amount = doubled switch - { - 1 => 40, - 2 => 80, - 3 => 160, - 4 => 320, - 5 => 640, - 6 => 999, - _ => 20 - }; - - gbi.Amount = gbi.MaxAmount = amount; - } - } - } - } - - break; - } - } - if (IsParagon) { IsParagon = false; } } + // Version 1 persisted which buy entries had grown restock amounts, packed by index into + // the live SBInfos tables. Restock is transient now: it rebuilds from SBInfos on load, so + // the pairs are read and discarded. + private void Deserialize(IGenericReader reader, int version) + { + while (reader.ReadEncodedInt() > 0) + { + reader.ReadEncodedInt(); + } + } + public override void AddCustomContextEntries(Mobile from, ref PooledRefList list) { if (from.Alive && IsActiveVendor) @@ -1488,7 +1411,7 @@ namespace Server.Mobiles else { // An offer may be available in about ~1_hours~ hours. - vendor.SayTo(vendor, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}"); + vendor.SayTo(from, 1049039, $"{Math.Ceiling(totalSeconds / 3600):F0}"); } vendor.SpeechHue = oldSpeechHue; diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index 847209869..b3fb4d721 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -357,7 +357,7 @@ namespace Server.Mobiles { pet.SetControlMaster(from); - if (pet.Summoned) + if (pet.SummonMaster != null) { pet.SummonMaster = from; } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs index 0c072141c..27430a302 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Scribe.cs @@ -29,6 +29,12 @@ namespace Server.Mobiles public override void InitSBInfo() { m_SBInfos.Add(new SBScribe()); + + if (Core.SE && IsTokunoVendor || Core.SA && IsTerMurVendor) + { + m_SBInfos.Add(new SBSamurai()); + m_SBInfos.Add(new SBNinja()); + } } public override void InitOutfit() diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 9b8f5a2f3..6dc44c973 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -188,7 +188,7 @@ public partial class PlayerVendor : Mobile for (var i = 0; i < count; i++) { var item = reader.ReadEntity(); - var vi = new VendorItem(); + var vi = new VendorItem(this); vi.Deserialize(reader); _sellItems[item] = vi; } @@ -447,7 +447,7 @@ public partial class PlayerVendor : Mobile { RemoveVendorItem(item); - var vi = new VendorItem(item, price, description, created); + var vi = new VendorItem(this, item, price, description, created); ReplaceInSellItems(item, vi); item.InvalidateProperties(); diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 99d1446e6..4c1e12467 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -7,6 +7,9 @@ namespace Server.Mobiles; [SerializationGenerator(0, false)] public partial class VendorItem { + [DirtyTrackingEntity] + private PlayerVendor _vendor; + [SerializableField(0)] private Item _item; @@ -16,12 +19,13 @@ public partial class VendorItem [SerializableField(3)] private DateTime _created; - public VendorItem() - { - } + // The generator deserializes dictionary values through this constructor so every entry + // knows its vendor. + public VendorItem(PlayerVendor vendor) => _vendor = vendor; - public VendorItem(Item item, int price, string description, DateTime created) + public VendorItem(PlayerVendor vendor, Item item, int price, string description, DateTime created) { + _vendor = vendor; _item = item; _price = price; _description = description ?? ""; diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index 19e9c020c..212a60386 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -31,6 +31,7 @@ namespace Server.Multis private DecayLevel m_CurrentStage; private DecayLevel m_LastDecayLevel; + private bool _wasUndecayable; // not serialized: decay tick state only private Mobile m_Owner; @@ -198,19 +199,58 @@ namespace Server.Multis { get { - DecayLevel result; - if (!CanDecay) { + return DecayLevel.Ageless; + } + + if (DynamicDecay.Enabled) + { + var stage = m_CurrentStage; + + if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0)) + { + return DecayLevel.DemolitionPending; + } + + return stage; + } + + return GetOldDecayLevel(); + } + } + + /// + /// Advances decay bookkeeping. Runs from the decay tick, never from a read: a getter that + /// writes persisted fields makes every house record change on every read. + /// + public virtual void UpdateDecay() + { + if (!CanDecay) + { + if (DynamicDecay.Enabled && m_CurrentStage != DecayLevel.Ageless) + { + ResetDynamicDecay(); + } + + _wasUndecayable = true; + } + else + { + if (_wasUndecayable) + { + // Leaving the undecayable state counts as a refresh, which the old getter + // guaranteed by restamping LastRefreshed on every read while undecayable. + _wasUndecayable = false; + LastRefreshed = Core.Now; + if (DynamicDecay.Enabled) { ResetDynamicDecay(); } - - LastRefreshed = Core.Now; - result = DecayLevel.Ageless; } - else if (DynamicDecay.Enabled) + + if (DynamicDecay.Enabled) { var stage = m_CurrentStage; @@ -218,32 +258,19 @@ namespace Server.Multis { SetDynamicDecay(++stage); } - - if (stage == DecayLevel.Collapsed && (HasRentedVendors || VendorInventories.Count > 0)) - { - result = DecayLevel.DemolitionPending; - } - else - { - result = stage; - } } - else + } + + var level = DecayLevel; + + if (level != m_LastDecayLevel) + { + m_LastDecayLevel = level; + + if (Sign?.GettingProperties == false) { - result = GetOldDecayLevel(); + Sign.InvalidateProperties(); } - - if (result != m_LastDecayLevel) - { - m_LastDecayLevel = result; - - if (Sign?.GettingProperties == false) - { - Sign.InvalidateProperties(); - } - } - - return result; } } @@ -582,6 +609,7 @@ namespace Server.Multis return false; } + UpdateDecay(); var oldLevel = DecayLevel; LastRefreshed = Core.Now; @@ -598,6 +626,8 @@ namespace Server.Multis public virtual bool CheckDecay() { + UpdateDecay(); + if (!Deleted && DecayLevel == DecayLevel.Collapsed) { Timer.StartTimer(Decay_Sandbox); @@ -2969,28 +2999,6 @@ namespace Server.Multis writer.Write(MaxLockDowns); writer.Write(MaxSecures); - - // Items in locked down containers that aren't locked down themselves must decay! - for (var i = 0; i < LockDowns.Count; ++i) - { - var item = LockDowns[i]; - - if (item is Container cont && !(cont is BaseBoard or Aquarium or FishBowl)) - { - var children = cont.Items; - - for (var j = 0; j < children.Count; ++j) - { - var child = children[j]; - - if (child.Decays && !child.IsLockedDown && !child.IsSecure && - child.LastMoved + child.DecayTime <= Core.Now) - { - Timer.StartTimer(child.Delete); - } - } - } - } } public override void Deserialize(IGenericReader reader) diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index e37431ddd..e930af423 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -5,7 +5,6 @@ using System.IO; using System.Runtime.InteropServices; using System.Text.Json.Serialization; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Json; using Server.Mobiles; diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index a8cd55ae4..b9f38f310 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Server.Collections; using Server.Engines.PartySystem; using Server.Factions; using Server.Guilds; diff --git a/Projects/UOContent/Skills/SkillCheck.cs b/Projects/UOContent/Skills/SkillCheck.cs index 5913b0a7f..59f8ebb6a 100644 --- a/Projects/UOContent/Skills/SkillCheck.cs +++ b/Projects/UOContent/Skills/SkillCheck.cs @@ -48,6 +48,13 @@ public static class SkillCheck return false; } + var success = CheckLocation(from, skill, minSkill, maxSkill); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckLocation(Mobile from, Skill skill, double minSkill, double maxSkill) + { var value = skill.Value; if (value < minSkill) @@ -76,6 +83,13 @@ public static class SkillCheck return false; } + var success = CheckDirectLocation(from, skill, chance); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckDirectLocation(Mobile from, Skill skill, double chance) + { if (chance < 0.0) { return false; // Too difficult @@ -156,6 +170,13 @@ public static class SkillCheck return false; } + var success = CheckTarget(from, skill, target, minSkill, maxSkill); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckTarget(Mobile from, Skill skill, object target, double minSkill, double maxSkill) + { var value = skill.Value; if (value < minSkill) @@ -182,6 +203,13 @@ public static class SkillCheck return false; } + var success = CheckDirectTarget(from, skill, target, chance); + SkillEvents.InvokeSkillUsed(from, skill, success); + return success; + } + + private static bool CheckDirectTarget(Mobile from, Skill skill, object target, double chance) + { if (chance < 0.0) { return false; // Too difficult diff --git a/Projects/UOContent/Skills/SkillEvents.cs b/Projects/UOContent/Skills/SkillEvents.cs new file mode 100644 index 000000000..dec624c45 --- /dev/null +++ b/Projects/UOContent/Skills/SkillEvents.cs @@ -0,0 +1,23 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Server.Misc; + +/// +/// Skill system events. Plain C# events so other assemblies can subscribe; generated events cannot be +/// subscribed across assemblies. +/// +public static class SkillEvents +{ + /// + /// Raised once per skill attempt from the four Mobile_SkillCheck* handlers with the attempt's + /// outcome, including attempts the handler resolves without a roll (too difficult, no challenge). + /// Not raised when the mobile lacks the skill. Fires for every , including + /// creatures. Subscribers must not block or allocate. + /// + public static event Action SkillUsed; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void InvokeSkillUsed(Mobile from, Skill skill, bool success) => + SkillUsed?.Invoke(from, skill, success); +} diff --git a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs index c0f3c3ed0..578bdc482 100644 --- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs +++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs @@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell // shared timer from either the caster or the target key, so a single call per mobile is enough. [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void OnCurseEnds(Mobile m) => RemoveCurse(m); private class ExpireTimer : Timer diff --git a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs index 585d37284..6a5d4850a 100644 --- a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs +++ b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs @@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] public static void OnDeathEvent(Mobile m) { diff --git a/Projects/UOContent/Systems/JailSystem/JailRecord.cs b/Projects/UOContent/Systems/JailSystem/JailRecord.cs index c57ef8622..2cec17d55 100644 --- a/Projects/UOContent/Systems/JailSystem/JailRecord.cs +++ b/Projects/UOContent/Systems/JailSystem/JailRecord.cs @@ -1,11 +1,18 @@ using System; using ModernUO.Serialization; +using Server.Mobiles; namespace Server.Systems.JailSystem; [SerializationGenerator(0)] public partial class JailRecord { + [DirtyTrackingEntity] + [CanBeNull] + private PlayerMobile _player; + + public JailRecord(PlayerMobile player) => _player = player; + [SerializableField(0)] private int _jailCount; diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index 27ac5d387..35a5322f2 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -96,7 +96,7 @@ public class JailSystem : GenericPersistence if (!PlayerJailRecords.TryGetValue(player, out var record)) { - PlayerJailRecords[player] = record = new JailRecord(); + PlayerJailRecords[player] = record = new JailRecord(player); } record.JailCount++; @@ -144,6 +144,7 @@ public class JailSystem : GenericPersistence bc.Internalize(); bc.SetControlMaster(null); + bc.SummonMaster = null; bc.IsStabled = true; bc.StabledBy = from; @@ -409,7 +410,7 @@ public class JailSystem : GenericPersistence for (var i = 0; i < count; i++) { var player = reader.ReadEntity(); - var record = new JailRecord(); + var record = new JailRecord(player); record.Deserialize(reader); if (player != null) diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 60f28ac6f..996ec9514 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,17 +41,17 @@ false - + - + - - + + diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index ca5b9422d..184773e84 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -192,6 +192,35 @@ namespace Server return false; } + // @"..." is the literal text inside, for values the bare text would be read as something + // else. See dev-docs/generic-commands.md. + private static bool TryGetQuotedLiteral(string value, out string literal) + { + if (value?.Length >= 3 && value[0] == '@' && value[1] == '"' && value[^1] == '"') + { + literal = value[2..^1]; + return true; + } + + literal = null; + return false; + } + + /// + /// for callers with nowhere to put an error string. + /// + public static object ParseOrThrow(Type type, string value) + { + var error = TryParse(type, value, out var constructed); + + if (error != null) + { + throw new InvalidOperationException(error); + } + + return constructed; + } + // Do not use this in "Parse" methods, it may cause a stack overflow public static string TryParse(Type type, string value, out object constructed) { @@ -244,7 +273,8 @@ namespace Server if (IsType(type, OfString)) { - constructed = value; + // Decodes what InternalGetValue writes, so [get output pastes back into [set. + constructed = TryGetQuotedLiteral(value, out var literal) ? literal : value; return null; } diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 6e0902b64..55aa32605 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,7 +27,8 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + AND clears move overrides, `SetMoveSpeed()` sets move only. Herding and pacing to a + master (`FollowMoveSpeed`) cap the resolved pace without writing either clock. The client `Running` bit is derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s diff --git a/dev-docs/commands-targeting.md b/dev-docs/commands-targeting.md index d4e96c793..48a28c8bf 100644 --- a/dev-docs/commands-targeting.md +++ b/dev-docs/commands-targeting.md @@ -339,3 +339,9 @@ public override void OnCast() | `Projects/Server/Targeting/TargetCancelType.cs` | Cancel types | | `Projects/Server/Targeting/LandTarget.cs` | Land target | | `Projects/Server/Targeting/StaticTarget.cs` | Static target | + +## See Also + +- `dev-docs/generic-commands.md` — the generic command system: scopes, `where` conditions, + `order by` / `distinct` / `limit`, dot notation, value and quoting syntax, `[interface`, `[batch`. +- — the full command list, regenerated with distro updates. diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 6205ec2d4..d2d301306 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs (combat decisions, target acquisition, spell timing). - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per step. Inherits the matching think value until overridden, so a creature configured with - only think speeds behaves as one clock. Any value is legal — steps are scheduled - independently of think ticks, so the two need not divide evenly. + only think speeds behaves as one clock. The properties read the raw override (`0` = + inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are + scheduled independently of think ticks, so the two need not divide evenly. Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: @@ -283,6 +284,17 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +Two conditions cap the resolved step pace without touching either clock, so nothing is +stored and nothing needs undoing when the condition ends: + +- **Herding** — a creature with a `TargetLocation` is driven at a fixed `HerdingMoveSpeed`. +- **Pacing to the master** — a pet following its master, or guarding from outside guard + range, is capped at `FollowMoveSpeed` (AOS 0.1, earlier eras 0; RunUO's pet sprint). It is + a cap, not an override: a creature configured faster keeps its own pace, and its + `ActiveMoveSpeed`/`PassiveMoveSpeed` are left untouched. Override the virtual to change + the pace or to enable it in an era that has it off. Decisions are unaffected — a following + pet thinks on its active clock. + The client's `Running` bit is derived from the step pace, never passed by callers (`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` / diff --git a/dev-docs/events.md b/dev-docs/events.md index 2fcbeda68..589860db6 100644 --- a/dev-docs/events.md +++ b/dev-docs/events.md @@ -242,6 +242,17 @@ public static void HandlePlayerLogin(PlayerMobile player) --- +## Static Content Events + +Generated events dispatch statically inside `UOContent`; content that other assemblies must observe exposes a +plain `static event` instead (shape: `Projects/UOContent/Engines/Help/HelpEvents.cs`). + +- `SkillEvents.SkillUsed` -- `Action`, raised once per skill attempt from the + four `Mobile_SkillCheck*` handlers with the attempt's outcome, including attempts resolved without a roll + (too difficult, no challenge). Not raised when the mobile lacks the skill. Fires for every `Mobile`. + +--- + ## Event Args Pooling Pattern Some EventArgs use object pooling to avoid allocation in hot paths: diff --git a/dev-docs/generic-commands.md b/dev-docs/generic-commands.md new file mode 100644 index 000000000..54019faf9 --- /dev/null +++ b/dev-docs/generic-commands.md @@ -0,0 +1,239 @@ +# Generic Commands (finding and manipulating entities) + +How staff find a set of objects and run a command against all of them: scopes, `where` conditions, +`order by` / `distinct` / `limit`, dot notation, value syntax, `[interface` and `[batch`. + +This covers the **generic command system** only. For the full per-command list (`[add`, `[props`, +`[tele`, …) see the in-game commands page shipped with the distro, published at +. + +## The shape of a generic command + +``` +[ [command args] [where ] [distinct ] [order by ] [limit ] +``` + +- **scope** — which objects to consider (`Global`, `Area`, `Region`, …). +- **command** — what to do to each (`Delete`, `Props`, `Set`, `Count`, `Interface`, …). +- **modifiers** — optional filters applied to the found set before the command runs. + +``` +[global count where Item Movable = false +[area delete where Item ItemID = 0x1F13 +[region interface where Mobile Hits < 10 order by Hits limit 20 +``` + +Commands opt into which scopes they support and whether they act on Items, Mobiles or both, so not +every command works under every scope. A command that does not support the scope reports +*"That is either an invalid command name or one that does not support this modifier."* + +## Scopes + +| Scope | Usage | Conditions? | Selects | +|---|---|---|---| +| `Global` | `[global [condition]` | yes | every object in the world | +| `Area` (`Group`) | `[area [condition]` | yes | a bounding box you drag | +| `Screen` | `[screen [condition]` | yes | everything on your screen | +| `Range` | `[range [condition]` | yes | within `` tiles of you | +| `Region` | `[region [condition]` | yes | your current region | +| `Facet` | `[facet [condition]` | yes | your whole map | +| `Contained` | `[contained [condition]` | yes¹ | inside a targeted container | +| `Online` | `[online [condition]` | yes | connected players | +| `IPAddress` | `[ipaddress [condition]` | yes | accounts sharing a targeted player's IP | +| `Multi` (`m`) | `[m ` | no | several objects you target in turn | +| `Single` | `[single ` | no | one targeted object | +| `Self` | `[self ` | no | you | +| `Serial` | `[serial ` | no | one object by serial | + +`Multi`, `Single`, `Self` and `Serial` do not parse modifiers at all — a `where` clause there is +not rejected, it is passed through to the command as ordinary arguments. + +¹ `Contained` honours conditions on the normal command path, but it never sets the +`SupportsConditionals` flag, and `[batch` is the one place that checks it. So a condition works +under `[contained` typed directly and is refused under `[batch` with that scope. + +## `where` + +The **first token after `where` is a type name**, and it is required. It filters the set to objects +of that type (subclasses included) and fixes the type whose properties the rest of the clause reads. + +``` +[global count where Item -- every Item +[global count where BaseCreature Hits < 10 +``` + +Only properties marked `[CommandProperty]` are visible, and your access level must meet the +attribute's read level. + +### Operators + +| Operator | Meaning | +|---|---| +| `=`, `==`, `is` | equal | +| `!=` | not equal | +| `>`, `<`, `>=`, `<=` | relational (needs a comparable type) | +| `=~`, `~=`, `==~`, `~==`, `is~`, `~is` | equal, case-insensitive | +| `!=~`, `~!=` | not equal, case-insensitive | +| `starts`, `ends`, `contains` | substring tests | +| `starts~`, `ends~`, `contains~` | substring tests, case-insensitive | + +The `~` may lead or trail — `~contains` and `contains~` are the same operator. + +Relational operators on a type with no ordering (no `IComparable`) are rejected rather than +silently misbehaving. Equality on such a type compares **by value**, not by reference. + +### Combining conditions + +Conditions separated by whitespace are ANDed. `or` (or `||`) starts a new alternative group, and +`not` (or `!`) negates the single condition that follows it. + +``` +[global count where Item Movable = true Hue = 0 +[global count where Item Hue = 0 or Hue = 1 +[global count where Item not Movable = true +``` + +## Dot notation + +A binding may walk a chain of properties: + +``` +[global count where SkillTeleporter Message.Number = 1060847 +[global interface where BaseCreature ControlMaster.Name =~ bob +``` + +If a link partway along the chain is null, the object simply does not match — it is not an error, +and it does not stop the sweep. The same applies under `not`: an unreadable binding never matches. + +For `order by` and `distinct`, which have no "no match" to give, a null link reads as the property +type's default (`0`, `null`, …). + +Chains are **read-only**. `[set Message.Number 5` fails when the intermediate's members are +get-only, as `TextDefinition`'s are. + +## Values + +A comparison constant is resolved by the same parser behind `[set`, `[add`, spawner props and the +props gump, so a value that works in one place works in all of them. + +| Form | Means | +|---|---| +| `123`, `-4` | a number | +| `0x1F13` | a number, hex | +| `true` / `false` | a boolean | +| `Magery` | an enum member, case-insensitive | +| `Felucca` | a `Map` | +| `Static`, `BaseCreature` | a `Type`, by name | +| `0x40001234` | an entity, resolved by serial | +| `hello world` | a string — quote it in the command line if it contains spaces | +| `null` | null (in `where` only — see below) | +| `(-null-)` | null (in `[set` / `[add` / spawner props) | +| `@"text"` | the literal text inside, for values that would otherwise be read as something else | +| `#1234` | a `TextDefinition` cliloc, explicitly | + +### Quoting, and why `@"..."` exists + +The command tokenizer strips real quotes before any parser sees them, so `"0"` and `0` arrive +identical. `@"..."` is the in-band escape that survives: + +``` +[set Name @"null" -- the four-letter string, not a null +[set Message @"1060847" -- the string "1060847", not cliloc 1060847 +[set Message #1060847 -- cliloc 1060847, explicitly +[set Message 1060847 -- cliloc 1060847 (a bare integer is always a cliloc) +``` + +`[get` writes the same form back for any value that would otherwise be misread, so its output can +be pasted straight into `[set`. + +### The one inconsistency: `null` + +`where` spells a null constant as a bare `null`. `[set` and friends use `(-null-)`, and read a bare +`null` as the four-letter string. This predates the shared parser and is preserved deliberately — +every existing `where … = null` clause depends on it. + +``` +[global count where Item Name = null -- Name is null +[set Name (-null-) -- set Name to null +[set Name null -- set Name to the string "null" +``` + +## `distinct`, `order by`, `limit` + +``` +[global interface where Item order by Hue desc limit 50 +[global interface where Mobile distinct Name order by Name +``` + +- `distinct [ …]` — keeps one object per distinct combination of those properties. It + sorts internally to do so, so it also reorders the set; add `order by` if the order matters. +- `order by [direction] [ …]` — `by` is optional. Direction is `+`/`up`/`asc`/`ascending` + or `-`/`down`/`desc`/`descending`, defaulting to ascending. Multiple keys break ties left to right. +- `limit ` — keeps the first `n` after the others have run. + +Keywords are case-insensitive, and **the order you type them does not matter**: they always apply +as `where` → `distinct` → `order by` → `limit`. + +## `[interface` + +``` +[ interface [view ] [condition] +``` + +Opens a gump listing every match instead of acting on them. Each row can be inspected, and `view` +adds columns for the properties you name. This is the safest way to see what a condition selects +before running something destructive with the same clause. + +``` +[global interface where Item Movable = false ItemID = 0x1F13 +[global interface view Hue Name where Mobile Hits < 10 +``` + +## `[batch` + +`[batch` opens a gump that runs **several commands against one found set**. Type `[batch` with no +arguments; the gump has three parts: + +- **Scope** — pick one of the scopes above. +- **Condition** — the whole clause, and it must start with `where`. +- **Commands** — one or more entries, each with a command and an optional **Object**. + +Every command runs against the same matched set, in the order listed. It is the tool for "find +these once, then do three things to them" without re-running an expensive sweep, and for +multi-step edits that would otherwise race against their own filter — a `where Hue = 0` clause +re-evaluated after the first command has changed `Hue` would no longer match. + +The **Object** field is a property chain that redirects that one command onto a sub-object of each +match. Leave it blank to act on the match itself; set it to `Backpack` to act on each mobile's +backpack instead. Objects whose chain is null or unreadable are skipped for that command. + +Logging is suppressed automatically when the set is larger than 20 objects. + +## Gotchas + +- The type after `where` is mandatory; `where Movable = true` is a parse error, not a wildcard. +- Only `[CommandProperty]` members are reachable, and write access is checked separately from read. +- Relational operators need a comparable type; equality is available on everything. +- A bare integer targeting a `TextDefinition` is always a cliloc. Use `@"123"` for the string. +- `where … = null` and `[set … (-null-)` are different spellings of the same idea. See above. +- Spawner **Params** are split on plain spaces, not the quoting tokenizer, so a constructor + argument cannot contain a space. Spawner **Props** use the normal tokenizer and value syntax. +- Test a destructive clause with `[interface` or `count` first. + +## Key files + +| Concern | File | +|---|---| +| Scopes | `Projects/UOContent/Commands/Generic/Implementors/` | +| `where` parsing, operators | `Projects/UOContent/Commands/Generic/Implementors/ObjectConditional.cs` | +| Condition/sort/distinct compilation | `Projects/UOContent/Commands/Generic/Extensions/Compilers/` | +| Modifier parsing and apply order | `Projects/UOContent/Commands/Generic/Extensions/BaseExtension.cs` | +| Value parsing | `Projects/UOContent/Utilities/Types.cs` | +| Command tokenizer | `Projects/Server/Commands.cs` (`Commands.Split`) | +| `[batch` | `Projects/UOContent/Commands/Batch.cs` | +| `[interface` | `Projects/UOContent/Commands/Generic/Commands/Interface.cs` | + +## See also + +- `dev-docs/commands-targeting.md` — registering commands and the targeting system. +- — the full command list, regenerated with distro updates.