diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dd461a03f..dedd914c9 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -47,7 +47,7 @@ public class ArcherAI : BaseAI { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 08a745296..548fe0d54 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -36,10 +36,32 @@ public abstract partial class BaseAI } } - private bool UseGroupMovement(Mobile target) => + /// + /// Crowding refinement for the final approach: engages only near the target when allies + /// contest the ring, so creatures spread instead of stacking. Chasing any real distance + /// always uses the pathfinding approach primitive. + /// + private bool UseGroupMovement(Mobile target, int range) => Mobile.Combatant == target && !Mobile.Controlled - && CountNearbyAllies(target) > 0; + && Mobile.InRange(target, range + 2) + && CountCrowdingAllies(target, range) > 0; + + private int CountCrowdingAllies(Mobile target, int range) + { + var crowding = 0; + + foreach (var m in target.GetMobilesInRange(range + 1)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + crowding++; + } + } + + return crowding; + } public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { @@ -56,6 +78,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { + return ai.MoveToWithCollisionAvoidance(target, run, range); } @@ -68,7 +91,15 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - return ai.DoMove(direction, true); + var res = ai.DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) + { + return true; + } + + // A blocked or wall-slid step is not progress — route around the obstacle. + return ai.ApproachTarget(target, run, range); } finally { @@ -76,21 +107,6 @@ public abstract partial class BaseAI } } - private int CountNearbyAllies(Mobile target) - { - var allies = 0; - foreach (var m in Mobile.GetMobilesInRange(8)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - allies++; - } - } - - return allies; - } - private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1063dd70a..06282a7cb 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * ************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; @@ -58,7 +59,15 @@ public abstract partial class BaseAI public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount >= NextMove; + return Core.TickCount - NextMove >= 0; + } + + // Caps movement at one actual step per AI think tick; pacing itself is the timer's + // cadence. Half a step keeps the budget below the timer interval so a legitimate + // next-tick move is never jitter-throttled. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -103,6 +112,8 @@ public abstract partial class BaseAI Mobile.CurrentSpeed = Mobile.PassiveSpeed; } + ConsumeMoveBudget(); + return MoveResult.Success; } @@ -151,6 +162,7 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { + ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -341,31 +353,72 @@ public abstract partial class BaseAI if (res == MoveResult.BadState) { - return false; // not allowed to move this tick; not a stall + return true; // not allowed to move this tick (frozen/casting/throttled); not a failure } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { + ResetApproach(); - return Mobile.InRange(target, range); + return true; // healthy en-route progress } + // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { + Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } + // Sample move-eligibility BEFORE the attempt: a successful step consumes the move + // budget, which would mask stall accounting and the progress signal. + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + if (Path.Follow(run, range)) { ResetApproach(); return true; } - TrackApproachProgress(target); - return false; + TrackApproachProgress(target, couldMove); + + // En-route progress is success; failure only when a move-eligible tick took no step + // (no working path), or the approach has given up. + var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); + + return progressed; + } + + /// + /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around + /// obstacles. Returns false on arrival or when genuinely unable to make progress. + /// + public bool MoveToPoint(IPoint3D goal, bool run) + { + if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) + { + return false; + } + + if (Path?.Goal != goal) + { + Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; + } + + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(run, 1)) + { + Path = null; + return false; // arrived + } + + return Mobile.Location != locBefore || !couldMove; } /// @@ -376,11 +429,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target) + private void TrackApproachProgress(Mobile target, bool couldMove) { - if (!CanMoveNow(out _)) + if (!couldMove) { - return; // a not-yet-due move (stun) is not a stall + return; // a tick that was never allowed to move (stun, stall) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -408,6 +461,7 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { + _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; @@ -444,7 +498,7 @@ public abstract partial class BaseAI return true; } - if (UseGroupMovement(m)) + if (UseGroupMovement(m, range)) { return MoveToWithGroup(this, m, shouldRun, range); } @@ -467,7 +521,11 @@ public abstract partial class BaseAI var direction = Mobile.GetDirectionTo(target); - if (DoMove(direction, true)) + // Wall-slide auto-turns must not count as progress, or a creature pinned on + // geometry reports success forever. + var res = DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) { return true; } @@ -476,14 +534,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMove(clockwise, true)) + if (DoMoveImpl(clockwise, true) == MoveResult.Success) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMove(counterclockwise, true)) + if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 57b6f6a25..4b872ee79 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,11 +26,25 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // Last-known-position tracking: recorded while the combatant is in LOS; drives the + // guard-time investigation and the instant re-engage. + private const int GuardGraceDuration = 10_000; + private const int LkpFreshDuration = 30_000; + private const int InvestigateDuration = 15_000; + private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + private Mobile _lkpTarget; + private Point3D _lkpLocation; + private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private long _lkpExpireTick; + private long _guardStopTick; + private long _investigateStopTick; + private bool _investigating; + public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -233,6 +247,15 @@ public abstract partial class BaseAI return true; } + if (_action == ActionType.Combat) + { + UpdateLastKnownLocation(); + } + else if (_action is ActionType.Wander or ActionType.Guard) + { + TryReengageLastKnown(); + } + switch (Action) { case ActionType.Wander: @@ -323,6 +346,15 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; + + // Investigate a fresh last-seen position that is not already in view; the guard + // grace period begins once the investigation ends. + _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && + !(Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); + _investigateStopTick = Core.TickCount + InvestigateDuration; + _guardStopTick = Core.TickCount + GuardGraceDuration; + _lkpGoal = null; } private void HandleFleeAction() @@ -453,18 +485,118 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (Mobile.Combatant == null) + if (_investigating) { - DebugSay("No threats found. Going home..."); - Action = ActionType.Wander; + if (InvestigateLastKnown()) + { + return true; + } + + _investigating = false; + _guardStopTick = Core.TickCount + GuardGraceDuration; } - DebugSay("I stopped being on guard."); + if (Core.TickCount - _guardStopTick < 0) + { + DebugSay("I am on guard."); + + if (Utility.Random(8) == 0) + { + Mobile.Direction = (Direction)Utility.Random(8); + } + + return true; + } + + DebugSay("I stopped being on guard. Going home..."); Action = ActionType.Wander; return true; } + /// + /// Records the combatant's position while it is visible and in line of sight. + /// + private void UpdateLastKnownLocation() + { + var combatant = Mobile.Combatant; + + if (combatant?.Deleted == false && combatant.Map == Mobile.Map && + Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) + { + _lkpTarget = combatant; + _lkpLocation = combatant.Location; + _lkpExpireTick = Core.TickCount + LkpFreshDuration; + } + } + + /// + /// Re-engages the last-seen target when it returns to view within perception range, + /// bypassing the reacquire throttle. + /// + private bool TryReengageLastKnown() + { + var target = _lkpTarget; + + if (target == null) + { + return false; + } + + if (target.Deleted || !target.Alive || target.Map != Mobile.Map || + target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) + { + ClearLastKnown(); + return false; + } + + if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) + { + return false; + } + + if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || + !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) + { + return false; + } + + DebugSay("There you are!"); + Mobile.Combatant = target; + Mobile.FocusMob = null; + Action = ActionType.Combat; + return true; + } + + /// + /// Walks toward the last-seen position until it is in view, reached, timed out, or + /// unreachable. Returns false when the investigation is finished. + /// + private bool InvestigateLastKnown() + { + if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) + { + return false; + } + + if (Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) + { + DebugSay("They truly disappeared..."); + return false; + } + + _lkpGoal ??= _lkpLocation; + return MoveToPoint(_lkpGoal, false); + } + + private void ClearLastKnown() + { + _lkpTarget = null; + _lkpGoal = null; + _investigating = false; + } + public virtual bool DoActionFlee() { var from = Mobile.Combatant; diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index e9450db98..61be59a1f 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) + else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -695,6 +695,23 @@ public class MageAI : BaseAI } } + // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until + // line of sight returns. Poisoned mages still fall through to cure. + if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) + { + DebugSay("I cannot see my target, moving to regain line of sight"); + + if (!MoveTo(c, false, 1)) + { + OnFailedMove(); + } + + _lastTarget = c; + _lastTargetLoc = c.Location; + + return true; + } + if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1018,7 +1035,16 @@ public class MageAI : BaseAI if (toTarget != null) { - RunTo(toTarget); + // Without line of sight the stand-off is pointless — close in so the held + // target can be invoked. + if (!Mobile.InLOS(toTarget)) + { + MoveTo(toTarget, true, 1); + } + else + { + RunTo(toTarget); + } } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index aa262caee..544770069 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -82,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 6c480ce18..d1a0c7faf 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -660,6 +660,13 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangePerception { get; set; } + /// + /// How far a chase may stretch before the creature gives up its combatant. Between + /// RangePerception and this leash it keeps chasing but may switch to closer targets. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual int ChaseLeashRange => RangePerception * 2; + [CommandProperty(AccessLevel.GameMaster)] public int RangeFight { get; set; }