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;