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

public enum OMRBotLocomotionMode
{
	Idle,
	Traverse,
	CombatApproach,
	CombatMicro,
	TacticalReposition,
	Flank,
	Search,
	Patrol,
	Recovery
}

/// <summary>
/// BOT 7B fluid movement layer.
///
/// This partial does not replace OMR locomotion or NavMesh pathfinding. It makes
/// the existing synthetic-input consumer more continuous: refreshed paths keep
/// their forward progress, upcoming corners are anticipated instead of followed
/// as hard point-to-point turns, and visible combat movement is selected from
/// short local movement candidates instead of a timed left/right metronome.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "Movement / Fluid" )]
	public bool FluidMovementEnabled { get; set; } = true;

	[Property, Group( "Movement / Fluid" )]
	public float FluidSteeringResponse { get; set; } = 9.0f;

	[Property, Group( "Movement / Fluid" )]
	public float CornerAnticipationDistance { get; set; } = 135f;

	[Property, Group( "Movement / Fluid" )]
	public float CornerSprintSlowDistance { get; set; } = 118f;

	[Property, Group( "Movement / Fluid" )]
	public float PathContinuityMatchDistance { get; set; } = 110f;

	[Property, Group( "Movement / Fluid" ), Range( 0.2f, 2f )]
	public float FailedPathRetryDelay { get; set; } = 0.65f;

	[Property, Group( "Movement / Fluid Combat" )]
	public float CombatMoveDecisionInterval { get; set; } = 0.42f;

	[Property, Group( "Movement / Fluid Combat" )]
	public float CombatMoveProbeDistance { get; set; } = 88f;

	[Property, Group( "Movement / Fluid Combat" )]
	public float CombatMoveCommitMinimum { get; set; } = 0.30f;

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

	private Vector3 _fluidSteeringDirection = Vector3.Zero;
	private Vector3 _previousPathSteeringPoint = Vector3.Zero;
	private OMRBotLocomotionMode _locomotionMode = OMRBotLocomotionMode.Idle;

	private Vector3 _combatCommittedInput = Vector3.Zero;
	private string _combatCommittedLabel = "HOLD";
	private float _combatCommitUntil = -1f;
	private float _nextCombatMoveDecisionAt;
	private float _combatCommittedScore;
	private float _combatLateralTieBias;

	private readonly struct CombatMoveCandidate
	{
		public Vector3 LocalInput { get; }
		public string Label { get; }
		public float BasePreference { get; }

		public CombatMoveCandidate( Vector3 localInput, string label, float basePreference )
		{
			LocalInput = localInput;
			Label = label;
			BasePreference = basePreference;
		}
	}

	private static readonly CombatMoveCandidate[] CombatMoveCandidates =
	{
		new( Vector3.Zero, "HOLD", 0.10f ),
		new( new Vector3( 0f, -0.92f, 0f ), "LEFT", 0.24f ),
		new( new Vector3( 0f, 0.92f, 0f ), "RIGHT", 0.24f ),
		new( new Vector3( -0.48f, -0.78f, 0f ), "BACK-LEFT", 0.18f ),
		new( new Vector3( -0.48f, 0.78f, 0f ), "BACK-RIGHT", 0.18f ),
		new( new Vector3( 0.34f, -0.78f, 0f ), "FORWARD-LEFT", 0.16f ),
		new( new Vector3( 0.34f, 0.78f, 0f ), "FORWARD-RIGHT", 0.16f ),
		new( new Vector3( -0.72f, 0f, 0f ), "BACK", 0.08f )
	};

	private void ResetFluidMovementState()
	{
		_fluidSteeringDirection = Vector3.Zero;
		_previousPathSteeringPoint = Vector3.Zero;
		_locomotionMode = OMRBotLocomotionMode.Idle;
		LocomotionStatus = "IDLE";
		_combatCommittedInput = Vector3.Zero;
		_combatCommittedLabel = "HOLD";
		_combatCommitUntil = -1f;
		_nextCombatMoveDecisionAt = 0f;
		_combatCommittedScore = 0f;
		_combatLateralTieBias = 0f;
	}

	private OMRBotLocomotionMode ResolveLocomotionMode(
		string movingIntent,
		bool visibleCombatTarget
	)
	{
		if ( visibleCombatTarget )
			return OMRBotLocomotionMode.CombatApproach;

		string intent = ( movingIntent ?? string.Empty ).ToUpperInvariant();

		if ( intent.Contains( "FLANK" ) )
			return OMRBotLocomotionMode.Flank;
		if ( intent.Contains( "SEARCH" ) || intent.Contains( "INVESTIGATE" ) )
			return OMRBotLocomotionMode.Search;
		if ( intent.Contains( "COVER" ) || intent.Contains( "REPOSITION" ) )
			return OMRBotLocomotionMode.TacticalReposition;
		if ( intent.Contains( "PATROL" ) )
			return OMRBotLocomotionMode.Patrol;
		if ( intent.Contains( "RECOVER" ) || intent.Contains( "BYPASS" ) )
			return OMRBotLocomotionMode.Recovery;

		return OMRBotLocomotionMode.Traverse;
	}

	private void SetLocomotionMode( OMRBotLocomotionMode mode )
	{
		_locomotionMode = mode;
		LocomotionStatus = mode.ToString().ToUpperInvariant();
	}

	/// <summary>
	/// A fresh NavMesh path starts near the bot each time. BOT 6.1 reset the
	/// consumer to point zero, which was safe but could produce tiny direction
	/// corrections on every repath. Keep the previous steering corner when the new
	/// path contains a nearby equivalent, otherwise start at the first point that
	/// is meaningfully ahead of the bot.
	/// </summary>
	private int ChoosePathContinuityIndex(
		IReadOnlyList<Vector3> refreshedPoints,
		Vector3 previousSteeringPoint
	)
	{
		if ( refreshedPoints is null || refreshedPoints.Count == 0 )
			return 0;

		float reachDistance = Math.Clamp( LookAheadDistance * 0.42f, 24f, 42f );
		int firstAhead = 0;

		while (
			firstAhead < refreshedPoints.Count &&
			( refreshedPoints[firstAhead] - WorldPosition ).WithZ( 0f ).Length <= reachDistance
		)
		{
			firstAhead++;
		}

		if ( firstAhead >= refreshedPoints.Count )
			return Math.Max( 0, refreshedPoints.Count - 1 );

		if ( previousSteeringPoint.IsNearlyZero( 0.01f ) )
			return firstAhead;

		float matchDistance = MathF.Max( 48f, PathContinuityMatchDistance );
		int maxIndex = Math.Min( refreshedPoints.Count - 1, firstAhead + 3 );
		int bestIndex = firstAhead;
		float bestDistance = float.MaxValue;

		for ( int i = firstAhead; i <= maxIndex; i++ )
		{
			float distance =
				( refreshedPoints[i] - previousSteeringPoint )
					.WithZ( 0f )
					.Length;

			if ( distance < bestDistance )
			{
				bestDistance = distance;
				bestIndex = i;
			}
		}

		if ( bestDistance > matchDistance )
			return firstAhead;

		if ( bestIndex > firstAhead && !IsDirectPathSteeringClear( refreshedPoints[bestIndex] ) )
			return firstAhead;

		return bestIndex;
	}

	private bool IsDirectPathSteeringClear( Vector3 point )
	{
		if ( Scene is null )
			return false;

		Vector3 flat = ( point - WorldPosition ).WithZ( 0f );
		if ( flat.IsNearlyZero( 0.01f ) )
			return true;

		Vector3 startLow = WorldPosition + Vector3.Up * 22f;
		Vector3 endLow = point.WithZ( WorldPosition.z ) + Vector3.Up * 22f;
		Vector3 startHigh = WorldPosition + Vector3.Up * 54f;
		Vector3 endHigh = point.WithZ( WorldPosition.z ) + Vector3.Up * 54f;

		SceneTraceResult low =
			Scene.Trace.Ray( startLow, endLow )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "trigger" )
				.UseHitboxes( false )
				.Run();

		if ( low.Hit )
			return false;

		SceneTraceResult high =
			Scene.Trace.Ray( startHigh, endHigh )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "trigger" )
				.UseHitboxes( false )
				.Run();

		return !high.Hit;
	}

	private Vector3 GetFluidNavigationDirection( Vector3 steeringPoint )
	{
		Vector3 direct = ( steeringPoint - WorldPosition ).WithZ( 0f );
		if ( direct.IsNearlyZero( 0.01f ) )
			return direct;

		Vector3 desired = direct.Normal;

		if (
			FluidMovementEnabled &&
			_pathPointIndex + 1 < _pathPoints.Count
		)
		{
			Vector3 nextSegment =
				( _pathPoints[_pathPointIndex + 1] - steeringPoint )
					.WithZ( 0f );

			if ( !nextSegment.IsNearlyZero( 0.01f ) )
			{
				float distanceToCorner = direct.Length;
				float anticipationDistance = GetCornerAnticipationDistance();
				float anticipation =
					1f - MathX.Clamp( distanceToCorner / anticipationDistance, 0f, 1f );

				// Never fully abandon the current corner. This is steering anticipation,
				// not a shortcut that lets the bot cut through geometry.
				float blend = anticipation * 0.42f;
				Vector3 anticipated =
					Vector3.Lerp( desired, nextSegment.Normal, blend )
						.WithZ( 0f );

				if ( !anticipated.IsNearlyZero( 0.01f ) )
				{
					anticipated = anticipated.Normal;
					if ( IsTraversalDirectionClear( anticipated ) )
						desired = anticipated;
				}
			}
		}

		if ( !FluidMovementEnabled )
			return desired;

		desired = ApplyHumanizedPathSteering( desired );

		float response = GetFluidSteeringResponse();
		float follow = 1f - MathF.Exp( -response * MathF.Max( Time.Delta, 0f ) );

		if ( _fluidSteeringDirection.IsNearlyZero( 0.01f ) )
		{
			_fluidSteeringDirection = desired;
		}
		else
		{
			float alignment = Vector3.Dot( _fluidSteeringDirection.Normal, desired );
			if ( alignment < -0.20f )
			{
				// A genuine reversal should not spend half a second smoothing through a
				// wall. Reset immediately when the route really turns back.
				_fluidSteeringDirection = desired;
			}
			else
			{
				_fluidSteeringDirection =
					Vector3.Lerp( _fluidSteeringDirection, desired, follow )
						.WithZ( 0f );

				if ( !_fluidSteeringDirection.IsNearlyZero( 0.01f ) )
					_fluidSteeringDirection = _fluidSteeringDirection.Normal;
			}
		}

		return _fluidSteeringDirection;
	}

	private float GetCornerAnticipationDistance()
	{
		float authored = MathF.Max( 64f, CornerAnticipationDistance );

		return _locomotionMode switch
		{
			OMRBotLocomotionMode.Search => authored * 0.72f,
			OMRBotLocomotionMode.TacticalReposition => authored * 0.82f,
			OMRBotLocomotionMode.Flank => authored * 1.08f,
			OMRBotLocomotionMode.CombatApproach => authored * 0.88f,
			OMRBotLocomotionMode.Recovery => authored * 0.62f,
			_ => authored
		};
	}

	private float GetFluidSteeringResponse()
	{
		float authored = MathF.Max( 2f, FluidSteeringResponse );

		return _locomotionMode switch
		{
			OMRBotLocomotionMode.Search => authored * 0.82f,
			OMRBotLocomotionMode.Flank => authored * 1.06f,
			OMRBotLocomotionMode.CombatApproach => authored * 1.12f,
			OMRBotLocomotionMode.Recovery => authored * 1.35f,
			_ => authored
		};
	}

	private bool ShouldSlowForUpcomingCorner()
	{
		if (
			!FluidMovementEnabled ||
			_pathPointIndex >= _pathPoints.Count ||
			_pathPointIndex + 1 >= _pathPoints.Count
		)
			return false;

		Vector3 toCorner =
			( _pathPoints[_pathPointIndex] - WorldPosition )
				.WithZ( 0f );

		if ( toCorner.Length > MathF.Max( 72f, CornerSprintSlowDistance ) )
			return false;

		Vector3 incoming = toCorner.Normal;
		Vector3 outgoing =
			( _pathPoints[_pathPointIndex + 1] - _pathPoints[_pathPointIndex] )
				.WithZ( 0f );

		if ( outgoing.IsNearlyZero( 0.01f ) )
			return false;

		float dot = Math.Clamp( Vector3.Dot( incoming, outgoing.Normal ), -1f, 1f );
		return dot < 0.58f;
	}

	/// <summary>
	/// BOT 7B local combat locomotion. This intentionally makes no strategic
	/// choice such as "take cover" or "flank"; BOT 7D will own those decisions.
	/// It only asks which human-equivalent movement input best serves the current
	/// visible firefight for the next short commitment window.
	/// </summary>
	private void UpdateFluidCombatMovement(
		OneMoreRoundWeapon weapon,
		float distance,
		float holdDistance
	)
	{
		_pathPoints.Clear();
		_pathPointIndex = 0;
		_fluidSteeringDirection = Vector3.Zero;
		SetLocomotionMode( OMRBotLocomotionMode.CombatMicro );

		NavigationStatus = "COMBAT MICRO";
		NavigationTargetName = _perceptionTarget?.DisplayName ?? "PLAYER";
		NavigationTargetDistance = MathF.Round( distance );

		bool needsDecision =
			Time.Now >= _nextCombatMoveDecisionAt ||
			Time.Now >= _combatCommitUntil ||
			!IsCommittedCombatMoveStillUsable();

		if ( needsDecision )
			ChooseCombatMovementCandidate( weapon, distance, holdDistance );

		Vector3 moveInput = _combatCommittedInput;
		bool crouchDown = false;
		bool jumpPressed = false;
		bool sprint = false;

		ApplyCombatEvasionActions(
			weapon,
			distance,
			ref crouchDown,
			ref jumpPressed
		);

		_movementInput.SetBotInput(
			moveInput,
			sprintDown: sprint,
			crouchDown: crouchDown,
			jumpPressed: jumpPressed
		);

		if ( _slide?.IsSliding == true )
		{
			SetMovementDiagnostics( "SLIDE" );
			CurrentIntent = "COMBAT / SLIDING";
		}
		else if ( jumpPressed )
		{
			SetMovementDiagnostics( _controller.IsAirborne ? "AIR JUMP REQUEST" : "JUMP REQUEST" );
			CurrentIntent = $"COMBAT / {_combatCommittedLabel} / JUMP";
		}
		else if ( _controller?.IsDucking == true || _crouch?.WantsCrouch == true )
		{
			SetMovementDiagnostics( $"CROUCH {_combatCommittedLabel}" );
			CurrentIntent = $"COMBAT / CROUCH {_combatCommittedLabel}";
		}
		else
		{
			SetMovementDiagnostics( $"MICRO {_combatCommittedLabel}" );
			CurrentIntent = $"COMBAT / {_combatCommittedLabel} · {_combatCommittedScore:0.00}";
		}
	}

	private void ChooseCombatMovementCandidate(
		OneMoreRoundWeapon weapon,
		float distance,
		float holdDistance
	)
	{
		OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
		_combatLateralTieBias = Game.Random.Float( -0.12f, 0.12f );
		float bestScore = float.MinValue;
		Vector3 bestInput = Vector3.Zero;
		string bestLabel = "HOLD";

		foreach ( CombatMoveCandidate candidate in CombatMoveCandidates )
		{
			if ( !TryScoreCombatMovementCandidate(
				candidate,
				doctrine,
				distance,
				holdDistance,
				out float score
			) )
			{
				continue;
			}

			if ( score <= bestScore )
				continue;

			bestScore = score;
			bestInput = candidate.LocalInput;
			bestLabel = candidate.Label;
		}

		if ( bestScore == float.MinValue )
		{
			bestScore = 0f;
			bestInput = Vector3.Zero;
			bestLabel = "HOLD";
		}

		_combatCommittedInput = bestInput.ClampLength( 1f );
		_combatCommittedLabel = bestLabel;
		_combatCommittedScore = bestScore;

		if ( MathF.Abs( _combatCommittedInput.y ) > 0.05f )
			_strafeDirection = _combatCommittedInput.y >= 0f ? 1f : -1f;

		float precision = GetCombatMovementPrecision( doctrine );
		float mobility = MathX.Clamp( doctrine.MovementFireTolerance, 0f, 1f );
		float variation = Game.Random.Float( -0.055f, 0.075f );
		float interval =
			(
				MathF.Max( 0.20f, CombatMoveDecisionInterval ) +
				precision * 0.12f -
				mobility * 0.06f +
				variation
			) * GetHumanizedCombatDecisionIntervalScale( doctrine );

		if ( Difficulty == OMRBotDifficulty.Easy )
			interval *= 1.18f;
		else if ( Difficulty == OMRBotDifficulty.Hard )
			interval *= 0.88f;

		interval = MathF.Max( MathF.Max( 0.16f, CombatMoveCommitMinimum ), interval );
		_nextCombatMoveDecisionAt = Time.Now + interval;
		_combatCommitUntil = Time.Now + MathF.Max( 0.16f, CombatMoveCommitMinimum );
	}

	private bool TryScoreCombatMovementCandidate(
		CombatMoveCandidate candidate,
		OMRBotWeaponDoctrine doctrine,
		float currentDistance,
		float holdDistance,
		out float score
	)
	{
		score = candidate.BasePreference;
		Vector3 localInput = candidate.LocalInput.ClampLength( 1f );
		float movementAmount = localInput.WithZ( 0f ).Length;
		bool isHold = movementAmount <= 0.01f;

		Vector3 predicted = WorldPosition;
		Vector3 worldDirection = Vector3.Zero;

		if ( !isHold )
		{
			Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
			worldDirection = ( basis * localInput ).WithZ( 0f );
			if ( worldDirection.IsNearlyZero( 0.01f ) )
				return false;

			worldDirection = worldDirection.Normal;
			if ( !IsTraversalDirectionClear( worldDirection ) )
				return false;

			float probeDistance =
				MathF.Max( 48f, CombatMoveProbeDistance ) *
				MathX.Clamp( movementAmount, 0.55f, 1f );

			Vector3 raw = WorldPosition + worldDirection * probeDistance;
			Vector3? projected = Scene?.NavMesh?.GetClosestPoint( raw, 58f );
			if ( projected is null )
				return false;

			if ( ( projected.Value - raw ).WithZ( 0f ).Length > 54f )
				return false;

			predicted = projected.Value;
		}

		float predictedDistance =
			( _observedTargetPosition - predicted )
				.WithZ( 0f )
				.Length;

		float band = MathF.Max( 80f, holdDistance * 0.42f );
		float distanceError = MathF.Abs( predictedDistance - holdDistance );
		float distanceFitness = 1f - MathX.Clamp( distanceError / band, 0f, 1f );
		score += distanceFitness * 1.10f;

		float tooCloseThreshold = holdDistance * MathX.Clamp( RetreatDistanceScale, 0.35f, 0.9f );
		bool tooClose = currentDistance < tooCloseThreshold;
		bool tooFar = currentDistance > holdDistance * 1.05f;

		if ( tooClose )
		{
			if ( localInput.x < -0.08f )
				score += 0.70f;
			else if ( localInput.x > 0.08f )
				score -= 0.70f;
		}
		else if ( tooFar )
		{
			if ( localInput.x > 0.08f )
				score += 0.34f * MathX.Clamp( doctrine.ClosePressureBias + 0.35f, 0.35f, 1.25f );
			else if ( localInput.x < -0.08f )
				score -= 0.28f;
		}

		float lateralAmount = MathF.Abs( localInput.y );
		score += lateralAmount * 0.24f;

		float movementTolerance = MathX.Clamp( doctrine.MovementFireTolerance, 0f, 1f );
		float precision = GetCombatMovementPrecision( doctrine );
		float matchupMovement = WeaponMatchupReasoningEnabled ? ActiveMatchup.MovementAdjustment : 0f;
		if ( isHold )
		{
			score -= matchupMovement * 0.24f;
			score += precision * 0.24f;
			score += doctrine.IsPrecisionMovementWeapon ? 0.28f : 0f;
			score += ( 1f - movementTolerance ) * 0.10f;
		}
		else
		{
			score += matchupMovement * movementAmount * 0.30f;
			score += movementAmount * movementTolerance * 0.34f;
			score += movementAmount * doctrine.ClosePressureBias * 0.14f;
			score -= movementAmount * ( 1f - movementTolerance ) * 0.42f;
			score -= movementAmount * precision * 0.16f;
		}

		// Avoid repeatedly stepping back into cells where this bot has already
		// learned that movement is dangerous. This uses BOT 6.1's own-death/damage
		// memory, never hidden opponent coordinates.
		float learnedRisk = GetSpatialMemoryRiskAt( predicted );
		score -= MathX.Clamp( learnedRisk / 8f, 0f, 1f ) * 0.42f;

		// Arena-wall hugging is visually bot-like and tends to collapse its escape
		// options. Penalize candidate footsteps that put the capsule right against
		// nearby geometry. Cover itself is handled by the tactical cover system.
		float wallClearance = MeasureWallClearance01( predicted );
		if ( !isHold && wallClearance <= 0.02f )
			return false;
		score += wallClearance * ( isHold ? 0.08f : 0.34f );
		score -= ( 1f - wallClearance ) * ( isHold ? 0.08f : 0.28f );

		if ( _perceptionTarget?.IsValid == true )
		{
			Vector3 threatReference = _observedTargetAimPoint;
			if ( threatReference.IsNearlyZero( 0.01f ) )
				threatReference = _observedTargetPosition + Vector3.Up * 34f;

			int coverSamples = CountBlockedThreatSamples( predicted, threatReference );
			float health = GetOwnHealthFraction();

			if ( coverSamples == 0 )
			{
				score += 0.16f;
			}
			else if ( coverSamples == 1 )
			{
				// Partial protection can be useful, especially for precision/cycle
				// weapons, but don't let micro-movement replace the actual cover system.
				score += doctrine.CoverCycleBias * 0.16f + ( 1f - health ) * 0.12f;
			}
			else
			{
				// Fully breaking sight during ordinary strafe is usually undesirable.
				// Low health and cover-centric weapons soften the penalty; tactical cover
				// selection remains responsible for deliberate disengagement.
				float tolerance = doctrine.CoverCycleBias * 0.24f + ( 1f - health ) * 0.24f;
				score -= MathF.Max( 0.12f, 0.62f - tolerance );
			}
		}

		// Commitment/hysteresis: don't bounce between nearly equivalent inputs every
		// scoring pass. The previous good choice receives a modest continuity bonus.
		if ( candidate.Label == _combatCommittedLabel )
			score += 0.10f;

		ApplyCombatSequenceMovementScore(
			localInput,
			doctrine,
			currentDistance,
			holdDistance,
			ref score
		);

		if ( !ApplyHumanizedCombatCandidateScore( localInput, wallClearance, ref score ) )
			return false;

		// Maintain bounded BOT 6 variation only between otherwise sensible choices.
		// The tiny signed bias breaks left/right ties without overriding geometry,
		// weapon accuracy, danger memory or desired combat range.
		float movementVariation = MathX.Clamp( _dynamicMovementBias, -0.25f, 0.25f );
		score += lateralAmount * movementVariation * 0.18f;
		score += localInput.y * _combatLateralTieBias;

		return true;
	}


	private float GetCombatMovementPrecision( OMRBotWeaponDoctrine doctrine )
	{
		if ( doctrine is null )
			return 0.5f;

		float precision = MathX.Clamp( doctrine.PrecisionBias, 0f, 1f );
		if ( doctrine.IsPrecisionMovementWeapon )
			precision = MathF.Max( precision, 0.78f );

		return precision;
	}

	private bool IsCommittedCombatMoveStillUsable()
	{
		Vector3 localInput = _combatCommittedInput.WithZ( 0f );
		if ( localInput.IsNearlyZero( 0.01f ) )
			return true;

		Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
		Vector3 worldDirection = ( basis * localInput ).WithZ( 0f );
		if ( worldDirection.IsNearlyZero( 0.01f ) )
			return false;

		return IsTraversalDirectionClear( worldDirection.Normal );
	}
}