diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs new file mode 100644 index 000000000..40b7b9961 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Guard-order following drives real movement primitives (and may pathfind), so it shares +// the pathfinding sequential collection like ApproachTargetTests. +[Collection("Sequential Pathfinding Tests")] +public class GuardFollowTests +{ + [Fact] + public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); // the order change may restart the timer + + var start = pet.Location; + ai.NextMove = 0; + ai.Obey(); + + var moved = pet.Location != start; + var hasIntent = ai.TryGetMoveWake(out _); + + pet.Delete(); + master.Delete(); + + Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + // Between-think move wakes require a registered move intent; bare greedy stepping + // quantizes guard-following to the think grid (issue #2593). + Assert.True(hasIntent, "guard-following must register a move intent"); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs new file mode 100644 index 000000000..ee47a3348 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the pet-obedience pacing policy (issue #2593): a controlled pet executing a +// master's movement order paces its steps on the think clock, not the wild-creature +// move table; combat chases and herding keep their own pacing. +[Collection("Sequential UOContent Tests")] +public class PetPacingTests : IDisposable +{ + private readonly List _created = new(); + + private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) + { + var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); + _created.Add(pair.master); + _created.Add(pair.pet); + return pair; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + [Fact] + public void ObeyingPet_PacesStepsOnThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); // wild-creature table pace; must not slow obedience + pet.SetCurrentSpeedToPassive(); + + Assert.Equal(OrderType.Come, pet.ControlOrder); + Assert.Equal(0.4, pet.CurrentMoveSpeed); + + pet.ControlOrder = OrderType.Guard; + Assert.Equal(0.4, pet.CurrentMoveSpeed); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + Assert.Equal(0.4, pet.CurrentMoveSpeed); + } + + // Boundary guard: a pet chasing a combatant keeps the move table. + [Fact] + public void CombatChasingPet_KeepsMoveTable() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var target = new PetTestStub(); + target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); + _created.Add(target); + + pet.SetMoveSpeed(0.3, 0.9); + pet.ControlOrder = OrderType.Guard; + pet.Combatant = target; + pet.SetCurrentSpeedToActive(); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // Boundary guard: herding overrides obedience pacing. + [Fact] + public void HerdedObeyingPet_KeepsHerdingPace() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.45, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.TargetLocation = new Point2D(1010, 1010); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace + } + + private sealed class ThinkProbe : PetTestStub + { + public int Thinks; + + public override void OnThink() + { + Thinks++; + base.OnThink(); + } + } + + private (PlayerMobile master, ThinkProbe pet) SpawnProbe() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new ThinkProbe(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + + return (master, pet); + } + + // Advances simulated time in 8ms lockstep with the wheel, like the real event loop, + // so wake schedules and Core.TickCount stay in sync. + private static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount < deadline) + { + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + } + + private static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount < deadline) + { + if (condition()) + { + return true; + } + + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + // Runs past the random spawn-stagger delay to a known think-tick anchor: returns + // right after a think fires, with the next one a full passive cadence (0.4s) away. + private ThinkProbe SettledProbe(out PlayerMobile master) + { + Core._tickCount = 0; + Timer.Init(0); + + var (m, pet) = SpawnProbe(); + master = m; + pet.ForceIdle = true; // no wandering; pure cadence + pet.ControlOrder = OrderType.Stay; + + var settled = RunUntil(() => pet.Thinks >= 2, 8000); + Assert.True(settled, "the AI must reach a steady think cadence"); + + return pet; + } + + [Fact] + public void OrderChange_WakesStaleThinkTimer() + { + var pet = SettledProbe(out var master); + var thinksBefore = pet.Thinks; + + // Mid-wait on the passive cadence: the next think is ~200ms out. + RunFor(200); + Assert.Equal(thinksBefore, pet.Thinks); + + // The player issues a command; the pet must not wait out the stale wake. + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + RunFor(80); + Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly"); + } + + [Fact] + public void SpeedUp_ReschedulesPendingWake() + { + var pet = SettledProbe(out _); + var thinksBefore = pet.Thinks; + + // Mid-wait on the passive cadence: the next think is ~200ms out. + RunFor(200); + Assert.Equal(thinksBefore, pet.Thinks); + + // The pet is sped up (e.g. a buff): the next think must move up to the new + // 0.1s cadence instead of waiting out the stale 0.4s deadline. + pet.CurrentSpeed = 0.1; + + RunFor(120); + Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake"); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index fe24e5f6d..e86f63044 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,6 +26,8 @@ public sealed class AITimer : Timer { private readonly BaseAI _owner; private long _nextThink; + private long _nextWake; // when the pending wheel entry fires; the wheel cannot tell us + private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -40,8 +42,31 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + + if (Running) + { + return; + } + + Start(); // keeps the current Delay: the construction stagger for spawn/sector wakes + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // A fresh command must not wait out the previous cadence: think now. Pets are exempt + // from sector deactivation, so dropping the stagger Delay here cannot bunch sector wakes. + public void Prod() + { + _nextThink = Core.TickCount; + + if (Running) + { + Reschedule(); + return; + } + + Delay = TimeSpan.Zero; Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } // A speed-up must not wait out a stale, longer think deadline. @@ -52,12 +77,54 @@ public sealed class AITimer : Timer if (candidate - _nextThink < 0) { _nextThink = candidate; + Reschedule(); + } + } + + // Restarts the timer when the new earliest deadline lands before the pending wake. + // The wheel reads Interval only after the next fire, so moving a pending wake earlier + // requires Stop, Delay = remaining, Start. + private void Reschedule() + { + if (_inTick || !Running) + { + return; // ScheduleNext reads the updated deadlines at tick end } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + var now = Core.TickCount; + var deadline = _nextThink; + + if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) + { + deadline = nextMove; + } + + if (deadline - _nextWake >= 0) + { + return; // the pending wake is already at or before the new deadline + } + + Stop(); + Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); + Start(); + _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() + { + _inTick = true; + + try + { + OnTickCore(); + } + finally + { + _inTick = false; + } + } + + private void OnTickCore() { if (ShouldStop()) { @@ -111,6 +178,7 @@ public sealed class AITimer : Timer // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. Interval = TimeSpan.FromMilliseconds(delay); + _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index cf64cae17..f86ac2bfe 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -64,7 +64,7 @@ public abstract partial class BaseAI if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Start(); + AITimer.Activate(); } if (Action != ActionType.Wander) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index ecb456d2e..0b7bdd0b1 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,8 @@ public abstract partial class BaseAI return; } - Activate(); + // A fresh command wakes the AI immediately — never wait out the previous cadence. + AITimer.Prod(); switch (Mobile.ControlOrder) { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..176463c50 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -309,13 +309,13 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - var guardLocation = controlMaster.Location; - - var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) { - DoMove(Mobile.GetDirectionTo(guardLocation)); + // Through the approach primitive so guard-following registers a move + // intent (between-think move wakes) and paths around obstacles. + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f64a289f4..28461e584 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -751,7 +751,8 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive /// maps to the matching movement value; a bespoke pace stays fused to both clocks. - /// A herded creature is always driven at . + /// A herded creature is always driven at ; a pet + /// executing a master's movement order paces on the think clock. /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -763,6 +764,14 @@ namespace Server.Mobiles return HerdingMoveSpeed; } + // Obedience is never slowed by the wild-creature move table; combat + // chases (combatant set) keep it. + if (Controlled && Combatant == null && + ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard) + { + return _currentSpeed; + } + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed : _currentSpeed;