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

/// <summary>
/// Post-BOT7 runtime polish driven by live testing.
///
/// This layer deliberately fixes player-facing behavior rather than adding a new
/// strategic architecture: active opening movement, short fair target retention
/// for automatic weapons, coarser blind-fire response, and wall-clearance probes.
/// All opponent-position inputs remain either observed or public arena knowledge.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "Runtime Polish / Initiative" )]
	public bool ProactiveNoContactMovement { get; set; } = true;

	[Property, Group( "Runtime Polish / Initiative" ), Range( 0.8f, 6f )]
	public float NoContactReplanInterval { get; set; } = 2.1f;

	[Property, Group( "Runtime Polish / Initiative" ), Range( 48f, 180f )]
	public float NoContactArrivalDistance { get; set; } = 72f;

	[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
	public float AutomaticSightRetentionNormal { get; set; } = 0.28f;

	[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
	public float AutomaticSightRetentionHard { get; set; } = 0.38f;

	[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
	public float AutomaticSightRetentionEasy { get; set; } = 0.16f;

	[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 1f, 2f )]
	public float AutomaticFireToleranceScale { get; set; } = 1.28f;

	[Property, Group( "Runtime Polish / Walls" ), Range( 32f, 160f )]
	public float PreferredWallClearance { get; set; } = 70f;

	[Property, Group( "Runtime Polish / Walls" ), Range( 16f, 80f )]
	public float MinimumWallClearance { get; set; } = 30f;

	[Property, Sync( SyncFlags.FromHost )]
	public string InitiativeStatus { get; set; } = "READY";

	private float _botLifeStartedAt = -999f;
	private bool _noContactTargetValid;
	private Vector3 _noContactTarget = Vector3.Zero;
	private float _nextNoContactPlanAt;
	private int _noContactPlanSide = 1;
	private int _blindThreatHits;
	private float _lastBlindThreatHitAt = -999f;

	private void BeginNewBotLife()
	{
		_botLifeStartedAt = Time.Now;
		_noContactTargetValid = false;
		_noContactTarget = Vector3.Zero;
		_nextNoContactPlanAt = 0f;
		_noContactPlanSide = Game.Random.Float( 0f, 1f ) < 0.5f ? -1 : 1;
		_blindThreatHits = 0;
		_lastBlindThreatHitAt = -999f;
		InitiativeStatus = "OPENING";

		// Round resets keep the bot GameObject alive while inventory/weapons and
		// positions are rebuilt. Clear transient intent so stale cover/search/path
		// state from the previous life cannot leak into the new round.
		_pathPoints.Clear();
		_pathPointIndex = 0;
		_nextRepathAt = 0f;
		ResetFluidMovementState();
		ResetCombatHumanization();
		ResetCombatContinuity();
		ResetPerformanceCaches();
		SafeReleaseLastCombatWeapon();

		_nextPerceptionScanAt = 0f;
		_perceptionTarget = null;
		_navigationTarget = null;
		_rawVisibleSince = -1f;
		_hasConfirmedSight = false;
		_lastConfirmedSeenAt = -999f;
		_observedTargetPosition = Vector3.Zero;
		_observedTargetAimPoint = Vector3.Zero;
		HasLastKnownPosition = false;
		LastKnownPosition = Vector3.Zero;
		ResetTacticalState();
		ResetUtilityBrainState();
		ResetOpponentBelief( "NEW LIFE" );
	}

	private void RegisterBlindThreatHit()
	{
		if ( _hasConfirmedSight )
		{
			_blindThreatHits = 0;
			return;
		}

		if ( Time.Now - _lastBlindThreatHitAt > 1.15f )
			_blindThreatHits = 0;

		_lastBlindThreatHitAt = Time.Now;
		_blindThreatHits = Math.Min( 4, _blindThreatHits + 1 );
	}

	private int GetRecentBlindThreatHits()
	{
		if ( Time.Now - _lastBlindThreatHitAt > 1.35f )
			return 0;
		return _blindThreatHits;
	}

	private float GetAutomaticSightRetentionDuration()
	{
		return Difficulty switch
		{
			OMRBotDifficulty.Easy => MathF.Max( 0f, AutomaticSightRetentionEasy ),
			OMRBotDifficulty.Hard => MathF.Max( 0f, AutomaticSightRetentionHard ),
			_ => MathF.Max( 0f, AutomaticSightRetentionNormal )
		};
	}

	private bool TryHandleAutomaticTargetRetention( OneMoreRoundWeapon weapon )
	{
		if (
			PerceptionStatus == "REACTING" ||
			weapon?.IsValid != true ||
			!weapon.IsAutomaticWeapon ||
			!HasLastKnownPosition ||
			_lastConfirmedSeenAt < 0f ||
			_perceptionTarget?.IsAlive != true ||
			weapon.IsReloading ||
			weapon.Clip1 <= 0
		)
			return false;

		float age = Time.Now - _lastConfirmedSeenAt;
		OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
		float retention = GetAutomaticSightRetentionDuration() *
			MathX.Lerp( 0.88f, 1.18f, MathX.Clamp( doctrine.ReacquirePersistence, 0f, 1f ) );
		if ( age < 0f || age > retention )
			return false;

		// The target is no longer visible. Keep aiming/firing only at the stale
		// observed sample for a tiny burst-continuity window. No live transform is
		// consulted here, so a player who changes direction behind cover defeats it.
		float distance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
		UpdateCombatAim( _observedTargetAimPoint );
		bool wantsAds = ShouldUseAds( weapon, distance );
		CombatWantsAim = wantsAds;
		weapon.SetBotAimIntent( wantsAds );

		if ( UsesAdvancedMovement() )
		{
			UpdateFluidCombatMovement(
				weapon,
				distance,
				GetCombatHoldDistance( weapon )
			);
		}
		else
		{
			_pathPoints.Clear();
			_pathPointIndex = 0;
			StopMovementOnly();
		}
		NavigationStatus = "CONTACT RETENTION";
		NavigationTargetName = _perceptionTarget.DisplayName;
		NavigationTargetDistance = MathF.Round( distance );
		CurrentIntent = "COMBAT / AUTOMATIC RETENTION";
		CombatStatus = $"RETENTION {MathF.Max( 0f, retention - age ):0.00}s";

		UpdateVisibleWeaponAttack( weapon, distance, wantsAds );
		return true;
	}

	private bool ShouldEvadeBlindThreatImmediately()
	{
		return GetRecentBlindThreatHits() >= 2 || GetOwnHealthFraction() <= 0.48f;
	}

	private void UpdateBlindThreatLocateReaction( OneMoreRoundWeapon weapon )
	{
		ReleaseBotWeaponIntent( weapon );
		OrientTowardThreat();

		// The first unseen hit gives only a rough directional clue. Do not instantly
		// solve cover from the source position: make a small non-directional human-like
		// adjustment while locating the attacker. Repeated hits may escalate to the
		// normal evade/cover response.
		float side = _strafeDirection >= 0f ? 1f : -1f;
		Vector3 moveInput = new Vector3( 0f, side * 0.26f, 0f );
		_movementInput?.SetBotInput(
			moveInput,
			sprintDown: false,
			crouchDown: GetBotCrouchButtonDown(),
			jumpPressed: false
		);

		SetMovementDiagnostics( "UNDER FIRE / LOCATE" );
		TacticalStatus = "UNDER FIRE / ROUGH BEARING";
		CoverStatus = "NOT COMMITTED";
		CombatStatus = "LOCATING ATTACKER";
		CurrentIntent = "TACTICAL / LOCATE FIRE";
	}

	private bool TryHandleProactiveNoContact( OneMoreRoundWeapon weapon )
	{
		if ( !ProactiveNoContactMovement || !UsesTacticalBehavior() || Scene?.NavMesh is null )
			return false;

		if ( _roundManager?.Mode == SessionMode.Match && _roundManager.Phase != RoundPhase.Fighting )
			return false;

		if ( Scene.NavMesh.IsGenerating )
			return false;

		if (
			!_noContactTargetValid ||
			Time.Now >= _nextNoContactPlanAt ||
			( _noContactTarget - WorldPosition ).WithZ( 0f ).Length <= MathF.Max( 36f, NoContactArrivalDistance )
		)
		{
			if ( !TryChooseNoContactTarget( weapon, out _noContactTarget ) )
				return false;

			_noContactTargetValid = true;
			_nextNoContactPlanAt = Time.Now + MathF.Max( 0.8f, NoContactReplanInterval );
			_noContactPlanSide *= -1;
		}

		float distance = ( _noContactTarget - WorldPosition ).WithZ( 0f ).Length;
		NavigateToWorldPosition(
			_noContactTarget,
			"TACTICAL PATROL",
			MathF.Max( 36f, NoContactArrivalDistance ),
			"NO CONTACT / PROACTIVE SEARCH",
			allowSprint: distance > 150f,
			faceMovement: true,
			advancedMovement: true,
			movementWeapon: weapon,
			visibleCombatTarget: false
		);

		InitiativeStatus = $"PATROL · {distance:0}u";
		TacticalStatus = "NO CONTACT / TAKE SPACE";
		SearchStatus = "PROACTIVE PATROL";
		CombatStatus = weapon?.IsReloading == true ? "RELOADING / PATROL" : "SEEKING CONTACT";
		return true;
	}

	private bool TryChooseNoContactTarget( OneMoreRoundWeapon weapon, out Vector3 target )
	{
		target = Vector3.Zero;
		if ( _roundManager?.IsValid != true || Scene?.NavMesh is null )
			return false;

		GameObject ownSpawn = _playerState?.TeamId == 0 ? _roundManager.SpawnA : _roundManager.SpawnB;
		GameObject enemySpawn = _playerState?.TeamId == 0 ? _roundManager.SpawnB : _roundManager.SpawnA;
		if ( enemySpawn is null )
			return false;

		Vector3 origin = ownSpawn is not null ? ownSpawn.WorldPosition : WorldPosition;
		Vector3 lane = ( enemySpawn.WorldPosition - origin ).WithZ( 0f );
		if ( lane.IsNearlyZero( 0.01f ) )
			return false;

		Vector3 forward = lane.Normal;
		Vector3 lateral = new Vector3( -forward.y, forward.x, 0f ).Normal;
		OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
		float pressure = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.ClosePressureBias;
		float precision = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.PrecisionBias;
		float fraction = MathX.Clamp( 0.50f + pressure * 0.12f - precision * 0.07f, 0.43f, 0.66f );
		float sideOffset = MathX.Lerp( 155f, 235f, pressure ) * _noContactPlanSide;

		List<Vector3> candidates = new()
		{
			origin + lane * fraction + lateral * sideOffset,
			origin + lane * fraction - lateral * sideOffset * 0.65f,
			origin + lane * MathX.Clamp( fraction + 0.10f, 0.48f, 0.74f ),
			origin + lane * MathX.Clamp( fraction - 0.08f, 0.36f, 0.62f ) + lateral * sideOffset * 0.45f
		};

		float bestScore = float.MinValue;
		foreach ( Vector3 raw in candidates )
		{
			Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 110f );
			if ( projected is null )
				continue;

			if (
				!TryEvaluateTacticalRoute(
					projected.Value,
					Vector3.Zero,
					hasThreatReference: false,
					out OMRBotRouteEvaluation route
				)
			)
				continue;

			Vector3 point = route.Destination;
			float clearance = MeasureWallClearance01( point );
			if ( clearance <= 0.04f )
				continue;

			float risk = MathX.Clamp( route.MemoryRisk / 8f, 0f, 1f );
			float travel = route.PathLength;
			float forwardProgress = Vector3.Dot( ( point - origin ).WithZ( 0f ), forward );
			float score = clearance * 1.2f - risk * 0.8f - travel * 0.0008f + forwardProgress * 0.0011f;

			if ( score <= bestScore )
				continue;
			bestScore = score;
			target = point;
		}

		return bestScore != float.MinValue;
	}

	private float MeasureWallClearance01( Vector3 position )
	{
		if ( Scene is null )
			return 1f;

		float probe = MathF.Max( 40f, PreferredWallClearance );
		Vector3 start = position + Vector3.Up * 34f;
		float minimum = probe;
		Vector3[] directions =
		{
			Vector3.Forward,
			Vector3.Backward,
			Vector3.Left,
			Vector3.Right
		};

		foreach ( Vector3 direction in directions )
		{
			SceneTrace trace = Scene.Trace.Ray( start, start + direction * probe )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "trigger" )
				.UseHitboxes( false );

			if ( _perceptionTarget?.GameObject is not null )
				trace = trace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );

			SceneTraceResult result = trace.Run();
			if ( !result.Hit )
				continue;

			float distance = ( result.EndPosition - start ).Length;
			minimum = MathF.Min( minimum, distance );
		}

		float minimumAllowed = MathF.Max( 8f, MinimumWallClearance );
		if ( minimum <= minimumAllowed )
			return 0f;

		return MathX.Clamp( ( minimum - minimumAllowed ) / MathF.Max( 1f, probe - minimumAllowed ), 0f, 1f );
	}
}