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:
parent
3eb3f0fb5c
commit
00688a04cf
4 changed files with 193 additions and 5 deletions
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue