OMRBotController.Performance.cs
using Sandbox;
using System.Collections.Generic;
using Sandbox.Navigation;
using System;
using System.Linq;

/// <summary>
/// BOT 9: separate strategic sampling from per-frame aim/input execution, and
/// keep all expensive paths behind one counted API boundary. Caches are owned
/// by this bot and cleared on spawn, layout changes, disable and destruction.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "BOT 9 / Performance" ), Range( 0.05f, 0.25f )]
	public float StrategicSampleInterval { get; set; } = 0.10f;

	[Property, Group( "BOT 9 / Performance" ), Range( 0.1f, 0.5f )]
	public float TacticalQueryBudgetInterval { get; set; } = 0.20f;

	[ConVar( "omr_bot_perf" )]
	public static bool BotPerformanceLogging { get; set; }

	public int PathQueriesLastSecond { get; private set; }
	public int TraversalProbesLastSecond { get; private set; }
	public string PerformanceStatus { get; private set; } = "READY";

	private OMRBotManager _navigationManager;
	private OneMoreRoundWeapon _strategyWeapon;
	private int _strategyWeaponRevision = -2;
	private RoundModifier _strategyModifier = (RoundModifier)(-1);
	private bool _strategyHadSight;
	private float _strategyHealth = -1f;
	private float _nextStrategicSampleAt;
	private float _nextTacticalBudgetAt;
	private float _nextMovementPathAt;
	private float _nextPerformanceReportAt;
	private int _pathQueries;
	private int _traversalProbes;
	private int _runtimeMatchRound = -1;
	private RoundPhase _runtimeMatchPhase = (RoundPhase)(-1);
	private SessionMode _runtimeSessionMode = (SessionMode)(-1);
	private readonly List<Vector3> _refreshedPathPoints = new();
	private readonly Dictionary<(int X, int Y, int Height), TraversalProbeSample> _traversalProbeCache = new();
	private readonly struct TraversalProbeSample
	{
		public readonly Vector3 Origin;
		public readonly Vector3 Direction;
		public readonly float ExpiresAt;
		public readonly bool Blocked;
		public TraversalProbeSample( Vector3 origin, Vector3 direction, bool blocked )
		{
			Origin = origin; Direction = direction; Blocked = blocked;
			ExpiresAt = Time.Now + 0.075f;
		}
	}

	private NavMeshPath CalculateCountedBotPath( Vector3 start, Vector3 target )
	{
		_pathQueries++;
		return Scene.NavMesh.CalculatePath( new CalculatePathRequest { Start = start, Target = target } );
	}

	private void RefreshTacticalQueryBudget()
	{
		if ( Time.Now < _nextTacticalBudgetAt ) return;
		_nextTacticalBudgetAt = Time.Now + MathF.Max( 0.10f, TacticalQueryBudgetInterval );
		_tacticalPathQueriesRemaining = Math.Clamp( TacticalPathQueryBudgetPerUpdate, 2, 8 );
	}

	private bool ShouldSampleStrategy( OneMoreRoundWeapon weapon )
	{
		float health = GetOwnHealthFraction();
		int revision = weapon?.StatRevision ?? -1;
		bool changed = _strategyHadSight != _hasConfirmedSight ||
			_strategyWeapon != weapon || MathF.Abs( _strategyHealth - health ) > 0.01f ||
			_strategyWeaponRevision != revision || _strategyModifier != _activeModifier;
		if ( !changed && Time.Now < _nextStrategicSampleAt ) return false;
		_strategyHadSight = _hasConfirmedSight;
		_strategyWeapon = weapon;
		_strategyHealth = health;
		_strategyWeaponRevision = revision;
		_strategyModifier = _activeModifier;
		_nextStrategicSampleAt = Time.Now + MathF.Max( 0.05f, StrategicSampleInterval );
		return true;
	}

	private bool PrepareBotSessionLifecycle()
	{
		if ( _roundManager?.IsValid != true )
			_roundManager = Scene?.GetAllComponents<RoundManager>().FirstOrDefault();
		if ( _navigationManager?.IsValid != true )
			_navigationManager = Scene?.GetAllComponents<OMRBotManager>().FirstOrDefault();
		if ( _roundManager?.IsValid != true ) return true;

		SessionMode mode = _roundManager.Mode;
		RoundPhase phase = _roundManager.Phase;
		int round = _roundManager.RoundNumber;
		bool changed = mode != _runtimeSessionMode;
		bool enteringFight = mode == SessionMode.Match && phase == RoundPhase.Fighting &&
			( _runtimeMatchPhase != RoundPhase.Fighting || round != _runtimeMatchRound );
		_runtimeSessionMode = mode;
		_runtimeMatchPhase = phase;
		_runtimeMatchRound = round;

		// A survivor may remain IsAlive across resets. Phase/round transitions must
		// also reset perception, paths and cover; death->alive alone misses that case.
		if ( ( changed || enteringFight ) && _playerState.IsAlive ) BeginNewBotLife();
		if ( mode == SessionMode.Menu || ( mode == SessionMode.Match && phase != RoundPhase.Fighting ) )
		{
			StopMovementOnly();
			SafeReleaseLastCombatWeapon();
			ReleaseBotWeaponIntent();
			CombatStatus = "ROUND LOCKED";
			CurrentIntent = "WAITING FOR ROUND";
			return false;
		}
		return true;
	}

	private void UpdatePerformanceCounters()
	{
		if ( Time.Now < _nextPerformanceReportAt ) return;
		PathQueriesLastSecond = _pathQueries;
		TraversalProbesLastSecond = _traversalProbes;
		_pathQueries = 0; _traversalProbes = 0;
		_nextPerformanceReportAt = Time.Now + 1f;
		PerformanceStatus = $"PATH {PathQueriesLastSecond}/s · PROBE {TraversalProbesLastSecond}/s";
		if ( BotPerformanceLogging )
			Log.Info( $"[OMR BOT PERF] {PerformanceStatus} | Round:{_runtimeMatchRound} | {CombatStatus}" );
	}

	private void ResetPerformanceCaches()
	{
		_strategyWeapon = null;
		_strategyWeaponRevision = -2;
		_strategyHealth = -1f;
		_nextStrategicSampleAt = 0f;
		_nextTacticalBudgetAt = 0f;
		_nextMovementPathAt = 0f;
		_refreshedPathPoints.Clear();
		_traversalProbeCache.Clear();
		_nextCoverShortlistAt = 0f;
		_coverShortlist.Clear();
		_tacticalRouteCache.Clear();
		_tacticalPathQueriesRemaining = 0;
		_navigationProjectionValid = false;
		_nextNavigationProjectionAt = 0f;
	}

	private bool _navigationProjectionValid;
	private Vector3 _navigationProjectionOrigin;
	private Vector3 _navigationProjectionTarget;
	private Vector3 _navigationStartOnMesh;
	private Vector3 _navigationTargetOnMesh;
	private float _nextNavigationProjectionAt;

	private bool TryProjectNavigationEndpoints( Vector3 target )
	{
		if ( Time.Now < _nextNavigationProjectionAt &&
			( WorldPosition - _navigationProjectionOrigin ).Length <= 48f &&
			( target - _navigationProjectionTarget ).Length <= 24f ) return _navigationProjectionValid;
		Vector3? start = Scene.NavMesh.GetClosestPoint( WorldPosition, 192f );
		Vector3? end = Scene.NavMesh.GetClosestPoint( target, 192f );
		_navigationProjectionValid = start.HasValue && end.HasValue;
		_navigationProjectionOrigin = WorldPosition;
		_navigationProjectionTarget = target;
		_nextNavigationProjectionAt = Time.Now + 0.15f;
		if ( !_navigationProjectionValid ) return false;
		_navigationStartOnMesh = start.Value;
		_navigationTargetOnMesh = end.Value;
		return true;
	}

	protected override void OnDisabled()
	{
		if ( Networking.IsHost && !IsProxy )
		{
			StopMovementOnly();
			SafeReleaseLastCombatWeapon();
		}
		ResetPerformanceCaches();
		base.OnDisabled();
	}

	protected override void OnDestroy()
	{
		SafeReleaseLastCombatWeapon();
		ResetPerformanceCaches();
		base.OnDestroy();
	}
}