diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 3e22e2e66..3f6d9ae52 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
- "version": "4.1.0",
+ "version": "4.2.0",
"commands": [
"ModernUOSchemaGenerator"
]
diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj
index b1462ba37..8ae939ce1 100644
--- a/Projects/Server.Tests/Server.Tests.csproj
+++ b/Projects/Server.Tests/Server.Tests.csproj
@@ -5,7 +5,7 @@
Server.Tests
-
+
diff --git a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs
new file mode 100644
index 000000000..c529f8a12
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs
@@ -0,0 +1,58 @@
+using Server.Items;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class ContainerBulkRemovalTests
+{
+ [Fact]
+ public void DeletingLargeContainer_DeletesAllChildren_AndTotalsStayConsistent()
+ {
+ var outer = new Container(0xE75);
+ var pack = new Container(0xE75);
+ outer.DropItem(pack);
+
+ var children = new Item[100_000];
+ for (var i = 0; i < children.Length; i++)
+ {
+ children[i] = new Item(0x1234);
+ pack.DropItem(children[i]);
+ }
+
+ Assert.Equal(children.Length + 1, outer.TotalItems);
+
+ pack.Delete();
+
+ for (var i = 0; i < children.Length; i++)
+ {
+ Assert.True(children[i].Deleted);
+ }
+
+ Assert.Empty(outer.Items);
+ Assert.Equal(0, outer.TotalItems);
+ outer.Delete();
+ }
+
+ // The tail fast path must not change which entry is removed when the item is elsewhere.
+ [Fact]
+ public void RemovingFromFrontMiddleAndEnd_RemovesExactlyThatItem()
+ {
+ var pack = new Container(0xE75);
+ var items = new Item[5];
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i] = new Item(0x1234);
+ pack.DropItem(items[i]);
+ }
+
+ items[0].Delete();
+ items[2].Delete();
+ items[4].Delete();
+
+ Assert.Equal(2, pack.Items.Count);
+ Assert.Same(items[1], pack.Items[0]);
+ Assert.Same(items[3], pack.Items[1]);
+ pack.Delete();
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs
new file mode 100644
index 000000000..695838185
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs
@@ -0,0 +1,462 @@
+using System;
+using Server.Items;
+using Server.Network;
+using Server.Tests.Network;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class ContainerChildRemovalTests
+{
+ private class PublicContainer : Container
+ {
+ public PublicContainer() : base(0xE75)
+ {
+ }
+
+ public override bool IsPublicContainer => true;
+ }
+
+ private class OneChildPublicContainer : Container
+ {
+ public OneChildPublicContainer() : base(0xE75)
+ {
+ }
+
+ public Item PublicChild { get; set; }
+
+ public override bool IsChildPublic(Item child) => child == PublicChild;
+ }
+
+ // An override that narrows below IsPublicContainer instead of only widening it, exercising the guard
+ // that keeps a public container's removals broadcast regardless of what IsChildPublic returns.
+ private class PublicContainerWithNarrowingOverride : Container
+ {
+ public PublicContainerWithNarrowingOverride() : base(0xE75)
+ {
+ }
+
+ public override bool IsPublicContainer => true;
+
+ public override bool IsChildPublic(Item child) => false;
+ }
+
+ private static readonly Point3D _ownerLoc = new(1500, 1500, 0);
+
+ private static (NetState, Mobile) CreateClient(Point3D location)
+ {
+ var ns = PacketTestUtilities.CreateTestNetState();
+ ns.Account = new MockAccount();
+
+ var mobile = new Mobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ ns.Mobile = mobile;
+ mobile.NetState = ns;
+ mobile.MoveToWorld(location, Map.Felucca);
+ return (ns, mobile);
+ }
+
+ private static Mobile CreateOwnerWith(Container pack)
+ {
+ var owner = new Mobile(World.NewMobile);
+ owner.DefaultMobileInit();
+ owner.MoveToWorld(_ownerLoc, Map.Felucca);
+ pack.Layer = Layer.ShopBuy;
+ pack.Visible = false;
+ owner.AddItem(pack);
+ return owner;
+ }
+
+ private static bool ReceivedRemove(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength];
+ OutgoingEntityPackets.CreateRemoveEntity(expected, serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ private static int CountRemoves(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength];
+ OutgoingEntityPackets.CreateRemoveEntity(expected, serial);
+
+ var span = ns.SendBuffer.GetReadSpan();
+ var count = 0;
+
+ while (true)
+ {
+ var index = span.IndexOf(expected);
+
+ if (index < 0)
+ {
+ break;
+ }
+
+ count++;
+ span = span[(index + expected.Length)..];
+ }
+
+ return count;
+ }
+
+ private static void DisposeClient(NetState ns, Mobile mobile)
+ {
+ ns.Mobile = null;
+ ns.Dispose();
+ mobile.Delete();
+ }
+
+ private static void Cleanup(NetState ns, Mobile client, Mobile owner)
+ {
+ DisposeClient(ns, client);
+ owner.Delete();
+ }
+
+ [Fact]
+ public void PrivateChildDelete_IsNotBroadcastToNearbyClients()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var owner = CreateOwnerWith(new Container(0xE75));
+ var child = new Item(0x1234);
+ ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child);
+
+ try
+ {
+ child.Delete();
+ Assert.False(ReceivedRemove(ns, child.Serial));
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void PrivateChildDelete_ReachesOpeners()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var pack = new Container(0xE75);
+ var owner = CreateOwnerWith(pack);
+ var child = new Item(0x1234);
+ pack.DropItem(child);
+ pack.Openers = [client];
+
+ try
+ {
+ child.Delete();
+ Assert.True(ReceivedRemove(ns, child.Serial));
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void PrivateChildDelete_ReachesTheRootOwnersClient()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var pack = new Container(0xE75)
+ {
+ Layer = Layer.Backpack,
+ Visible = false
+ };
+ owner.AddItem(pack);
+
+ var child = new Item(0x1234);
+ pack.DropItem(child);
+
+ try
+ {
+ child.Delete();
+ Assert.True(ReceivedRemove(ownerNs, child.Serial));
+ Assert.False(ReceivedRemove(bystanderNs, child.Serial));
+ }
+ finally
+ {
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void PublicContainerChildDelete_IsBroadcast()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var owner = CreateOwnerWith(new PublicContainer());
+ var child = new Item(0x1234);
+ ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child);
+
+ try
+ {
+ child.Delete();
+ Assert.True(ReceivedRemove(ns, child.Serial));
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void PublicContainer_NarrowingChildOverride_StillBroadcastsRemoval()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var owner = CreateOwnerWith(new PublicContainerWithNarrowingOverride());
+ var child = new Item(0x1234);
+ ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child);
+
+ try
+ {
+ child.Delete();
+ Assert.True(ReceivedRemove(ns, child.Serial));
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_BroadcastsOnlyThatChild()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var pack = new OneChildPublicContainer();
+ var owner = CreateOwnerWith(pack);
+ var publicChild = new Item(0x1234);
+ var privateChild = new Item(0x1235);
+ pack.DropItem(publicChild);
+ pack.DropItem(privateChild);
+ pack.PublicChild = publicChild;
+
+ try
+ {
+ privateChild.Delete();
+ Assert.False(ReceivedRemove(ns, privateChild.Serial));
+
+ publicChild.Delete();
+ Assert.True(ReceivedRemove(ns, publicChild.Serial));
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void DeletingOwner_SendsNoRemoveForPrivateChildren()
+ {
+ var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+ var pack = new Container(0xE75);
+ var owner = CreateOwnerWith(pack);
+ var children = new Item[50];
+ for (var i = 0; i < children.Length; i++)
+ {
+ children[i] = new Item(0x1234);
+ pack.DropItem(children[i]);
+ }
+
+ try
+ {
+ owner.Delete();
+
+ for (var i = 0; i < children.Length; i++)
+ {
+ Assert.True(children[i].Deleted);
+ Assert.False(ReceivedRemove(ns, children[i].Serial));
+ }
+ }
+ finally
+ {
+ Cleanup(ns, client, owner);
+ }
+ }
+
+ [Fact]
+ public void TradePartner_ReceivesRemoveForNestedItem()
+ {
+ var (nsA, a) = CreateClient(_ownerLoc);
+ var (nsB, b) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var trade = new SecureTrade(a, b);
+ var pouch = new Container(0xE75);
+ trade.From.Container.DropItem(pouch);
+ var item = new Item(0x1234);
+ pouch.DropItem(item);
+
+ try
+ {
+ item.Delete();
+ Assert.True(ReceivedRemove(nsB, item.Serial));
+ }
+ finally
+ {
+ trade.From.Container.Delete();
+ trade.To.Container.Delete();
+ DisposeClient(nsA, a);
+ DisposeClient(nsB, b);
+ }
+ }
+
+ [Fact]
+ public void FacetChange_DoesNotRemoveNestedContainerChildren()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var backpack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(backpack);
+
+ var pouch = new Container(0xE75);
+ backpack.DropItem(pouch);
+
+ var reagent = new Item(0xF7A);
+ pouch.DropItem(reagent);
+
+ try
+ {
+ owner.MoveToWorld(_ownerLoc, Map.Trammel);
+
+ Assert.False(ReceivedRemove(ownerNs, pouch.Serial));
+ Assert.False(ReceivedRemove(ownerNs, reagent.Serial));
+ Assert.False(ReceivedRemove(bystanderNs, pouch.Serial));
+ Assert.False(ReceivedRemove(bystanderNs, reagent.Serial));
+ }
+ finally
+ {
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void NoDuplicateRemove_WhenRootIsAlsoAnOpener()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+
+ var pack = new Container(0xE75)
+ {
+ Layer = Layer.Backpack,
+ Visible = false
+ };
+ owner.AddItem(pack);
+ pack.Openers = [owner];
+
+ var child = new Item(0x1234);
+ pack.DropItem(child);
+
+ try
+ {
+ child.Delete();
+ Assert.Equal(1, CountRemoves(ownerNs, child.Serial));
+ }
+ finally
+ {
+ DisposeClient(ownerNs, owner);
+ }
+ }
+
+ [Fact]
+ public void LiftingFromOwnBackpack_DoesNotBroadcastRemove()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var pack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(pack);
+
+ var item = new Item(0x1234);
+ pack.DropItem(item);
+
+ try
+ {
+ item.Internalize();
+
+ Assert.True(ReceivedRemove(ownerNs, item.Serial));
+ Assert.False(ReceivedRemove(bystanderNs, item.Serial));
+ }
+ finally
+ {
+ item.Delete();
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void MovingPrivateChildToGroundSameMap_DoesNotSendOldLocationRemoveToBystanders()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var pack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(pack);
+
+ var item = new Item(0x1234);
+ pack.DropItem(item);
+
+ var farLocation = new Point3D(_ownerLoc.X + 100, _ownerLoc.Y, 0);
+
+ try
+ {
+ item.MoveToWorld(farLocation, Map.Felucca);
+
+ Assert.False(ReceivedRemove(bystanderNs, item.Serial));
+ }
+ finally
+ {
+ item.Delete();
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void LiftingEquippedItem_StillBroadcastsRemove()
+ {
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var weapon = new Item(0x1234) { Layer = Layer.OneHanded };
+ owner.AddItem(weapon);
+
+ try
+ {
+ weapon.Internalize();
+
+ Assert.True(ReceivedRemove(bystanderNs, weapon.Serial));
+ }
+ finally
+ {
+ weapon.Delete();
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void GroundItemMove_StillSendsOldLocationRemove()
+ {
+ var (bystanderNs, bystander) = CreateClient(_ownerLoc);
+
+ var groundLocation = new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0);
+ var item = new Item(0x1234);
+ item.MoveToWorld(groundLocation, Map.Felucca);
+
+ var farLocation = new Point3D(_ownerLoc.X + 100, _ownerLoc.Y, 0);
+
+ try
+ {
+ item.MoveToWorld(farLocation, Map.Felucca);
+
+ Assert.True(ReceivedRemove(bystanderNs, item.Serial));
+ }
+ finally
+ {
+ item.Delete();
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs
new file mode 100644
index 000000000..5b1cd471c
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs
@@ -0,0 +1,146 @@
+using System;
+using Server.Items;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class ContainerChildSkipTests
+{
+ private class SkippingContainer : Container
+ {
+ public SkippingContainer() : base(0xE75)
+ {
+ }
+
+ public SkippingContainer(Serial serial) : base(serial)
+ {
+ }
+
+ public override bool SkipsChildSerialization => true;
+ }
+
+ [Fact]
+ public void ChildOfSkippingContainer_SkipsUntilMovedOut()
+ {
+ var pack = new SkippingContainer();
+ var normal = new Container(0xE75);
+ var item = new Item(0x1234);
+
+ try
+ {
+ pack.DropItem(item);
+ Assert.True(item.SkipSerialization);
+ Assert.False(pack.SkipSerialization);
+
+ normal.DropItem(item);
+ Assert.False(item.SkipSerialization);
+ }
+ finally
+ {
+ item.Delete();
+ pack.Delete();
+ normal.Delete();
+ }
+ }
+
+ [Fact]
+ public void GrandchildOfSkippingContainer_IsNotSkipped()
+ {
+ var pack = new SkippingContainer();
+ var inner = new Container(0xE75);
+ var item = new Item(0x1234);
+
+ try
+ {
+ pack.DropItem(inner);
+ inner.DropItem(item);
+
+ Assert.True(inner.SkipSerialization);
+ Assert.False(item.SkipSerialization);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ // A skipped child is not saved, so the container must load without it.
+ [Fact]
+ public void SkippingContainer_LoadsWithoutItsSkippedChildren()
+ {
+ var pack = new SkippingContainer();
+ var item = new Item(0x1234);
+ pack.DropItem(item);
+
+ var writer = new BufferWriter(true);
+ pack.Serialize(writer);
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ item.Delete();
+ pack.Delete();
+
+ var copy = new SkippingContainer(World.NewItem);
+ copy.Deserialize(new BufferReader(buffer));
+
+ Assert.Empty(copy.Items);
+ }
+
+ // A written child serial could resolve at load to an unrelated item that was handed the same serial.
+ [Fact]
+ public void SkippingContainer_DoesNotWriteAliveChildSerial()
+ {
+ var pack = new SkippingContainer();
+ var item = new Item(0x1234);
+ pack.DropItem(item);
+
+ var writer = new BufferWriter(true);
+ pack.Serialize(writer);
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ // item stays alive and in pack's list past this point, so its serial is still resolvable
+ // when copy deserializes below - deleting pack cascades to item, so both go together.
+ try
+ {
+ var copy = new SkippingContainer(World.NewItem);
+ copy.Deserialize(new BufferReader(buffer));
+
+ Assert.Empty(copy.Items);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ // An item can be saved naming a skipping container as parent while absent from its list; it must load
+ // as parentless so the missing-parent path deletes it rather than leaving an unreachable orphan.
+ [Fact]
+ public void ItemLoadingWithSkippingContainerAsParent_ClearsParent()
+ {
+ var pack = new SkippingContainer();
+ var item = new Item(0x1234);
+ pack.DropItem(item);
+
+ var writer = new BufferWriter(true);
+ item.Serialize(writer);
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ item.Delete();
+
+ try
+ {
+ var copy = new Item(World.NewItem);
+ copy.Deserialize(new BufferReader(buffer));
+
+ Assert.Null(copy.Parent);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs
new file mode 100644
index 000000000..97cffbba9
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs
@@ -0,0 +1,311 @@
+using System;
+using System.Buffers;
+using Server.Items;
+using Server.Network;
+using Server.Tests.Network;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class LiftRejectResyncTests
+{
+ private static readonly Point3D _baseLoc = new(2200, 2200, 0);
+
+ private static (NetState, Mobile) CreateClient(Point3D location)
+ {
+ var ns = PacketTestUtilities.CreateTestNetState();
+ ns.Account = new MockAccount();
+
+ var mobile = new Mobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ ns.Mobile = mobile;
+ mobile.NetState = ns;
+ mobile.MoveToWorld(location, Map.Felucca);
+ return (ns, mobile);
+ }
+
+ private static void DisposeClient(NetState ns, Mobile mobile)
+ {
+ ns.Mobile = null;
+ ns.Dispose();
+ mobile.Delete();
+ }
+
+ private static bool ReceivedContentUpdate(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[5];
+ var writer = new SpanWriter(expected);
+ writer.Write((byte)0x25); // ContainerContentUpdate packet ID
+ writer.Write(serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ private static bool ReceivedWorldItem(NetState ns, Item item)
+ {
+ Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength];
+ var length = OutgoingItemPackets.CreateWorldItem(expected, item);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0;
+ }
+
+ private static bool ReceivedEquipUpdate(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[5];
+ var writer = new SpanWriter(expected);
+ writer.Write((byte)0x2E); // EquipUpdate packet ID
+ writer.Write(serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ [Fact]
+ public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander()
+ {
+ var (ownerNs, owner) = CreateClient(_baseLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0));
+
+ var backpack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(backpack);
+ var pouch = new Container(0xE75);
+ backpack.DropItem(pouch);
+ var item = new Item(0x1234);
+ pouch.DropItem(item);
+
+ try
+ {
+ bystander.Lift(item, item.Amount, out var rejected, out _);
+
+ // Whichever check trips first (CheckNonlocalLift, accessibility, ...), the bystander was
+ // never sent this private child, so the rejection must not resynchronize it to them.
+ Assert.True(rejected);
+ Assert.False(ReceivedContentUpdate(bystanderNs, item.Serial));
+ }
+ finally
+ {
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void OwnerAlreadyHolding_RejectedLift_ResyncsPrivateChildToOwner()
+ {
+ var (ownerNs, owner) = CreateClient(_baseLoc);
+
+ var backpack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(backpack);
+ var item = new Item(0x1234);
+ backpack.DropItem(item);
+
+ owner.Holding = new Item(0x1);
+
+ try
+ {
+ owner.Lift(item, item.Amount, out var rejected, out var reject);
+
+ Assert.True(rejected);
+ Assert.Equal(LRReason.AreHolding, reject);
+ Assert.True(ReceivedContentUpdate(ownerNs, item.Serial));
+ }
+ finally
+ {
+ owner.Holding?.Delete();
+ DisposeClient(ownerNs, owner);
+ }
+ }
+
+ [Fact]
+ public void GroundItemOutOfRange_RejectedLift_DoesNotResyncRequester()
+ {
+ var (requesterNs, requester) = CreateClient(_baseLoc);
+
+ var item = new Item(0x1234);
+ item.MoveToWorld(new Point3D(_baseLoc.X, _baseLoc.Y + 100, 0), Map.Felucca);
+
+ try
+ {
+ requester.Lift(item, item.Amount, out var rejected, out var reject);
+
+ Assert.True(rejected);
+ Assert.Equal(LRReason.OutOfRange, reject);
+ Assert.False(ReceivedWorldItem(requesterNs, item));
+ }
+ finally
+ {
+ DisposeClient(requesterNs, requester);
+ item.Delete();
+ }
+ }
+
+ [Fact]
+ public void GroundContainerOpener_ForcedRejection_ResyncsPrivateChildToOpener()
+ {
+ var (requesterNs, requester) = CreateClient(_baseLoc);
+
+ var container = new Container(0xE75);
+ container.MoveToWorld(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0), Map.Felucca);
+ var item = new Item(0x1234);
+ container.DropItem(item);
+ container.Openers = [requester];
+
+ requester.Holding = new Item(0x1);
+
+ try
+ {
+ requester.Lift(item, item.Amount, out var rejected, out var reject);
+
+ Assert.True(rejected);
+ Assert.Equal(LRReason.AreHolding, reject);
+ Assert.True(ReceivedContentUpdate(requesterNs, item.Serial));
+ }
+ finally
+ {
+ requester.Holding?.Delete();
+ DisposeClient(requesterNs, requester);
+ container.Delete();
+ }
+ }
+
+ [Fact]
+ public void TradePartner_RejectedLift_ResyncsNestedItemToTradePartner()
+ {
+ var (aNs, a) = CreateClient(_baseLoc);
+ var (bNs, b) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0));
+
+ var trade = new SecureTrade(a, b);
+ var pouch = new Container(0xE75);
+ trade.From.Container.DropItem(pouch);
+ var item = new Item(0x1234);
+ pouch.DropItem(item);
+
+ b.Holding = new Item(0x1);
+
+ try
+ {
+ // b is a's trade partner: not the item's root and never an Opener of the pouch, but a
+ // sanctioned recipient of this private child through the trade relationship.
+ b.Lift(item, item.Amount, out var rejected, out var reject);
+
+ Assert.True(rejected);
+ Assert.Equal(LRReason.AreHolding, reject);
+ Assert.True(ReceivedContentUpdate(bNs, item.Serial));
+ }
+ finally
+ {
+ b.Holding?.Delete();
+ trade.From.Container.Delete();
+ trade.To.Container.Delete();
+ DisposeClient(aNs, a);
+ DisposeClient(bNs, b);
+ }
+ }
+
+ [Fact]
+ public void InvisibleItem_RejectedLift_DoesNotResyncToNonStaffOwner()
+ {
+ var (ownerNs, owner) = CreateClient(_baseLoc);
+
+ var backpack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(backpack);
+ var item = new Item(0x1234) { Visible = false };
+ backpack.DropItem(item);
+
+ owner.Holding = new Item(0x1);
+
+ try
+ {
+ owner.Lift(item, item.Amount, out var rejected, out var reject);
+
+ // ProcessDelta never sends an invisible item to a non-staff owner (CanSee gates every
+ // private recipient); the rejection resync must honor the same gate.
+ Assert.True(rejected);
+ Assert.Equal(LRReason.AreHolding, reject);
+ Assert.False(ReceivedContentUpdate(ownerNs, item.Serial));
+ }
+ finally
+ {
+ owner.Holding?.Delete();
+ DisposeClient(ownerNs, owner);
+ }
+ }
+
+ [Fact]
+ public void InvisibleItem_RejectedLift_ResyncsToStaffOwner()
+ {
+ var (ownerNs, owner) = CreateClient(_baseLoc);
+ owner.AccessLevel = AccessLevel.GameMaster;
+
+ var backpack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(backpack);
+ var item = new Item(0x1234) { Visible = false };
+ backpack.DropItem(item);
+
+ owner.Holding = new Item(0x1);
+
+ try
+ {
+ owner.Lift(item, item.Amount, out var rejected, out var reject);
+
+ Assert.True(rejected);
+ Assert.Equal(LRReason.AreHolding, reject);
+ Assert.True(ReceivedContentUpdate(ownerNs, item.Serial));
+ }
+ finally
+ {
+ owner.Holding?.Delete();
+ DisposeClient(ownerNs, owner);
+ }
+ }
+
+ [Fact]
+ public void EquippedItemOnHiddenMobile_RejectedLift_NoEquipResync()
+ {
+ var (ownerNs, owner) = CreateClient(_baseLoc);
+ var (requesterNs, requester) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0));
+
+ owner.Hidden = true;
+ var weapon = new Item(0x1234) { Layer = Layer.OneHanded };
+ owner.AddItem(weapon);
+
+ try
+ {
+ requester.Lift(weapon, weapon.Amount, out var rejected, out var reject);
+
+ // The requester can't see a hidden, non-staff owner: the lift is rejected OutOfSight and
+ // IsSentTo's CanSee gate must suppress the equip resync.
+ Assert.True(rejected);
+ Assert.Equal(LRReason.OutOfSight, reject);
+ Assert.False(ReceivedEquipUpdate(requesterNs, weapon.Serial));
+ }
+ finally
+ {
+ weapon.Delete();
+ DisposeClient(ownerNs, owner);
+ DisposeClient(requesterNs, requester);
+ }
+ }
+
+ [Fact]
+ public void InvisibleGroundItemInRange_RejectedLift_NoWorldResync()
+ {
+ var (requesterNs, requester) = CreateClient(_baseLoc);
+
+ var item = new Item(0x1234) { Visible = false };
+ item.MoveToWorld(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0), Map.Felucca);
+
+ try
+ {
+ requester.Lift(item, item.Amount, out var rejected, out var reject);
+
+ // In range this time (unlike GroundItemOutOfRange above): only Visible gates it, so the
+ // rejection resync must not hand the requester a world packet for an item they can't see.
+ Assert.True(rejected);
+ Assert.Equal(LRReason.OutOfSight, reject);
+ Assert.False(ReceivedWorldItem(requesterNs, item));
+ }
+ finally
+ {
+ DisposeClient(requesterNs, requester);
+ item.Delete();
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs
new file mode 100644
index 000000000..4481b08b2
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Buffers;
+using Server.Items;
+using Server.Network;
+using Server.Tests.Network;
+using Xunit;
+
+namespace Server.Tests;
+
+[Collection("Sequential Server Tests")]
+public class PrivateChildPropertiesTests
+{
+ private class PublicContainer : Container
+ {
+ public PublicContainer() : base(0xE75)
+ {
+ }
+
+ public override bool IsPublicContainer => true;
+ }
+
+ private static readonly Point3D _ownerLoc = new(1600, 1600, 0);
+
+ private static (NetState, Mobile) CreateClient(Point3D location)
+ {
+ var ns = PacketTestUtilities.CreateTestNetState();
+ ns.Account = new MockAccount();
+
+ var mobile = new Mobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ ns.Mobile = mobile;
+ mobile.NetState = ns;
+ mobile.MoveToWorld(location, Map.Felucca);
+ return (ns, mobile);
+ }
+
+ private static void DisposeClient(NetState ns, Mobile mobile)
+ {
+ ns.Mobile = null;
+ ns.Dispose();
+ mobile.Delete();
+ }
+
+ private static bool ReceivedOplInfo(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[5];
+ var writer = new SpanWriter(expected);
+ writer.Write((byte)0xDC); // OPL info packet ID
+ writer.Write(serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ // AddItem/MoveToWorld already queue their own Update (+Properties) delta as a side effect of the
+ // Map setter. Draining it first isolates the properties-only delta the test actually exercises;
+ // otherwise the leftover Update flag routes through the Update branch and the test would pass or
+ // fail for the wrong reason.
+ private static void DrainPendingDelta(Item item) => item.ProcessDelta();
+
+ private static void InvalidateAndFlush(Item item)
+ {
+ // InvalidateProperties() only queues a delta when the OPL hash actually changes, which the
+ // drain above already stabilized. Queue ItemDelta.Properties directly so the test deterministically
+ // exercises the properties-only (no Update) branch under test.
+ item.Delta(ItemDelta.Properties);
+ item.ProcessDelta();
+ }
+
+ [Fact]
+ public void PrivateChildInOwnerBackpack_PropertiesOnlyUpdate_ReachesOwnerNotBystander()
+ {
+ var wasEnabled = ObjectPropertyList.Enabled;
+ ObjectPropertyList.Enabled = true;
+
+ var (ownerNs, owner) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0));
+
+ var pack = new Container(0xE75) { Layer = Layer.Backpack };
+ owner.AddItem(pack);
+
+ var child = new Item(0x1234);
+ pack.DropItem(child);
+
+ try
+ {
+ DrainPendingDelta(child);
+ InvalidateAndFlush(child);
+
+ Assert.True(ReceivedOplInfo(ownerNs, child.Serial));
+ Assert.False(ReceivedOplInfo(bystanderNs, child.Serial));
+ }
+ finally
+ {
+ child.Delete();
+ DisposeClient(ownerNs, owner);
+ DisposeClient(bystanderNs, bystander);
+ ObjectPropertyList.Enabled = wasEnabled;
+ }
+ }
+
+ [Fact]
+ public void PrivateGroundContainer_PropertiesOnlyUpdate_ReachesOpenerNotNonOpener()
+ {
+ var wasEnabled = ObjectPropertyList.Enabled;
+ ObjectPropertyList.Enabled = true;
+
+ var (openerNs, opener) = CreateClient(_ownerLoc);
+ var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 2, _ownerLoc.Y, 0));
+
+ var container = new Container(0xE75);
+ container.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca);
+ container.Openers = [opener];
+
+ var child = new Item(0x1234);
+ container.DropItem(child);
+
+ try
+ {
+ DrainPendingDelta(child);
+ InvalidateAndFlush(child);
+
+ Assert.True(ReceivedOplInfo(openerNs, child.Serial));
+ Assert.False(ReceivedOplInfo(bystanderNs, child.Serial));
+ }
+ finally
+ {
+ container.Delete();
+ DisposeClient(openerNs, opener);
+ DisposeClient(bystanderNs, bystander);
+ ObjectPropertyList.Enabled = wasEnabled;
+ }
+ }
+
+ [Fact]
+ public void PublicContainerChild_PropertiesOnlyUpdate_StillReachesBystander()
+ {
+ var wasEnabled = ObjectPropertyList.Enabled;
+ ObjectPropertyList.Enabled = true;
+
+ var (bystanderNs, bystander) = CreateClient(_ownerLoc);
+
+ var pack = new PublicContainer();
+ pack.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca);
+
+ var child = new Item(0x1234);
+ pack.DropItem(child);
+
+ try
+ {
+ DrainPendingDelta(child);
+ InvalidateAndFlush(child);
+
+ Assert.True(ReceivedOplInfo(bystanderNs, child.Serial));
+ }
+ finally
+ {
+ pack.Delete();
+ DisposeClient(bystanderNs, bystander);
+ ObjectPropertyList.Enabled = wasEnabled;
+ }
+ }
+
+ [Fact]
+ public void GroundItem_PropertiesOnlyUpdate_StillReachesBystander()
+ {
+ var wasEnabled = ObjectPropertyList.Enabled;
+ ObjectPropertyList.Enabled = true;
+
+ var (bystanderNs, bystander) = CreateClient(_ownerLoc);
+
+ var item = new Item(0x1234);
+ item.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca);
+
+ try
+ {
+ DrainPendingDelta(item);
+ InvalidateAndFlush(item);
+
+ Assert.True(ReceivedOplInfo(bystanderNs, item.Serial));
+ }
+ finally
+ {
+ item.Delete();
+ DisposeClient(bystanderNs, bystander);
+ ObjectPropertyList.Enabled = wasEnabled;
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs
index 458409705..fb12d0ab6 100644
--- a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs
+++ b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs
@@ -436,6 +436,39 @@ public class ItemByDistanceEnumeratorTests
}
}
+ [Fact]
+ public void ItemByDistanceEnumerator_MatchesBoundsEnumeratorAtMapEdge()
+ {
+ var map = Map.Felucca;
+ var center = new Point3D(5, 5, 0);
+ const int range = 18;
+
+ // Off-map items live in the shared invalid sector, which ring cells past the map edge resolve to
+ var items = new TestItem[1];
+ try
+ {
+ items[0] = CreateItem(map, new Point3D(3, -2, 0));
+
+ var byDistance = new List- ();
+ foreach (var (item, _) in map.GetItemsInRangeByDistance(center, range))
+ {
+ byDistance.Add(item);
+ }
+
+ var inRange = new List
- ();
+ foreach (var item in map.GetItemsInRange(center, range))
+ {
+ inRange.Add(item);
+ }
+
+ Assert.Equal(inRange, byDistance);
+ }
+ finally
+ {
+ DeleteAll(items);
+ }
+ }
+
private static TestItem CreateItem(Map map, Point3D location)
{
var Item = new TestItem(World.NewItem);
diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs
index cb06fe48d..c064623bf 100644
--- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs
+++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs
@@ -386,6 +386,35 @@ public class MultiEnumeratorTests
}
}
+ [Fact]
+ public void MultiEnumerator_NestedQueryDoesNotDisturbOuterDeduplication()
+ {
+ var map = Map.Felucca;
+
+ // Components reach two tiles either side, so at x = 1024 the multi spans sectors 63 and 64
+ var spanning = new SpanningMulti();
+ try
+ {
+ spanning.MoveToWorld(new Point3D(1024, 1000, 0), map);
+
+ var found = new List();
+ foreach (var multi in map.GetMultisInBounds(new Rectangle2D(1000, 990, 48, 20)))
+ {
+ found.Add(multi);
+
+ foreach (var _ in map.GetMultisInBounds(new Rectangle2D(3000, 3000, 4, 4)))
+ {
+ }
+ }
+
+ Assert.Equal([spanning], found);
+ }
+ finally
+ {
+ spanning.Delete();
+ }
+ }
+
private static TestMulti CreateMulti(Map map, Point3D location)
{
var multi = new TestMulti();
@@ -408,5 +437,22 @@ public class MultiEnumeratorTests
{
}
}
+
+ private class SpanningMulti : BaseMulti
+ {
+ // Entries after the first need non-zero flags or MultiComponentList drops them
+ private static readonly MultiComponentList _components = new(
+ [
+ new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background),
+ new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background)
+ ]
+ );
+
+ public SpanningMulti() : base(0x1)
+ {
+ }
+
+ public override MultiComponentList Components => _components;
+ }
}
diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs
index 1ac335f7a..edce65c89 100644
--- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs
+++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs
@@ -293,6 +293,55 @@ public class StaticTileEnumeratorTests
}
}
+ [Fact]
+ public void StaticTileEnumerator_MultiWithEmptyCellDoesNotHideLaterMultis()
+ {
+ var map = Map.Felucca;
+ var location = new Point2D(1101, 1100);
+
+ TestMulti outer = null;
+ TestMulti inner = null;
+ try
+ {
+ // Outer's bounds cover the probe cell but it has no tile there; placing it first puts it
+ // ahead of inner in the sector's multi list
+ outer = new TestMulti(World.NewItem, GappedComponents);
+ outer.MoveToWorld(new Point3D(1100, 1100, 0), map);
+
+ inner = new TestMulti(World.NewItem, SingleTileComponents);
+ inner.MoveToWorld(new Point3D(location.X, location.Y, 20), map);
+
+ var tiles = new List();
+ foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: false, includeMultis: true))
+ {
+ tiles.Add(tile);
+ }
+
+ // Only inner sits at z 20; item ids are masked by tiledata, which CI does not have
+ var single = Assert.Single(tiles);
+ Assert.Equal(20, single.Z);
+ }
+ finally
+ {
+ outer?.Delete();
+ inner?.Delete();
+ }
+ }
+
+ // Entries after the first need non-zero flags or MultiComponentList drops them
+ private static readonly MultiComponentList GappedComponents = new(
+ [
+ new MultiTileEntry(0x1, 0, 0, 0, TileFlag.Background),
+ new MultiTileEntry(0x2, 2, 0, 0, TileFlag.Background)
+ ]
+ );
+
+ private static readonly MultiComponentList SingleTileComponents = new(
+ [
+ new MultiTileEntry(0x3, 0, 0, 0, TileFlag.Background)
+ ]
+ );
+
private static TestMulti CreateMultiWithComponents(Map map, Point3D location)
{
var multi = new TestMulti(World.NewItem);
@@ -302,11 +351,12 @@ public class StaticTileEnumeratorTests
private class TestMulti : BaseMulti
{
- public TestMulti(Serial serial) : base(serial)
- {
- }
+ private readonly MultiComponentList _components;
- public override MultiComponentList Components => DefaultComponents;
+ public TestMulti(Serial serial, MultiComponentList components = null) : base(serial) =>
+ _components = components ?? DefaultComponents;
+
+ public override MultiComponentList Components => _components;
private static readonly MultiComponentList DefaultComponents = new(
[
diff --git a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs
new file mode 100644
index 000000000..5a64a61fe
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs
@@ -0,0 +1,80 @@
+using System.Net.Sockets;
+using Server.Network;
+using Xunit;
+
+namespace Server.Tests.Network;
+
+[Collection("Sequential Server Tests")]
+public class NetworkStatsTests
+{
+ private static byte[] Pattern(int length, int seed)
+ {
+ var data = new byte[length];
+ new System.Random(seed).NextBytes(data);
+ return data;
+ }
+
+ [Fact]
+ public void MaintainSendBuffers_RecordsTheRefusalsItResets()
+ {
+ var previous = NetState._availableMemoryBytes;
+ NetState ns = null;
+ Socket client = null;
+
+ try
+ {
+ ns = PacketTestUtilities.CreateTestNetState(out client);
+ ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin;
+ ns.Account = new MockAccount();
+ var baseSize = ns._socket.SendBuffer.PhysicalSize;
+
+ NetState.MaintainSendBuffers(); // drain whatever earlier tests refused
+ NetState._availableMemoryBytes = 1; // any working set is above 80% of one byte
+
+ for (var i = 0; i < 6; i++)
+ {
+ ns.Send(Pattern(baseSize / 4, i));
+ }
+
+ // The sweep refreshes _availableMemoryBytes, so refusals must happen before it
+ NetState.MaintainSendBuffers();
+ var stats = NetState.GetNetworkStats();
+
+ Assert.True(stats.LastSweep.Ran);
+ Assert.True(stats.LastSweep.CeilingRefusals >= 1);
+
+ // Counters are per sweep, not cumulative
+ NetState.MaintainSendBuffers();
+ Assert.Equal(0, NetState.GetNetworkStats().LastSweep.CeilingRefusals);
+ }
+ finally
+ {
+ NetState._availableMemoryBytes = previous;
+ ns?.Dispose();
+ client?.Close();
+ }
+ }
+
+ [Fact]
+ public void GetNetworkStats_CountsAuthenticatedConnectionsSeparately()
+ {
+ var ns = PacketTestUtilities.CreateTestNetState(out var client);
+
+ try
+ {
+ var before = NetState.GetNetworkStats();
+ ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin;
+ ns.Account = new MockAccount();
+ var after = NetState.GetNetworkStats();
+
+ Assert.Equal(before.Connected, after.Connected);
+ Assert.Equal(before.Authenticated + 1, after.Authenticated);
+ Assert.True(after.Connected >= after.Authenticated);
+ }
+ finally
+ {
+ ns.Dispose();
+ client.Close();
+ }
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs
index e18b999f0..00dfc4054 100644
--- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs
+++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs
@@ -1,3 +1,4 @@
+using System.Buffers.Binary;
using Server.Items;
using Server.Network;
using Xunit;
@@ -196,4 +197,135 @@ public class ContainerPacketTests
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
+
+ [Fact]
+ public void TestContainerContent_CapsAtProtocolLimit()
+ {
+ var cont = new Container(World.NewItem);
+
+ // Past the 3448-entry cap (19-byte entries) so truncation engages.
+ const int itemCount = 5000;
+ for (var i = 0; i < itemCount; i++)
+ {
+ cont.AddItem(new Item(World.NewItem));
+ }
+
+ cont.Map = Map.Felucca;
+
+ var m = new Mobile((Serial)0x1);
+ m.DefaultMobileInit();
+ m.AccessLevel = AccessLevel.Administrator;
+ m.Map = Map.Felucca;
+
+ try
+ {
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ // A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it
+ // on demand instead of exhausting the buffer and disconnecting.
+ ns.Account = new MockAccount();
+
+ var ex = Record.Exception(() => ns.SendContainerContent(m, cont));
+ Assert.Null(ex);
+
+ var span = ns.SendBuffer.GetReadSpan();
+ Assert.Equal((byte)0x3C, span[0]);
+
+ var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]);
+ var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]);
+
+ const int entrySize = 19; // no grid lines negotiated on this NetState
+ var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize;
+
+ Assert.Equal(65517, length);
+ Assert.Equal(length, span.Length);
+ Assert.Equal(expectedMaxEntries, count);
+ }
+ finally
+ {
+ cont.Delete();
+ m.Delete();
+ }
+ }
+
+ [Fact]
+ public void TestContainerContent_CapsAtProtocolLimit_WithGridLines()
+ {
+ var cont = new Container(World.NewItem);
+
+ // Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages.
+ const int itemCount = 5000;
+ for (var i = 0; i < itemCount; i++)
+ {
+ cont.AddItem(new Item(World.NewItem));
+ }
+
+ cont.Map = Map.Felucca;
+
+ var m = new Mobile((Serial)0x1);
+ m.DefaultMobileInit();
+ m.AccessLevel = AccessLevel.Administrator;
+ m.Map = Map.Felucca;
+
+ try
+ {
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines;
+ ns.Account = new MockAccount();
+
+ var ex = Record.Exception(() => ns.SendContainerContent(m, cont));
+ Assert.Null(ex);
+
+ var span = ns.SendBuffer.GetReadSpan();
+ Assert.Equal((byte)0x3C, span[0]);
+
+ var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]);
+ var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]);
+
+ Assert.Equal(65525, length);
+ Assert.Equal(length, span.Length);
+ Assert.Equal(3276, count);
+ }
+ finally
+ {
+ cont.Delete();
+ m.Delete();
+ }
+ }
+
+ [Fact]
+ public void TestContainerContent_RentedBuffer_MatchesReferencePacket()
+ {
+ var cont = new Container(World.NewItem);
+
+ // Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent
+ // rents its buffer instead of using the stack.
+ const int itemCount = 100;
+ for (var i = 0; i < itemCount; i++)
+ {
+ cont.AddItem(new Item(World.NewItem));
+ }
+
+ cont.Map = Map.Felucca;
+
+ var m = new Mobile((Serial)0x1);
+ m.DefaultMobileInit();
+ m.AccessLevel = AccessLevel.Administrator;
+ m.Map = Map.Felucca;
+
+ try
+ {
+ var expected = new ContainerContent(m, cont).Compile();
+
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.SendContainerContent(m, cont);
+
+ var result = ns.SendBuffer.GetReadSpan();
+ AssertThat.Equal(result, expected);
+ }
+ finally
+ {
+ cont.Delete();
+ m.Delete();
+ }
+ }
}
diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs
index 7889c57c8..41444ea56 100644
--- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs
+++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs
@@ -53,24 +53,33 @@ public class AnchoredItemSerializationTests
public void LastMovedAndDecayReset_RoundTripExactly()
{
var item = new Item(0x1F13);
- item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
+ Item restored = null;
- // Sub-minute precision that the old minutes encoding would have destroyed.
- var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123);
- item.LastMoved = moved;
+ try
+ {
+ item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
- item.RestartDecay();
- var decayReset = item.DecayResetTime;
- Assert.NotEqual(default(DateTime), decayReset);
+ // Sub-minute precision that the old minutes encoding would have destroyed.
+ var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123);
+ item.LastMoved = moved;
- var bytes = SerializeItem(item);
+ item.RestartDecay();
+ var decayReset = item.DecayResetTime;
+ Assert.NotEqual(default(DateTime), decayReset);
- var restored = new Item((Serial)0x7ffff123u);
- restored.Deserialize(new BufferReader(bytes));
+ var bytes = SerializeItem(item);
- Assert.Equal(moved, restored.LastMoved);
- Assert.Equal(decayReset, restored.DecayResetTime);
+ // Deserialize enters the copy into the map's sectors, so it must be deleted too.
+ restored = new Item((Serial)0x7ffff123u);
+ restored.Deserialize(new BufferReader(bytes));
- item.Delete();
+ Assert.Equal(moved, restored.LastMoved);
+ Assert.Equal(decayReset, restored.DecayResetTime);
+ }
+ finally
+ {
+ item.Delete();
+ restored?.Delete();
+ }
}
}
diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs
index 18aa27be5..d2fb7b590 100644
--- a/Projects/Server/Items/Container.cs
+++ b/Projects/Server/Items/Container.cs
@@ -390,6 +390,21 @@ public partial class Container : Item
}
}
+ ///
+ /// Direct children are left out of world saves; the container itself is still saved and
+ /// loads empty.
+ ///
+ public virtual bool SkipsChildSerialization => false;
+
+ ///
+ /// Whether was sent to every client in range rather than only to the root
+ /// mobile, the secure-trade partner and openers. Removal of a public child is broadcast; removal of any
+ /// other child reaches only the clients that were sent it. Overrides may only widen this beyond
+ /// , never narrow it: a public container's children always broadcast
+ /// their removal regardless of what an override returns here.
+ ///
+ public virtual bool IsChildPublic(Item child) => IsPublicContainer;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override void OnItemAdded(Item item)
{
diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs
index 03ff9d000..4226f100f 100644
--- a/Projects/Server/Items/Item.cs
+++ b/Projects/Server/Items/Item.cs
@@ -922,7 +922,10 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
}
var info = LookupCompactInfo();
- var items = LookupItems();
+ // Skipping containers never write their child serials: those children are not saved as
+ // top-level records (see SkipSerialization), so a written serial can be handed to an
+ // unrelated new item during the next load's synchronous deserialize loop.
+ var items = this is Container { SkipsChildSerialization: true } ? EmptyItems : LookupItems();
if (m_Direction != Direction.North)
{
@@ -1185,6 +1188,11 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
// are final yet.
LastMoved = Core.Now;
+ // A parented item's removal already reached every client that was sent it (root, trade
+ // parties, openers) via RemoveItem below; sending another remove at oldLocation would
+ // broadcast it to bystanders who never knew about it.
+ var wasContained = Parent != null;
+
if (Parent is Mobile mobile)
{
mobile.RemoveItem(this);
@@ -1202,7 +1210,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
{
m_Map.OnLeave(this);
- if (oldLocation.m_X != 0)
+ if (!wasContained && oldLocation.m_X != 0)
{
SendRemovePacket(oldLocation);
}
@@ -1278,7 +1286,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
}
else if (m_Map != null)
{
- if (oldLocation.m_X != 0)
+ if (!wasContained && oldLocation.m_X != 0)
{
var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket();
@@ -1344,7 +1352,12 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Map.OnLeave(this);
}
- SendRemovePacket();
+ if (m_Parent is not Container)
+ {
+ // A contained item only changes map along with its parent or root; their own
+ // removal already clears this subtree for every client that knew about it.
+ SendRemovePacket();
+ }
}
var items = LookupItems();
@@ -1479,6 +1492,43 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
return;
}
+ // A properties-only invalidation (no Update) on a private child must stay as scoped as every
+ // other private-child send, otherwise a bystander who was never sent the item learns its OPL hash.
+ if (!update && ObjectPropertyList.Enabled && (flags & ItemDelta.Properties) != 0 &&
+ TryGetPrivateParent(out var cont))
+ {
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ SendPrivateOplTo(root, worldLoc);
+
+ if (tradeFrom != root)
+ {
+ SendPrivateOplTo(tradeFrom, worldLoc);
+ }
+
+ if (tradeTo != root && tradeTo != tradeFrom)
+ {
+ SendPrivateOplTo(tradeTo, worldLoc);
+ }
+
+ var privateOpeners = cont.Openers;
+
+ if (privateOpeners != null)
+ {
+ for (var i = 0; i < privateOpeners.Count; ++i)
+ {
+ var mob = privateOpeners[i];
+
+ if (mob != root && mob != tradeFrom && mob != tradeTo)
+ {
+ SendPrivateOplTo(mob, worldLoc);
+ }
+ }
+ }
+
+ return;
+ }
+
// A second item sharing an equipment layer is omitted by SendMobileIncoming (0x78);
// sending it on its own via EquipUpdate/OPL would leave the client with two items on
// one slot. Skip the per-client sends entirely for the dupe.
@@ -1581,7 +1631,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
ClearProperties();
}
- public virtual bool SkipSerialization => false;
+ public virtual bool SkipSerialization => m_Parent is Container { SkipsChildSerialization: true };
[IgnoreDupe]
public ISpawner Spawner
@@ -2926,6 +2976,13 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Parent = null;
}
+ // A skipping container never wrote its children's serials (see Serialize), so this
+ // item's frozen parent reference is stale/unreachable: treat it as a missing parent.
+ if (m_Parent is Container { SkipsChildSerialization: true })
+ {
+ m_Parent = null;
+ }
+
if (m_Parent == null && (parent.IsMobile || parent.IsItem))
{
Timer.DelayCall(Delete);
@@ -3089,6 +3146,13 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Parent = null;
}
+ // A skipping container never wrote its children's serials (see Serialize), so this
+ // item's frozen parent reference is stale/unreachable: treat it as a missing parent.
+ if (m_Parent is Container { SkipsChildSerialization: true })
+ {
+ m_Parent = null;
+ }
+
if (m_Parent == null && (parent.IsMobile || parent.IsItem))
{
Timer.DelayCall(Delete);
@@ -3552,8 +3616,19 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
{
var items = LookupItems();
- if (items.Remove(item))
+ // Bulk removal (Delete, purges) walks children back to front; finding the tail in O(1) keeps
+ // deleting a large container linear instead of quadratic.
+ var index = items.Count - 1;
+
+ if (index < 0 || !ReferenceEquals(items[index], item))
{
+ index = items.IndexOf(item);
+ }
+
+ if (index >= 0)
+ {
+ items.RemoveAt(index);
+
if (this is not Container)
{
AcquireCompactInfo().Version++;
@@ -4018,6 +4093,43 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket();
+ if (TryGetPrivateParent(out var cont))
+ {
+ // Private children are only ever sent to these recipients (see ProcessDelta); nobody else knows them.
+ OutgoingEntityPackets.CreateRemoveEntity(removeEntity, Serial);
+
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ SendRemoveTo(root, worldLoc, removeEntity);
+
+ if (tradeFrom != root)
+ {
+ SendRemoveTo(tradeFrom, worldLoc, removeEntity);
+ }
+
+ if (tradeTo != root && tradeTo != tradeFrom)
+ {
+ SendRemoveTo(tradeTo, worldLoc, removeEntity);
+ }
+
+ var openers = cont.Openers;
+
+ if (openers != null)
+ {
+ for (var i = 0; i < openers.Count; ++i)
+ {
+ var mob = openers[i];
+
+ if (mob != root && mob != tradeFrom && mob != tradeTo)
+ {
+ SendRemoveTo(mob, worldLoc, removeEntity);
+ }
+ }
+ }
+
+ return;
+ }
+
foreach (var state in m_Map.GetClientsInRange(worldLoc, GetMaxUpdateRange()))
{
var m = state.Mobile;
@@ -4030,6 +4142,78 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
}
}
+ private void SendRemoveTo(Mobile m, Point3D worldLoc, ReadOnlySpan removeEntity)
+ {
+ if (m?.NetState != null && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m)))
+ {
+ m.NetState.Send(removeEntity);
+ }
+ }
+
+ // Same recipient gate as SendRemoveTo, plus CanSee: this is an informational OPL push, not
+ // cleanup of a reference the client may already hold regardless of visibility.
+ private void SendPrivateOplTo(Mobile m, Point3D worldLoc)
+ {
+ var ns = m?.NetState;
+
+ if (ns != null && m.CanSee(this) && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m)))
+ {
+ SendOPLPacketTo(ns);
+ }
+ }
+
+ // Shared with IsSentTo so the private-child guard and recipient set (root/trade/openers) each live
+ // in one place.
+ private bool TryGetPrivateParent(out Container cont)
+ {
+ cont = m_Parent as Container;
+ return cont != null && !cont.IsPublicContainer && !cont.IsChildPublic(this);
+ }
+
+ private void GetPrivateChildRecipients(Container cont, out Mobile root, out Mobile tradeFrom, out Mobile tradeTo)
+ {
+ root = cont.RootParent as Mobile;
+
+ var trade = GetSecureTradeCont()?.Trade;
+ // Trade.From/To are unassigned while SecureTrade's own constructor is still adding the
+ // VirtualCheck to each side's container, so both must stay null-conditional.
+ tradeFrom = trade?.From?.Mobile;
+ tradeTo = trade?.To?.Mobile;
+ }
+
+ ///
+ /// Whether 's client is expected to hold this item; gates resends keyed by a
+ /// client-supplied serial.
+ ///
+ public bool IsSentTo(Mobile m)
+ {
+ if (m == null || Deleted)
+ {
+ return false;
+ }
+
+ if (TryGetPrivateParent(out var cont))
+ {
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ if (m == root)
+ {
+ // Mirrors ProcessDelta's own root gate: CanSee and in range.
+ return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
+ var isTradeOrOpener = m == tradeFrom || m == tradeTo || cont.Openers?.Contains(m) == true;
+
+ // Trade parties and openers are re-validated against ProcessDelta's own gate (CanSee,
+ // current map, update range), so an invisible item or a stale Openers entry that hasn't
+ // been pruned yet never resyncs to them.
+ return isTradeOrOpener && m.CanSee(this) && m.Map == m_Map &&
+ m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
+ return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
public virtual int GetDropSound() => -1;
public Point3D GetWorldLocation()
diff --git a/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
index eb79410ae..54d61797c 100644
--- a/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
@@ -85,6 +85,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -115,11 +118,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -202,7 +205,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -225,7 +228,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -233,6 +236,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
index 207928f68..91da4969f 100644
--- a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
@@ -113,6 +113,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -143,11 +146,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -228,7 +231,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -251,7 +254,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -259,6 +262,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
index e5a8d0b91..92e9dcbb2 100644
--- a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
@@ -108,6 +108,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -138,11 +141,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -223,7 +226,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -246,7 +249,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -254,6 +257,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.MultiEnumerator.cs b/Projects/Server/Maps/Map.MultiEnumerator.cs
index b0e069a75..bbd04a53c 100644
--- a/Projects/Server/Maps/Map.MultiEnumerator.cs
+++ b/Projects/Server/Maps/Map.MultiEnumerator.cs
@@ -85,8 +85,6 @@ public partial class Map
public MultiBoundsEnumerable GetMultisInBounds(Rectangle2D bounds, bool makeBoundsInclusive = false) where T : BaseMulti =>
new(this, bounds, makeBoundsInclusive);
- private static readonly HashSet _sharedDupes = [];
-
public ref struct MultiSectorEnumerable(Map map, Point2D loc) where T : BaseMulti
{
public static MultiSectorEnumerable Empty
@@ -193,13 +191,11 @@ public partial class Map
private int _currentVersion;
private Sector _currentSector;
private T _current;
-
+ private HashSet _unanchoredSeen;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public MultiBoundsEnumerator(Map map, Rectangle2D bounds, bool makeBoundsInclusive)
{
- _sharedDupes.Clear();
-
_map = map;
_bounds = bounds;
@@ -255,14 +251,10 @@ public partial class Map
while (++_currentIndex < _currentList.Length)
{
var item = _currentList[_currentIndex];
- if (item is T { Deleted: false } o && bounds.Contains(o.Location))
+ if (item is T { Deleted: false } o && bounds.Contains(o.Location) && ShouldYieldFromCurrentSector(map, o))
{
- // Multis can span multiple sectors, so we need to deduplicate
- if (_sharedDupes.Add(o.Serial))
- {
- _current = o;
- return true;
- }
+ _current = o;
+ return true;
}
}
}
@@ -290,6 +282,27 @@ public partial class Map
}
}
+ // A multi is listed in every sector it spans, but its location is in exactly one sector, which the bounds
+ // filter guarantees is visited. Yield it from that sector only. A multi missing from its location's sector
+ // (components changed after it was placed) falls back to a per-enumerator set, so nested queries stay independent.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool ShouldYieldFromCurrentSector(Map map, T multi)
+ {
+ var home = map.GetSector(map.Bound(multi.Location));
+
+ if (home == _currentSector)
+ {
+ return true;
+ }
+
+ if (home.Multis.Contains(multi))
+ {
+ return false;
+ }
+
+ return (_unanchoredSeen ??= []).Add(multi.Serial);
+ }
+
public T Current
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/Projects/Server/Maps/Map.StaticTileEnumerator.cs b/Projects/Server/Maps/Map.StaticTileEnumerator.cs
index 27075b59d..73fec0684 100644
--- a/Projects/Server/Maps/Map.StaticTileEnumerator.cs
+++ b/Projects/Server/Maps/Map.StaticTileEnumerator.cs
@@ -99,7 +99,12 @@ public partial class Map
{
_tiles = multi.Components.Tiles[offsetX][offsetY];
_index = -1;
- return SetTile();
+
+ // A multi can have no tile at this cell; later multis in the sector still may
+ if (SetTile())
+ {
+ return true;
+ }
}
}
diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs
index 4504c2115..c0e800bab 100644
--- a/Projects/Server/Mobiles/Mobile.cs
+++ b/Projects/Server/Mobiles/Mobile.cs
@@ -5209,7 +5209,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro
{
state.SendLiftReject(reject);
- if (item.Deleted)
+ // A rejection must not resynchronize state this client was never sent (client-supplied serial).
+ if (item.Deleted || !item.IsSentTo(this))
{
return;
}
diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs
index 287fa78a4..f100f85c3 100644
--- a/Projects/Server/Network/NetState/NetState.Network.cs
+++ b/Projects/Server/Network/NetState/NetState.Network.cs
@@ -56,10 +56,7 @@ public partial class NetState
internal static long _availableMemoryBytes;
private static Timer.DelayCallTimer _maintenanceTimer;
- private static long _lastTierCapacityBytes;
- private static int _lastTierInUse;
- private static int _lastTierRetainFloor;
- private static long _lastBaseCapacityBytes;
+ private static SendBufferSweep _lastSweep;
private static readonly Queue _disposed = [];
private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds
@@ -238,14 +235,23 @@ public partial class NetState
_capRefusals = 0;
var stats = _socketManager.Maintain();
- var changed = stats.TierCapacityBytes != _lastTierCapacityBytes ||
- stats.TierInUse != _lastTierInUse ||
- stats.TierRetainFloor != _lastTierRetainFloor ||
- stats.BaseCapacityBytes != _lastBaseCapacityBytes;
- _lastTierCapacityBytes = stats.TierCapacityBytes;
- _lastTierInUse = stats.TierInUse;
- _lastTierRetainFloor = stats.TierRetainFloor;
- _lastBaseCapacityBytes = stats.BaseCapacityBytes;
+ var changed = stats.TierCapacityBytes != _lastSweep.TierCapacityBytes ||
+ stats.TierInUse != _lastSweep.TierInUse ||
+ stats.TierRetainFloor != _lastSweep.TierRetainFloor ||
+ stats.BaseCapacityBytes != _lastSweep.BaseCapacityBytes;
+ _lastSweep = new SendBufferSweep(
+ true,
+ Core.TickCount,
+ stats.TierCapacityBytes,
+ stats.TierInUse,
+ stats.TierRetainFloor,
+ stats.BuffersReleased,
+ stats.BaseCapacityBytes,
+ stats.BaseBuffersReleased,
+ stats.GrowthRefusals,
+ capRefusals,
+ ceilingRefusals
+ );
// Quiet unless something moved
if (changed || stats.BuffersReleased > 0 || stats.BaseBuffersReleased > 0 || stats.GrowthRefusals > 0 ||
@@ -266,6 +272,48 @@ public partial class NetState
}
}
+ ///
+ /// Read-only diagnostics snapshot; never rotates the maintenance window. Cold path: allocates.
+ ///
+ public static NetworkStats GetNetworkStats()
+ {
+ var manager = _socketManager;
+ var tiers = manager == null ? [] : new SendBufferTierUsage[manager.SendBufferTierCount];
+ for (var i = 0; i < tiers.Length; i++)
+ {
+ var tier = manager.GetSendBufferTierStats(i);
+ tiers[i] = new SendBufferTierUsage(tier.BufferSize, tier.Capacity, tier.InUse, tier.RetainFloor);
+ }
+
+ var authenticated = 0;
+ foreach (var ns in _instances)
+ {
+ if (ns.Account != null)
+ {
+ authenticated++;
+ }
+ }
+
+ return new NetworkStats(
+ manager?.ConnectedCount ?? 0,
+ manager?.MaxSockets ?? MaxConnections,
+ authenticated,
+ RecvBufferSize,
+ SendBufferSize,
+ MaxSendBufferSize,
+ InitialRecvBufferSize,
+ InitialSendBufferSize,
+ _sendBufferGrowthBudget,
+ _memoryCeilingPercent,
+ _availableMemoryBytes,
+ tiers,
+ _lastSweep,
+ _throttled.Count + _throttledPending.Count,
+ _flushPending.Count,
+ _pendingDisconnects.Count
+ );
+ }
+
///
/// Slabs each base pool is divided into, after the transport applies its minimum slab size.
///
diff --git a/Projects/Server/Network/NetworkStats.cs b/Projects/Server/Network/NetworkStats.cs
new file mode 100644
index 000000000..370b8b7ff
--- /dev/null
+++ b/Projects/Server/Network/NetworkStats.cs
@@ -0,0 +1,91 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: NetworkStats.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 . *
+ *************************************************************************/
+
+namespace Server.Network;
+
+/// One send buffer growth tier's pool usage, in buffers of bytes.
+/// Physical size of every buffer in this tier.
+/// Buffers the tier's allocated slabs hold.
+/// Buffers currently handed out.
+/// Peak usage the retention window is still holding capacity for.
+public readonly record struct SendBufferTierUsage(int BufferSize, int Capacity, int InUse, int RetainFloor);
+
+///
+/// What the last once-a-minute send buffer maintenance sweep saw. The refusal counts cover the
+/// minute before that sweep only.
+///
+/// False until the first sweep; every other field is then meaningless.
+/// when the sweep ran; compare by subtraction only.
+/// Slab capacity allocated across the growth tiers.
+/// Growth-tier buffers handed out, summed across tiers.
+/// Retention floors summed across tiers, in buffers.
+/// Growth-tier buffers the sweep returned to the OS.
+/// Slab capacity allocated across the base and pre-auth pools.
+/// Base and pre-auth buffers the sweep returned to the OS.
+/// Growth refused because the tiers were at the growth budget.
+/// Growth refused because the buffer was already at the maximum size.
+/// Growth refused because the process was above the memory ceiling.
+public readonly record struct SendBufferSweep(
+ bool Ran,
+ long Tick,
+ long TierCapacityBytes,
+ int TierInUse,
+ int TierRetainFloor,
+ int TierBuffersReleased,
+ long BaseCapacityBytes,
+ int BaseBuffersReleased,
+ int BudgetRefusals,
+ int CapRefusals,
+ int CeilingRefusals
+);
+
+///
+/// Read-only snapshot of the network layer for diagnostics. Connection counts and tier usage are
+/// live; everything under is up to a minute old. Cold path: allocates.
+///
+/// Sockets the transport currently holds, authenticated or not.
+/// Socket slots the transport was built with.
+/// Connections past login, and so holding full-size buffers.
+/// Base receive buffer size.
+/// Base send buffer size.
+/// Largest size a send buffer can grow to.
+/// Pre-auth receive buffer size; 0 when pre-auth buffers are off.
+/// Pre-auth send buffer size; 0 when pre-auth buffers are off.
+/// Bytes the growth tiers may hold in total.
+/// Growth stops once the process exceeds this share of available memory.
+/// Memory available to the process as of the last sweep.
+/// Live usage of each growth tier, smallest first.
+/// What the last maintenance sweep saw.
+/// Connections whose parser is paused on a packet throttle.
+/// Connections with unsent bytes queued for the next flush.
+/// Connections closing after their final flush.
+public readonly record struct NetworkStats(
+ int Connected,
+ int MaxConnections,
+ int Authenticated,
+ int RecvBufferSize,
+ int SendBufferSize,
+ int MaxSendBufferSize,
+ int InitialRecvBufferSize,
+ int InitialSendBufferSize,
+ long SendBufferGrowthBudget,
+ int MemoryCeilingPercent,
+ long AvailableMemoryBytes,
+ SendBufferTierUsage[] Tiers,
+ SendBufferSweep LastSweep,
+ int Throttled,
+ int FlushPending,
+ int PendingDisconnects
+);
diff --git a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
index fdb71bf90..b1d846e7b 100644
--- a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
+++ b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
@@ -16,6 +16,7 @@
using System;
using System.Buffers;
using System.IO;
+using Server.Buffers;
using Server.Logging;
namespace Server.Network;
@@ -166,6 +167,10 @@ public static class OutgoingContainerPackets
ns.Send(writer.Span);
}
+ // 0x3C's length and item-count fields are both 16-bit, so a container's contents can never
+ // legally fill more than this many bytes regardless of how many items it actually holds.
+ private const int MaxContainerContentBytes = ushort.MaxValue;
+
public static void SendContainerContent(this NetState ns, Mobile beholder, Item beheld)
{
if (ns.CannotSendPackets())
@@ -175,43 +180,63 @@ public static class OutgoingContainerPackets
var items = beheld.Items;
var count = items.Count;
+ var entrySize = ns.ContainerGridLines ? 20 : 19;
- var writer = new SpanWriter(stackalloc byte[5 + items.Count * (ns.ContainerGridLines ? 20 : 19)]);
- writer.Write((byte)0x3C); // Packet ID
- writer.Seek(4, SeekOrigin.Current); // Length & written count
+ var maxEntries = (MaxContainerContentBytes - 5) / entrySize;
+ var entriesToSend = Math.Min(count, maxEntries);
+ var length = 5 + entriesToSend * entrySize;
- var written = 0;
+ // A container holding thousands of items would blow the stack; rent from the pool past a
+ // small, safe threshold instead.
+ byte[] rented = null;
+ var buffer = length <= 1024 ? stackalloc byte[length] : rented = STArrayPool.Shared.Rent(length);
- for (var i = 0; i < count; ++i)
+ try
{
- var child = items[i];
+ var writer = new SpanWriter(buffer[..length]);
+ writer.Write((byte)0x3C); // Packet ID
+ writer.Seek(4, SeekOrigin.Current); // Length & written count
- if (!child.Deleted && beholder.CanSee(child))
+ var written = 0;
+
+ for (var i = 0; i < count && written < entriesToSend; ++i)
{
- var loc = child.Location;
+ var child = items[i];
- writer.Write(child.Serial);
- writer.Write((ushort)child.ItemID);
- writer.Write((byte)0); // signed, itemID offset
- writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue));
- writer.Write((short)loc.X);
- writer.Write((short)loc.Y);
- if (ns.ContainerGridLines)
+ if (!child.Deleted && beholder.CanSee(child))
{
- writer.Write((byte)0); // Grid Location?
- }
- writer.Write(beheld.Serial);
- writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue));
+ var loc = child.Location;
- ++written;
+ writer.Write(child.Serial);
+ writer.Write((ushort)child.ItemID);
+ writer.Write((byte)0); // signed, itemID offset
+ writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue));
+ writer.Write((short)loc.X);
+ writer.Write((short)loc.Y);
+ if (ns.ContainerGridLines)
+ {
+ writer.Write((byte)0); // Grid Location?
+ }
+ writer.Write(beheld.Serial);
+ writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue));
+
+ ++written;
+ }
+ }
+
+ writer.Seek(1, SeekOrigin.Begin);
+ writer.Write((ushort)writer.BytesWritten);
+ writer.Write((ushort)written);
+ writer.Seek(0, SeekOrigin.End);
+
+ ns.Send(writer.Span);
+ }
+ finally
+ {
+ if (rented != null)
+ {
+ STArrayPool.Shared.Return(rented);
}
}
-
- writer.Seek(1, SeekOrigin.Begin);
- writer.Write((ushort)writer.BytesWritten);
- writer.Write((ushort)written);
- writer.Seek(0, SeekOrigin.End);
-
- ns.Send(writer.Span);
}
}
diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index ad1a069c1..3730f1dc8 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -39,8 +39,8 @@
-
-
+
+
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs
index 20d729c0f..667995278 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs
@@ -371,6 +371,9 @@ public class BitmapAStarAlgorithmTests
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 2;
+ // Pinned: promotion needs both Finds inside the window, and Linux's coarse TickCount can
+ // step several ms between two back-to-back Finds.
+ StepCache.Instance.MissPromotionWindowMs = 30_000;
var map = Map.Maps[1];
var stub = new DefaultWalkerStub();
diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
index 7b630173c..b4257f112 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
@@ -14,8 +15,14 @@ namespace Server.Tests.Pathfinding;
/// layers, and LRU eviction.
///
[Collection("Sequential Pathfinding Tests")]
-public class StepCacheLifecycleTests
+public class StepCacheLifecycleTests : IDisposable
{
+ // The window is a knob on the singleton and Clear() leaves it alone, so a test that narrows
+ // it would otherwise leak the narrow window into every later test in the collection.
+ private readonly uint _savedWindowMs = StepCache.Instance.MissPromotionWindowMs;
+
+ public void Dispose() => StepCache.Instance.MissPromotionWindowMs = _savedWindowMs;
+
/// Resets to a known state and returns the singleton.
private static StepCache FreshCache(int promotionThreshold)
{
diff --git a/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs
new file mode 100644
index 000000000..ed399714d
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using Server.Items;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class DoorChainTests
+{
+ [Fact]
+ public void GetChain_LoopNotThroughStart_YieldsEachDoorOnce()
+ {
+ var a = new MetalDoor(DoorFacing.WestCW);
+ var b = new MetalDoor(DoorFacing.WestCW);
+ var c = new MetalDoor(DoorFacing.WestCW);
+
+ try
+ {
+ // A -> B -> C -> B: the chain never returns to A
+ a.Link = b;
+ b.Link = c;
+ c.Link = b;
+
+ var chain = new List();
+ foreach (var door in a.GetChain())
+ {
+ chain.Add(door);
+ Assert.True(chain.Count <= 3, "Chain enumeration did not terminate");
+ }
+
+ Assert.Equal([a, b, c], chain);
+ }
+ finally
+ {
+ a.Delete();
+ b.Delete();
+ c.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs
new file mode 100644
index 000000000..01a58a697
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Accounting;
+using Server.Items;
+using Server.Network;
+using Server.Tests.Network;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseChildPublicTests
+{
+ // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the
+ // equip is silently refused and the corpse's worn-gear list stays empty.
+ private static VikingSword EquipSword(Mobile owner)
+ {
+ var sword = new VikingSword { Layer = Layer.OneHanded };
+ Assert.True(owner.EquipItem(sword));
+ return sword;
+ }
+
+ [Fact]
+ public void IsChildPublic_HumanCorpseWithWornGear_ReturnsTrue()
+ {
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+
+ var wornItem = EquipSword(owner);
+
+ var corpse = new Corpse(owner, owner.Items);
+
+ try
+ {
+ Assert.Equal(0x2006, corpse.ItemID);
+ Assert.True(((Body)corpse.Amount).IsHuman);
+ Assert.True(corpse.IsChildPublic(wornItem));
+ }
+ finally
+ {
+ corpse.Delete();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse()
+ {
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+
+ var wornItem = EquipSword(owner);
+
+ var corpse = new Corpse(owner, owner.Items);
+
+ var lootItem = new Gold(100);
+ corpse.DropItem(lootItem);
+
+ try
+ {
+ Assert.Equal(0x2006, corpse.ItemID);
+ Assert.True(((Body)corpse.Amount).IsHuman);
+ Assert.False(corpse.IsChildPublic(lootItem));
+ }
+ finally
+ {
+ corpse.Delete();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_BoneCorpse_ReturnsFalse()
+ {
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+
+ var wornItem = EquipSword(owner);
+
+ var corpse = new Corpse(owner, owner.Items);
+
+ try
+ {
+ corpse.ItemID = 0xECA;
+
+ Assert.True(((Body)corpse.Amount).IsHuman);
+ Assert.NotEqual(0x2006, corpse.ItemID);
+ Assert.False(corpse.IsChildPublic(wornItem));
+ }
+ finally
+ {
+ corpse.Delete();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_NonHumanCorpse_ReturnsFalse()
+ {
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0xC9;
+
+ var wornItem = EquipSword(owner);
+
+ var corpse = new Corpse(owner, owner.Items);
+
+ try
+ {
+ Assert.Equal(0x2006, corpse.ItemID);
+ Assert.False(((Body)corpse.Amount).IsHuman);
+ Assert.False(corpse.IsChildPublic(wornItem));
+ }
+ finally
+ {
+ corpse.Delete();
+ owner.Delete();
+ }
+ }
+
+ private static bool ReceivedRemove(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength];
+ OutgoingEntityPackets.CreateRemoveEntity(expected, serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ [Fact]
+ public void HumanCorpse_WornGearRemovalBroadcasts_LootRemovalDoesNot()
+ {
+ var corpseLoc = new Point3D(1500, 1500, 0);
+
+ var bystanderNs = PacketTestUtilities.CreateTestNetState();
+ bystanderNs.Account = new MockAccount();
+ var bystander = new Mobile(World.NewMobile);
+ bystander.DefaultMobileInit();
+ bystanderNs.Mobile = bystander;
+ bystander.NetState = bystanderNs;
+ bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca);
+
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+
+ var sword = new VikingSword();
+ var corpse = new Corpse(owner, [sword]);
+ corpse.AddItem(sword);
+
+ var lootItem = new Gold(100);
+ corpse.DropItem(lootItem);
+
+ corpse.MoveToWorld(corpseLoc, Map.Felucca);
+
+ Container pouch = null;
+
+ try
+ {
+ // A container not in the world is a valid destination; only the removal from the corpse matters here.
+ pouch = new Container(0xE75);
+ pouch.AddItem(sword);
+
+ Assert.True(ReceivedRemove(bystanderNs, sword.Serial));
+
+ lootItem.Delete();
+ Assert.False(ReceivedRemove(bystanderNs, lootItem.Serial));
+ }
+ finally
+ {
+ pouch?.Delete();
+ corpse.Delete();
+ owner.Delete();
+ bystanderNs.Mobile = null;
+ bystanderNs.Dispose();
+ bystander.Delete();
+ }
+ }
+
+ [Fact]
+ public void HumanCorpse_LiftingWornGear_BroadcastsRemoval()
+ {
+ var corpseLoc = new Point3D(1500, 1500, 0);
+
+ var bystanderNs = PacketTestUtilities.CreateTestNetState();
+ bystanderNs.Account = new MockAccount();
+ var bystander = new Mobile(World.NewMobile);
+ bystander.DefaultMobileInit();
+ bystanderNs.Mobile = bystander;
+ bystander.NetState = bystanderNs;
+ bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca);
+
+ var owner = new Mobile(World.NewMobile);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+ owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca);
+
+ var sword = new VikingSword { Layer = Layer.OneHanded };
+ var corpse = new Corpse(owner, [sword]);
+ corpse.AddItem(sword);
+ corpse.MoveToWorld(corpseLoc, Map.Felucca);
+
+ try
+ {
+ // Lifting takes the piece off the worn list before the remove is sent; the remove must
+ // still reach everyone who was shown it worn.
+ owner.Lift(sword, sword.Amount, out var rejected, out _);
+
+ Assert.False(rejected);
+ Assert.DoesNotContain(sword, corpse.EquipItems);
+ Assert.True(ReceivedRemove(bystanderNs, sword.Serial));
+ }
+ finally
+ {
+ owner.Holding = null;
+ sword.Delete();
+ corpse.Delete();
+ owner.Delete();
+ bystanderNs.Mobile = null;
+ bystanderNs.Dispose();
+ bystander.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs
new file mode 100644
index 000000000..26556155e
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+using Server;
+using Server.Items;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseLootTests
+{
+ [Fact]
+ public void LiftedWornGear_PutBack_StaysLoot()
+ {
+ var owner = new Mobile((Serial)0x1);
+ owner.DefaultMobileInit();
+ owner.Body = 0x190;
+
+ // Weapons take their layer from tiledata, which CI doesn't load.
+ var sword = new VikingSword { Layer = Layer.OneHanded };
+ Assert.True(owner.EquipItem(sword));
+
+ var corpse = new Corpse(owner, new List- { sword });
+ corpse.DropItem(sword);
+
+ try
+ {
+ Assert.Contains(sword, corpse.EquipItems);
+
+ corpse.OnItemLifted(owner, sword);
+ sword.Internalize();
+ corpse.DropItem(sword);
+
+ Assert.Same(corpse, sword.Parent);
+ Assert.DoesNotContain(sword, corpse.EquipItems);
+ }
+ finally
+ {
+ corpse.Delete();
+ owner.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs
index 40ad7c6d7..b5cec8aa6 100644
--- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs
@@ -21,13 +21,21 @@ public class CorpsePacketTests
var c = new Corpse(m, m.Items);
- var expected = new CorpseEquip(m, c).Compile();
+ try
+ {
+ var expected = new CorpseEquip(m, c).Compile();
- using var ns = PacketTestUtilities.CreateTestNetState();
- ns.SendCorpseEquip(m, c);
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.SendCorpseEquip(m, c);
- var result = ns.SendBuffer.GetReadSpan();
- AssertThat.Equal(result, expected);
+ var result = ns.SendBuffer.GetReadSpan();
+ AssertThat.Equal(result, expected);
+ }
+ finally
+ {
+ c.Delete();
+ weapon.Delete();
+ }
}
[Theory]
@@ -43,14 +51,22 @@ public class CorpsePacketTests
var c = new Corpse(m, m.Items);
- using var ns = PacketTestUtilities.CreateTestNetState();
- ns.ProtocolChanges = changes;
+ try
+ {
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.ProtocolChanges = changes;
- var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile();
+ var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile();
- ns.SendCorpseContent(m, c);
+ ns.SendCorpseContent(m, c);
- var result = ns.SendBuffer.GetReadSpan();
- AssertThat.Equal(result, expected);
+ var result = ns.SendBuffer.GetReadSpan();
+ AssertThat.Equal(result, expected);
+ }
+ finally
+ {
+ c.Delete();
+ weapon.Delete();
+ }
}
}
diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs
new file mode 100644
index 000000000..633025042
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Server;
+using Server.Items;
+using Server.Mobiles;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class TreasureMapChestGuardianTests
+{
+ // A treasure.cfg chest site with room to dig the chest up and spawn its guardians.
+ private const int SiteX = 2643;
+ private const int SiteY = 851;
+
+ // The guardian list starts null and is created by the first guardian added. A level 0 chest
+ // stays shut to players until its guardians are dead.
+ [Fact]
+ public void AddToGuardians_OnNewChest_KeepsLevelZeroChestShut()
+ {
+ var map = Map.Felucca;
+ var location = new Point3D(5000, 610, 0);
+ var player = CreatePlayerMobile(map, location);
+ var guardian = new Mobile(World.NewMobile);
+ guardian.DefaultMobileInit();
+ var chest = new TreasureMapChest(0);
+
+ try
+ {
+ Assert.Null(chest.Guardians);
+
+ chest.AddToGuardians(guardian);
+
+ Assert.Same(guardian, Assert.Single(chest.Guardians));
+ Assert.True(chest.CheckLocked(player));
+
+ guardian.Delete();
+
+ Assert.False(chest.CheckLocked(player));
+ }
+ finally
+ {
+ chest.Delete();
+ guardian.Delete();
+ player.Delete();
+ }
+ }
+
+ // Drives the real dig, one DigTimer tick per call, until the chest surfaces and its guardians
+ // spawn. Levels 0 and 2+ spawn guardians; level 1 spawns none.
+ [SkippableTheory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public void Dig_AddsSpawnedGuardiansToTheChest(int level)
+ {
+ TileDataRequirement.SkipIfMissing();
+
+ var map = Map.Felucca;
+ var site = new Point3D(SiteX, SiteY, map.GetAverageZ(SiteX, SiteY));
+ var player = CreatePlayerMobile(map, new Point3D(site.X + 1, site.Y, site.Z));
+ var treasureMap = new TreasureMap(level, map);
+
+ try
+ {
+ Assert.True(map.CanFit(site.X, site.Y, site.Z, 16, true));
+ Assert.True(player.BeginAction());
+
+ var timerType = typeof(TreasureMap).GetNestedType("DigTimer", BindingFlags.NonPublic)!;
+ var onTick = timerType.GetMethod("OnTick", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var digTimer = Activator.CreateInstance(timerType, player, treasureMap, site, map);
+
+ for (var i = 0; i < 30 && !treasureMap.Completed; i++)
+ {
+ onTick.Invoke(digTimer, BindingFlags.DoNotWrapExceptions, null, null, null);
+ }
+
+ var chest = FindChest(map, site);
+
+ Assert.True(treasureMap.Completed);
+ Assert.NotNull(chest);
+ Assert.NotNull(chest.Guardians);
+ Assert.NotEmpty(chest.Guardians);
+ }
+ finally
+ {
+ Cleanup(map, site);
+ treasureMap.Delete();
+ player.Delete();
+ }
+ }
+
+ private static TreasureMapChest FindChest(Map map, Point3D site)
+ {
+ foreach (var chest in map.GetItemsInRange(site, 0))
+ {
+ return chest;
+ }
+
+ return null;
+ }
+
+ // Sweeps the site rather than trusting references, so a dig that throws partway still leaves
+ // nothing behind for the next case: the chest, both dirt piles and any guardian spawned so far.
+ private static void Cleanup(Map map, Point3D site)
+ {
+ var leftovers = new List();
+
+ foreach (var m in map.GetMobilesInRange(site, 3))
+ {
+ leftovers.Add(m);
+ }
+
+ foreach (var item in map.GetItemsInRange(site, 1))
+ {
+ leftovers.Add(item);
+ }
+
+ foreach (var item in map.GetItemsInRange(site, 1))
+ {
+ leftovers.Add(item);
+ }
+
+ foreach (var entity in leftovers)
+ {
+ entity.Delete();
+ }
+ }
+
+ private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
+ {
+ var mobile = new PlayerMobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ mobile.MoveToWorld(location, map);
+ return mobile;
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
new file mode 100644
index 000000000..cc0ccad42
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
@@ -0,0 +1,111 @@
+using Server.Items;
+using Xunit;
+
+namespace Server.Tests.Items;
+
+[Collection("Sequential UOContent Tests")]
+public class VendorBuybackPackTests
+{
+ [Fact]
+ public void AddBuyback_EvictsOldestFirst_WhenFull()
+ {
+ var pack = new VendorBuybackPack();
+ var items = new Item[5];
+
+ try
+ {
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i] = new Item(0x1234);
+ pack.AddBuyback(items[i], 3);
+ }
+
+ Assert.Equal(3, pack.Items.Count);
+ Assert.True(items[0].Deleted);
+ Assert.True(items[1].Deleted);
+ Assert.Same(items[2], pack.Items[0]);
+ Assert.Same(items[3], pack.Items[1]);
+ Assert.Same(items[4], pack.Items[2]);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ [Fact]
+ public void AddBuyback_WithNoCapacity_ConsumesTheItem()
+ {
+ var pack = new VendorBuybackPack();
+ var item = new Item(0x1234);
+
+ try
+ {
+ pack.AddBuyback(item, 0);
+
+ Assert.True(item.Deleted);
+ Assert.Empty(pack.Items);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ // DropItem rejects deleted items, so the stale entry is forced into Items directly.
+ [Fact]
+ public void AddBuyback_SkipsAlreadyDeletedEntry_WithoutSpinning()
+ {
+ var pack = new VendorBuybackPack();
+ var real1 = new Item(0x1234);
+ var stale = new Item(0x1234);
+ var real2 = new Item(0x1234);
+
+ try
+ {
+ pack.AddBuyback(real1, 100);
+
+ stale.Delete();
+ pack.Items.Insert(0, stale);
+
+ pack.AddBuyback(real2, 1);
+
+ Assert.True(real1.Deleted);
+ Assert.True(stale.Deleted);
+ Assert.Single(pack.Items);
+ Assert.Same(real2, pack.Items[0]);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ [Fact]
+ public void Purge_DeletesEveryChild()
+ {
+ var pack = new VendorBuybackPack();
+ var items = new Item[10];
+
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i] = new Item(0x1234);
+ pack.AddBuyback(items[i], 250);
+ }
+
+ try
+ {
+ pack.Purge();
+
+ Assert.Empty(pack.Items);
+ for (var i = 0; i < items.Length; i++)
+ {
+ Assert.True(items[i].Deleted);
+ }
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs
new file mode 100644
index 000000000..a46c56003
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs
@@ -0,0 +1,40 @@
+using Server;
+using Server.Items;
+using Server.Misc;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseNotorietyTests
+{
+ [Theory]
+ [InlineData(0, Notoriety.Innocent)] // Human body
+ [InlineData(746, Notoriety.Innocent)] // Horrific Beast, a monster body
+ [InlineData(0, Notoriety.Murderer, 5)]
+ [InlineData(746, Notoriety.Murderer, 5)]
+ public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0)
+ {
+ var victim = new PlayerMobile(World.NewMobile);
+ victim.DefaultMobileInit();
+ victim.Kills = kills;
+ victim.BodyMod = bodyMod;
+
+ var looter = new PlayerMobile(World.NewMobile);
+ looter.DefaultMobileInit();
+
+ var corpse = new Corpse(victim, []);
+
+ try
+ {
+ Assert.Equal(expected, NotorietyHandlers.CorpseNotoriety(looter, corpse));
+ }
+ finally
+ {
+ corpse.Delete();
+ victim.Delete();
+ looter.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs
new file mode 100644
index 000000000..de6825c9f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using Server.Configurations;
+using Server.Misc;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class EmailTests
+{
+ [Fact]
+ public void SendCrashEmailDoesNothingWhileEmailIsDisabled()
+ {
+ Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email");
+
+ // Missing input must be ignored while email is disabled.
+ var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log");
+
+ Email.SendCrashEmail(report);
+ }
+
+ [Fact]
+ public void SendQueueEmailDoesNothingWhileEmailIsDisabled()
+ {
+ Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email");
+
+ Email.SendQueueEmail(null, "Other");
+ }
+
+ [Fact]
+ public void RetryWaitsDoubleFromTheConfiguredDelayAndStopAtTheLastAttempt()
+ {
+ TimeSpan[] expected =
+ [
+ TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(24)
+ ];
+
+ Assert.Equal(expected, Email.RetryDelays(5, 3));
+ }
+
+ [Theory]
+ [InlineData(int.MinValue)]
+ [InlineData(0)]
+ [InlineData(1)]
+ public void FewerThanTwoAttemptsNeverWait(int attempts)
+ {
+ Assert.Empty(Email.RetryDelays(attempts, 3));
+ }
+
+ [Theory]
+ [InlineData(8)]
+ [InlineData(int.MaxValue)]
+ public void RetryWaitsNeverOverflowAndStopDoublingAtAnHour(int firstDelaySeconds)
+ {
+ var delays = Email.RetryDelays(64, firstDelaySeconds);
+
+ Assert.All(delays, delay => Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromHours(1)));
+ Assert.Equal(TimeSpan.FromHours(1), delays[^1]);
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs
new file mode 100644
index 000000000..383e2e103
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs
@@ -0,0 +1,95 @@
+using System;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class DetectHiddenTimerTests
+{
+ private sealed class DetectingCreature : PetTestStub
+ {
+ public override bool PlayerRangeSensitive => false;
+ }
+
+ private sealed class DetectingAI : BaseAI
+ {
+ public int Detections { get; private set; }
+
+ public DetectingAI(BaseCreature creature) : base(creature)
+ {
+ }
+
+ public override bool Think() => true;
+
+ public override void DetectHidden() => Detections++;
+ }
+
+ [SkippableTheory]
+ [InlineData(0, 108000, 132000)]
+ [InlineData(1, 108000, 132000)]
+ [InlineData(100, 108000, 132000)]
+ [InlineData(250, 108000, 132000)]
+ [InlineData(251, 107100, 130900)]
+ [InlineData(1000, 27000, 33000)]
+ [InlineData(30000, 900, 1100)]
+ public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry(
+ int intelligence, long minDelay, long maxDelay)
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ Core._tickCount = 0;
+ Timer.Init(0);
+ var creature = new DetectingCreature();
+ DetectingAI ai = null;
+
+ try
+ {
+ creature.Int = Math.Max(1, intelligence);
+ if (intelligence == 0)
+ {
+ creature.AddStatMod(new StatMod(StatType.Int, "DetectHiddenZeroInt", -1, TimeSpan.Zero));
+ }
+
+ Assert.Equal(intelligence, creature.Int);
+ creature.Skills.DetectHidden.Base = 100;
+ creature.MoveToWorld(new Point3D(1500, 1600, Map.Felucca.GetAverageZ(1500, 1600)), Map.Felucca);
+ creature.AIObject.AITimer.Stop();
+ ai = new DetectingAI(creature);
+ ai.AITimer.Activate();
+
+ AdvanceUntil(() => ai.Detections > 0, 1024);
+ Assert.Equal(1, ai.Detections);
+ Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay);
+ Assert.True(ai.AITimer.Running);
+
+ var deadline = ai._nextDetectHidden;
+ while (Core.TickCount + 8 < deadline)
+ {
+ Core._tickCount += 8;
+ Timer.Slice(Core.TickCount);
+ }
+
+ Assert.Equal(1, ai.Detections);
+ AdvanceUntil(() => ai.Detections > 1, 1024);
+ Assert.Equal(2, ai.Detections);
+ Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay);
+ }
+ finally
+ {
+ ai?.AITimer.Stop();
+ creature.Delete();
+ }
+ }
+
+ private static void AdvanceUntil(Func condition, int milliseconds)
+ {
+ for (var elapsed = 0; !condition() && elapsed < milliseconds; elapsed += 8)
+ {
+ Core._tickCount += 8;
+ Timer.Slice(Core.TickCount);
+ }
+
+ Assert.True(condition(), "The AI timer did not reach the expected detection.");
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs
new file mode 100644
index 000000000..ec11a3ba5
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class GuardFriendlyFireTests : IDisposable
+{
+ private readonly List _created = [];
+
+ private (PlayerMobile owner, PetTestStub pet) Spawn()
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ var z = Map.Felucca.GetAverageZ(1500, 1600);
+ var owner = new PlayerMobile();
+ owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca);
+ _created.Add(owner);
+ var pet = OtherPet(owner, new Point3D(1499, 1600, z));
+ pet.IssueOrder(OrderType.Guard, owner);
+ pet.AIObject.AITimer.Stop();
+ return (owner, pet);
+ }
+
+ private PetTestStub OtherPet(PlayerMobile owner, Point3D location)
+ {
+ var pet = new PetTestStub();
+ pet.MoveToWorld(location, owner.Map);
+ pet.SetControlMaster(owner);
+ pet.AIObject.AITimer.Stop();
+ _created.Add(pet);
+ return pet;
+ }
+
+ [SkippableFact]
+ public void GuardFallbackDoesNotAcquireItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ Assert.Null(pet.ControlTarget); // speech dispatch clears this; guard must not fall through to wild acquisition.
+ Assert.True(pet.CanSee(owner));
+ Assert.True(pet.InLOS(owner));
+ pet.NextReacquireTime = Core.TickCount - 1;
+ Assert.False(pet.AIObject.AcquireFocusMob(pet.RangePerception, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ Assert.Null(pet.Combatant);
+ }
+
+ [SkippableFact]
+ public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.IssueOrder(OrderType.Attack, owner, sibling);
+ Assert.Same(sibling, pet.Combatant);
+ pet.IssueOrder(OrderType.Guard, owner);
+ pet.AIObject.DoOrderGuard();
+ Assert.Null(pet.Combatant);
+ Assert.Null(pet.FocusMob);
+ Assert.Equal(OrderType.Guard, pet.ControlOrder);
+ }
+
+ [SkippableFact]
+ public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage()
+ {
+ var (owner, pet) = Spawn();
+ pet.AggressiveAction(owner, false);
+ Assert.Null(pet.Combatant);
+ Assert.Equal(OrderType.Guard, pet.ControlOrder);
+ }
+
+ [SkippableFact]
+ public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.AggressiveAction(sibling, false);
+ Assert.Null(pet.Combatant);
+ }
+
+ [SkippableFact]
+ public void GuardReacquiresAnEnemyAttackingItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ var enemy = new PetTestStub();
+ _created.Add(enemy);
+ enemy.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map);
+ enemy.AIObject.AITimer.Stop();
+ enemy.Combatant = owner;
+ Assert.Same(owner, enemy.Combatant);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(enemy, pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void GuardStillDefendsAgainstAnotherPlayersPet()
+ {
+ var (owner, pet) = Spawn();
+ var enemyOwner = new PlayerMobile();
+ _created.Add(enemyOwner);
+ enemyOwner.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map);
+ var enemy = OtherPet(enemyOwner, new Point3D(1498, 1600, owner.Z));
+ enemy.IssueOrder(OrderType.Attack, enemyOwner, owner);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(enemy, pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void GuardIgnoresSiblingInOwnersAggressorHistory()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ owner.AggressiveAction(sibling, false);
+ Assert.Contains(owner.Aggressors, entry => entry.Attacker == sibling);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void GuardIgnoresOwnControlledSummonAttackingOwner()
+ {
+ var (owner, pet) = Spawn();
+ var summon = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ summon.Summoned = true;
+ summon.Master = owner;
+ summon.Combatant = owner;
+ Assert.Same(owner, summon.Combatant);
+ pet.AggressiveAction(summon, false);
+ Assert.Null(pet.Combatant);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner()
+ {
+ var (owner, pet) = Spawn();
+ var vortex = new PetTestStub { Summoned = true, Master = owner };
+ _created.Add(vortex);
+ vortex.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map);
+ vortex.AIObject.AITimer.Stop();
+ vortex.Combatant = owner;
+ Assert.Same(owner, vortex.Combatant);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(vortex, pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void FriendlyDamageDoesNotInterruptGuardCombat()
+ {
+ var (owner, pet) = Spawn();
+ var enemy = new PetTestStub();
+ _created.Add(enemy);
+ enemy.MoveToWorld(new Point3D(1496, 1600, owner.Z), owner.Map);
+ enemy.AIObject.AITimer.Stop();
+ pet.Combatant = enemy;
+ pet.AggressiveAction(owner, false);
+ Assert.Same(enemy, pet.Combatant);
+ }
+
+ [SkippableFact]
+ public void ExplicitAttackStillAcquiresTheCommandedTarget()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.IssueOrder(OrderType.Attack, owner, sibling);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(sibling, pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void BardProvocationStillOverridesGuard()
+ {
+ var (owner, pet) = Spawn();
+ pet.BardProvoked = true;
+ pet.BardTarget = owner;
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(owner, pet.FocusMob);
+ }
+
+ [SkippableTheory]
+ [InlineData(OrderType.Follow)]
+ [InlineData(OrderType.Come)]
+ [InlineData(OrderType.Stay)]
+ public void NonCombatOrdersDoNotAcquireTheirFriendlyTarget(OrderType order)
+ {
+ var (owner, pet) = Spawn();
+ pet.IssueOrder(order, owner, owner);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [SkippableFact]
+ public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears()
+ {
+ var (owner, _) = Spawn();
+ var wyrm = new WhiteWyrm();
+ _created.Add(wyrm);
+ wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map);
+ wyrm.SetControlMaster(owner);
+ wyrm.IssueOrder(OrderType.Guard, owner);
+ wyrm.AIObject.AITimer.Stop();
+ var enemy = new PetTestStub();
+ _created.Add(enemy);
+ enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map);
+ enemy.AIObject.AITimer.Stop();
+ wyrm.Combatant = enemy;
+ enemy.Hidden = true;
+ wyrm.NextReacquireTime = Core.TickCount - 1;
+ Assert.IsType(wyrm.AIObject);
+ wyrm.AIObject.DoActionCombat();
+ Assert.NotSame(owner, wyrm.Combatant);
+ Assert.Null(wyrm.FocusMob);
+ wyrm.AIObject.DoOrderGuard();
+ Assert.Null(wyrm.Combatant);
+ }
+
+ public void Dispose()
+ {
+ foreach (var mobile in _created)
+ {
+ mobile.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs
new file mode 100644
index 000000000..3ced177b5
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+// A meer mage fights FightMode.Evil and its enraged creatures carry -1000 karma, so the karma rules alone
+// make each an enemy of the other.
+[Collection("Sequential UOContent Tests")]
+public class MeerEnragedTests : IDisposable
+{
+ private readonly List _created = [];
+
+ public void Dispose()
+ {
+ for (var i = 0; i < _created.Count; i++)
+ {
+ _created[i].Delete();
+ }
+ }
+
+ private T Track(T m) where T : Mobile
+ {
+ _created.Add(m);
+ return m;
+ }
+
+ private static void Place(BaseCreature bc, int x)
+ {
+ var map = Map.Felucca;
+ bc.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); // flat, clear line of sight both ways
+ bc.AIObject.AITimer.Stop();
+ }
+
+ private static bool Acquire(BaseCreature creature)
+ {
+ creature.NextReacquireTime = Core.TickCount - 1;
+ return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true);
+ }
+
+ [Fact]
+ public void MeerAndItsEnragedCreature_AreNotEnemies()
+ {
+ var meer = Track(new MeerMage());
+ var enraged = Track(new EnragedRabbit(meer));
+
+ Assert.False(enraged.IsEnemy(meer));
+ Assert.False(meer.IsEnemy(enraged));
+ }
+
+ [Fact]
+ public void AnotherMeersEnragedCreature_StaysAnEnemy()
+ {
+ var meer = Track(new MeerMage());
+ var other = Track(new MeerMage());
+ var enraged = Track(new EnragedRabbit(other));
+
+ Assert.True(enraged.IsEnemy(meer));
+ Assert.True(meer.IsEnemy(enraged));
+ }
+
+ [SkippableFact]
+ public void MeerAndItsEnragedCreature_NeverAcquireEachOther()
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+
+ var meer = Track(new MeerMage());
+ var enraged = Track(new EnragedBlackBear(meer));
+ Place(meer, 1600);
+ Place(enraged, 1601);
+
+ Assert.False(Acquire(enraged));
+ Assert.False(Acquire(meer));
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs
new file mode 100644
index 000000000..ddebd79f8
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs
@@ -0,0 +1,82 @@
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class PetFriendMembershipTests
+{
+ [Fact]
+ public void AddPetFriend_Twice_StoresOneMembership()
+ {
+ var pet = new PetTestStub();
+ var friend = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(friend);
+ pet.AddPetFriend(friend);
+
+ Assert.Single(pet.Friends);
+ Assert.True(pet.IsPetFriend(friend));
+ }
+ finally
+ {
+ pet.Delete();
+ friend.Delete();
+ }
+ }
+
+ [Fact]
+ public void RemovePetFriend_AfterRepeatedAdd_RevokesMembership()
+ {
+ var pet = new PetTestStub();
+ var friend = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(friend);
+ pet.AddPetFriend(friend);
+ pet.RemovePetFriend(friend);
+
+ Assert.False(pet.IsPetFriend(friend));
+ Assert.Null(pet.Friends);
+ }
+ finally
+ {
+ pet.Delete();
+ friend.Delete();
+ }
+ }
+
+ [Fact]
+ public void RemoveAndReadd_LeavesOtherFriendsUnchanged()
+ {
+ var pet = new PetTestStub();
+ var first = new PlayerMobile();
+ var second = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(first);
+ pet.AddPetFriend(second);
+ pet.RemovePetFriend(first);
+
+ Assert.False(pet.IsPetFriend(first));
+ Assert.True(pet.IsPetFriend(second));
+ Assert.Single(pet.Friends);
+
+ pet.AddPetFriend(first);
+ Assert.True(pet.IsPetFriend(first));
+ Assert.True(pet.IsPetFriend(second));
+ Assert.Equal(2, pet.Friends.Count);
+ }
+ finally
+ {
+ pet.Delete();
+ first.Delete();
+ second.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
index ca30ee7d9..7d12ed744 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
@@ -762,7 +762,7 @@ public class PetOrderTests : IDisposable
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Summoned = true;
- pet.SummonMaster = master;
+ pet.Master = master;
pet.ControlOrder = OrderType.Release;
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs
index d5074704c..41b52a189 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs
@@ -197,7 +197,7 @@ public class PetRetaliationTests : IDisposable
var summon = new StandDownPet();
summon.Summoned = true;
- summon.SummonMaster = caster;
+ summon.Master = caster;
summon.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
summon.AIObject.AITimer?.Stop();
_created.Add(summon);
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs
new file mode 100644
index 000000000..28b311186
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class SummonAcquireRulesTests : IDisposable
+{
+ private readonly List _created = [];
+ private readonly Expansion _previous = Core.Expansion;
+
+ private PlayerMobile Player(Map map, int x, int intel)
+ {
+ var player = new PlayerMobile { Player = true, RawInt = intel };
+ player.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map);
+ _created.Add(player);
+ return player;
+ }
+
+ private BaseCreature Summon(BaseCreature summon, Mobile caster, int x)
+ {
+ _created.Add(summon);
+ summon.Summoned = true;
+ summon.Master = caster;
+ summon.MoveToWorld(new Point3D(x, 1600, caster.Map.GetAverageZ(x, 1600)), caster.Map);
+ summon.AIObject.AITimer.Stop();
+ return summon;
+ }
+
+ private static bool Acquire(BaseCreature creature)
+ {
+ creature.NextReacquireTime = Core.TickCount - 1;
+ return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true);
+ }
+
+ [SkippableTheory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void PreAosFeluccaVortexTurnsOnItsCaster(bool bladeSpirits)
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ Core.Expansion = Expansion.UOR;
+
+ var caster = Player(Map.Felucca, 1500, 100);
+ var summon = Summon(bladeSpirits ? new BladeSpirits() : new EnergyVortex(), caster, 1499);
+
+ Assert.True(Acquire(summon));
+ Assert.Same(caster, summon.FocusMob);
+ }
+
+ [SkippableFact]
+ public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster()
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ Core.Expansion = Expansion.UOR;
+
+ // The caster outranks the bystander on the vortex's int-over-distance ranking.
+ var caster = Player(Map.Felucca, 1500, 100);
+ var bystander = Player(Map.Felucca, 1496, 10);
+ var vortex = Summon(new EnergyVortex(), caster, 1499);
+
+ Assert.True(Acquire(vortex));
+ Assert.Same(bystander, vortex.FocusMob);
+ }
+
+ [SkippableTheory]
+ [InlineData(Expansion.UOR, 1)] // Trammel
+ [InlineData(Expansion.AOS, 0)] // Felucca
+ public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex)
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ Core.Expansion = expansion;
+
+ var caster = Player(Map.Maps[mapIndex], 1500, 100);
+ var vortex = Summon(new EnergyVortex(), caster, 1499);
+
+ Assert.False(Acquire(vortex));
+ Assert.Null(vortex.FocusMob);
+ }
+
+ [SkippableFact]
+ public void MasterlessSummonDoesNotBreakAcquisition()
+ {
+ Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
+ Core.Expansion = Expansion.AOS;
+
+ var player = Player(Map.Felucca, 1500, 100);
+ var hunter = new PetTestStub();
+ _created.Add(hunter);
+ hunter.MoveToWorld(new Point3D(1499, 1600, player.Z), player.Map);
+ hunter.AIObject.AITimer.Stop();
+
+ var masterless = new PetTestStub { Summoned = true };
+ _created.Add(masterless);
+ masterless.MoveToWorld(new Point3D(1498, 1600, player.Z), player.Map);
+ masterless.AIObject.AITimer.Stop();
+
+ Assert.True(Acquire(hunter));
+ }
+
+ public void Dispose()
+ {
+ Core.Expansion = _previous;
+
+ foreach (var mobile in _created)
+ {
+ mobile.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
index 51a4de573..ba106218c 100644
--- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
+++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
@@ -182,22 +182,35 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(master, copy.LastOwner);
}
- [Fact]
- public void UncontrolledSummon_KeepsItsSummonMaster()
+ // One serialized master; the Controlled and Summoned flags decide which views expose it.
+ [Theory]
+ [InlineData(true, false)] // controlled pet
+ [InlineData(true, true)] // controlled summon
+ [InlineData(false, true)] // energy vortex: summoned, never controlled
+ [InlineData(false, false)] // EnragedCreature: a summon master without Summoned
+ public void MasterShapes_RoundTrip(bool controlled, bool summoned)
{
var bc = NewCreature();
var master = NewMaster();
- // Energy vortex-style: summoned with a master, never controlled.
- bc.Summoned = true;
- bc.SummonMaster = master;
+ bc.Summoned = summoned;
+
+ if (controlled)
+ {
+ bc.SetControlMaster(master);
+ }
+ else
+ {
+ bc.Master = master;
+ }
var copy = Load(Snapshot(bc));
- Assert.True(copy.Summoned);
- Assert.False(copy.Controlled);
- Assert.Equal(master, copy.SummonMaster);
- Assert.Null(copy.ControlMaster);
+ Assert.Equal(controlled, copy.Controlled);
+ Assert.Equal(summoned, copy.Summoned);
+ Assert.Equal(master, copy.Master);
+ Assert.Equal(controlled ? master : null, copy.ControlMaster);
+ Assert.Equal(summoned ? master : null, copy.SummonMaster);
}
[Fact]
@@ -418,8 +431,8 @@ public class BaseCreatureSerializationTests : IDisposable
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.
+ // A master survives without Summoned (EnragedCreature), and a controlled summon reports the
+ // same mobile as both.
[Theory]
[InlineData(true, true, false, false)] // controlled pet
[InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire
@@ -445,11 +458,87 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(controlled, copy.Controlled);
Assert.Equal(summoned, copy.Summoned);
Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster);
- Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster);
+ Assert.Equal(master, copy.Master);
+ Assert.Equal(summoned ? master : null, copy.SummonMaster);
if (summoned)
{
Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
}
}
+
+ // Before summon ownership followed transfers, a traded summon kept its caster as summon master.
+ [Fact]
+ public void LegacyV22Stream_DifferingMasters_OwnerWins()
+ {
+ var owner = NewMaster();
+ var caster = NewMaster();
+
+ var copy = LoadLegacyV22(true, owner, true, caster, Core.Now + TimeSpan.FromMinutes(5));
+
+ Assert.Equal(owner, copy.ControlMaster);
+ Assert.Equal(owner, copy.SummonMaster);
+ }
+
+ // v23 bit positions: every save-flagged field in schema order (DefaultAI is not flagged).
+ private const int V23Controlled = 13;
+ private const int V23ControlMaster = 14;
+ private const int V23Summoned = 20;
+ private const int V23SummonEnd = 21;
+ private const int V23SummonMaster = 22;
+ private const int V23RemoveStep = 50;
+ private const int V23CorpseNameOverride = 52;
+
+ // v23 wrote the owner and the summoner to separate slots; v24 keeps one master.
+ [Fact]
+ public void V23Stream_MigratesToOneMaster_AndDefaultsAbsentFields()
+ {
+ var owner = NewMaster();
+ var caster = NewMaster();
+ var summonEnd = Core.Now + TimeSpan.FromMinutes(5);
+
+ var donor = new MobileStub();
+ donor.DefaultMobileInit();
+ _created.Add(donor);
+
+ var writer = new BufferWriter(true);
+ donor.Serialize(writer);
+
+ writer.Write(23); // version
+ writer.Write(
+ 1UL << V23Controlled | 1UL << V23ControlMaster | 1UL << V23Summoned | 1UL << V23SummonEnd |
+ 1UL << V23SummonMaster | 1UL << V23RemoveStep | 1UL << V23CorpseNameOverride
+ );
+ writer.WriteEncodedInt((int)AIType.AI_Melee); // DefaultAI
+ writer.Write(owner); // ControlMaster
+ writer.WriteAnchoredTime(summonEnd); // SummonEnd
+ writer.Write(caster); // SummonMaster
+ writer.WriteEncodedInt(3); // RemoveStep
+ writer.Write("a migrated corpse"); // CorpseNameOverride
+
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ var copy = Load(buffer);
+
+ Assert.True(copy.Controlled);
+ Assert.True(copy.Summoned);
+ Assert.Equal(owner, copy.ControlMaster);
+ Assert.Equal(owner, copy.SummonMaster);
+ Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
+ Assert.Equal(3, copy.RemoveStep);
+ Assert.Equal("a migrated corpse", copy.CorpseNameOverride);
+
+ // Absent fields take the class defaults, not default(T).
+ Assert.Equal(AIType.AI_Melee, copy.AI);
+ Assert.Equal(-1, copy.HitsMaxSeed);
+ Assert.Equal(1, copy.ControlSlots);
+ Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
+ Assert.Equal(100, copy.PhysicalDamage);
+ Assert.Equal(10, copy.RangeHome);
+ Assert.Equal(FightMode.Closest, copy.FightMode);
+ Assert.Equal(0.3, copy.ActiveSpeed);
+ Assert.Equal(0.6, copy.PassiveSpeed);
+ Assert.NotNull(copy.Owners);
+ }
}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs
new file mode 100644
index 000000000..f01e5c7bd
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Server.Spells.Necromancy;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles;
+
+// A deleted master must not leave creatures pointing at it.
+[Collection("Sequential UOContent Tests")]
+public class DeletedMasterTests : IDisposable
+{
+ private readonly List _created = [];
+
+ public void Dispose()
+ {
+ for (var i = 0; i < _created.Count; i++)
+ {
+ _created[i].Delete();
+ }
+ }
+
+ private sealed class Stub : BaseCreature
+ {
+ public Stub() : base(AIType.AI_Melee) => Body = 0xC9;
+
+ public Stub(Serial serial) : base(serial)
+ {
+ }
+ }
+
+ private Stub NewCreature()
+ {
+ var bc = new Stub();
+ _created.Add(bc);
+ return bc;
+ }
+
+ private PlayerMobile NewPlayer()
+ {
+ var pm = new PlayerMobile(World.NewMobile);
+ pm.DefaultMobileInit();
+ World.AddEntity(pm);
+ _created.Add(pm);
+ return pm;
+ }
+
+ [Fact]
+ public void DeletingAPlayer_ReleasesAnUnbondedPet()
+ {
+ var owner = NewPlayer();
+ var pet = NewCreature();
+ pet.SetControlMaster(owner);
+
+ owner.Delete();
+
+ Assert.False(pet.Deleted);
+ Assert.False(pet.Controlled);
+ Assert.Null(pet.Master);
+ Assert.True(pet.PendingDeleteTimer?.Running);
+ }
+
+ [Theory]
+ [InlineData(false)] // bonded
+ [InlineData(true)] // bonded and dead
+ public void DeletingAPlayer_DeletesABondedPet(bool dead)
+ {
+ var owner = NewPlayer();
+ var pet = NewCreature();
+ pet.SetControlMaster(owner);
+ pet.IsBonded = true;
+ pet.IsDeadPet = dead;
+
+ owner.Delete();
+
+ Assert.True(pet.Deleted);
+ }
+
+ [Theory]
+ [InlineData(false)] // energy vortex
+ [InlineData(true)] // controlled summon
+ public void DeletingAPlayer_DeletesItsSummons(bool controlled)
+ {
+ var caster = NewPlayer();
+ var summon = NewCreature();
+
+ if (controlled)
+ {
+ summon.SetControlMaster(caster);
+ }
+
+ summon.Summoned = true;
+ summon.Master = caster;
+
+ caster.Delete();
+
+ Assert.True(summon.Deleted);
+ }
+
+ [Fact]
+ public void DeletingAFamiliar_UnregistersIt()
+ {
+ var caster = NewPlayer();
+ var familiar = NewCreature();
+ familiar.SetControlMaster(caster);
+ familiar.Summoned = true;
+ SummonFamiliarSpell.Table[caster] = familiar;
+
+ familiar.Delete();
+
+ Assert.False(SummonFamiliarSpell.Table.ContainsKey(caster));
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs
new file mode 100644
index 000000000..20578ecfc
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles;
+
+// One stored master, read through gates on Controlled and Summoned.
+[Collection("Sequential UOContent Tests")]
+public class MasterViewsTests : IDisposable
+{
+ private readonly List _created = [];
+
+ public void Dispose()
+ {
+ for (var i = 0; i < _created.Count; i++)
+ {
+ _created[i].Delete();
+ }
+ }
+
+ private sealed class Stub : BaseCreature
+ {
+ public Stub() : base(AIType.AI_Melee) => Body = 0xC9;
+
+ public Stub(Serial serial) : base(serial)
+ {
+ }
+ }
+
+ private Stub NewCreature()
+ {
+ var bc = new Stub();
+ _created.Add(bc);
+ return bc;
+ }
+
+ private PlayerMobile NewPlayer()
+ {
+ var pm = new PlayerMobile(World.NewMobile);
+ pm.DefaultMobileInit();
+ World.AddEntity(pm);
+ _created.Add(pm);
+ return pm;
+ }
+
+ public enum Shape
+ {
+ Wild,
+ Pet,
+ ControlledSummon,
+ UncontrolledSummon,
+ Enraged
+ }
+
+ [Theory]
+ [InlineData(Shape.Wild)]
+ [InlineData(Shape.Pet)]
+ [InlineData(Shape.ControlledSummon)]
+ [InlineData(Shape.UncontrolledSummon)]
+ [InlineData(Shape.Enraged)]
+ public void Views_ByShape(Shape shape)
+ {
+ var bc = NewCreature();
+ var master = NewPlayer();
+
+ switch (shape)
+ {
+ case Shape.Pet:
+ {
+ bc.SetControlMaster(master);
+ break;
+ }
+ case Shape.ControlledSummon:
+ {
+ bc.SetControlMaster(master);
+ bc.Summoned = true;
+ break;
+ }
+ case Shape.UncontrolledSummon:
+ {
+ bc.Summoned = true;
+ bc.Master = master;
+ break;
+ }
+ case Shape.Enraged:
+ {
+ bc.Master = master;
+ break;
+ }
+ }
+
+ var controlled = shape is Shape.Pet or Shape.ControlledSummon;
+ var summoned = shape is Shape.ControlledSummon or Shape.UncontrolledSummon;
+
+ Assert.Equal(shape == Shape.Wild ? null : master, bc.Master);
+ Assert.Equal(controlled ? master : null, bc.ControlMaster);
+ Assert.Equal(summoned ? master : null, bc.SummonMaster);
+ Assert.Equal(controlled || summoned ? master : null, bc.GetMaster());
+ }
+
+ [Fact]
+ public void Master_Reassignment_MovesFollowerSlots()
+ {
+ var bc = NewCreature();
+ bc.ControlSlots = 2;
+ var first = NewPlayer();
+ var second = NewPlayer();
+
+ bc.Master = first;
+ Assert.Equal(2, first.Followers);
+
+ bc.Master = second;
+ Assert.Equal(0, first.Followers);
+ Assert.Equal(2, second.Followers);
+ Assert.Null(bc.ControlMaster); // not Controlled
+
+ bc.Master = null;
+ Assert.Equal(0, second.Followers);
+ }
+
+ [Fact]
+ public void SetControlMasterNull_KeepsAnUncontrolledSummonsCaster()
+ {
+ var bc = NewCreature();
+ var caster = NewPlayer();
+ bc.Summoned = true;
+ bc.Master = caster;
+
+ bc.SetControlMaster(null);
+
+ Assert.Equal(caster, bc.SummonMaster);
+ Assert.Equal(bc.ControlSlots, caster.Followers);
+ }
+
+ [Fact]
+ public void Delete_ReturnsTheCastersFollowerSlots()
+ {
+ var bc = NewCreature();
+ var caster = NewPlayer();
+ bc.Summoned = true;
+ bc.Master = caster;
+
+ bc.Delete();
+
+ Assert.Equal(0, caster.Followers);
+ Assert.Null(bc.Master);
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
new file mode 100644
index 000000000..f61ca6229
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using Server.Items;
+using Server.Mobiles;
+using Xunit;
+
+namespace Server.Tests.Mobiles;
+
+[Collection("Sequential UOContent Tests")]
+public class VendorBuybackTests : IDisposable
+{
+ private readonly List _created = [];
+
+ public void Dispose()
+ {
+ for (var i = 0; i < _created.Count; i++)
+ {
+ _created[i].Delete();
+ }
+ }
+
+ private sealed class BandageSB : SBInfo
+ {
+ private readonly GenericSellInfo _sellInfo = new();
+
+ public BandageSB() => _sellInfo.Add(typeof(Bandage), 2);
+
+ public override IShopSellInfo SellInfo => _sellInfo;
+
+ public override List BuyInfo { get; } = [];
+ }
+
+ private sealed class VendorStub : BaseVendor
+ {
+ private readonly List _sbInfos = [];
+
+ public VendorStub() : base("the stub")
+ {
+ }
+
+ public VendorStub(Serial serial) : base(serial)
+ {
+ }
+
+ protected override List SBInfos => _sbInfos;
+
+ public override void InitSBInfo()
+ {
+ _sbInfos.Add(new BandageSB());
+ }
+
+ public override void InitOutfit()
+ {
+ }
+
+ public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
+ {
+ activeSpeed = 0.3;
+ passiveSpeed = 0.6;
+ }
+ }
+
+ // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0.
+ private sealed class FullStockSB : SBInfo
+ {
+ private readonly GenericSellInfo _sellInfo = new();
+ private readonly List _buyInfo = [];
+
+ public FullStockSB()
+ {
+ _sellInfo.Add(typeof(Bandage), 2);
+
+ for (var i = 0; i < 250; i++)
+ {
+ _buyInfo.Add(new GenericBuyInfo(typeof(Bandage), 5, 20, 0xE21, 0));
+ }
+ }
+
+ public override IShopSellInfo SellInfo => _sellInfo;
+
+ public override List BuyInfo => _buyInfo;
+ }
+
+ private sealed class FullStockVendorStub : BaseVendor
+ {
+ private readonly List _sbInfos = [];
+
+ public FullStockVendorStub() : base("the stub")
+ {
+ }
+
+ protected override List SBInfos => _sbInfos;
+
+ public override void InitSBInfo()
+ {
+ _sbInfos.Add(new FullStockSB());
+ }
+
+ public override void InitOutfit()
+ {
+ }
+
+ public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
+ {
+ activeSpeed = 0.3;
+ passiveSpeed = 0.6;
+ }
+ }
+
+ private VendorStub NewVendor()
+ {
+ var vendor = new VendorStub();
+ _created.Add(vendor);
+ return vendor;
+ }
+
+ private FullStockVendorStub NewFullStockVendor()
+ {
+ var vendor = new FullStockVendorStub();
+ _created.Add(vendor);
+ return vendor;
+ }
+
+ private Mobile NewSeller()
+ {
+ var seller = new Mobile(World.NewMobile);
+ seller.DefaultMobileInit();
+ seller.AddItem(new Backpack());
+ _created.Add(seller);
+ return seller;
+ }
+
+ [Theory]
+ [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one
+ [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now
+ [InlineData(5.5, 6.0)] // lazy restock hasn't run for hours: stay on the grid, don't fire now
+ public void NextBuybackPurge_IsTheNextRestockBoundaryAfterNow(double hoursSinceRestock, double expectedHours)
+ {
+ var lastRestock = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var now = lastRestock + TimeSpan.FromHours(hoursSinceRestock);
+
+ var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now);
+
+ Assert.Equal(lastRestock + TimeSpan.FromHours(expectedHours), next);
+ }
+
+ // Backwards clock adjustment: LastRestock ahead of now clamps to now.
+ [Fact]
+ public void NextBuybackPurge_FutureAnchor_IsClampedToNow()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var lastRestock = now + TimeSpan.FromHours(4);
+
+ var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now);
+
+ Assert.Equal(now + TimeSpan.FromHours(1), next);
+ }
+
+ [Fact]
+ public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder()
+ {
+ var vendor = NewVendor();
+ var seller = NewSeller();
+ var bandages = new Bandage(10);
+ seller.Backpack.DropItem(bandages);
+
+ Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)]));
+
+ Assert.Equal(6, bandages.Amount);
+ Assert.Same(seller.Backpack, bandages.Parent);
+
+ var buyback = vendor.BuyPack.Items;
+ Assert.Single(buyback);
+ Assert.IsType(buyback[0]);
+ Assert.Equal(4, buyback[0].Amount);
+ Assert.True(buyback[0].SkipSerialization);
+ Assert.True(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void Restock_PurgesBuyback()
+ {
+ var vendor = NewVendor();
+ var seller = NewSeller();
+ var bandages = new Bandage(10);
+ seller.Backpack.DropItem(bandages);
+ vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]);
+ Assert.Single(vendor.BuyPack.Items);
+
+ vendor.Restock();
+
+ Assert.Empty(vendor.BuyPack.Items);
+ Assert.True(bandages.Deleted);
+ }
+
+ [Fact]
+ public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt()
+ {
+ var vendor = NewVendor();
+ var seller = NewSeller();
+ var bandages = new Bandage(10);
+ seller.Backpack.DropItem(bandages);
+ vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]);
+ Assert.True(vendor.BuybackPurgeScheduled);
+
+ vendor.Restock();
+
+ Assert.False(vendor.BuybackPurgeScheduled);
+
+ var seller2 = NewSeller();
+ var moreBandages = new Bandage(10);
+ seller2.Backpack.DropItem(moreBandages);
+ vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]);
+
+ Assert.True(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void Delete_CancelsBuybackPurgeTimer()
+ {
+ var vendor = NewVendor();
+ var seller = NewSeller();
+ var bandages = new Bandage(10);
+ seller.Backpack.DropItem(bandages);
+ vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]);
+ Assert.True(vendor.BuybackPurgeScheduled);
+
+ vendor.Delete();
+
+ Assert.False(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void LegacyBuyPack_IsReplaced_AndItsContentsDeleted()
+ {
+ var vendor = NewVendor();
+ vendor.FindItemOnLayer(Layer.ShopBuy).Delete();
+
+ var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false };
+ vendor.AddItem(legacy);
+
+ var children = new Item[20_000];
+ for (var i = 0; i < children.Length; i++)
+ {
+ children[i] = new Item(0x1234);
+ legacy.DropItem(children[i]);
+ }
+
+ var pack = vendor.BuyPack;
+
+ Assert.IsType(pack);
+ Assert.Same(pack, vendor.FindItemOnLayer(Layer.ShopBuy));
+ Assert.True(legacy.Deleted);
+ for (var i = 0; i < children.Length; i++)
+ {
+ Assert.True(children[i].Deleted);
+ }
+ }
+
+ [Fact]
+ public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays()
+ {
+ var vendor = NewFullStockVendor();
+ var seller = NewSeller();
+ var bandages = new Bandage(10);
+ seller.Backpack.DropItem(bandages);
+
+ Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]));
+
+ Assert.True(bandages.Deleted);
+ Assert.Empty(vendor.BuyPack.Items);
+ Assert.False(vendor.BuybackPurgeScheduled);
+
+ var gold = seller.Backpack.FindItemByType();
+ Assert.NotNull(gold);
+ Assert.Equal(20, gold.Amount);
+ }
+}
diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj
index 1dc4440ae..8b83f7992 100644
--- a/Projects/UOContent.Tests/UOContent.Tests.csproj
+++ b/Projects/UOContent.Tests/UOContent.Tests.csproj
@@ -4,14 +4,14 @@
Debug;Release;Analyze
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs
index d3a9d3350..d1a20707e 100644
--- a/Projects/UOContent/Commands/Handlers.cs
+++ b/Projects/UOContent/Commands/Handlers.cs
@@ -213,7 +213,7 @@ namespace Server.Commands
{
if (m is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster == master || bc.Summoned && bc.SummonMaster == master)
+ if (bc.GetMaster() == master)
{
pets.Add(bc);
}
diff --git a/Projects/UOContent/Engines/ConPVP/Arena.cs b/Projects/UOContent/Engines/ConPVP/Arena.cs
index aebfed7b0..5c0c3d6f0 100644
--- a/Projects/UOContent/Engines/ConPVP/Arena.cs
+++ b/Projects/UOContent/Engines/ConPVP/Arena.cs
@@ -226,8 +226,8 @@ namespace Server.Engines.ConPVP
new[,] { { 0, -1 }, { -1, 0 } }, // south
new[,] { { +1, 0 }, { 0, +1 } }, // nw
new[,] { { -1, 0 }, { 0, -1 } }, // se
- new[,] { { 0, +1 }, { +1, 0 } }, // sw
- new[,] { { 0, -1 }, { -1, 0 } } // ne
+ new[,] { { +1, 0 }, { 0, -1 } }, // sw
+ new[,] { { -1, 0 }, { 0, +1 } } // ne
};
private bool m_Active;
@@ -535,11 +535,13 @@ namespace Server.Engines.ConPVP
public override string ToString() => "...";
- public Point3D GetBaseStartPoint(int index) => Points.Points[Math.Max(index, 0) % Points.Points.Length];
+ private int StartPointIndex(int index) => Math.Max(index, 0) % Points.Points.Length;
+
+ public Point3D GetBaseStartPoint(int index) => Points.Points[StartPointIndex(index)];
public void MoveInside(DuelPlayer[] players, int index)
{
- index = Math.Min(index, 0) % Points.Points.Length;
+ index = StartPointIndex(index);
var start = Points.Points[index];
@@ -570,10 +572,10 @@ namespace Server.Engines.ConPVP
p = offsets[^1];
}
- p.X = p.X * matrix[0, 0] + p.Y * matrix[0, 1];
- p.Y = p.X * matrix[1, 0] + p.Y * matrix[1, 1];
+ var x = p.X * matrix[0, 0] + p.Y * matrix[0, 1];
+ var y = p.X * matrix[1, 0] + p.Y * matrix[1, 1];
- mob.MoveToWorld(new Point3D(start.X + p.X, start.Y + p.Y, start.Z), m_Facet);
+ mob.MoveToWorld(new Point3D(start.X + x, start.Y + y, start.Z), m_Facet);
mob.Direction = mob.GetDirectionTo(Wall);
Players.Add(mob);
@@ -637,7 +639,7 @@ namespace Server.Engines.ConPVP
foreach (var mob in facet.GetMobilesInBounds(m_Bounds))
{
- if (mob is BaseCreature pet && pet.Controlled && pet.ControlMaster != null &&
+ if (mob is BaseCreature pet && pet.ControlMaster != null &&
Players.Contains(pet.ControlMaster))
{
pets.Add(pet);
diff --git a/Projects/UOContent/Engines/ConPVP/SafeZone.cs b/Projects/UOContent/Engines/ConPVP/SafeZone.cs
index 69ba11a66..e800fdd34 100644
--- a/Projects/UOContent/Engines/ConPVP/SafeZone.cs
+++ b/Projects/UOContent/Engines/ConPVP/SafeZone.cs
@@ -31,7 +31,7 @@ namespace Server.Engines.ConPVP
}
var pm = m as PlayerMobile ??
- (m is BaseCreature bc && bc.Summoned ? bc.SummonMaster as PlayerMobile : null);
+ (m as BaseCreature)?.SummonMaster as PlayerMobile;
if (pm?.DuelContext?.StartedBeginCountdown == true)
{
diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs
index 80f950ab8..a7a5feccc 100644
--- a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs
+++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs
@@ -278,6 +278,8 @@ public class TailoringMenu : ItemListMenu
}
else
{
+ // Make Last picks the hue-aware overload from LastHue, so a leather craft must clear the cloth hue
+ context.LastHue = -1;
DefTailoring.CraftSystem.CreateItem(from, itemDef.ItemType, resourceType, _tool, itemDef);
}
}
diff --git a/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs b/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs
index 6c983e0e9..55cb60ea1 100644
--- a/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs
+++ b/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs
@@ -47,7 +47,7 @@ namespace Server.Engines.Doom
return;
}
}
- else if (m is BaseCreature bc && (bc.Controlled && bc.ControlMaster == m_Controller.Successful ||
+ else if (m is BaseCreature bc && (bc.ControlMaster == m_Controller.Successful ||
bc.Summoned))
{
return;
diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs
index 6b3ef49ae..b45c44b72 100644
--- a/Projects/UOContent/Engines/Harvest/Fishing.cs
+++ b/Projects/UOContent/Engines/Harvest/Fishing.cs
@@ -12,22 +12,23 @@ namespace Server.Engines.Harvest
private static readonly MutateEntry[] _mutateTable =
{
- new(80.0, 80.0, 4080.0, true, typeof(SpecialFishingNet)),
- new(80.0, 80.0, 4080.0, true, typeof(BigFish)),
- new(90.0, 80.0, 4080.0, true, typeof(TreasureMap)),
- new(100.0, 80.0, 4080.0, true, typeof(MessageInABottle)),
+ new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(SpecialFishingNet)),
+ new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(BigFish)),
+ new(90.0, 80.0, 4080.0, true, Expansion.T2A, typeof(TreasureMap)),
+ new(100.0, 80.0, 4080.0, true, Expansion.T2A, typeof(MessageInABottle)),
new(
0.0,
125.0,
-2375.0,
false,
+ Expansion.T2A,
typeof(PrizedFish),
typeof(WondrousFish),
typeof(TrulyRareFish),
typeof(PeculiarFish)
),
- new(0.0, 105.0, -420.0, false, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)),
- new(0.0, 200.0, -200.0, false, new Type[] { null })
+ new(0.0, 105.0, -420.0, false, Expansion.T2A, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)),
+ new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null })
};
private static readonly int[] _waterLandTiles =
@@ -173,6 +174,11 @@ namespace Server.Engines.Harvest
{
var entry = _mutateTable[i];
+ if (Core.Expansion < entry.m_Expansion)
+ {
+ continue;
+ }
+
if (!deepWater && entry.m_DeepWater)
{
continue;
@@ -200,7 +206,7 @@ namespace Server.Engines.Harvest
{
foreach (var sos in pack.FindItemsByType())
{
- if ((from.Map == Map.Felucca || from.Map == Map.Trammel) && from.InRange(sos.TargetLocation, 60))
+ if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60))
{
return true;
}
@@ -240,7 +246,7 @@ namespace Server.Engines.Harvest
// We don't have to queue since we are returning on the first SOS.
foreach (var sos in pack.FindItemsByType())
{
- if ((from.Map == Map.Felucca || from.Map == Map.Trammel) && from.InRange(sos.TargetLocation, 60))
+ if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60))
{
Item preLoot = null;
@@ -296,14 +302,17 @@ namespace Server.Engines.Harvest
LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest();
- if (sos.IsAncient)
+ if (sos.IsAncient && Core.SE)
{
chest.Hue = 0x481;
}
TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level)));
- chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet());
+ if (Core.UOR)
+ {
+ chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet());
+ }
chest.Movable = true;
chest.Locked = false;
@@ -531,17 +540,21 @@ namespace Server.Engines.Harvest
private class MutateEntry
{
public readonly bool m_DeepWater;
+ public readonly Expansion m_Expansion;
public readonly double m_MaxSkill;
public readonly double m_MinSkill;
public readonly double m_ReqSkill;
public readonly Type[] m_Types;
- public MutateEntry(double reqSkill, double minSkill, double maxSkill, bool deepWater, params Type[] types)
+ public MutateEntry(
+ double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, params Type[] types
+ )
{
m_ReqSkill = reqSkill;
m_MinSkill = minSkill;
m_MaxSkill = maxSkill;
m_DeepWater = deepWater;
+ m_Expansion = expansion;
m_Types = types;
}
}
diff --git a/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs b/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs
index d3f7d4ef5..170cbcf91 100644
--- a/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs
+++ b/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs
@@ -20,7 +20,7 @@ public partial class CrystalCaveBarrier : Item
if (m is BaseCreature creature)
{
- mob = creature.ControlMaster;
+ mob = creature.Controlled ? creature.ControlMaster : null;
}
if (mob is not PlayerMobile pm)
diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs
index 8ed6636f5..bf3ff678a 100644
--- a/Projects/UOContent/Gumps/AdminGump.cs
+++ b/Projects/UOContent/Gumps/AdminGump.cs
@@ -234,27 +234,16 @@ namespace Server.Gumps
AddLabel(20, 130, LabelHue, "Event Loop:");
AddLabel(40, 150, LabelHue, loopStatus);
- using var sb = ValueStringBuilder.Create();
+ var net = NetState.GetNetworkStats();
- ThreadPool.GetAvailableThreads(out var curUser, out var curIOCP);
- ThreadPool.GetMaxThreads(out var maxUser, out var maxIOCP);
+ AddLabel(20, 170, LabelHue, "Connections:");
+ AddLabel(150, 170, LabelHue, $"{net.Connected} / {net.MaxConnections} ({net.Authenticated} authenticated)");
- sb.Append("Worker Threads:
Capacity: ");
- sb.Append(maxUser);
- sb.Append("
Available: ");
- sb.Append(curUser);
- sb.Append("
Usage: ");
- sb.Append((maxUser - curUser) * 100 / maxUser);
- sb.Append("%
IOCP Threads:
Capacity: ");
- sb.Append(maxIOCP);
- sb.Append("
Available: ");
- sb.Append(curIOCP);
- sb.Append("
Usage: ");
- sb.Append((maxIOCP - curIOCP) * 100 / maxIOCP);
- sb.Append('%');
+ AddLabel(20, 190, LabelHue, "Loop Queues:");
+ AddLabel(150, 190, LabelHue, $"throttled {net.Throttled}, flush {net.FlushPending}, closing {net.PendingDisconnects}");
- AddLabel(20, 200, LabelHue, "Pooling:");
- AddHtml(20, 220, 380, 150, sb.ToString(), true, true);
+ AddLabel(20, 210, LabelHue, "Send Buffers:");
+ AddHtml(20, 230, 380, 180, FormatSendBuffers(net), true, true);
AddPageButton(200, 20, GetButtonID(0, 0), "General", AdminGumpPage.Information_General);
AddPageButton(200, 40, GetButtonID(0, 5), "Performance", AdminGumpPage.Information_Perf);
@@ -1368,6 +1357,48 @@ namespace Server.Gumps
public static string FormatTimeSpan(TimeSpan ts) =>
$"{ts.Days:D2}:{ts.Hours % 24:D2}:{ts.Minutes % 60:D2}:{ts.Seconds % 60:D2}";
+ private static string FormatSendBuffers(NetworkStats net)
+ {
+ using var sb = ValueStringBuilder.Create();
+
+ sb.Append($"Base: {FormatByteAmount(net.RecvBufferSize)} recv, {FormatByteAmount(net.SendBufferSize)} send");
+ if (net.InitialRecvBufferSize > 0)
+ {
+ sb.Append($"; pre-auth {FormatByteAmount(net.InitialRecvBufferSize)} / {FormatByteAmount(net.InitialSendBufferSize)}");
+ }
+
+ var sweep = net.LastSweep;
+ if (sweep.Ran)
+ {
+ sb.Append($"
Base pools: {FormatByteAmount(sweep.BaseCapacityBytes)}");
+ }
+
+ long tierBytes = 0;
+ sb.Append("
Growth tiers (live):");
+ for (var i = 0; i < net.Tiers.Length; i++)
+ {
+ var tier = net.Tiers[i];
+ tierBytes += (long)tier.Capacity * tier.BufferSize;
+ sb.Append($"
{FormatByteAmount(tier.BufferSize)}: {tier.InUse} / {tier.Capacity} in use, floor {tier.RetainFloor}");
+ }
+
+ sb.Append($"
Budget: {FormatByteAmount(tierBytes)} of {FormatByteAmount(net.SendBufferGrowthBudget)}, max {FormatByteAmount(net.MaxSendBufferSize)} per socket");
+ sb.Append($"
Ceiling: {net.MemoryCeilingPercent}% of {FormatByteAmount(net.AvailableMemoryBytes)} available");
+
+ if (sweep.Ran)
+ {
+ var ageSeconds = (Core.TickCount - sweep.Tick) / 1000;
+ sb.Append($"
Last sweep {ageSeconds}s ago: released {sweep.TierBuffersReleased} tier, {sweep.BaseBuffersReleased} base");
+ sb.Append($"
Refused: budget {sweep.BudgetRefusals}, at max {sweep.CapRefusals}, ceiling {sweep.CeilingRefusals}");
+ }
+ else
+ {
+ sb.Append("
First sweep pending");
+ }
+
+ return sb.ToString();
+ }
+
public static string FormatByteAmount(long totalBytes)
{
return totalBytes switch
diff --git a/Projects/UOContent/Gumps/Base/GumpSystem.cs b/Projects/UOContent/Gumps/Base/GumpSystem.cs
index ddbb91382..f5252b1ad 100644
--- a/Projects/UOContent/Gumps/Base/GumpSystem.cs
+++ b/Projects/UOContent/Gumps/Base/GumpSystem.cs
@@ -31,7 +31,7 @@ public static partial class GumpSystem
{
IncomingPackets.Register(0xB1, 0, true, &DisplayGumpResponse);
- EventSink.Disconnected += EventSink_Disconnected;
+ EventSink.BeforeDisconnected += EventSink_Disconnected;
}
private static void EventSink_Disconnected(Mobile m)
diff --git a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs
index aa3bdc4bc..b3f4efc35 100644
--- a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs
+++ b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs
@@ -36,7 +36,7 @@ public class ConfirmReleaseGump : StaticGump
public override void OnResponse(NetState sender, in RelayInfo info)
{
if (info.ButtonID != 2 || _pet.Deleted ||
- !(_pet.Controlled && _from == _pet.ControlMaster &&
+ !(_from == _pet.ControlMaster &&
_from.CheckAlive() && _pet.Map == _from.Map && _pet.InRange(_from, 14)))
{
return;
diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs
index ef779760b..e95e87c13 100644
--- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs
+++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs
@@ -484,8 +484,9 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable
}
else
{
- _current = _current.Link;
- valid = _current?.Deleted == false && _current != _door;
+ var next = _current.Link;
+ valid = next?.Deleted == false && !IsVisited(next);
+ _current = next;
}
if (!valid)
@@ -497,6 +498,27 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable
return valid;
}
+ // Links are set pairwise by GMs, so a chain can loop back to a door other than the start
+ private readonly bool IsVisited(BaseDoor door)
+ {
+ var visited = _door;
+
+ while (true)
+ {
+ if (visited == door)
+ {
+ return true;
+ }
+
+ if (visited == _current || visited == null)
+ {
+ return false;
+ }
+
+ visited = visited.Link;
+ }
+ }
+
public BaseDoor Current
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/Projects/UOContent/Items/Containers/Container.cs b/Projects/UOContent/Items/Containers/Container.cs
index 164c285c4..77620db3c 100644
--- a/Projects/UOContent/Items/Containers/Container.cs
+++ b/Projects/UOContent/Items/Containers/Container.cs
@@ -237,7 +237,7 @@ public partial class StrongBackpack : Backpack // Used on Pack animals
base.CheckHold(m, item, false, checkItems, plusItems, plusWeight);
public override bool CheckContentDisplay(Mobile from) =>
- RootParent is BaseCreature creature && creature.Controlled && creature.ControlMaster == from ||
+ RootParent is BaseCreature creature && creature.ControlMaster == from ||
base.CheckContentDisplay(from);
}
diff --git a/Projects/UOContent/Items/Maps/TreasureMap.cs b/Projects/UOContent/Items/Maps/TreasureMap.cs
index 25711e120..fd2a9d75d 100644
--- a/Projects/UOContent/Items/Maps/TreasureMap.cs
+++ b/Projects/UOContent/Items/Maps/TreasureMap.cs
@@ -806,7 +806,7 @@ public partial class TreasureMap : MapItem
if (bc != null)
{
- m_Chest.Guardians.Add(bc);
+ m_Chest.AddToGuardians(bc);
}
}
}
diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
index 6133dd443..a07dada23 100644
--- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
+++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
@@ -100,6 +100,10 @@ public partial class Corpse : Container, ICarvable
private Dictionary- _instancedItems;
+ // Worn gear being lifted is off _equipItems before its remove goes out; until then it stays public so
+ // the remove reaches everyone who was shown it worn.
+ private Item _liftingWornItem;
+
[SerializableField(0)]
private List
- _restoreEquip;
@@ -197,6 +201,12 @@ public partial class Corpse : Container, ICarvable
// Why was this public?
// public override bool IsPublicContainer => true;
+ // Human corpses show their worn gear to everyone who sees them (SendInfoTo), not only to openers.
+ private bool ShowsWornGear => ((Body)Amount).IsHuman && ItemID == 0x2006;
+
+ public override bool IsChildPublic(Item child) =>
+ ShowsWornGear && (child == _liftingWornItem || _equipItems?.Contains(child) == true);
+
public Corpse(Mobile owner, List
- equipItems) : this(owner, 0, 0, 0, 0, equipItems)
{
}
@@ -416,7 +426,7 @@ public partial class Corpse : Container, ICarvable
{
from.SendLocalizedMessage(500485); // You see nothing useful to carve from the corpse.
}
- else if (((Body)Amount).IsHuman && ItemID == 0x2006)
+ else if (ShowsWornGear)
{
new Blood(0x122D).MoveToWorld(Location, Map);
@@ -587,6 +597,7 @@ public partial class Corpse : Container, ICarvable
{
_decayTimer?.Stop();
_decayTimer = null;
+ _liftingWornItem = null;
}
public static string GetCorpseName(Mobile m) => m is BaseCreature bc ? bc.CorpseNameOverride ?? bc.CorpseName : null;
@@ -740,7 +751,7 @@ public partial class Corpse : Container, ICarvable
{
base.SendInfoTo(ns, world);
- if (((Body)Amount).IsHuman && ItemID == 0x2006)
+ if (ShowsWornGear)
{
ns.SendCorpseContent(ns.Mobile, this);
ns.SendCorpseEquip(ns.Mobile, this);
@@ -812,6 +823,23 @@ public partial class Corpse : Container, ICarvable
AddToLooters(from);
_instancedItems?.Remove(item);
+
+ // Lifted gear is loot from then on. Clients only learn worn gear from 0x89 on first sight, so a
+ // piece put back would show worn after a relog but loose to everyone already watching.
+ if (_equipItems != null && this.Remove(_equipItems, item))
+ {
+ _liftingWornItem = item;
+ }
+ }
+
+ public override void OnItemRemoved(Item item)
+ {
+ base.OnItemRemoved(item);
+
+ if (item == _liftingWornItem)
+ {
+ _liftingWornItem = null;
+ }
}
public override void GetContextMenuEntries(Mobile from, ref PooledRefList list)
diff --git a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs
index e97c88239..b19e17290 100644
--- a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs
+++ b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs
@@ -30,7 +30,7 @@ public partial class BigFish : Item, ICarvable
{
base.GetProperties(list);
- if (Weight >= 20)
+ if (Core.SE && Weight >= 20)
{
if (_fisher != null)
{
diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs
index 3713056c0..8f2054add 100644
--- a/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs
+++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs
@@ -218,7 +218,7 @@ public partial class TrophyAddon : Item, IAddon
{
base.GetProperties(list);
- if (_animalWeight >= 20)
+ if (Core.SE && _animalWeight >= 20)
{
if (!string.IsNullOrWhiteSpace(_hunter))
{
@@ -346,7 +346,7 @@ public partial class TrophyDeed : Item
{
base.GetProperties(list);
- if (_animalWeight >= 20)
+ if (Core.SE && _animalWeight >= 20)
{
if (!string.IsNullOrWhiteSpace(_hunter))
{
diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs
index bf626c2fd..7b8f3878c 100644
--- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs
+++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs
@@ -37,7 +37,7 @@ public partial class MessageInABottle : Item
public static int GetRandomLevel()
{
- if (Core.AOS && Utility.Random(25) < 1)
+ if (Core.AOS && Utility.Random(Core.SE ? 25 : 40) < 1)
{
return 4; // ancient
}
diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
index bf0953041..2a881d97f 100644
--- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
+++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs
@@ -228,11 +228,6 @@ public partial class BallOfSummoning : Item, TranslocationItem
{
pet.SetControlMaster(from);
- if (pet.SummonMaster != null)
- {
- pet.SummonMaster = from;
- }
-
pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow;
diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs
index 5473658ca..c296b461e 100644
--- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs
+++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs
@@ -1698,12 +1698,12 @@ public abstract partial class BaseWeapon
return 0;
}
- if (attacker is not BaseCreature bc || bc.PackInstinct == PackInstinct.None || !bc.Controlled && !bc.Summoned)
+ if (attacker is not BaseCreature bc || bc.PackInstinct == PackInstinct.None)
{
return 0;
}
- var master = bc.ControlMaster ?? bc.SummonMaster;
+ var master = bc.GetMaster();
if (master == null)
{
@@ -1713,8 +1713,7 @@ public abstract partial class BaseWeapon
var inPack = 1;
foreach (var m in defender.GetMobilesInRange(1))
{
- if (m != attacker && (m.PackInstinct & bc.PackInstinct) != 0 && (m.Controlled || m.Summoned) &&
- master == (m.ControlMaster ?? m.SummonMaster) && m.Combatant == defender)
+ if (m != attacker && (m.PackInstinct & bc.PackInstinct) != 0 && master == m.GetMaster() && m.Combatant == defender)
{
inPack++;
}
@@ -1736,7 +1735,7 @@ public abstract partial class BaseWeapon
{
foreach (var m in defender.GetMobilesInRange(4))
{
- if (m?.Summoned == true && m.SummonMaster == defender)
+ if (m?.SummonMaster == defender)
{
// Your attack has been diverted to a nearby mirror image of your target!
attacker.SendLocalizedMessage(1063141);
@@ -2100,7 +2099,7 @@ public abstract partial class BaseWeapon
if (attacker is VampireBatFamiliar bc)
{
- var caster = bc.ControlMaster ?? bc.SummonMaster;
+ var caster = bc.GetMaster();
if (caster != null && caster.Map == bc.Map && caster.InRange(bc, 2))
{
diff --git a/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json
new file mode 100644
index 000000000..38b4bbd59
--- /dev/null
+++ b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json
@@ -0,0 +1,4 @@
+{
+ "version": 0,
+ "type": "Server.Items.VendorBuybackPack"
+}
\ No newline at end of file
diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json
new file mode 100644
index 000000000..b1f4fe2fd
--- /dev/null
+++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json
@@ -0,0 +1,465 @@
+{
+ "version": 24,
+ "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": "Master",
+ "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": "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/Misc/Email.cs b/Projects/UOContent/Misc/Email.cs
index 5e30ed30a..4b8f89661 100644
--- a/Projects/UOContent/Misc/Email.cs
+++ b/Projects/UOContent/Misc/Email.cs
@@ -6,11 +6,22 @@ using MimeKit;
using Server.Accounting;
using Server.Configurations;
using Server.Engines.Help;
+using Server.Logging;
namespace Server.Misc
{
public static class Email
{
+ private static readonly ILogger _logger = LogFactory.GetLogger(typeof(Email));
+
+ // How long SendCrashEmail holds up the exit after a crash: at the default settings, room for the first
+ // attempts (3 and 6 s apart; a fourth starts at 21 s when they fail fast), and no longer when the SMTP
+ // server is dead.
+ private static readonly TimeSpan CrashEmailTimeout = TimeSpan.FromSeconds(30);
+
+ // A retry wait stops doubling at an hour: no multi-day waits, and nothing left to overflow.
+ private const int MaxRetryDelaySeconds = 3600;
+
///
/// Sends Queue-Page request using Email
///
@@ -69,16 +80,17 @@ namespace Server.Misc
}.ToMessageBody();
}
- SendAsync(message);
+ _ = SendAsync(message);
}
///
- /// Sends crash email
+ /// Sends crash email, blocking for up to the crash timeout (30 s) while it is sent: the core exits
+ /// as soon as the crash handlers return, and a send left running dies with it.
///
///
public static void SendCrashEmail(string filePath)
{
- if (EmailConfiguration.EmailEnabled)
+ if (!EmailConfiguration.EmailEnabled)
{
return;
}
@@ -94,27 +106,37 @@ namespace Server.Misc
};
builder.Attachments.Add(filePath);
message.Body = builder.ToMessageBody();
+
+ try
+ {
+ if (!SendAsync(message).Wait(CrashEmailTimeout))
+ {
+ _logger.Warning("Crash email not sent after {Timeout}, giving up", CrashEmailTimeout);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Sending crash email failed");
+ }
}
///
/// Sends emails async
///
///
- private static async void SendAsync(MimeMessage message)
+ private static async Task SendAsync(MimeMessage message)
{
if (!EmailConfiguration.EmailEnabled)
{
return;
}
- var now = Core.Now;
- var messageID = $"<{now:yyyyMMdd}.{now:HHmmssff}@{EmailConfiguration.EmailServer}>";
- message.Headers.Add("Message-ID", messageID);
- message.From.Add(EmailConfiguration.FromAddress);
+ var attempts = EmailConfiguration.EmailSendRetryCount;
+ var retryDelays = RetryDelays(attempts, EmailConfiguration.EmailSendRetryDelay);
+ Exception lastException = null;
- var delay = EmailConfiguration.EmailSendRetryDelay;
-
- for (var i = 0; i < EmailConfiguration.EmailSendRetryCount; i++)
+ // SendCrashEmail can block the game loop on this task: every await needs ConfigureAwait(false).
+ for (var i = 0; i < attempts; i++)
{
try
{
@@ -130,17 +152,40 @@ namespace Server.Misc
}
catch (Exception ex)
{
+ lastException = ex;
+
if (i == 0)
{
- Console.WriteLine(ex.Message);
- Console.WriteLine(ex.StackTrace);
+ _logger.Warning(ex, "Sending email {Subject} failed", message.Subject);
}
- delay *= delay;
-
- await Task.Delay(delay * 1000).ConfigureAwait(false);
+ if (i < retryDelays.Length)
+ {
+ await Task.Delay(retryDelays[i]).ConfigureAwait(false);
+ }
}
}
+
+ _logger.Error(lastException, "Email {Subject} not sent after {Attempts} attempts", message.Subject, attempts);
+ }
+
+ ///
+ /// The waits between send attempts: the configured delay, doubling each time, and none after the last.
+ ///
+ ///
+ ///
+ internal static TimeSpan[] RetryDelays(int attempts, int firstDelaySeconds)
+ {
+ var delays = new TimeSpan[Math.Max(attempts, 1) - 1];
+ var seconds = Math.Clamp(firstDelaySeconds, 0, MaxRetryDelaySeconds);
+
+ for (var i = 0; i < delays.Length; i++)
+ {
+ delays[i] = TimeSpan.FromSeconds(seconds);
+ seconds = Math.Min(seconds * 2, MaxRetryDelaySeconds);
+ }
+
+ return delays;
}
}
}
diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs
index 4416b09b5..f31ad3450 100644
--- a/Projects/UOContent/Misc/Notoriety.cs
+++ b/Projects/UOContent/Misc/Notoriety.cs
@@ -228,16 +228,11 @@ namespace Server.Misc
}
if (bcTarg?.Controlled == true
- || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true)
+ || bcTarg?.SummonMaster is { Player: true } summoner && summoner != from)
{
return false; // Cannot harm other controlled mobiles from players
}
- if (pmFrom == null && bcFrom != null && bcFrom.Summoned && target.Player)
- {
- return true; // Summons from monsters can attack players
- }
-
if (target.Player)
{
return false; // Cannot harm other players
@@ -275,7 +270,24 @@ namespace Server.Misc
return Notoriety.CanBeAttacked;
}
- Body body = target.Amount;
+ // BaseCreatures are deleted on death, so target.Owner is null after a server restart.
+ // The OwnerWasBaseCreature flag is the persisted snapshot that survives the live mobile.
+ var creature = target.Owner as BaseCreature;
+ var ownerWasCreature = target.OwnerWasBaseCreature || creature != null;
+
+ // A player's murderer and criminal status outrank guild standing, as on the live mobile.
+ if (!ownerWasCreature)
+ {
+ if (target.Murderer)
+ {
+ return Notoriety.Murderer;
+ }
+
+ if (target.Criminal && (target.Map?.Rules & MapRules.HarmfulRestrictions) == 0)
+ {
+ return Notoriety.Criminal;
+ }
+ }
var sourceGuild = GetGuildFor(source.Guild as Guild, source);
var targetGuild = GetGuildFor(target.Guild, target.Owner);
@@ -297,11 +309,7 @@ namespace Server.Misc
}
}
- // BaseCreatures are deleted on death, so target.Owner is null after a server restart.
- // The OwnerWasBaseCreature flag is the persisted snapshot that survives the live mobile.
- var creature = target.Owner as BaseCreature;
-
- if (target.OwnerWasBaseCreature || creature != null)
+ if (ownerWasCreature)
{
if (srcFaction != null && trgFaction != null && srcFaction != trgFaction && source.Map == Faction.Facet)
{
@@ -314,6 +322,7 @@ namespace Server.Misc
}
var actual = Notoriety.CanBeAttacked;
+ Body body = target.Amount;
if (target.Murderer || body.IsMonster && target.OwnerWasSummoned || target.OwnerWasAnimatedDead)
{
@@ -338,16 +347,6 @@ namespace Server.Misc
return Notoriety.Innocent;
}
- if (target.Murderer || body.IsMonster)
- {
- return Notoriety.Murderer;
- }
-
- if (target.Criminal && (target.Map?.Rules & MapRules.HarmfulRestrictions) == 0)
- {
- return Notoriety.Criminal;
- }
-
if (srcFaction != null && trgFaction != null && srcFaction != trgFaction && source.Map == Faction.Facet)
{
for (var i = 0; i < list.Count; ++i)
@@ -422,7 +421,7 @@ namespace Server.Misc
return Notoriety.CanBeAttacked;
}
- master = bcTarg.ControlMaster;
+ master = bcTarg.Controlled ? bcTarg.ControlMaster : null;
if (Core.ML && master != null)
{
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs
index e11268e87..f2cfceb0e 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs
@@ -225,7 +225,7 @@ public sealed class AITimer : Timer
private void CacheDetectHiddenDelays()
{
- var delay = Math.Min(30000 / _owner.Mobile.Int, 120);
+ var delay = Math.Min(30000 / Math.Max(1, _owner.Mobile.Int), 120);
_detectHiddenMinDelay = delay * 900; // 26s to 108s
_detectHiddenMaxDelay = delay * 1100; // 32s to 132s
}
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs
index 9b42136a6..0c5b9423b 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs
@@ -131,21 +131,57 @@ public abstract partial class BaseAI
}
}
- public virtual void OnAggressiveAction(Mobile aggressor)
+ ///
+ /// The retaliation policy: how the creature answers . Returns false when it
+ /// stands down, and then skips its combat bookkeeping.
+ ///
+ public virtual bool OnAggressiveAction(Mobile aggressor)
+ {
+ if (Mobile.RefusesGuardTarget(aggressor))
+ {
+ DebugSay("Friendly fire; I keep guarding.");
+ return false;
+ }
+
+ // Only a creature somebody can command has been told anything; wild creatures rest on None.
+ var toldToStandDown = Mobile.ControlMaster != null && Mobile.Commandable &&
+ IsStandDownOrder(Mobile.ControlOrder);
+
+ if (Mobile.StandsDownOnCommand && toldToStandDown)
+ {
+ DebugSay("I'm being attacked but my master told me not to fight.");
+ Mobile.Warmode = false;
+ return false;
+ }
+
+ PreferCloserAggressor(aggressor);
+
+ if (aggressor.ChangingCombatant && toldToStandDown)
+ {
+ // With stand-down off, a resting pet answers a direct attack with a full Attack order.
+ Mobile.IssueOrder(OrderType.Attack, null, aggressor);
+ }
+ else if (Mobile.Combatant == null && !Mobile.BardPacified)
+ {
+ Mobile.Warmode = true;
+ Mobile.Combatant = aggressor;
+ }
+
+ return true;
+ }
+
+ // A visible aggressor closer than the current combatant takes over.
+ protected void PreferCloserAggressor(Mobile aggressor)
{
if (aggressor.Hidden)
{
return;
}
- var currentCombat = Mobile.Combatant;
+ var current = Mobile.Combatant;
- if (currentCombat == null || currentCombat == aggressor)
- {
- return;
- }
-
- if (Mobile.GetDistanceToSqrt(aggressor) < Mobile.GetDistanceToSqrt(currentCombat))
+ if (current != null && current != aggressor &&
+ Mobile.GetDistanceToSqrt(aggressor) < Mobile.GetDistanceToSqrt(current))
{
Mobile.Combatant = aggressor;
}
@@ -826,7 +862,19 @@ public abstract partial class BaseAI
return false;
}
- if (HandleBardProvoked() || HandleControlled() || HandleConstantFocus())
+ if (HandleBardProvoked())
+ {
+ return true;
+ }
+
+ // A controlled creature must not fall through to wild target selection when
+ // its order has no target. Guard in particular normally has no ControlTarget.
+ if (Mobile.Controlled)
+ {
+ return HandleControlled();
+ }
+
+ if (HandleConstantFocus())
{
return true;
}
@@ -881,8 +929,17 @@ public abstract partial class BaseAI
private bool HandleControlled()
{
- if (!Mobile.Controlled)
+ if (Mobile.ControlOrder == OrderType.Guard)
{
+ Mobile.FocusMob = FindGuardTarget();
+ return Mobile.FocusMob != null;
+ }
+
+ // Follow and other non-combat orders may target the owner or a friend.
+ // Only an explicit attack order supplies a combat target.
+ if (Mobile.ControlOrder != OrderType.Attack)
+ {
+ Mobile.FocusMob = null;
return false;
}
@@ -939,6 +996,7 @@ public abstract partial class BaseAI
Mobile enemySummonMob = null;
var val = double.MinValue;
var enemySummonVal = double.MinValue;
+ Mobile summonMaster = null;
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
{
@@ -948,14 +1006,24 @@ public abstract partial class BaseAI
}
var bc = m as BaseCreature;
- var pm = m as PlayerMobile;
- if (IsInvalidSummonTarget(m, bc, pm) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe)
- || IsInvalidFightModeTarget(m, acqType, bc))
+ if (IsInvalidSummonTarget(m, bc) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe)
+ || IsInvalidFightModeTarget(m, acqType, bc))
{
continue;
}
+ // Only a summon that ignores the acquire rules gets here with its caster; anyone else comes first.
+ if (m == Mobile.SummonMaster)
+ {
+ if (Mobile.InLOS(m))
+ {
+ summonMaster = m;
+ }
+
+ continue;
+ }
+
var theirVal = Mobile.GetFightModeRanking(m, acqType, bPlayerOnly);
if (theirVal > val && Mobile.InLOS(m))
@@ -963,15 +1031,14 @@ public abstract partial class BaseAI
newFocusMob = m;
val = theirVal;
}
- else if (Core.AOS && theirVal > enemySummonVal
- && Mobile.InLOS(m) && bc?.Summoned == true && bc.Controlled != true)
+ else if (Core.AOS && theirVal > enemySummonVal && Mobile.InLOS(m) && bc is { Summoned: true, Controlled: false })
{
enemySummonMob = m;
enemySummonVal = theirVal;
}
}
- Mobile.FocusMob = newFocusMob ?? enemySummonMob;
+ Mobile.FocusMob = newFocusMob ?? enemySummonMob ?? summonMaster;
return Mobile.FocusMob != null;
}
@@ -979,21 +1046,28 @@ public abstract partial class BaseAI
m.Deleted || m.Blessed || m == Mobile || m is BaseFamiliar || !m.Alive || m.IsDeadBondedPet ||
m.AccessLevel > AccessLevel.Player || bPlayerOnly && !m.Player || !Mobile.CanSee(m);
- private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc, PlayerMobile pm)
+ private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc)
{
- if (Core.AOS && bc?.Summoned == true &&
- (bc.SummonMaster == Mobile || !bc.SummonMaster.Player && IsHostile(bc.SummonMaster)))
+ // A summon whose caster was deleted before a reload comes back with no master.
+ if (Core.AOS &&
+ (bc?.SummonMaster == Mobile || bc?.SummonMaster is { Player: false } summonMaster && IsHostile(summonMaster)))
{
return true;
}
- if (!Mobile.Summoned || Mobile.SummonMaster == null)
+ var master = Mobile.SummonMaster;
+
+ if (master == null)
{
return false;
}
- return m == Mobile.SummonMaster || !SpellHelper.ValidIndirectTarget(Mobile.SummonMaster, m) ||
- Mobile.IsAnimatedDead && (pm != null || bc?.IsAnimatedDead == true || bc?.Controlled == true);
+ if (Mobile.IsAnimatedDead && (m.Player || bc?.IsAnimatedDead == true || bc?.Controlled == true))
+ {
+ return true;
+ }
+
+ return Mobile.FollowsAcquireRules && (m == master || !SpellHelper.ValidIndirectTarget(master, m));
}
private bool IsInvalidFactionTarget(Mobile m, bool bFacFriend, bool bFacFoe) =>
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
index 7ce0994f6..deda86702 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs
@@ -451,11 +451,6 @@ public abstract partial class BaseAI
Mobile.SetControlMaster(e.Mobile);
- if (Mobile.SummonMaster != null)
- {
- Mobile.SummonMaster = e.Mobile;
- }
-
return true;
}
}
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
index 1a166d493..e3224ea93 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs
@@ -769,6 +769,15 @@ public abstract partial class BaseAI
}
}
+ // Uncontrolled summons (energy vortex, blade spirits) turn on their caster, so a guard still defends against them.
+ internal bool IsGuardAlly(Mobile target)
+ {
+ var master = Mobile.ControlMaster;
+
+ return target == Mobile || master != null &&
+ (target == master || target is BaseCreature creature && creature.ControlMaster == master);
+ }
+
///
/// Selects the aggressor closest to the master. The current combatant is kept
/// unless a strictly closer one exists. Never mutates order state.
@@ -779,12 +788,12 @@ public abstract partial class BaseAI
var anchor = controlMaster ?? Mobile;
var current = Mobile.Combatant;
- var best = current != controlMaster && IsValidCombatant(current) ? current : null;
+ var best = !IsGuardAlly(current) && IsValidCombatant(current) ? current : null;
var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue;
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
{
- if (aggr == best || aggr == Mobile || aggr == controlMaster ||
+ if (aggr == best || IsGuardAlly(aggr) ||
aggr.IsDeadBondedPet || !aggr.Alive ||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
{
@@ -808,7 +817,7 @@ public abstract partial class BaseAI
{
var aggressor = aggressors[i].Attacker;
- if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
+ if (aggressor == best || aggressor?.Deleted != false || IsGuardAlly(aggressor) || !aggressor.Alive ||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
{
continue;
diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
index 7eac17ae5..08dcda911 100644
--- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
+++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs
@@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item
private void TransferPetOwnership(Mobile from, Mobile to)
{
- if (_creature.SummonMaster != null)
- {
- _creature.SummonMaster = to;
- }
-
_creature.ClearPetFriends();
_creature.IssueOrder(OrderType.Follow, null, to);
_creature.BondingBegin = DateTime.MinValue;
diff --git a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs
index fcaf96c69..2b5b22c29 100644
--- a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs
+++ b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs
@@ -47,21 +47,25 @@ public class FamiliarAI : BaseAI
return Mobile.Controlled ? OrderType.Come : OrderType.None;
}
- public override void OnAggressiveAction(Mobile aggressor)
+ // No orders to stand down on; never fights for a hidden caster.
+ public override bool OnAggressiveAction(Mobile aggressor)
{
if (!Familiar.AssistsMaster || aggressor.Hidden || Familiar.ControlMaster?.Hidden == true)
{
- return;
+ return false;
}
if (Mobile.Combatant == null)
{
Mobile.Warmode = true;
Mobile.Combatant = aggressor;
- return;
+ }
+ else
+ {
+ PreferCloserAggressor(aggressor);
}
- base.OnAggressiveAction(aggressor);
+ return true;
}
private bool Act()
@@ -148,7 +152,7 @@ public class FamiliarAI : BaseAI
var combatant = target.Combatant;
return combatant == Mobile || combatant == master ||
- combatant is BaseCreature { Controlled: true } pet && pet.ControlMaster == master;
+ combatant is BaseCreature pet && pet.ControlMaster == master;
}
// Closest mobile in a fight with the caster's side.
diff --git a/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs b/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs
index 3666d21ef..bfbf7be55 100644
--- a/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs
+++ b/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs
@@ -48,7 +48,7 @@ public abstract class SummonUndeadCounter : MonsterAbility
summon = CreateSummon(source);
summon.Team = source.Team;
summon.Summoned = true;
- summon.SummonMaster = source;
+ summon.Master = source;
summon.FightMode = FightMode.Closest;
summon.Combatant = target;
summon.Home = source.Home;
diff --git a/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs b/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs
index ca2d4e3dd..822027184 100644
--- a/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs
+++ b/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs
@@ -137,7 +137,7 @@ namespace Server.Mobiles
public static bool CheckAccess(BaseCreature animal, Mobile from) =>
from == animal || from.AccessLevel >= AccessLevel.GameMaster || from.Alive && animal.Controlled &&
!animal.IsDeadPet &&
- (from == animal.ControlMaster || from == animal.SummonMaster || animal.IsPetFriend(from));
+ (from == animal.GetMaster() || animal.IsPetFriend(from));
public static void CombineBackpacks(BaseCreature animal)
{
diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs
index 43e7a598c..f4b516447 100644
--- a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs
+++ b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs
@@ -239,8 +239,7 @@ public abstract partial class BaseMount : BaseCreature, IMount
if (from.InRange(this, 1))
{
var canAccess = from.AccessLevel >= AccessLevel.GameMaster
- || Controlled && ControlMaster == from
- || Summoned && SummonMaster == from;
+ || GetMaster() == from;
if (canAccess)
{
diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs
index bba194d3d..ffef6350b 100644
--- a/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs
+++ b/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs
@@ -71,7 +71,7 @@ namespace Server.Mobiles
public override void OnHarmfulSpell(Mobile from)
{
- if (!Controlled && ControlMaster == null)
+ if (!Controlled)
{
CurrentSpeed = BoostedSpeed;
}
@@ -79,7 +79,7 @@ namespace Server.Mobiles
public override void OnCombatantChange()
{
- if (Combatant == null && !Controlled && ControlMaster == null)
+ if (Combatant == null && !Controlled)
{
CurrentSpeed = PassiveSpeed;
}
diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs
index 0d1c0f989..5795c72a8 100644
--- a/Projects/UOContent/Mobiles/BaseCreature.cs
+++ b/Projects/UOContent/Mobiles/BaseCreature.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
-using ModernUO.CodeGeneratedEvents;
using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
@@ -136,7 +135,7 @@ namespace Server.Mobiles
public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage);
}
- [SerializationGenerator(23, false)]
+ [SerializationGenerator(24, false)]
public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature));
@@ -451,10 +450,10 @@ namespace Server.Mobiles
InvalidateProperties();
}
- // Follower bookkeeping brackets the assignment, so the property is hand-written.
- private Mobile _controlMaster;
+ // Follower bookkeeping brackets the assignment, so Master is hand-written.
+ private Mobile _master;
- private bool ShouldSerializeControlMaster() => _controlMaster != null;
+ private bool ShouldSerializeMaster() => _master != null;
[SerializableField(16)]
[SaveFlag(nameof(ShouldSerializeControlTarget))]
@@ -508,13 +507,8 @@ namespace Server.Mobiles
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)]
+ [SerializableField(23)]
[SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))]
[SerializedCommandProperty(AccessLevel.Administrator)]
private int _controlSlots = 1;
@@ -524,7 +518,7 @@ namespace Server.Mobiles
private int ControlSlotsDefaultValue() => 1;
[EncodedInt]
- [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))]
+ [SerializableField(24, allowFieldChange: nameof(ClampLoyalty))]
[SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _loyalty;
@@ -539,7 +533,7 @@ namespace Server.Mobiles
return true;
}
- [SerializableField(26)]
+ [SerializableField(25)]
[SaveFlag(nameof(ShouldSerializeCurrentWayPoint))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private WayPoint _currentWayPoint;
@@ -547,7 +541,7 @@ namespace Server.Mobiles
private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null;
[EncodedInt]
- [SerializableField(27)]
+ [SerializableField(26)]
[SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _hitsMaxSeed = -1;
@@ -557,7 +551,7 @@ namespace Server.Mobiles
private int HitsMaxSeedDefaultValue() => -1;
[EncodedInt]
- [SerializableField(28)]
+ [SerializableField(27)]
[SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _stamMaxSeed = -1;
@@ -567,7 +561,7 @@ namespace Server.Mobiles
private int StamMaxSeedDefaultValue() => -1;
[EncodedInt]
- [SerializableField(29)]
+ [SerializableField(28)]
[SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _manaMaxSeed = -1;
@@ -577,7 +571,7 @@ namespace Server.Mobiles
private int ManaMaxSeedDefaultValue() => -1;
[EncodedInt]
- [SerializableField(30)]
+ [SerializableField(29)]
[SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _damageMin = -1;
@@ -587,7 +581,7 @@ namespace Server.Mobiles
private int DamageMinDefaultValue() => -1;
[EncodedInt]
- [SerializableField(31)]
+ [SerializableField(30)]
[SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _damageMax = -1;
@@ -597,7 +591,7 @@ namespace Server.Mobiles
private int DamageMaxDefaultValue() => -1;
[EncodedInt]
- [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SerializableField(31, fieldChanged: nameof(OnResistanceSeedChange))]
[SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _physicalResistanceSeed;
@@ -607,7 +601,7 @@ namespace Server.Mobiles
private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances();
[EncodedInt]
- [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))]
[SaveFlag(nameof(ShouldSerializeFireResistSeed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _fireResistSeed;
@@ -615,7 +609,7 @@ namespace Server.Mobiles
private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0;
[EncodedInt]
- [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))]
[SaveFlag(nameof(ShouldSerializeColdResistSeed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _coldResistSeed;
@@ -623,7 +617,7 @@ namespace Server.Mobiles
private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0;
[EncodedInt]
- [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))]
[SaveFlag(nameof(ShouldSerializePoisonResistSeed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _poisonResistSeed;
@@ -631,7 +625,7 @@ namespace Server.Mobiles
private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0;
[EncodedInt]
- [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))]
+ [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))]
[SaveFlag(nameof(ShouldSerializeEnergyResistSeed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _energyResistSeed;
@@ -639,7 +633,7 @@ namespace Server.Mobiles
private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0;
[EncodedInt]
- [SerializableField(37)]
+ [SerializableField(36)]
[SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _physicalDamage = 100;
@@ -649,7 +643,7 @@ namespace Server.Mobiles
private int PhysicalDamageDefaultValue() => 100;
[EncodedInt]
- [SerializableField(38)]
+ [SerializableField(37)]
[SaveFlag(nameof(ShouldSerializeFireDamage))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _fireDamage;
@@ -657,7 +651,7 @@ namespace Server.Mobiles
private bool ShouldSerializeFireDamage() => _fireDamage != 0;
[EncodedInt]
- [SerializableField(39)]
+ [SerializableField(38)]
[SaveFlag(nameof(ShouldSerializeColdDamage))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _coldDamage;
@@ -665,7 +659,7 @@ namespace Server.Mobiles
private bool ShouldSerializeColdDamage() => _coldDamage != 0;
[EncodedInt]
- [SerializableField(40)]
+ [SerializableField(39)]
[SaveFlag(nameof(ShouldSerializePoisonDamage))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _poisonDamage;
@@ -673,7 +667,7 @@ namespace Server.Mobiles
private bool ShouldSerializePoisonDamage() => _poisonDamage != 0;
[EncodedInt]
- [SerializableField(41)]
+ [SerializableField(40)]
[SaveFlag(nameof(ShouldSerializeEnergyDamage))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _energyDamage;
@@ -681,7 +675,7 @@ namespace Server.Mobiles
private bool ShouldSerializeEnergyDamage() => _energyDamage != 0;
[Tidy]
- [SerializableField(42, setter: "private")]
+ [SerializableField(41, setter: "private")]
[SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))]
private List _owners;
@@ -693,13 +687,13 @@ namespace Server.Mobiles
private List OwnersDefaultValue() => new();
- [SerializableField(43)]
+ [SerializableField(42)]
[SaveFlag(nameof(ShouldSerializeIsDeadPet))]
private bool _isDeadPet;
private bool ShouldSerializeIsDeadPet() => _isDeadPet;
- [SerializableField(44, fieldChanged: nameof(OnBondedChange))]
+ [SerializableField(43, fieldChanged: nameof(OnBondedChange))]
[SaveFlag(nameof(ShouldSerializeIsBonded))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _isBonded;
@@ -708,21 +702,21 @@ namespace Server.Mobiles
private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties();
- [SerializableField(45)]
+ [SerializableField(44)]
[SaveFlag(nameof(ShouldSerializeBondingBegin))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _bondingBegin;
private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue;
- [SerializableField(46)]
+ [SerializableField(45)]
[SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _ownerAbandonTime;
private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue;
- [SerializableField(47)]
+ [SerializableField(46)]
[SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))]
private bool _hasGeneratedLoot;
@@ -734,7 +728,7 @@ namespace Server.Mobiles
private bool ShouldSerializeIsParagon() => _isParagon;
[Tidy]
- [SerializableField(49, setter: "private")]
+ [SerializableField(48, setter: "private")]
[SaveFlag(nameof(ShouldSerializeFriends))]
private List _friends;
@@ -744,7 +738,7 @@ namespace Server.Mobiles
return _friends?.Count > 0;
}
- [SerializableField(50)]
+ [SerializableField(49)]
[SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _removeIfUntamed;
@@ -752,21 +746,21 @@ namespace Server.Mobiles
private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed;
[EncodedInt]
- [SerializableField(51)]
+ [SerializableField(50)]
[SaveFlag(nameof(ShouldSerializeRemoveStep))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _removeStep;
private bool ShouldSerializeRemoveStep() => _removeStep != 0;
- [SerializableField(52, setter: "private")]
+ [SerializableField(51, 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);
+ _pendingDeleteTimer?.Running == true && !IsStabled && ControlMaster == null;
private void DeserializePendingDeleteTimer(TimeSpan delay)
{
@@ -774,7 +768,7 @@ namespace Server.Mobiles
_pendingDeleteTimer.Start();
}
- [SerializableField(53)]
+ [SerializableField(52)]
[SaveFlag(nameof(ShouldSerializeCorpseNameOverride))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _corpseNameOverride;
@@ -852,7 +846,6 @@ namespace Server.Mobiles
Debug = false;
_controlled = false;
- _controlMaster = null;
ControlTarget = null;
_controlOrder = OrderType.None;
@@ -925,6 +918,10 @@ namespace Server.Mobiles
[CommandProperty(AccessLevel.GameMaster)]
public bool IsPrisoner { get; set; }
+ ///
+ /// When false, a summon may target its own caster and anyone the caster's harmful spells would spare.
+ /// It still prefers any other target over the caster.
+ ///
public virtual bool FollowsAcquireRules => true;
public virtual Faction FactionAllegiance => null;
@@ -932,7 +929,7 @@ namespace Server.Mobiles
public virtual double WeaponAbilityChance => 0.4;
- [SerializableProperty(48, useField: nameof(_isParagon))]
+ [SerializableProperty(47, useField: nameof(_isParagon))]
[SaveFlag(nameof(ShouldSerializeIsParagon))]
[CommandProperty(AccessLevel.GameMaster)]
public bool IsParagon
@@ -1029,8 +1026,8 @@ namespace Server.Mobiles
}
public virtual bool IsNecroFamiliar =>
- Summoned && _controlMaster != null &&
- SummonFamiliarSpell.Table.TryGetValue(_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;
@@ -1158,23 +1155,26 @@ namespace Server.Mobiles
}
}
- [SerializableProperty(15, useField: nameof(_controlMaster))]
- [SaveFlag(nameof(ShouldSerializeControlMaster))]
+ // Whoever this creature answers to. ControlMaster, SummonMaster and GetMaster() gate it by
+ // Controlled and Summoned; an enraged creature has a master with neither flag.
+ [SerializableProperty(15, useField: nameof(_master))]
+ [SaveFlag(nameof(ShouldSerializeMaster))]
[CommandProperty(AccessLevel.GameMaster)]
- public Mobile ControlMaster
+ public Mobile Master
{
- get => _controlMaster;
+ get => _master;
set
{
- if (_controlMaster == value || this == value)
+ if (_master == value || this == value)
{
return;
}
RemoveFollowers();
- _controlMaster = value;
+ _master = value;
AddFollowers();
- if (_controlMaster != null)
+
+ if (value != null)
{
StopDeleteTimer();
}
@@ -1184,26 +1184,20 @@ namespace Server.Mobiles
}
}
- [SerializableProperty(23, useField: nameof(_summonMaster))]
- [SaveFlag(nameof(ShouldSerializeSummonMaster))]
+ /// The owner. Setting it sets .
+ [CommandProperty(AccessLevel.GameMaster)]
+ public Mobile ControlMaster
+ {
+ get => _controlled ? _master : null;
+ set => Master = value;
+ }
+
+ /// The summoner, controlled or not. Setting it sets .
[CommandProperty(AccessLevel.GameMaster)]
public Mobile SummonMaster
{
- get => _summonMaster;
- set
- {
- if (_summonMaster == value || this == value)
- {
- return;
- }
-
- RemoveFollowers();
- _summonMaster = value;
- AddFollowers();
-
- Delta(MobileDelta.Noto);
- this.MarkDirty();
- }
+ get => _summoned ? _master : null;
+ set => Master = value;
}
// Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor).
@@ -1272,7 +1266,7 @@ namespace Server.Mobiles
}
InvalidateProperties();
- _controlMaster?.InvalidateProperties();
+ ControlMaster?.InvalidateProperties();
this.MarkDirty();
}
@@ -2197,9 +2191,75 @@ namespace Server.Mobiles
}
}
+ // Absent fields resolve through the same default-value methods the live reader uses.
+ private void MigrateFrom(V23Content content)
+ {
+ _defaultAI = content.DefaultAI;
+ _currentAI = content.CurrentAI ?? CurrentAIDefaultValue();
+ _rangePerception = content.RangePerception ?? RangePerceptionDefaultValue();
+ _rangeFight = content.RangeFight ?? RangeFightDefaultValue();
+ _rangeHome = content.RangeHome ?? RangeHomeDefaultValue();
+ _team = content.Team ?? 0;
+ _fightMode = content.FightMode ?? FightModeDefaultValue();
+ _activeSpeed = content.ActiveSpeed ?? ActiveSpeedDefaultValue();
+ _passiveSpeed = content.PassiveSpeed ?? PassiveSpeedDefaultValue();
+ _currentSpeed = content.CurrentSpeed ?? CurrentSpeedDefaultValue();
+ _activeMoveSpeed = content.ActiveMoveSpeed ?? ActiveMoveSpeedDefaultValue();
+ _passiveMoveSpeed = content.PassiveMoveSpeed ?? PassiveMoveSpeedDefaultValue();
+ _home = content.Home ?? Point3D.Zero;
+ _homeMap = content.HomeMap;
+ _controlled = content.Controlled;
+ // A traded summon could still carry its caster as summon master; the owner wins.
+ _master = content.ControlMaster ?? content.SummonMaster;
+ _controlTarget = content.ControlTarget;
+ _controlDest = content.ControlDest ?? Point3D.Zero;
+ _controlOrder = content.ControlOrder ?? OrderType.None;
+ _minTameSkill = content.MinTameSkill ?? 0;
+ _tamable = content.Tamable;
+ _summoned = content.Summoned;
+ _summonEnd = content.SummonEnd ?? DateTime.MinValue;
+ _controlSlots = content.ControlSlots ?? ControlSlotsDefaultValue();
+ _loyalty = content.Loyalty ?? LoyaltyDefaultValue();
+ _currentWayPoint = content.CurrentWayPoint;
+ _hitsMaxSeed = content.HitsMaxSeed ?? HitsMaxSeedDefaultValue();
+ _stamMaxSeed = content.StamMaxSeed ?? StamMaxSeedDefaultValue();
+ _manaMaxSeed = content.ManaMaxSeed ?? ManaMaxSeedDefaultValue();
+ _damageMin = content.DamageMin ?? DamageMinDefaultValue();
+ _damageMax = content.DamageMax ?? DamageMaxDefaultValue();
+ _physicalResistanceSeed = content.PhysicalResistanceSeed ?? 0;
+ _fireResistSeed = content.FireResistSeed ?? 0;
+ _coldResistSeed = content.ColdResistSeed ?? 0;
+ _poisonResistSeed = content.PoisonResistSeed ?? 0;
+ _energyResistSeed = content.EnergyResistSeed ?? 0;
+ _physicalDamage = content.PhysicalDamage ?? PhysicalDamageDefaultValue();
+ _fireDamage = content.FireDamage ?? 0;
+ _coldDamage = content.ColdDamage ?? 0;
+ _poisonDamage = content.PoisonDamage ?? 0;
+ _energyDamage = content.EnergyDamage ?? 0;
+ _owners = content.Owners ?? OwnersDefaultValue();
+ _isDeadPet = content.IsDeadPet;
+ _isBonded = content.IsBonded;
+ _bondingBegin = content.BondingBegin ?? DateTime.MinValue;
+ _ownerAbandonTime = content.OwnerAbandonTime ?? DateTime.MinValue;
+ _hasGeneratedLoot = content.HasGeneratedLoot;
+ _isParagon = content.IsParagon;
+ _friends = content.Friends;
+ _removeIfUntamed = content.RemoveIfUntamed;
+ _removeStep = content.RemoveStep ?? 0;
+
+ if (content.PendingDeleteTimerDelay != TimeSpan.MinValue)
+ {
+ DeserializePendingDeleteTimer(content.PendingDeleteTimerDelay);
+ }
+
+ _corpseNameOverride = content.CorpseNameOverride;
+ }
+
// Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom.
private void Deserialize(IGenericReader reader, int version)
{
+ Mobile controlMaster = null;
+
_currentAI = (AIType)reader.ReadInt();
_defaultAI = (AIType)reader.ReadInt();
@@ -2247,7 +2307,7 @@ namespace Server.Mobiles
_fightMode = (FightMode)reader.ReadInt();
_controlled = reader.ReadBool();
- _controlMaster = reader.ReadEntity();
+ controlMaster = reader.ReadEntity();
_controlTarget = reader.ReadEntity();
_controlDest = reader.ReadPoint3D();
_controlOrder = (OrderType)reader.ReadInt();
@@ -2275,7 +2335,6 @@ namespace Server.Mobiles
_fightMode = FightMode.Closest;
_controlled = false;
- _controlMaster = null;
_controlTarget = null;
_controlOrder = OrderType.None;
}
@@ -2296,9 +2355,12 @@ namespace Server.Mobiles
if (version >= 5)
{
- _summonMaster = reader.ReadEntity();
+ _master = reader.ReadEntity();
}
+ // A transferred summon kept its caster as summon master; the owner wins.
+ _master = controlMaster ?? _master;
+
if (version >= 6)
{
_hitsMaxSeed = reader.ReadInt();
@@ -2459,7 +2521,7 @@ namespace Server.Mobiles
if (IsAnimatedDead)
{
- AnimateDeadSpell.Register(_summonMaster, this);
+ AnimateDeadSpell.Register(SummonMaster, this);
}
}
@@ -2565,7 +2627,7 @@ namespace Server.Mobiles
public void RemoveFollowers()
{
- var master = _controlMaster ?? _summonMaster;
+ var master = _master;
if (master != null)
{
master.Followers -= Math.Min(ControlSlots, master.Followers);
@@ -2579,7 +2641,7 @@ namespace Server.Mobiles
public void AddFollowers()
{
- var master = _controlMaster ?? _summonMaster;
+ var master = _master;
if (master != null)
{
master.Followers += ControlSlots;
@@ -2658,16 +2720,6 @@ namespace Server.Mobiles
FocusMob = null;
- if (IsAnimatedDead)
- {
- AnimateDeadSpell.Unregister(_summonMaster, this);
- }
-
- if (Summoned && SummonMaster != null)
- {
- SummonFamiliarSpell.Unregister(SummonMaster, this);
- }
-
if (MLQuestSystem.Enabled)
{
MLQuestSystem.HandleDeletion(this);
@@ -2738,20 +2790,10 @@ namespace Server.Mobiles
aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true));
}
- var ct = _controlOrder;
-
- if (AIObject != null)
+ // The AI owns the retaliation policy; a creature that stands down keeps its flee and acquire state.
+ if (AIObject?.OnAggressiveAction(aggressor) != true)
{
- if (!StandsDownOnCommand || !BaseAI.IsStandDownOrder(ct))
- {
- AIObject.OnAggressiveAction(aggressor);
- }
- else
- {
- AIObject.DebugSay("I'm being attacked but my master told me not to fight.");
- Warmode = false;
- return;
- }
+ return;
}
StopFlee();
@@ -2767,17 +2809,6 @@ namespace Server.Mobiles
pl.FinishShield();
}
}
-
- // Only reachable when the pet does not stand down: the orders above returned early.
- if (aggressor.ChangingCombatant && (_controlled || _summoned) && BaseAI.IsStandDownOrder(ct))
- {
- IssueOrder(OrderType.Attack, null, aggressor);
- }
- else if (Combatant == null && !BardPacified)
- {
- Warmode = true;
- Combatant = aggressor;
- }
}
public override bool OnMoveOver(Mobile m)
@@ -2862,7 +2893,7 @@ namespace Server.Mobiles
}
public override bool IsHarmfulCriminal(Mobile target) =>
- (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) &&
+ target != GetMaster() &&
(target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) &&
(target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target);
@@ -2870,16 +2901,9 @@ namespace Server.Mobiles
{
base.CriminalAction(message);
- if (Controlled || Summoned)
+ if (GetMaster() is { Player: true } master)
{
- if (_controlMaster?.Player == true)
- {
- _controlMaster.CriminalAction(false);
- }
- else if (_summonMaster?.Player == true)
- {
- _summonMaster.CriminalAction(false);
- }
+ master.CriminalAction(false);
}
}
@@ -2887,7 +2911,7 @@ namespace Server.Mobiles
{
base.DoHarmful(target, indirect);
- if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned)
+ if (target == this || GetMaster() is not { } owner || target == owner)
{
return;
}
@@ -3301,20 +3325,8 @@ namespace Server.Mobiles
return bonus;
}
- public Mobile GetMaster()
- {
- if (Controlled && ControlMaster != null)
- {
- return ControlMaster;
- }
-
- if (Summoned && SummonMaster != null)
- {
- return SummonMaster;
- }
-
- return null;
- }
+ // Who answers for this creature: its owner or its summoner. Never an enraged creature's meer.
+ public Mobile GetMaster() => ControlMaster ?? SummonMaster;
public virtual bool IsMonster => !Controlled || (GetMaster() as BaseCreature)?.IsMonster == true;
@@ -3728,16 +3740,47 @@ namespace Server.Mobiles
{
CreatureEvents.CreatureDeletedEvent(this);
- var m = _controlMaster;
+ // Both registries are keyed by the master, which is cleared below.
+ if (IsAnimatedDead)
+ {
+ AnimateDeadSpell.Unregister(SummonMaster, this);
+ }
+
+ if (SummonMaster != null)
+ {
+ SummonFamiliarSpell.Unregister(SummonMaster, this);
+ }
+
+ var m = ControlMaster;
SetControlMaster(null);
- SummonMaster = null;
+ Master = null;
ReceivedHonorContext?.Cancel();
base.OnDelete();
m?.InvalidateProperties();
}
+ // A guard never fights its master or the master's other pets. An explicit Attack order and bard
+ // provocation still may.
+ public bool RefusesGuardTarget(Mobile target) =>
+ _controlled && _controlOrder == OrderType.Guard && !BardProvoked && AIObject?.IsGuardAlly(target) == true;
+
+ // Mobile.AggressiveAction assigns the first aggressor as Combatant before any retaliation policy runs.
+ public override Mobile Combatant
+ {
+ get => base.Combatant;
+ set
+ {
+ if (value != null && RefusesGuardTarget(value))
+ {
+ return;
+ }
+
+ base.Combatant = value;
+ }
+ }
+
public override bool CanBeHarmful(Mobile target, bool message, bool ignoreOurBlessedness)
{
if (target is BaseFactionGuard)
@@ -3766,14 +3809,19 @@ namespace Server.Mobiles
}
public override bool CanBeRenamedBy(Mobile from) =>
- Controlled && from == ControlMaster && !from.Region.IsPartOf() ||
+ from == ControlMaster && !from.Region.IsPartOf() ||
base.CanBeRenamedBy(from);
public bool SetControlMaster(Mobile m)
{
if (m == null)
{
- ControlMaster = null;
+ // An uncontrolled summon keeps its caster.
+ if (_controlled)
+ {
+ Master = null;
+ }
+
Controlled = false;
ControlTarget = null;
ControlOrder = OrderType.None;
@@ -3796,7 +3844,7 @@ namespace Server.Mobiles
Home = Point3D.Zero;
- ControlMaster = m;
+ Master = m;
Controlled = true;
ControlTarget = null;
ControlOrder = OrderType.Come;
@@ -3858,7 +3906,7 @@ namespace Server.Mobiles
creature.RangeHome = 10;
creature.Summoned = true;
- creature.SummonMaster = caster;
+ creature.Master = caster;
var pack = creature.Backpack;
@@ -4060,14 +4108,11 @@ namespace Server.Mobiles
return BardMaster;
}
- if (_controlled && _controlMaster != null)
- {
- return _controlMaster;
- }
+ var master = GetMaster();
- if (_summoned && _summonMaster != null)
+ if (master != null)
{
- return _summonMaster;
+ return master;
}
return base.GetDamageMaster(damagee);
@@ -4413,16 +4458,27 @@ namespace Server.Mobiles
public virtual void AddPetFriend(Mobile m)
{
+ if (IsPetFriend(m))
+ {
+ return;
+ }
+
Friends ??= [];
- Friends.Add(m);
- this.MarkDirty();
+ AddToFriends(m);
}
public virtual void RemovePetFriend(Mobile m)
{
- if (Friends?.Remove(m) == true)
+ if (Friends == null)
{
- this.MarkDirty();
+ return;
+ }
+
+ RemoveFromFriends(m);
+
+ if (Friends.Count == 0)
+ {
+ Friends = null;
}
}
@@ -4647,7 +4703,7 @@ namespace Server.Mobiles
if (IsBondable && !IsBonded)
{
- var master = _controlMaster;
+ var master = ControlMaster;
if (master != null && master == from) // So friends can't start the bonding process
{
diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs
index 6da24edfe..917e928d3 100644
--- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs
+++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs
@@ -94,7 +94,7 @@ public abstract partial class BaseFamiliar : BaseCreature
{
base.GetContextMenuEntries(from, ref list);
- if (from.Alive && Controlled && from == ControlMaster && from.InRange(this, 14))
+ if (from.Alive && from == ControlMaster && from.InRange(this, 14))
{
list.Add(new ReleaseEntry());
}
diff --git a/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs b/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs
index 32e321011..a39c54d44 100644
--- a/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs
+++ b/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs
@@ -52,7 +52,7 @@ public partial class DarkWolfFamiliar : BaseFamiliar
m_NextRestore = Core.Now + TimeSpan.FromSeconds(2.0);
- var caster = ControlMaster ?? SummonMaster;
+ var caster = GetMaster();
if (caster != null)
{
diff --git a/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs b/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs
index 004d9cb9f..1af1dc204 100644
--- a/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs
+++ b/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs
@@ -62,7 +62,7 @@ public partial class ShadowWispFamiliar : BaseFamiliar
private void Flare()
{
- var caster = ControlMaster ?? SummonMaster;
+ var caster = GetMaster();
if (caster == null)
{
diff --git a/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs b/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs
index ad97905dd..5fb37aec6 100644
--- a/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs
@@ -21,14 +21,9 @@ namespace Server.Mobiles
{
if (m is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster is PlayerMobile)
+ if (bc.GetMaster() is PlayerMobile master)
{
- return CheckRedFriendship(bc.ControlMaster);
- }
-
- if (bc.Summoned && bc.SummonMaster is PlayerMobile)
- {
- return CheckRedFriendship(bc.SummonMaster);
+ return CheckRedFriendship(master);
}
}
@@ -39,14 +34,9 @@ namespace Server.Mobiles
{
if (m is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster is PlayerMobile)
+ if (bc.GetMaster() is PlayerMobile master)
{
- return CheckBlackFriendship(bc.ControlMaster);
- }
-
- if (bc.Summoned && bc.SummonMaster is PlayerMobile)
- {
- return CheckBlackFriendship(bc.SummonMaster);
+ return CheckBlackFriendship(master);
}
}
@@ -57,13 +47,9 @@ namespace Server.Mobiles
{
if (from is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster is PlayerMobile)
+ if (bc.GetMaster() is PlayerMobile master)
{
- OnRedDamage(bc.ControlMaster);
- }
- else if (bc.Summoned && bc.SummonMaster is PlayerMobile)
- {
- OnRedDamage(bc.SummonMaster);
+ OnRedDamage(master);
}
}
@@ -83,13 +69,9 @@ namespace Server.Mobiles
{
if (from is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster is PlayerMobile)
+ if (bc.GetMaster() is PlayerMobile master)
{
- OnBlackDamage(bc.ControlMaster);
- }
- else if (bc.Summoned && bc.SummonMaster is PlayerMobile)
- {
- OnBlackDamage(bc.SummonMaster);
+ OnBlackDamage(master);
}
}
diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs
index a0d486535..10c6b078c 100644
--- a/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs
+++ b/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs
@@ -126,6 +126,10 @@ namespace Server.Mobiles
SpellHelper.Turn(this, toBuff);
+ var maxHits = toBuff.HitsMaxSeed;
+ var rawStr = toBuff.RawStr;
+ var rawDex = toBuff.RawDex;
+
var toScale = toBuff.HitsMaxSeed;
if (toScale > 0)
@@ -154,9 +158,6 @@ namespace Server.Mobiles
toBuff.FixedParticles(0x375A, 10, 15, 5017, EffectLayer.Waist);
toBuff.PlaySound(0x1EE);
- var maxHits = toBuff.HitsMaxSeed;
- var rawStr = toBuff.RawStr;
- var rawDex = toBuff.RawDex;
Timer.StartTimer(
TimeSpan.FromSeconds(20.0),
diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs
index ce4f3526d..cc6eb8cd0 100644
--- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs
+++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs
@@ -92,12 +92,15 @@ namespace Server.Mobiles
Karma = -1000;
Tamable = false;
- SummonMaster = summoner;
+ Master = summoner;
}
+ // The meer's FightMode.Evil against our negative karma would otherwise make us enemies.
+ public override bool IsEnemy(Mobile m) => m != Master && base.IsEnemy(m);
+
public override void OnThink()
{
- if (SummonMaster?.Deleted != false)
+ if (Master?.Deleted != false)
{
Delete();
}
@@ -108,7 +111,7 @@ namespace Server.Mobiles
*/
else if (!Combat(this))
{
- AIObject?.MoveTo(SummonMaster, 5);
+ AIObject?.MoveTo(Master, 5);
}
/*
On OSI, if the summon attacks a mobile, the summoner meer also
@@ -116,11 +119,11 @@ namespace Server.Mobiles
is a player or controlled/summoned, and the summoner is not already
engaged in combat.
*/
- else if (!Combat(SummonMaster))
+ else if (!Combat(Master))
{
- if (Combatant.Player || Combatant is BaseCreature bc && (bc.Controlled || bc.SummonMaster != null))
+ if (Combatant.Player || Combatant is BaseCreature bc && bc.GetMaster() != null)
{
- SummonMaster.Combatant = Combatant;
+ Master.Combatant = Combatant;
}
}
else
diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
index fca41ec25..fe0a2e2d5 100644
--- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
+++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs
@@ -59,6 +59,9 @@ namespace Server.Mobiles
public override bool InitialInnocent => true;
+ // FightMode.Evil would otherwise target our own enraged creatures for their negative karma.
+ public override bool IsEnemy(Mobile m) => (m as BaseEnraged)?.Master != this && base.IsEnemy(m);
+
public override void GenerateLoot()
{
AddLoot(LootPack.FilthyRich);
@@ -85,7 +88,7 @@ namespace Server.Mobiles
if (combatant is BaseCreature bc)
{
- if (bc.Controlled && bc.ControlMaster?.Deleted == false && bc.ControlMaster.Alive)
+ if (bc.ControlMaster is { Deleted: false, Alive: true })
{
if (bc.ControlMaster.Map == Map && bc.ControlMaster.InRange(this, 12) &&
!UnderEffect(bc.ControlMaster))
diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs
index a0966eaf4..4d6dcf251 100644
--- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs
@@ -55,7 +55,8 @@ namespace Server.Mobiles
public override bool BleedImmune => true;
public override Poison PoisonImmune => Poison.Lethal;
- public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca;
+ public override bool FollowsAcquireRules => Core.AOS || SummonMaster?.Player != true ||
+ (Map?.Rules & MapRules.HarmfulRestrictions) != 0;
public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) =>
(m.Str + m.Skills.Tactics.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs
index 3bb2e69fa..c68ecee8a 100644
--- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs
@@ -63,7 +63,8 @@ namespace Server.Mobiles
public override bool BleedImmune => true;
public override Poison PoisonImmune => Poison.Lethal;
- public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca;
+ public override bool FollowsAcquireRules => Core.AOS || SummonMaster?.Player != true ||
+ (Map?.Rules & MapRules.HarmfulRestrictions) != 0;
public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) =>
(m.Int + m.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0);
diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs
index ff40211e8..21927419b 100644
--- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs
@@ -142,22 +142,17 @@ namespace Server.Mobiles
public override void OnDamage(int amount, Mobile from, bool willKill)
{
- if (Controlled || Summoned)
+ if (GetMaster() is { Player: true } master && master.Map == Map && master.InRange(Location, 20))
{
- var master = ControlMaster ?? SummonMaster;
-
- if (master?.Player == true && master.Map == Map && master.InRange(Location, 20))
+ if (master.Mana >= amount)
{
- if (master.Mana >= amount)
- {
- master.Mana -= amount;
- }
- else
- {
- amount -= master.Mana;
- master.Mana = 0;
- master.Damage(amount);
- }
+ master.Mana -= amount;
+ }
+ else
+ {
+ amount -= master.Mana;
+ master.Mana = 0;
+ master.Damage(amount);
}
}
diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs
index 8e7271348..e4ffe5ba3 100644
--- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs
@@ -50,7 +50,6 @@ namespace Server.Mobiles
CanSwim = true;
CantWalk = true;
- PackItem(new MessageInABottle());
PackItem(new Rope { ItemID = 0x14F8 });
PackItem(new Rope { ItemID = 0x14FA });
}
@@ -63,6 +62,13 @@ namespace Server.Mobiles
public override int TreasureMapLevel => 5;
+ public override bool OnBeforeDeath()
+ {
+ PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel));
+
+ return base.OnBeforeDeath();
+ }
+
public static Type[] Artifacts { get; } =
{
// Decorations
diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs
index 21f7d949f..919f77b34 100644
--- a/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs
+++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs
@@ -46,12 +46,10 @@ namespace Server.Mobiles
rope.ItemID = 0x14F8;
PackItem(rope);
- if (Utility.RandomDouble() < .05)
+ if (Core.UOR)
{
- PackItem(new MessageInABottle());
+ PackItem(new SpecialFishingNet()); // Confirm?
}
-
- PackItem(new SpecialFishingNet()); // Confirm?
}
public override string CorpseName => "a krakens corpse";
@@ -59,6 +57,16 @@ namespace Server.Mobiles
public override int TreasureMapLevel => 4;
+ public override bool OnBeforeDeath()
+ {
+ if (Core.T2A && Utility.RandomDouble() < .05)
+ {
+ PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel));
+ }
+
+ return base.OnBeforeDeath();
+ }
+
public override void GenerateLoot()
{
AddLoot(LootPack.Rich);
diff --git a/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs b/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs
index f45f7a7d7..53c13fefe 100644
--- a/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs
+++ b/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs
@@ -59,7 +59,7 @@ namespace Server.Mobiles
public override void OnHarmfulSpell(Mobile from)
{
- if (!Controlled && ControlMaster == null)
+ if (!Controlled)
{
CurrentSpeed = BoostedSpeed;
}
@@ -67,7 +67,7 @@ namespace Server.Mobiles
public override void OnCombatantChange()
{
- if (Combatant == null && !Controlled && ControlMaster == null)
+ if (Combatant == null && !Controlled)
{
CurrentSpeed = PassiveSpeed;
}
diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs
index c3fa4433b..27d75a9b1 100644
--- a/Projects/UOContent/Mobiles/PlayerMobile.cs
+++ b/Projects/UOContent/Mobiles/PlayerMobile.cs
@@ -3612,7 +3612,7 @@ namespace Server.Mobiles
foreach (var follower in allFollowers)
{
- if (follower is not BaseCreature pet || pet.ControlMaster == null)
+ if (follower is not BaseCreature { Controlled: true } pet || pet.ControlMaster == null)
{
continue;
}
@@ -3648,7 +3648,6 @@ namespace Server.Mobiles
pet.Internalize();
pet.SetControlMaster(null);
- pet.SummonMaster = null;
pet.IsStabled = true;
pet.StabledBy = this;
@@ -3697,11 +3696,6 @@ namespace Server.Mobiles
{
pet.SetControlMaster(this);
- if (pet.SummonMaster != null)
- {
- pet.SummonMaster = this;
- }
-
pet.ControlTarget = this;
pet.ControlOrder = OrderType.Follow;
@@ -3953,6 +3947,35 @@ namespace Server.Mobiles
Stabled = null;
}
+
+ if (_allFollowers?.Count > 0)
+ {
+ // Releasing or deleting a follower removes it from _allFollowers.
+ using var followers = PooledRefQueue.Create(_allFollowers.Count);
+
+ foreach (var follower in _allFollowers)
+ {
+ // Escorts and hirelings notice a deleted master and walk off on their own.
+ if (follower is BaseCreature bc and not (BaseEscortable or BaseHire))
+ {
+ followers.Enqueue(bc);
+ }
+ }
+
+ while (followers.Count > 0)
+ {
+ var bc = followers.Dequeue();
+ if (bc.Summoned || bc.IsBonded || bc.IsDeadPet)
+ {
+ bc.Delete();
+ }
+ else
+ {
+ // An unbonded pet goes wild and despawns on the abandoned-pet timer.
+ bc.ControlOrder = OrderType.Release;
+ }
+ }
+ }
}
public override int ComputeMovementSpeed(Direction dir, bool checkTurning = true)
diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
index a6467746b..27f270648 100644
--- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
+++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs
@@ -31,7 +31,10 @@ namespace Server.Mobiles
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor));
private const int MaxSell = 500;
- private static readonly TimeSpan InventoryDecayTime = TimeSpan.FromHours(1.0);
+ // One (not all) of the buy packets counts entries in a byte.
+ private const int BuyListLimit = 250;
+
+ private TimerExecutionToken _buybackPurgeToken;
private readonly List _buyInfo = new();
private readonly List _sellInfo = new();
@@ -71,11 +74,8 @@ namespace Server.Mobiles
SetSpeed(0.5, 2.0);
// these packs MUST exist, or the client will crash when the packets are sent
- Container pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false };
- AddItem(pack);
-
- pack = new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false };
- AddItem(pack);
+ AddItem(new VendorBuybackPack());
+ AddItem(new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false });
LastRestock = Core.Now;
}
@@ -105,20 +105,79 @@ namespace Server.Mobiles
public override bool ShowFameTitle => false;
- public Container BuyPack
+ public VendorBuybackPack BuyPack
{
get
{
- if (FindItemOnLayer(Layer.ShopBuy) is not Container pack)
+ var existing = FindItemOnLayer(Layer.ShopBuy);
+
+ if (existing is VendorBuybackPack pack)
{
- pack = new Backpack { Layer = Layer.ShopBuy, Visible = false };
- AddItem(pack);
+ return pack;
}
+ // Legacy pack; deleting it stays linear even with a huge backlog.
+ existing?.Delete();
+
+ pack = new VendorBuybackPack();
+ AddItem(pack);
return pack;
}
}
+ internal bool BuybackPurgeScheduled => _buybackPurgeToken.Running;
+
+ internal static DateTime GetNextBuybackPurge(DateTime lastRestock, TimeSpan restockDelay, DateTime now)
+ {
+ if (restockDelay <= TimeSpan.Zero)
+ {
+ return now;
+ }
+
+ // A backwards clock adjustment can future-date the anchor past RestockDelay.
+ if (lastRestock > now)
+ {
+ lastRestock = now;
+ }
+
+ var next = lastRestock + restockDelay;
+
+ if (next > now)
+ {
+ return next;
+ }
+
+ // Restock is lazy and may be hours overdue; stay on its grid.
+ var periods = (now - lastRestock).Ticks / restockDelay.Ticks + 1;
+ return lastRestock + TimeSpan.FromTicks(restockDelay.Ticks * periods);
+ }
+
+ private void AddToBuyback(Item item, int capacity)
+ {
+ BuyPack.AddBuyback(item, capacity);
+
+ if (!item.Deleted && !_buybackPurgeToken.Running)
+ {
+ var delay = GetNextBuybackPurge(LastRestock, RestockDelay, Core.Now) - Core.Now;
+ Timer.StartTimer(delay, CancelAndPurgeBuyback, out _buybackPurgeToken);
+ }
+ }
+
+ private void PurgeBuyback() => (FindItemOnLayer(Layer.ShopBuy) as VendorBuybackPack)?.Purge();
+
+ // A fired token-bearing timer isn't returned to its pool until its token is cancelled.
+ private void CancelAndPurgeBuyback()
+ {
+ _buybackPurgeToken.Cancel();
+ PurgeBuyback();
+ }
+
+ public override void OnDelete()
+ {
+ _buybackPurgeToken.Cancel();
+ base.OnDelete();
+ }
+
public virtual bool IsTokunoVendor => Map == Map.Tokuno;
public virtual bool IsTerMurVendor => Map == Map.TerMur;
@@ -132,6 +191,7 @@ namespace Server.Mobiles
public virtual void Restock()
{
LastRestock = Core.Now;
+ CancelAndPurgeBuyback();
var buyInfo = GetBuyInfo();
@@ -427,6 +487,7 @@ namespace Server.Mobiles
var info = GetSellInfo();
var buyInfo = GetBuyInfo();
+ var buybackCapacity = Math.Max(0, BuyListLimit - buyInfo.Length);
var GiveGold = 0;
var Sold = 0;
@@ -498,28 +559,14 @@ namespace Server.Mobiles
if (!found)
{
- var cont = BuyPack;
+ var sold = resp.Item;
if (amount < resp.Item.Amount)
{
- var item = LiftItemDupe(resp.Item, resp.Item.Amount - amount);
+ sold = LiftItemDupe(resp.Item, resp.Item.Amount - amount) ?? resp.Item;
+ }
- if (item != null)
- {
- item.SetLastMoved();
- cont.DropItem(item);
- }
- else
- {
- resp.Item.SetLastMoved();
- cont.DropItem(resp.Item);
- }
- }
- else
- {
- resp.Item.SetLastMoved();
- cont.DropItem(resp.Item);
- }
+ AddToBuyback(sold, buybackCapacity);
}
}
else
@@ -738,6 +785,9 @@ namespace Server.Mobiles
CheckMorph();
LoadSBInfo();
+
+ // LoadSBInfo re-anchored the restock grid. Not done there since it also runs on deserialize.
+ CancelAndPurgeBuyback();
}
public virtual int GetRandomNecromancerHue()
@@ -882,7 +932,7 @@ namespace Server.Mobiles
{
var buyItem = buyInfo[idx];
- if (buyItem.Amount <= 0 || list.Count >= 250)
+ if (buyItem.Amount <= 0 || list.Count >= BuyListLimit)
{
continue;
}
@@ -914,21 +964,6 @@ namespace Server.Mobiles
var playerItems = cont.Items;
- for (var i = playerItems.Count - 1; i >= 0; --i)
- {
- if (i >= playerItems.Count)
- {
- continue;
- }
-
- var item = playerItems[i];
-
- if (item.LastMoved + InventoryDecayTime <= Core.Now)
- {
- item.Delete();
- }
- }
-
for (var i = 0; i < playerItems.Count; ++i)
{
var item = playerItems[i];
@@ -946,17 +981,13 @@ namespace Server.Mobiles
}
}
- if (name != null && list.Count < 250)
+ if (name != null && list.Count < BuyListLimit)
{
list.Add(new BuyItemState(name, cont.Serial, item.Serial, price, item.Amount, item.ItemID, item.Hue));
opls.Enqueue(item.PropertyList);
}
}
- // one (not all) of the packets uses a byte to describe number of items in the list. Osi = dumb.
- // if (list.Count > 255)
- // Console.WriteLine( "Vendor Warning: Vendor {0} has more than 255 buy items, may cause client errors!", this );
-
if (list.Count <= 0)
{
return;
@@ -988,17 +1019,9 @@ namespace Server.Mobiles
public virtual void SendPacksTo(Mobile from)
{
- var pack = FindItemOnLayer(Layer.ShopBuy);
+ from.NetState.SendEquipUpdate(BuyPack);
- if (pack == null)
- {
- pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false };
- AddItem(pack);
- }
-
- from.NetState.SendEquipUpdate(pack);
-
- pack = FindItemOnLayer(Layer.ShopSell);
+ var pack = FindItemOnLayer(Layer.ShopSell);
if (pack != null)
{
@@ -1306,6 +1329,20 @@ namespace Server.Mobiles
{
LoadSBInfo();
+ if (FindItemOnLayer(Layer.ShopBuy) is not VendorBuybackPack)
+ {
+ // Deleting during deserialization is unsafe; replace on the first tick.
+ Timer.StartTimer(
+ () =>
+ {
+ if (!Deleted)
+ {
+ _ = BuyPack;
+ }
+ }
+ );
+ }
+
if (IsParagon)
{
IsParagon = false;
diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
index 6da1acb0a..6a6a62839 100644
--- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
+++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs
@@ -256,7 +256,6 @@ namespace Server.Mobiles
pet.Internalize();
pet.SetControlMaster(null);
- pet.SummonMaster = null;
pet.IsStabled = true;
pet.StabledBy = from;
@@ -358,11 +357,6 @@ namespace Server.Mobiles
{
pet.SetControlMaster(from);
- if (pet.SummonMaster != null)
- {
- pet.SummonMaster = from;
- }
-
pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow;
diff --git a/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs
new file mode 100644
index 000000000..d72bec895
--- /dev/null
+++ b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs
@@ -0,0 +1,56 @@
+using ModernUO.Serialization;
+
+namespace Server.Items;
+
+///
+/// Holds items players sold to an NPC vendor until the next restock. The contents are never saved
+/// and their removal reaches only the vendor and openers, like any private container.
+///
+[SerializationGenerator(0)]
+public partial class VendorBuybackPack : Backpack
+{
+ [Constructible]
+ public VendorBuybackPack()
+ {
+ Layer = Layer.ShopBuy;
+ Movable = false;
+ Visible = false;
+ }
+
+ public override bool SkipsChildSerialization => true;
+
+ // DropItem appends, so Items[0] is always the oldest entry.
+ public void AddBuyback(Item item, int capacity)
+ {
+ if (capacity <= 0)
+ {
+ item.Delete();
+ return;
+ }
+
+ while (Items.Count >= capacity)
+ {
+ var oldest = Items[0];
+ oldest.Delete();
+
+ // Delete() is a no-op on an already-deleted item, which would spin this loop.
+ if (Items.Count > 0 && Items[0] == oldest)
+ {
+ Items.RemoveAt(0);
+ }
+ }
+
+ DropItem(item);
+ }
+
+ public void Purge()
+ {
+ for (var i = Items.Count - 1; i >= 0; --i)
+ {
+ if (i < Items.Count)
+ {
+ Items[i].Delete();
+ }
+ }
+ }
+}
diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs
index 55f846902..c0c0db50f 100644
--- a/Projects/UOContent/Multis/Boats/BaseBoat.cs
+++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs
@@ -1924,8 +1924,18 @@ namespace Server.Multis
_includeBoat = includeBoat;
_boat = boat;
- _mobiles = boat.Map.GetMobilesInBounds(bounds).GetEnumerator();
- _items = boat.Map.GetItemsInBounds(bounds).GetEnumerator();
+ var map = boat.Map;
+
+ if (map == null)
+ {
+ _mobiles = Map.MobileBoundsEnumerable.Empty.GetEnumerator();
+ _items = Map.ItemBoundsEnumerable
- .Empty.GetEnumerator();
+ }
+ else
+ {
+ _mobiles = map.GetMobilesInBounds(bounds).GetEnumerator();
+ _items = map.GetItemsInBounds(bounds).GetEnumerator();
+ }
_iterateMobiles = true;
_iterateItems = true;
}
diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs
index 212a60386..587f9907e 100644
--- a/Projects/UOContent/Multis/Houses/BaseHouse.cs
+++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs
@@ -3660,12 +3660,7 @@ namespace Server.Multis
return true;
}
- if (!(bc.Controlled || bc.Summoned))
- {
- return false;
- }
-
- m = bc.ControlMaster ?? bc.SummonMaster;
+ m = bc.GetMaster();
return m != null && (m.AccessLevel > AccessLevel.Player || IsFriend(m) || Access?.Contains(m) == true);
}
diff --git a/Projects/UOContent/Skills/Begging.cs b/Projects/UOContent/Skills/Begging.cs
index 83180baca..7bc2e1d85 100644
--- a/Projects/UOContent/Skills/Begging.cs
+++ b/Projects/UOContent/Skills/Begging.cs
@@ -158,7 +158,7 @@ namespace Server.SkillHandlers
}
else
{
- _target.SendLocalizedMessage(500404); // They seem unwilling to give you any money.
+ _from.SendLocalizedMessage(500404); // They seem unwilling to give you any money.
}
const int TargeterCooldown = 30000; // 30s
diff --git a/Projects/UOContent/Skills/Stealing.cs b/Projects/UOContent/Skills/Stealing.cs
index 99c92a4c9..bf8dbb1bc 100644
--- a/Projects/UOContent/Skills/Stealing.cs
+++ b/Projects/UOContent/Skills/Stealing.cs
@@ -7,6 +7,7 @@ using Server.Factions;
using Server.Items;
using Server.Misc;
using Server.Mobiles;
+using Server.Network;
using Server.Spells;
using Server.Spells.Fifth;
using Server.Spells.Ninjitsu;
@@ -45,6 +46,9 @@ public static class Stealing
public static bool IsEmptyHanded(Mobile from) =>
from.FindItemOnLayer(Layer.OneHanded) == null && from.FindItemOnLayer(Layer.TwoHanded) == null;
+ private static bool IsInLockedContainer(Item item) =>
+ item.Parent is Item parent && (parent is LockableContainer { Locked: true } || IsInLockedContainer(parent));
+
public static TimeSpan OnUse(Mobile m)
{
if (!IsEmptyHanded(m))
@@ -249,15 +253,24 @@ public static class Stealing
{
_thief.SendLocalizedMessage(502710); // You can't steal that!
}
+ else if (_thief.AccessLevel < AccessLevel.GameMaster && IsInLockedContainer(toSteal))
+ {
+ _thief.SendLocalizedMessage(501747); // It appears to be locked.
+ }
else
{
var w = toSteal.Weight + toSteal.TotalWeight;
+ var reject = LRReason.Inspecific;
if (w > MaxWeightToSteal)
{
// This item is too heavy to steal from someone's backpack.
_thief.SendLocalizedMessage(502722);
}
+ else if (root is Container { IsDecoContainer: false } && !toSteal.CheckLift(_thief, toSteal, ref reject))
+ {
+ _thief.SendLocalizedMessage(502710); // You can't steal that!
+ }
else
{
if (toSteal.Stackable && toSteal.Amount > 1)
@@ -368,6 +381,11 @@ public static class Stealing
if (stolen != null)
{
+ if (root is Container)
+ {
+ stolen.OnItemLifted(from, stolen);
+ }
+
from.AddToBackpack(stolen);
if (!(stolen is Container || stolen.Stackable))
diff --git a/Projects/UOContent/Spells/Base/SpellHelper.cs b/Projects/UOContent/Spells/Base/SpellHelper.cs
index e373bc6d8..537c56f1d 100644
--- a/Projects/UOContent/Spells/Base/SpellHelper.cs
+++ b/Projects/UOContent/Spells/Base/SpellHelper.cs
@@ -407,32 +407,8 @@ namespace Server.Spells
};
}
- public static Guild GetGuildFor(Mobile m)
- {
- var g = m.Guild as Guild;
-
- if (g == null && m is BaseCreature c)
- {
- m = c.ControlMaster;
-
- if (m != null)
- {
- g = m.Guild as Guild;
- }
-
- if (g == null)
- {
- m = c.SummonMaster;
-
- if (m != null)
- {
- g = m.Guild as Guild;
- }
- }
- }
-
- return g;
- }
+ public static Guild GetGuildFor(Mobile m) =>
+ m.Guild as Guild ?? (m as BaseCreature)?.GetMaster()?.Guild as Guild;
public static bool ValidIndirectTarget(Mobile from, Mobile to)
{
@@ -473,29 +449,17 @@ namespace Server.Spells
return false;
}
- if (bcTarg != null && (bcTarg.Controlled || bcTarg.Summoned))
+ if (bcTarg?.GetMaster() is { } targMaster)
{
- if (bcTarg.ControlMaster == from || bcTarg.SummonMaster == from)
- {
- return false;
- }
-
- if (p != null && (p.Contains(bcTarg.ControlMaster) || p.Contains(bcTarg.SummonMaster)))
+ if (targMaster == from || p?.Contains(targMaster) == true)
{
return false;
}
}
- if (bcFrom != null && (bcFrom.Controlled || bcFrom.Summoned))
+ if (bcFrom?.GetMaster() is { } fromMaster)
{
- if (bcFrom.ControlMaster == to || bcFrom.SummonMaster == to)
- {
- return false;
- }
-
- p = Party.Get(to);
-
- if (p != null && (p.Contains(bcFrom.ControlMaster) || p.Contains(bcFrom.SummonMaster)))
+ if (fromMaster == to || Party.Get(to)?.Contains(fromMaster) == true)
{
return false;
}
diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs
index 63788aa69..5ca42f997 100644
--- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs
+++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs
@@ -158,7 +158,7 @@ namespace Server.Mobiles
Warmode = true;
Summoned = true;
- SummonMaster = caster;
+ Master = caster;
ControlOrder = OrderType.Follow;
ControlTarget = caster;
diff --git a/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs b/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs
index f73fb3111..fd70ea273 100644
--- a/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs
+++ b/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs
@@ -86,7 +86,7 @@ namespace Server.Spells.Seventh
if (!Core.AOS && CheckResisted(m))
{
- damage *= 0.5;
+ toDeal *= 0.5;
m.SendLocalizedMessage(501783); // You feel yourself resisting magical energy.
}
diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
index 8da7319c9..3e0d7fa4f 100644
--- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs
+++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs
@@ -144,7 +144,6 @@ public class JailSystem : GenericPersistence
bc.Internalize();
bc.SetControlMaster(null);
- bc.SummonMaster = null;
bc.IsStabled = true;
bc.StabledBy = from;
@@ -418,7 +417,6 @@ public class JailSystem : GenericPersistence
PlayerJailRecords[player] = record;
if (record.IsCurrentlyJailed)
{
- CurrentlyBeingJailed.Add(player);
var jailTime = record.JailEndTime - Core.Now;
JailTimers[player] = Timer.DelayCall(jailTime, ReleasePlayer, record.JailedBy, player);
}
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 996ec9514..5789eabc6 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -42,7 +42,7 @@
-
+
@@ -50,8 +50,8 @@
-
-
+
+
diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md
index 7310c104e..bd994b983 100644
--- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md
+++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md
@@ -31,7 +31,9 @@ description: >
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement
- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition
+- `BaseAI.OnAggressiveAction(aggressor)` is `bool`, not `void`: the AI owns the retaliation policy (`BaseCreature` no longer sets `Combatant` after it); an early `return;` that refused a fight -> `return false;`, otherwise `return true;`; swap-only overrides call `PreferCloserAggressor(aggressor)` instead of `base` -> same doc § Retaliation
- `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries
+- `ControlMaster`/`SummonMaster` are views of one `Master`, gated by `Controlled`/`Summoned`; assignments still compile (they set `Master`). `ControlMaster ?? SummonMaster` and controlled-or-summoned pairs -> `GetMaster()`; a creature that sets `SummonMaster` without `Summoned` (RunUO's `BaseEnraged`) must read `Master`, because `SummonMaster` now reads null -> same doc § Masters
## Anti-Patterns
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)
diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md
index 55aa32605..ae83888af 100644
--- a/dev-docs/claude-skills/modernuo-content-patterns.md
+++ b/dev-docs/claude-skills/modernuo-content-patterns.md
@@ -40,6 +40,12 @@ description: >
trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a
zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see
`dev-docs/content-patterns.md` § OnThink: the excess-call contract
+9. **One master, read through views** -- `ControlMaster` (owner, only while `Controlled`),
+ `SummonMaster` (summoner, only while `Summoned`), `GetMaster()` (either: who answers for the
+ creature), `Master` (the stored reference, flags ignored; both setters assign it). Tame with
+ `SetControlMaster`, summon with `BaseCreature.Summon`. Don't repeat the view's flag:
+ `Controlled && ControlMaster == x` -> `ControlMaster == x`, but `!=` does not reduce -- see
+ `dev-docs/content-patterns.md` § Masters
## New Item Template
diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md
index 85ce6075c..b138ce9f0 100644
--- a/dev-docs/content-patterns.md
+++ b/dev-docs/content-patterns.md
@@ -231,6 +231,9 @@ class — `OnThink` is for content extras (see the excess-call contract below).
creature is dispatched through `Obey()`, an uncontrolled one through `Think()`; an AI that owns
both routes them into one decision (`FamiliarAI.Act`). Overriding `IssueOrder` to return a
fixed order is how "command immunity" is expressed without bypassing the order machinery.
+`OnAggressiveAction(aggressor) -> bool` is the retaliation policy: `BaseCreature` sets no
+combatant of its own and runs `StopFlee`/`ForceReacquire` only on `true`, so an AI that
+returns `false` has refused the fight outright.
### Fight Modes
| FightMode | Behavior |
@@ -264,6 +267,37 @@ public override int TreasureMapLevel => 3; // Drops treasure map
public override double WeaponAbilityChance => 0.4; // Weapon ability chance
```
+### Masters: owner, summoner, responsible party
+
+A creature stores one master. Read it through the view that matches the question:
+
+| Read | Returns | Use it for |
+|---|---|---|
+| `ControlMaster` | the master while `Controlled`, else null | the owner: commands, bonding, friends, stabling |
+| `SummonMaster` | the master while `Summoned`, else null | the summoner: dispel, summon acquire rules |
+| `GetMaster()` | `ControlMaster ?? SummonMaster` | whoever answers for the creature: notoriety, kill credit, guild and party checks |
+| `Master` | the stored reference, flags ignored | a creature's own AI following or defending a master it has no flag for |
+
+A master with neither flag is legal. A meer mage's enraged creatures carry their meer in `Master`
+without being `Controlled` or `Summoned`, so `GetMaster()` is null and the meer never answers for them.
+
+To set a master, tame through `SetControlMaster(m)` (it raises `Controlled`, resets the order and
+checks follower slots) and summon through `BaseCreature.Summon(...)`. Assign `Master` directly only
+for a master with no flag. The `ControlMaster` and `SummonMaster` setters are aliases for `Master`:
+they don't set `Controlled` or `Summoned`, so the value reads back as null until the flag is set.
+Every assignment moves the creature's follower slots from the old master to the new one.
+
+The view already checks its flag, so don't repeat it:
+
+```csharp
+// Redundant: ControlMaster is null unless Controlled
+if (Controlled && ControlMaster == from) { ... }
+if (ControlMaster == from) { ... }
+
+// Not equivalent: without the flag this is true for every wild creature
+if (Controlled && ControlMaster != from) { ... }
+```
+
### Creature Speeds (think vs move clocks)
All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks:
diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md
index def2ecf48..7bde0a898 100644
--- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md
+++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md
@@ -497,6 +497,83 @@ An isolated step (after the creature stood for at least a walk interval) goes ou
walk regardless of pace — only a continuing cadence, or a pace faster than the run
interpolation, flags run.
+## Retaliation: `OnAggressiveAction` Returns `bool`
+
+RunUO's `BaseAI.OnAggressiveAction` was a `void` hook that only swapped to a closer
+aggressor; `BaseCreature.AggressiveAction` then decided the response itself and set
+`Combatant` regardless of what the AI had done. ModernUO moves the whole retaliation policy
+into the hook — the stand-down verdict for commanded pets, the closer-aggressor swap
+(`PreferCloserAggressor`), and the `IssueOrder(Attack)` vs `Combatant =` choice — and
+`BaseCreature` only runs its bookkeeping (`StopFlee`, `ForceReacquire`, ethics shield) when
+the hook returns `true`:
+
+```csharp
+// RunUO — swap only; BaseCreature picks the combatant afterwards
+public override void OnAggressiveAction(Mobile aggressor)
+{
+ if (m_Mobile.Combatant == null)
+ {
+ m_Mobile.Combatant = aggressor;
+ return;
+ }
+
+ base.OnAggressiveAction(aggressor);
+}
+
+// ModernUO — the AI owns the response; false = stood down, nothing else fires
+public override bool OnAggressiveAction(Mobile aggressor)
+{
+ if (Mobile.Combatant == null)
+ {
+ Mobile.Combatant = aggressor;
+ }
+ else
+ {
+ PreferCloserAggressor(aggressor);
+ }
+
+ return true;
+}
+```
+
+An override that used to `return;` early to refuse a fight now returns `false`; calling
+`base.OnAggressiveAction` runs the full stock policy, including setting `Combatant`, so an AI
+that only wants the closer-aggressor swap calls `PreferCloserAggressor` instead.
+
+## Masters: One Reference, Not Two
+
+RunUO kept `ControlMaster` and `SummonMaster` as two independent fields. ModernUO stores one `Master`.
+`ControlMaster` and `SummonMaster` are views of it gated by `Controlled` and `Summoned`, and
+`GetMaster()` is `ControlMaster ?? SummonMaster` (see `dev-docs/content-patterns.md` § Masters).
+
+`ControlMaster != null` implies `Controlled`, so a bare `ControlMaster` check needs no `Controlled` in front of it.
+
+Assignments compile unchanged, because both setters assign `Master`. Reads need a look:
+
+| RunUO | ModernUO |
+|---|---|
+| `bc.ControlMaster ?? bc.SummonMaster` | `bc.GetMaster()` |
+| `bc.Controlled && bc.ControlMaster == m \|\| bc.Summoned && bc.SummonMaster == m` | `bc.GetMaster() == m` |
+| `Controlled && ControlMaster == m` (`m` non-null) | `ControlMaster == m` |
+| `SummonMaster` on a creature that sets it without `Summoned = true` (RunUO's `BaseEnraged`) | `Master` |
+
+Gotchas:
+
+- **A summon master without `Summoned` reads back null.** A RunUO creature that assigned
+ `SummonMaster = caster;` without `Summoned = true` and later read `SummonMaster` now reads null.
+ The write still compiles; only the read changes. Use `Master` for both, and override `IsEnemy` if the
+ creature must never target its master (see `BaseEnraged`).
+- **One master per creature.** `SummonMaster = x` on a tamed pet replaces its owner, and
+ `ControlMaster = null` on an uncontrolled summon clears its caster. Code that tracked a different
+ owner and summoner keeps only the last assignment.
+- **`ControlMaster = x;` is not a tame.** The setter only moves follower slots. It skips everything
+ `SetControlMaster(x)` does: raising `Controlled`, checking the follower cap, resetting the order
+ and `ControlTarget`, unlinking from the spawner, clearing the waypoint, home and guild. On its own it
+ leaves a creature that uses up `x`'s follower slots, can't be commanded and still reads as wild.
+ Use `SetControlMaster(x)`.
+- **`SetControlMaster(null)` keeps an uncontrolled summon's caster.** It clears the master only
+ while the creature is `Controlled`.
+
## Target Acquisition: `AcquireOnApproach` Is a Delay
RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now
diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md
index b6c12bfe8..788a7cd78 100644
--- a/dev-docs/runuo-migration-docs/11-api-reference.md
+++ b/dev-docs/runuo-migration-docs/11-api-reference.md
@@ -134,6 +134,7 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior |
+| `BaseAI.OnAggressiveAction(m)` (`void`) | `OnAggressiveAction(m)` (`bool`) | The AI owns the retaliation policy; `false` = stood down; swap-only overrides call `PreferCloserAggressor` |
| `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` |
| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | |
| `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged |