diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 40b7b9961..476956c88 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -36,6 +36,8 @@ public class GuardFollowTests var moved = pet.Location != start; var hasIntent = ai.TryGetMoveWake(out _); + var currentSpeed = pet.CurrentSpeed; + var currentMoveSpeed = pet.CurrentMoveSpeed; pet.Delete(); master.Delete(); @@ -44,5 +46,54 @@ public class GuardFollowTests // 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"); + + // RunUO AOS parity: the guard return sprints at the bespoke 0.1, fused to both + // clocks, and the per-step speed flip must not undo it (fixture era is EJ). + Assert.Equal(0.1, currentSpeed); + Assert.Equal(0.1, currentMoveSpeed); + } + + [Fact] + public void GuardReturn_PreAOS_RunsActive() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + 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); + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist + + ai.NextMove = 0; + ai.Obey(); + + var currentSpeed = pet.CurrentSpeed; + + pet.Delete(); + master.Delete(); + + // No sprint pre-AOS: the return runs organically active. + Assert.Equal(0.2, currentSpeed); + } + finally + { + Core.Expansion = previous; + } } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index abaae1aab..eac4113ce 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -32,58 +32,64 @@ public class PetPacingTests : IDisposable _created.Clear(); } + // Issuing an order sets the think clock organically (RunUO OnCurrentOrderChanged + // parity): movement orders run active, resting orders run passive. The move clock + // then resolves through the normal classification — no special-casing. [Fact] - public void ObeyingPet_PacesStepsOnThinkClock() + public void OrderIssue_SetsThinkClock() { 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.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; - Assert.Equal(0.4, pet.CurrentMoveSpeed); - } - - // A guarding pet with nothing to fight returns to its master at the follow sprint - // pace (RunUO guard parity) while its think cadence stays untouched. - [Fact] - public void GuardReturn_SprintsOnMoveClock() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); pet.SetMoveSpeed(0.3, 0.9); pet.SetCurrentSpeedToPassive(); - pet.ControlOrder = OrderType.Guard; // fixture era is EJ: Core.AOS is true + pet.ControlOrder = OrderType.Come; + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); // organic: verbatim active -> activeMove - Assert.Equal(0.1, pet.CurrentMoveSpeed); - Assert.Equal(0.4, pet.CurrentSpeed); // think clock unaffected + pet.ControlOrder = OrderType.Stay; + Assert.Equal(0.4, pet.CurrentSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + Assert.Equal(0.2, pet.CurrentSpeed); + + pet.ControlOrder = OrderType.Guard; + Assert.Equal(0.2, pet.CurrentSpeed); } - // Boundary guard: pre-AOS eras have no sprint — guard paces on the think clock. + // RunUO AOS parity: a pet following its master sprints — DoOrderFollow writes the + // bespoke 0.1, which fuses to both clocks through the normal classification. [Fact] - public void GuardReturn_PreAOS_PacesThinkClock() + public void FollowMaster_ObeySprints() { - var previous = Core.Expansion; + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); - try - { - Core.Expansion = Expansion.UOR; + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true + pet.AIObject.Obey(); - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.SetCurrentSpeedToPassive(); + Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } - pet.ControlOrder = OrderType.Guard; + // A guarding pet at its master's side stays organically active — never the + // stale-warmode passive lottery, and no sprint while there is nowhere to go. + [Fact] + public void GuardAtMastersSide_IsActive() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); - Assert.Equal(0.4, pet.CurrentMoveSpeed); - } - finally - { - Core.Expansion = previous; - } + pet.ControlOrder = OrderType.Guard; + pet.AIObject.Obey(); // nothing to guard against, master adjacent + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); } // Boundary guard: a pet chasing a combatant keeps the move table. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1a58ac4d2..bd93b6515 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -118,18 +118,19 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. - if (Core.AOS && IsFollowingMaster()) + // An obeying pet's pace is owned by its order handler (issue sets the think + // clock; guard/follow write the AOS sprint) — the per-step flip re-derives + // speed for wild creatures and combat only, or it would fight those writes. + if (!IsObeyingMoveOrder()) { - Mobile.CurrentSpeed = 0.1; - } - else if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); + if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); + } } ConsumeMoveBudget(); @@ -541,8 +542,7 @@ public abstract partial class BaseAI { nextMove = NextMove; - return (_moveIntentTarget != null || _moveIntentPoint != null) && - Core.TickCount - _moveIntentExpire < 0; + return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; } /// @@ -599,6 +599,14 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // A pet executing a master's movement order with no combat; its order handler owns + // the speed clocks (mirrors RunUO's OnCurrentOrderChanged/DoOrder* speed writes). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsObeyingMoveOrder() => + Mobile.Controlled && + Mobile.Combatant == null && + Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { var distance = (int)Mobile.GetDistanceToSqrt(target); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 4c8aa10d3..6479b4723 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -37,6 +37,11 @@ public abstract partial class BaseAI break; } case OrderType.Come: + { + // RunUO parity: movement orders run the active think clock. + Mobile.SetCurrentSpeedToActive(); + break; + } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -136,6 +141,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive } private void HandleTransferOrder() @@ -149,6 +155,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -163,6 +170,7 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); // Only a freshly issued command plays the flourish; resuming the persistent // order after an explicit attack must not replay it after every kill. @@ -199,6 +207,7 @@ public abstract partial class BaseAI } Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -214,6 +223,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToActive(); // RunUO parity: movement orders run active Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -229,6 +239,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); // RunUO parity: resting orders run passive Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e9a101b89..0a6ab8a70 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -128,6 +128,13 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); + // RunUO AOS parity: a pet sprints after its master (bespoke 0.1 fuses to both + // clocks); other targets keep the active pace the order issue set. + if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) + { + Mobile.CurrentSpeed = 0.1; + } + if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); @@ -320,12 +327,23 @@ public abstract partial class BaseAI if (distance > 3) { - // Through the approach primitive so guard-following registers a move - // intent (between-think move wakes) and paths around obstacles. + // RunUO parity: the AOS return sprints (bespoke 0.1 fuses to both + // clocks); earlier eras run active. Through the approach primitive so + // guard-following registers a move intent and paths around obstacles. + if (Core.AOS) + { + Mobile.CurrentSpeed = 0.1; + } + else + { + Mobile.SetCurrentSpeedToActive(); + } + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { + Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 4fbdf6561..7f99d6b99 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -750,9 +750,9 @@ 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 pet - /// executing a master's movement order paces on the think clock. + /// maps to the matching movement value; a bespoke pace (e.g. the AOS 0.1 sprint + /// pet orders write) stays fused to both clocks. A herded creature is always + /// driven at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -764,18 +764,6 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - // Obedience is never slowed by the wild-creature move table; combat - // chases (combatant set) keep it. A guarding pet returns to its master - // at the follow sprint pace (RunUO guard parity) with its think cadence - // untouched. - if (Controlled && Combatant == null && - ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard) - { - return Core.AOS && ControlOrder == OrderType.Guard - ? Math.Min(_currentSpeed, 0.1) - : _currentSpeed; - } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed : _currentSpeed;