Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Source/ACE.Server/Command/Handlers/AdminCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2200,6 +2200,7 @@ private static void DoCopyChar(Session session, string existingCharName, uint ex
});
}


/// <summary>
/// Creates an object or objects in the world
/// </summary>
Expand Down
125 changes: 114 additions & 11 deletions Source/ACE.Server/WorldObjects/Monster_Awareness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;

using ACE.Common;
using ACE.Common.Extensions;
Expand All @@ -23,6 +24,49 @@ partial class Creature
/// </summary>
public bool IsAwake = false;

/// <summary>
/// Cache for visible targets to reduce expensive lookups
/// </summary>
private List<Creature> _cachedVisibleTargets = new List<Creature>();
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;

/// <summary>
/// Invalidates both target and distance caches
/// </summary>
private void InvalidateTargetCaches()
{
_cachedVisibleTargets = new List<Creature>();
_lastTargetCacheTime = 0.0;
InvalidateDistanceCache();
}

/// <summary>
/// Sets the attack target and invalidates all caches in one operation.
/// Ensures consistency across all code paths.
/// </summary>
private void SetAttackTargetAndInvalidate(Creature target)
{
AttackTarget = target;
InvalidateTargetCaches();
}

/// <summary>
/// Gets cache performance statistics for monitoring
/// </summary>
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);
}

/// <summary>
/// Transitions a monster from idle to awake state
/// </summary>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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...
Expand All @@ -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;
}

Expand All @@ -234,22 +286,69 @@ public virtual bool FindNextTarget()

/// <summary>
/// Returns a list of attackable targets currently visible to this monster
/// Uses caching to reduce expensive lookups
/// </summary>
public List<Creature> 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<Creature>(_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<Creature>(visibleTargets);
Volatile.Write(ref _lastTargetCacheTime, currentTime);

return visibleTargets;
}

/// <summary>
/// Returns a list of attackable targets currently visible to this monster
/// Always performs fresh calculation (no caching)
/// </summary>
public List<Creature> GetAttackTargetsUncached()
{
var visibleTargets = new List<Creature>();
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;

/*if (Location.SquaredDistanceTo(creature.Location) > chaseDistSq)
continue;*/

if (creature.PhysicsObj == null)
continue;
if (PhysicsObj.get_distance_sq_to_object(creature.PhysicsObj, true) > chaseDistSq)
continue;

Expand All @@ -272,7 +371,6 @@ public List<Creature> GetAttackTargets()

visibleTargets.Add(creature);
}

return visibleTargets;
}

Expand Down Expand Up @@ -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
Expand Down
74 changes: 66 additions & 8 deletions Source/ACE.Server/WorldObjects/Monster_Navigation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,59 @@ partial class Creature
public static readonly float MaxChaseRange = 96.0f;
public static readonly float MaxChaseRangeSq = MaxChaseRange * MaxChaseRange;

/// <summary>
/// Cache for physics calculations to reduce expensive operations
/// </summary>
private float _cachedDistanceToTarget = -1.0f;
private double _lastDistanceCacheTime = 0.0;
private const double DISTANCE_CACHE_DURATION = 0.25; // Cache for 0.25 seconds

/// <summary>
/// Invalidate distance cache when target changes
/// </summary>
public void InvalidateDistanceCache()
{
_cachedDistanceToTarget = -1.0f;
_lastDistanceCacheTime = 0.0;
}

/// <summary>
/// Cached distance calculation to reduce expensive physics operations
/// </summary>
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;
}

/// <summary>
/// Determines if a monster is within melee range of target
/// </summary>
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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();
}
Expand All @@ -161,15 +214,15 @@ public float EstimateTurnTo()
/// </summary>
public bool IsMeleeRange()
{
return GetDistanceToTarget() <= MaxMeleeRange;
return GetCachedDistanceToTarget() <= MaxMeleeRange;
}

/// <summary>
/// Returns TRUE if monster in range for current attack type
/// </summary>
public bool IsAttackRange()
{
return GetDistanceToTarget() <= MaxRange;
return GetCachedDistanceToTarget() <= MaxRange;
}

/// <summary>
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion Source/ACE.Server/WorldObjects/Monster_Tick.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down