diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs deleted file mode 100644 index 476956c88..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -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 _); - var currentSpeed = pet.CurrentSpeed; - var currentMoveSpeed = pet.CurrentMoveSpeed; - - 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"); - - // 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/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs deleted file mode 100644 index c93a8ed7a..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pins guard-order combat semantics (issue #2595): a guarding pet engages and fights -// without ever leaving the Guard order, retargets toward the master's closest aggressor, -// and stands down deterministically when there is nothing to guard against. The scene -// sits on the proven-open (1495..1500, 1600) Trammel segment from ApproachTargetTests; -// combat targets are adjacent so no pathfinding runs. -[Collection("Sequential UOContent Tests")] -public class GuardOrderTests : IDisposable -{ - private readonly List _created = new(); - - private sealed class AggressorStub : Mobile - { - public AggressorStub() => Body = 0xC9; - } - - public void Dispose() - { - foreach (var m in _created) - { - m?.Delete(); - } - - _created.Clear(); - } - - private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z) - { - map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - _created.Add(master); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); - pet.SetControlMaster(master); - _created.Add(pet); - - pet.AIObject.AITimer?.Stop(); // drive manually - pet.ControlOrder = OrderType.Guard; - pet.AIObject.AITimer?.Stop(); // the order change restarts the timer - - return (master, pet); - } - - private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) - { - var aggr = new AggressorStub(); - aggr.MoveToWorld(loc, pet.Map); - _created.Add(aggr); - - // Guards the test setup itself: the scene must stay on open, LOS-clear terrain - // and the combatant assignment must not be vetoed. - Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}"); - - if (attacking != null) - { - aggr.Combatant = attacking; - Assert.Same(attacking, aggr.Combatant); - } - - return aggr; - } - - [Fact] - public void GuardEngage_KeepsGuardOrder() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); - - pet.AIObject.Obey(); - - Assert.Same(aggr, pet.Combatant); - Assert.Equal(OrderType.Guard, pet.ControlOrder); - Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder); - } - - [Fact] - public void Guard_RetargetsToAggressorClosestToMaster() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master); - var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); - - pet.Combatant = far; // already fighting the far aggressor - - pet.AIObject.Obey(); - - Assert.Same(near, pet.Combatant); // defends the master, not the current fight - Assert.Equal(OrderType.Guard, pet.ControlOrder); - } - - [Fact] - public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - - // Explicit kill order on a target that then becomes invalid. - var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null); - pet.ControlTarget = victim; - pet.ControlOrder = OrderType.Attack; - victim.Hidden = true; - - // A second aggressor is still after the master; FightMode.Closest would chain it. - var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master); - - pet.AIObject.Obey(); // attack completes -> resume the persistent Guard - - Assert.Equal(OrderType.Guard, pet.ControlOrder); - - pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order - - Assert.Same(aggr2, pet.Combatant); - Assert.Equal(OrderType.Guard, pet.ControlOrder); - } - - [Fact] - public void PeacefulGuard_StandsDown() - { - var (_, pet) = SpawnGuardingPet(out _, out _); - Assert.True(pet.Warmode); // the guard order opens in war stance - - pet.AIObject.Obey(); // nothing to guard against - - Assert.False(pet.Warmode); - Assert.Null(pet.Combatant); - Assert.Null(pet.FocusMob); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs deleted file mode 100644 index eac4113ce..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ /dev/null @@ -1,235 +0,0 @@ -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(); - } - - // 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 OrderIssue_SetsThinkClock() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.SetCurrentSpeedToPassive(); - - pet.ControlOrder = OrderType.Come; - Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.3, pet.CurrentMoveSpeed); // organic: verbatim active -> activeMove - - 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); - } - - // 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 FollowMaster_ObeySprints() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.AIObject.AITimer?.Stop(); - - pet.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; // fixture era is EJ: Core.AOS is true - pet.AIObject.Obey(); - - Assert.Equal(0.1, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); - } - - // 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(); - - 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. - [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/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index bd93b6515..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -118,19 +118,18 @@ public abstract partial class BaseAI if (TryMove(d)) { - // 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()) + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. + if (Core.AOS && IsFollowingMaster()) { - if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); - } + Mobile.CurrentSpeed = 0.1; + } + else if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); } ConsumeMoveBudget(); @@ -542,7 +541,8 @@ 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,14 +599,6 @@ 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); @@ -657,12 +649,14 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); + var shouldRun = run && iCurrDist > 5; + if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) { return false; } @@ -673,19 +667,18 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - // The caller's run flag is honored as-is: it only sets the client-side animation - // (server pace is the move budget), and the callers that pass anything but false — - // follow, guard, clone — gate it on their own distance thresholds. private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { + var shouldRun = run && iCurrDist > 5; + if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, run, iWantDistMax); + return ApproachTarget(m, shouldRun, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, run), true)) + if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 2b970296e..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,8 +26,6 @@ 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; @@ -42,34 +40,8 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - - 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. - // Spam-safe: at most one think per command and no compounding, and a think grants no - // action — steps (NextMove budget), swings, casts, abilities and detect-hidden are all - // gated by their own budgets/timers that this never touches. - public void Prod() - { - _nextThink = Core.TickCount; - - if (Running) - { - Reschedule(); - return; - } - - Delay = TimeSpan.Zero; + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); - _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } // A speed-up must not wait out a stale, longer think deadline. @@ -80,54 +52,12 @@ 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 } - 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; + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); } protected override void OnTick() - { - _inTick = true; - - try - { - OnTickCore(); - } - finally - { - _inTick = false; - } - } - - private void OnTickCore() { if (ShouldStop()) { @@ -181,7 +111,6 @@ 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 f86ac2bfe..cf64cae17 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.Activate(); + AITimer.Start(); } if (Action != ActionType.Wander) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 6479b4723..ecb456d2e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,8 +26,7 @@ public abstract partial class BaseAI return; } - // A fresh command wakes the AI immediately — never wait out the previous cadence. - AITimer.Prod(); + Activate(); switch (Mobile.ControlOrder) { @@ -37,11 +36,6 @@ 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: @@ -141,7 +135,6 @@ 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() @@ -155,7 +148,6 @@ 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; } @@ -170,17 +162,9 @@ 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. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. _commandIssuer = null; } @@ -207,7 +191,6 @@ public abstract partial class BaseAI } Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -223,7 +206,6 @@ 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; } @@ -239,7 +221,6 @@ 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 0a6ab8a70..1b7139a96 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -128,13 +128,6 @@ 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); @@ -298,15 +291,14 @@ public abstract partial class BaseAI return true; } - var combatant = FindGuardTarget(); + FindCombatant(); - if (combatant != null) + if (IsValidCombatant(Mobile.Combatant)) { + var combatant = Mobile.Combatant; + this.DebugSayFormatted($"Attacking target: {combatant.Name}"); - // Engage without leaving the Guard order (#2595): the (guarding)/guarded - // tags persist, recall/gate keeps the pet, and the per-tick scan retargets - // toward the master's closest aggressor for the whole fight. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -317,33 +309,16 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - // Stand down deterministically: a stale Warmode otherwise leaves the return - // pace active or passive by combat history. - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; + var guardLocation = controlMaster.Location; - var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); + var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); if (distance > 3) { - // 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); + DoMove(Mobile.GetDirectionTo(guardLocation)); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -384,86 +359,67 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - // A resumed Guard engages through its own scan without ever leaving the order; - // only non-guard fallbacks chain the next aggressor through an explicit Attack. - if (Mobile.ControlOrder == OrderType.Guard || - Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) + if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) { - return; - } - - var next = FindGuardTarget(); - - if (next != null) - { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; - - this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); - - Think(); + FindCombatant(); } } - /// - /// Selects the aggressor a pet should defend against, preferring whichever is - /// closest to the master (RunUO guard parity — a guarding pet retargets to protect - /// its owner). The current combatant is the baseline and is kept unless a strictly - /// closer aggressor exists. Pure selection: never mutates any order state. - /// - private Mobile FindGuardTarget() + private void FindCombatant() { var controlMaster = Mobile.ControlMaster; - var anchor = controlMaster ?? Mobile; - - var current = Mobile.Combatant; - var best = current != controlMaster && IsValidCombatant(current) ? current : null; - var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (aggr == best || aggr == Mobile || aggr == controlMaster || - aggr.IsDeadBondedPet || !aggr.Alive || - aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) + if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) { continue; } - var dist = aggr.GetDistanceToSqrt(anchor); + var isAttackingPet = aggr.Combatant == Mobile; + var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; - if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) + if (isAttackingPet || isAttackingMaster) { - best = aggr; - bestDist = dist; + if (Mobile.InLOS(aggr)) + { + Mobile.ControlTarget = aggr; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggr; + + var target = isAttackingMaster ? "master" : "me"; + this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); + + Think(); + return; + } } } - var aggressors = controlMaster?.Aggressors; - - if (aggressors != null) + if (controlMaster?.Aggressors != null) { - for (var i = 0; i < aggressors.Count; i++) + for (var i = 0; i < controlMaster.Aggressors.Count; i++) { - var aggressor = aggressors[i].Attacker; + var aggressor = controlMaster.Aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || - aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) + if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) { continue; } - var dist = aggressor.GetDistanceToSqrt(anchor); - - if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - best = aggressor; - bestDist = dist; + Mobile.ControlTarget = aggressor; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggressor; + + this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); + + Think(); + return; } } } - - return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 7f99d6b99..f64a289f4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -750,9 +750,8 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// 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 . + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. + /// A herded creature is always driven at . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -846,10 +845,6 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public Point3D ControlDest { get; set; } - // Deliberately fires on every assignment, not just changes: a reissued order is a - // command ("all attack" retargets via ControlTarget, "all follow" breaks off combat, - // "all stay" re-anchors Home) and must run its handler and wake the AI. Handlers - // needing change-detection compare against the previous order they receive. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index b18c7fa18..776ec269d 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.X; + var ez = rect.End.Z; if (l1 < l2) { diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index cdb3f9e49..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,13 +29,6 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only -- see `dev-docs/content-patterns.md` § Creature Speeds -8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the - think cadence (player commands prod it; speed-ups reschedule it). Gate consequential - work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call - random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the - trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a - zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see - `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 80d925580..ec56e1bb3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,61 +283,6 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). -### OnThink: the excess-call contract - -`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the -think cadence (`CurrentSpeed`), but it can and does fire more often: a player command -wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and -players run command macros that drive extra thinks deliberately (order spam is spam-safe -by design — reaction, never action). RunUO had the same property (its timer restarted -with a random delay on every speed change), so this has never been a fixed-rate callback. - -**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant -an extra action: - -- Gate consequential work on its own deadline field, compared in subtraction form - (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. -- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — - its frequency then scales with think rate, which players can influence. Per-call rolls - are acceptable only for pure cosmetics (idle animations, flavor sounds). -- The engine already gates the expensive things: steps (the `NextMove` budget), weapon - swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, - rummage, aura) all carry their own clocks. Follow that pattern. - -```csharp -private long _nextSpecial; - -public override void OnThink() -{ - base.OnThink(); - - if (Core.TickCount - _nextSpecial >= 0) - { - DoSpecial(); - _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here - } -} -``` - -### MonsterAbility: same contract - -`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and -`CombatAction`-triggered abilities, so abilities live under the same rule: - -- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds - no matter how often thinks fire. Always give a triggered ability a real cooldown. -- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected - trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as - the rate limiter, and keep cooldowns long relative to the think interval so the jitter - stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between - natural and spammed think rates). -- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled - think that passes its chance — only ever correct for passive alteration hooks, never - for `Think`/`CombatAction` triggers. -- An ability that breaks pet orders (fear-style effects) must own its duration explicitly - (a hold state, or a "refuses orders until" deadline checked in the order handlers) — - pets react to re-issued commands immediately, so think latency is not a hold. - --- ## New Spell