From 00688a04cf1394c4e579acd63d771e78c72d10c3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:42:38 -0700 Subject: [PATCH] fix(ai): guard zero-int detection cooldown and duplicate pet friends (#2667) Supersedes #2658 (rebased onto main; edits by maintainers were not enabled on the original branch). Credit to @MithrilHammer for both fixes and the regression tests; their three commits are kept as authored. Addresses two items from #2627: - The detect-hidden cooldown clamps its divisor to at least one. A creature whose effective intelligence reaches zero now uses the existing capped cooldown instead of throwing. - Pet friends are unique. `AddPetFriend` ignores an existing friend, so one removal revokes the membership. On top of that, `AddPetFriend` and `RemovePetFriend` now go through the generated `AddToFriends` / `RemoveFromFriends`, which mark the creature dirty. `Friends` goes back to null when the last friend is removed, and removing a non-friend no longer touches the list. Duplicates already stored in saves are not migrated. ## Tests `DetectHiddenTimerTests` (7 cases) drives the real AI timer and checks cooldown bounds and retry timing. `PetFriendMembershipTests` (3 cases) covers duplicate adds, removal, and re-adding without disturbing other friends. `UOContent.Tests`: 1158 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/DetectHiddenTimerTests.cs | 95 +++++++++++++++++++ .../Mobiles/AI/PetFriendMembershipTests.cs | 82 ++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 19 +++- 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs 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; } }