diff --git a/Source/ACE.Server/Command/Handlers/AdminCommands.cs b/Source/ACE.Server/Command/Handlers/AdminCommands.cs index eb8d84bd7..e5d3f7bda 100644 --- a/Source/ACE.Server/Command/Handlers/AdminCommands.cs +++ b/Source/ACE.Server/Command/Handlers/AdminCommands.cs @@ -2200,6 +2200,7 @@ private static void DoCopyChar(Session session, string existingCharName, uint ex }); } + /// /// Creates an object or objects in the world /// diff --git a/Source/ACE.Server/WorldObjects/Monster_Awareness.cs b/Source/ACE.Server/WorldObjects/Monster_Awareness.cs index a9072e030..ea519605e 100644 --- a/Source/ACE.Server/WorldObjects/Monster_Awareness.cs +++ b/Source/ACE.Server/WorldObjects/Monster_Awareness.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using System.Threading; using ACE.Common; using ACE.Common.Extensions; @@ -23,6 +24,49 @@ partial class Creature /// public bool IsAwake = false; + /// + /// Cache for visible targets to reduce expensive lookups + /// + private List _cachedVisibleTargets = new List(); + private double _lastTargetCacheTime = 0.0; + private const double TARGET_CACHE_DURATION = 0.5; // Cache for 0.5 seconds + + // Cache performance counters + private static long _cacheHits = 0; + private static long _cacheMisses = 0; + + /// + /// Invalidates both target and distance caches + /// + private void InvalidateTargetCaches() + { + _cachedVisibleTargets = new List(); + _lastTargetCacheTime = 0.0; + InvalidateDistanceCache(); + } + + /// + /// Sets the attack target and invalidates all caches in one operation. + /// Ensures consistency across all code paths. + /// + private void SetAttackTargetAndInvalidate(Creature target) + { + AttackTarget = target; + InvalidateTargetCaches(); + } + + /// + /// Gets cache performance statistics for monitoring + /// + public static (long hits, long misses, double hitRate) GetCacheStats() + { + var hits = Interlocked.Read(ref _cacheHits); + var misses = Interlocked.Read(ref _cacheMisses); + var total = hits + misses; + var hitRate = total > 0 ? (double)hits / total : 0.0; + return (hits, misses, hitRate); + } + /// /// Transitions a monster from idle to awake state /// @@ -56,6 +100,9 @@ public virtual void Sleep() IsMoving = false; MonsterState = State.Idle; + // Clear both target and distance caches consistently + InvalidateTargetCaches(); + PhysicsObj.CachedVelocity = Vector3.Zero; ClearRetaliateTargets(); @@ -140,7 +187,8 @@ public virtual bool FindNextTarget() SelectTargetingTactic(); SetNextTargetTime(); - var visibleTargets = GetAttackTargets(); + // Don't use cached targets for critical target finding decisions + var visibleTargets = GetAttackTargetsUncached(); if (visibleTargets.Count == 0) { if (MonsterState != State.Return) @@ -174,7 +222,7 @@ public virtual bool FindNextTarget() // this is a very common tactic with monsters, // although it is not truly random, it is weighted by distance var targetDistances = BuildTargetDistance(visibleTargets); - AttackTarget = SelectWeightedDistance(targetDistances); + SetAttackTargetAndInvalidate(SelectWeightedDistance(targetDistances)); break; case TargetingTactic.Focused: @@ -185,14 +233,18 @@ public virtual bool FindNextTarget() var lastDamager = DamageHistory.LastDamager?.TryGetAttacker() as Creature; if (lastDamager != null) - AttackTarget = lastDamager; + { + SetAttackTargetAndInvalidate(lastDamager); + } break; case TargetingTactic.TopDamager: var topDamager = DamageHistory.TopDamager?.TryGetAttacker() as Creature; if (topDamager != null) - AttackTarget = topDamager; + { + SetAttackTargetAndInvalidate(topDamager); + } break; // these below don't seem to be used in PY16 yet... @@ -203,19 +255,19 @@ public virtual bool FindNextTarget() // in case a bunch of levels of same level are in a group, // so the same player isn't always selected var lowestLevel = visibleTargets.OrderBy(p => p.Level).FirstOrDefault(); - AttackTarget = lowestLevel; + SetAttackTargetAndInvalidate(lowestLevel); break; case TargetingTactic.Strongest: var highestLevel = visibleTargets.OrderByDescending(p => p.Level).FirstOrDefault(); - AttackTarget = highestLevel; + SetAttackTargetAndInvalidate(highestLevel); break; case TargetingTactic.Nearest: var nearest = BuildTargetDistance(visibleTargets); - AttackTarget = nearest[0].Target; + SetAttackTargetAndInvalidate(nearest[0].Target); break; } @@ -234,15 +286,60 @@ public virtual bool FindNextTarget() /// /// Returns a list of attackable targets currently visible to this monster + /// Uses caching to reduce expensive lookups /// public List GetAttackTargets() + { + var currentTime = Timers.RunningTime; + var last = Volatile.Read(ref _lastTargetCacheTime); + + // Check if cache is still valid + if (last > 0.0 && (currentTime - last) < TARGET_CACHE_DURATION) + { + Interlocked.Increment(ref _cacheHits); + return new List(_cachedVisibleTargets); + } + + // Cache expired, refresh it + Interlocked.Increment(ref _cacheMisses); + var visibleTargets = GetAttackTargetsUncached(); + + // Update cache with a copy; return the fresh list to avoid double enumeration + _cachedVisibleTargets = new List(visibleTargets); + Volatile.Write(ref _lastTargetCacheTime, currentTime); + + return visibleTargets; + } + + /// + /// Returns a list of attackable targets currently visible to this monster + /// Always performs fresh calculation (no caching) + /// + public List GetAttackTargetsUncached() { var visibleTargets = new List(); var listOfCreatures = PhysicsObj.ObjMaint.GetVisibleTargetsValuesOfTypeCreature(); + foreach (var creature in listOfCreatures) { + // exclude dead creatures + if (creature.IsDead) + continue; + // ensure attackable - if (!creature.Attackable && creature.TargetingTactic == TargetingTactic.None || creature.Teleporting) continue; + if (!creature.Attackable) + continue; + + // hidden players shouldn't be valid visible targets + if (creature is Player p && (p.Hidden ?? false)) + continue; + + // Only apply TargetingTactic-based skip to non-players (players are excluded above) + if (creature.TargetingTactic == TargetingTactic.None && !(creature is Player)) + continue; + + if (creature.Teleporting) + continue; // ensure within 'detection radius' ? var chaseDistSq = creature == AttackTarget ? MaxChaseRangeSq : VisualAwarenessRangeSq; @@ -250,6 +347,8 @@ public List GetAttackTargets() /*if (Location.SquaredDistanceTo(creature.Location) > chaseDistSq) continue;*/ + if (creature.PhysicsObj == null) + continue; if (PhysicsObj.get_distance_sq_to_object(creature.PhysicsObj, true) > chaseDistSq) continue; @@ -272,7 +371,6 @@ public List GetAttackTargets() visibleTargets.Add(creature); } - return visibleTargets; } @@ -518,8 +616,13 @@ public void FactionMob_CheckMonsters() if (creature is Player || creature is CombatPet) continue; - // ensure attackable - if (creature.IsDead || !creature.Attackable && creature.TargetingTactic == TargetingTactic.None || creature.Teleporting) + // ensure valid/attackable + if (creature.IsDead || creature.Teleporting) + continue; + if (!creature.Attackable) + continue; + // Don't skip players based on TargetingTactic - that's for monster behavior, not target validity + if (creature.TargetingTactic == TargetingTactic.None && !(creature is Player)) continue; // ensure another faction diff --git a/Source/ACE.Server/WorldObjects/Monster_Navigation.cs b/Source/ACE.Server/WorldObjects/Monster_Navigation.cs index b7a90db2d..cec8d17ec 100644 --- a/Source/ACE.Server/WorldObjects/Monster_Navigation.cs +++ b/Source/ACE.Server/WorldObjects/Monster_Navigation.cs @@ -22,6 +22,59 @@ partial class Creature public static readonly float MaxChaseRange = 96.0f; public static readonly float MaxChaseRangeSq = MaxChaseRange * MaxChaseRange; + /// + /// Cache for physics calculations to reduce expensive operations + /// + private float _cachedDistanceToTarget = -1.0f; + private double _lastDistanceCacheTime = 0.0; + private const double DISTANCE_CACHE_DURATION = 0.25; // Cache for 0.25 seconds + + /// + /// Invalidate distance cache when target changes + /// + public void InvalidateDistanceCache() + { + _cachedDistanceToTarget = -1.0f; + _lastDistanceCacheTime = 0.0; + } + + /// + /// Cached distance calculation to reduce expensive physics operations + /// + public float GetCachedDistanceToTarget() + { + var currentTime = Timers.RunningTime; + + // Check if cache is still valid + if (currentTime - _lastDistanceCacheTime < DISTANCE_CACHE_DURATION && _cachedDistanceToTarget >= 0) + { + return _cachedDistanceToTarget; + } + + // Cache expired or invalid, calculate new distance + var myPhysics = PhysicsObj; + var target = AttackTarget; + if (target == null) + { + _cachedDistanceToTarget = float.MaxValue; + } + else + { + var targetPhysics = target.PhysicsObj; + if (myPhysics == null || targetPhysics == null) + { + _cachedDistanceToTarget = float.MaxValue; + } + else + { + _cachedDistanceToTarget = (float)myPhysics.get_distance_to_object(targetPhysics, true); + } + } + + _lastDistanceCacheTime = currentTime; + return _cachedDistanceToTarget; + } + /// /// Determines if a monster is within melee range of target /// @@ -94,7 +147,7 @@ public void StartTurn() IsTurning = true; // send network actions - var targetDist = GetDistanceToTarget(); + var targetDist = GetCachedDistanceToTarget(); var turnTo = IsRanged || (CurrentAttack == CombatType.Magic && targetDist <= GetSpellMaxRange()) || AiImmobile; if (turnTo) TurnTo(AttackTarget); @@ -111,7 +164,7 @@ public void StartTurn() // Initialize stuck detection LastStuckCheckTime = Timers.RunningTime; - var mvp = GetMovementParameters(); + var mvp = GetMovementParameters(targetDist); if (turnTo) PhysicsObj.TurnToObject(AttackTarget.PhysicsObj, mvp); else @@ -135,7 +188,7 @@ public override void OnMoveComplete(WeenieError status) if (AiImmobile && CurrentAttack == CombatType.Melee) { - var targetDist = GetDistanceToTarget(); + var targetDist = GetCachedDistanceToTarget(); if (targetDist > MaxRange) ResetAttack(); } @@ -161,7 +214,7 @@ public float EstimateTurnTo() /// public bool IsMeleeRange() { - return GetDistanceToTarget() <= MaxMeleeRange; + return GetCachedDistanceToTarget() <= MaxMeleeRange; } /// @@ -169,7 +222,7 @@ public bool IsMeleeRange() /// public bool IsAttackRange() { - return GetDistanceToTarget() <= MaxRange; + return GetCachedDistanceToTarget() <= MaxRange; } /// @@ -487,7 +540,11 @@ public bool IsFacing(WorldObject target) if (target?.Location == null) return false; var angle = GetAngle(target); - var dist = Math.Max(0, GetDistanceToTarget()); + var dist = target == AttackTarget + ? Math.Max(0, GetCachedDistanceToTarget()) + : (PhysicsObj != null && target?.PhysicsObj != null + ? (float)PhysicsObj.get_distance_to_object(target.PhysicsObj, true) + : float.MaxValue); // rotation accuracy? var threshold = 5.0f; @@ -503,14 +560,15 @@ public bool IsFacing(WorldObject target) return angle < threshold; } - public MovementParameters GetMovementParameters() + public MovementParameters GetMovementParameters(float? targetDistance = null) { var mvp = new MovementParameters(); // set non-default params for monster movement mvp.Flags &= ~MovementParamFlags.CanWalk; - var turnTo = IsRanged || (CurrentAttack == CombatType.Magic && GetDistanceToTarget() <= GetSpellMaxRange()) || AiImmobile; + var distance = targetDistance ?? GetCachedDistanceToTarget(); + var turnTo = IsRanged || (CurrentAttack == CombatType.Magic && distance <= GetSpellMaxRange()) || AiImmobile; if (!turnTo) mvp.Flags |= MovementParamFlags.FailWalk | MovementParamFlags.UseFinalHeading | MovementParamFlags.Sticky | MovementParamFlags.MoveAway; diff --git a/Source/ACE.Server/WorldObjects/Monster_Tick.cs b/Source/ACE.Server/WorldObjects/Monster_Tick.cs index d8270cd43..6760be339 100644 --- a/Source/ACE.Server/WorldObjects/Monster_Tick.cs +++ b/Source/ACE.Server/WorldObjects/Monster_Tick.cs @@ -7,7 +7,7 @@ namespace ACE.Server.WorldObjects { partial class Creature { - protected const double monsterTickInterval = 0.2; + protected const double monsterTickInterval = 0.3; public double NextMonsterTickTime;