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 |