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.
This commit is contained in:
Kamron Batman 2026-09-23 10:42:38 -07:00 • committed by GitHub
parent 3eb3f0fb5c
commit 00688a04cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 193 additions and 5 deletions

View file

@ -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<bool> 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.");
}
}

View file

@ -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();
}
}
}

View file

@ -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
}

View file

@ -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;
}
}