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/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/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/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 119446f27..6d8c22d0d 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -4304,16 +4304,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; } }