OMRBotController.CombatContinuity.cs
using Sandbox;
using System.Collections.Generic;
using System;

/// <summary>BOT 9: aim, trigger and cover movement share one contact policy.</summary>
public sealed partial class OMRBotController
{
	[Property, Group( "BOT 9 / Tracking" ), Range( 0f, 0.25f )]
	public float VisibleTrackingPredictionLimit { get; set; } = 0.16f;

	[Property, Group( "BOT 9 / Tracking" ), Range( 0.1f, 1f )]
	public float CombatFacingMemoryDuration { get; set; } = 0.65f;

	[Property, Group( "BOT 9 / Cover" ), Range( 72f, 240f )]
	public float MaximumExposedCoverApproach { get; set; } = 160f;

	private bool _automaticTriggerCommitted;
	private OneMoreRoundWeapon _automaticTrackingWeapon;
	private float _lastTrackingAimAt = -999f;
	private float _coverTravelStartedAt = -1f;
	private float _nextCoverShortlistAt;
	private Vector3 _coverShortlistOrigin;
	private Vector3 _coverShortlistThreat;
	private readonly List<(Vector3 Point, float Priority)> _coverShortlist = new();
	private static readonly float[] CoverSearchAngles = { -86f, 86f, -58f, 58f, -32f, 32f, 0f, -118f, 118f, 155f, -155f };

	private void ResetCombatContinuity()
	{
		_automaticTriggerCommitted = false;
		_automaticTrackingWeapon = null;
		_lastTrackingAimAt = -999f;
		_coverTravelStartedAt = -1f;
		_coverShortlist.Clear();
		_nextCoverShortlistAt = 0f;
	}

	private bool HasCombatFacingEvidence() => _hasConfirmedSight ||
		( HasLastKnownPosition && Time.Now - _lastConfirmedSeenAt <= MathF.Max( 0.1f, CombatFacingMemoryDuration ) );

	private Vector3 ResolveTrackedAimPoint( Vector3 observedAim )
	{
		// Visible sample extrapolation compensates recognition-sampling and follow
		// lag. Hidden-contact retention intentionally continues to use the old point.
		if ( !_hasConfirmedSight || _previousObservedAt < 0f ||
			( observedAim - _observedTargetAimPoint ).Length > 2f ) return observedAim;
		float age = MathF.Max( 0f, Time.Now - _previousObservedAt );
		float follow = MathF.Max( 1f, GetAimFollowSpeed() );
		float skill = Difficulty switch
		{
			OMRBotDifficulty.Easy => 0.22f,
			OMRBotDifficulty.Hard => 0.90f,
			_ => 0.65f
		};
		float prediction = MathF.Min( MathF.Max( 0f, VisibleTrackingPredictionLimit ), age + skill / follow );
		return observedAim + _lastSeenVelocity.ClampLength( 500f ) * prediction;
	}

	private bool AllowAutomaticTrackingFire( OneMoreRoundWeapon weapon, float acquireTolerance )
	{
		if ( _automaticTrackingWeapon != weapon || Time.Now - _lastTrackingAimAt > 0.25f )
			_automaticTriggerCommitted = false;
		_automaticTrackingWeapon = weapon;
		_lastTrackingAimAt = Time.Now;
		float releaseTolerance = acquireTolerance * ( _automaticTriggerCommitted ? 1.55f : 1f );
		if ( CombatAimError > releaseTolerance )
		{
			_automaticTriggerCommitted = false;
			return false;
		}
		_automaticTriggerCommitted = true;
		return true;
	}

	private bool IsCoverApproachSafe( OMRBotRouteEvaluation route )
	{
		if ( !_hasConfirmedSight ) return true;
		float maxExposure = MathF.Max( 72f, MaximumExposedCoverApproach );
		if ( route.DetourRatio > 2f || route.FirstProtectionDistance < 0f ||
			route.FirstProtectionDistance > maxExposure ) return false;
		return route.ExposureFraction <= 0.80f || route.FirstProtectionDistance <= 96f;
	}

	private bool UpdateCoverTravel( OneMoreRoundWeapon weapon, float distance )
	{
		if ( _coverTravelStartedAt < 0f ) _coverTravelStartedAt = Time.Now;
		if ( Time.Now - _coverTravelStartedAt > 3f || NavigationStatus == "LOCAL BLOCKED" ||
			( NavigationStatus?.StartsWith( "NO PATH" ) ?? false ) || NavigationStatus == "NO START POINT" )
		{
			ClearCoverPlan( "APPROACH FAILED / REENGAGE" );
			_utilityCoverFailureUntil = Time.Now + 0.75f;
			_nextUtilityDecisionAt = MathF.Min( _nextUtilityDecisionAt, Time.Now + 0.12f );
			return false;
		}

		bool hasVisual = _hasConfirmedSight && _perceptionTarget?.IsAlive == true;
		bool faceThreat = HasCombatFacingEvidence();
		float enemyDistance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
		bool wantsAds = hasVisual && ShouldUseAds( weapon, enemyDistance );
		if ( hasVisual ) UpdateCombatAim( _observedTargetAimPoint );
		else if ( faceThreat ) LookTowardWorldPoint( _observedTargetAimPoint, 7f );
		else if ( HasRecentThreat() ) OrientTowardThreat();
		CombatWantsAim = wantsAds;
		weapon?.SetBotAimIntent( wantsAds );

		NavigateToWorldPosition(
			_coverTarget, "TACTICAL COVER", MathF.Max( 28f, CoverArrivalDistance ),
			$"TACTICAL COVER / {_coverReason}",
			allowSprint: !faceThreat && !HasRecentThreat() && distance >= 210f,
			faceMovement: !faceThreat && !HasRecentThreat(),
			advancedMovement: true, movementWeapon: weapon, visibleCombatTarget: hasVisual
		);
		// Retreating is a footwork decision, not permission to stop fighting. Real
		// reload/cycle/ammo gates still decide whether a shot is physically possible.
		if ( hasVisual ) UpdateVisibleWeaponAttack( weapon, enemyDistance, wantsAds );
		else CombatStatus = weapon?.IsReloading == true ? "RELOAD / MOVING TO COVER" : "BREAKING CONTACT";
		TacticalStatus = $"SEEK COVER / {_coverReason}";
		CoverStatus = $"MOVING {_coverPosture.ToString().ToUpperInvariant()} · {distance:0}u";
		return true;
	}
}