Bot/BotController.cs
/// <summary>
/// Host-only AI brain. Attach alongside PlayerPawn to make it a bot.
/// Runs a simple state machine: Patrol → Chase → Attack → Evade.
/// Drives the pawn by writing InputDirection, ViewAngles, BotFirePrimary and BotWantsBoost.
/// </summary>
[Title( "Bot Controller" ), Icon( "smart_toy" )]
public sealed class BotController : Component
{
	public enum BotState { Patrol, Chase, Attack, Evade }

	// ── Tuning ────────────────────────────────────────────────────────────────

	/// <summary>Distance at which the bot starts attacking.</summary>
	[Property] public float AttackRange { get; set; } = 2200f;

	/// <summary>Preferred engagement distance — bot coasts and fires from here.</summary>
	[Property] public float OptimalAttackDistance { get; set; } = 1200f;

	/// <summary>Distance at which the bot starts chasing a spotted target.</summary>
	[Property] public float ChaseRange { get; set; } = 5000f;

	/// <summary>Half-angle (degrees) within which the bot will fire.</summary>
	[Property] public float FireCone { get; set; } = 22f;

	/// <summary>Max distance from arena centre before the bot is forced to turn back.</summary>
	[Property] public float MaxRoamDistance { get; set; } = 8000f;

	/// <summary>How aggressively the bot turns toward its target (0-1 per second).</summary>
	[Property] public float TurnSpeed { get; set; } = 5f;

	/// <summary>Health fraction below which the bot enters evade state.</summary>
	[Property] public float EvadeHealthFraction { get; set; } = 0.25f;

	/// <summary>Seconds spent in evade before returning to chase.</summary>
	[Property] public float EvadeDuration { get; set; } = 2.5f;

	/// <summary>Whether the bot uses boost during attack runs.</summary>
	[Property] public bool UseBoostOnAttack { get; set; } = true;

	/// <summary>Random aim offset in degrees — higher = less accurate.</summary>
	[Property, Range( 0f, 45f )] public float AimError { get; set; } = 4f;

	/// <summary>How far ahead to look for walls.</summary>
	[Property] public float WallAvoidDistance { get; set; } = 1200f;

	/// <summary>How strongly wall avoidance deflects the steering direction.</summary>
	[Property] public float WallAvoidStrength { get; set; } = 3f;

	/// <summary>Delay before bots react to a newly seen/lost target.</summary>
	[Property] public float ReactionTime { get; set; } = 0.3f;

	/// <summary>Chance that a bot counter-attacks (instead of evading) when recently hit.</summary>
	[Property, Range( 0f, 1f )] public float CounterAttackChanceOnHit { get; set; } = 0.35f;

	// ── State ─────────────────────────────────────────────────────────────────

	public BotState State { get; private set; } = BotState.Patrol;

	private PlayerPawn _pawn;
	private PlayerPawn _target;
	private PlayerPawn _candidateTarget;
	private Vector3 _patrolPoint;
	private TimeSince _timeSincePatrolUpdate;
	private TimeSince _timeSinceTargetSearch;
	private TimeSince _timeSinceEvadeStart;
	private Vector3 _evadeDirection;
	private Vector3 _evadeBaseDirection;   // away-from-attacker baseline
	private TimeSince _timeSinceWeaveFlip; // how long since we last flipped the weave
	private float _weaveSign = 1f;         // current weave lateral sign
	private float _weaveFreq;             // randomised flip interval per evade

	// Anti-circling: detect when distance to target stops closing
	private float _lastTargetDist;
	private TimeSince _timeSinceDistChanged;
	private Vector3 _breakOffset;

	// Stuck detection
	private Vector3 _lastPosition;
	private TimeSince _timeSincePositionChanged;
	private Vector3 _escapeDirection;
	private bool _isEscaping;

	// Aim scatter — updated periodically so it doesn't jitter every frame
	private Vector3 _aimScatterOffset;
	private TimeSince _timeSinceAimScatterUpdate;

	// Evade cooldown — prevents spam-evading on every hit
	private TimeSince _timeSinceLastEvade;
	private TimeSince _timeSinceLastHitReactionRoll;

	// How long since we last had a live target in chase/attack range
	private TimeSince _timeSinceHadTarget;
	private TimeSince _timeSinceCandidateChanged;
	private TimeSince _timeSinceTargetAcquired;

	// How long since we last successfully fired at a target
	private TimeSince _timeSinceLastFired;
	private PlayerPawn _fireCandidate;
	private TimeSince _timeSinceFireCandidateSeen;

	// Weapon cycling
	private TimeSince _timeSinceWeaponSwitch;
	private float _weaponSwitchInterval;

	// Strafing oscillator
	private float _strafeSign = 1f;
	private TimeSince _timeSinceStrafFlip;
	private float _strafeFlipInterval;

	// Attack sub-mode: alternate between strafing runs and hold-and-fire
	private enum AttackMode { StrafeRun, HoldFire }
	private AttackMode _attackMode = AttackMode.StrafeRun;
	private TimeSince _timeSinceAttackModeSwitch;
	private float _attackModeDuration;

	// Arena centre — average of all spawn points, computed once
	private Vector3 _arenaCenter;

	// ── Lifecycle ─────────────────────────────────────────────────────────────

	protected override void OnStart()
	{
		_pawn = Components.Get<PlayerPawn>( FindMode.InSelf );
		PickNewPatrolPoint();
		_weaponSwitchInterval = Game.Random.Float( 8f, 15f );
		_strafeSign           = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
		_strafeFlipInterval   = Game.Random.Float( 0.8f, 2.0f );

		// Compute arena centre from spawn points so bots know where "home" is
		var spawns = Game.ActiveScene?.GetAllComponents<SpawnPoint>().ToList();
		if ( spawns != null && spawns.Count > 0 )
		{
			var sum = Vector3.Zero;
			foreach ( var s in spawns ) sum += s.WorldPosition;
			_arenaCenter = sum / spawns.Count;
		}
	}

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost ) return;
		if ( _pawn == null || !_pawn.IsAlive ) return;

		ResolvePenetration();
		DetectStuck();
		CycleWeapon();
		RefreshTarget();
		UpdateState();
		ApplySteering();
	}

	/// <summary>
	/// Cycles through available weapons. Switches immediately when the current weapon is
	/// out of ammo; otherwise rotates on a randomised interval.
	/// </summary>
	private void CycleWeapon()
	{
		if ( _pawn.Weapons.Count <= 1 ) return;

		// Switch immediately if current weapon has no ammo
		var currentAmmo = _pawn.ActiveWeapon?.Components.Get<AmmoComponent>();
		bool outOfAmmo = currentAmmo != null && !currentAmmo.HasEnoughAmmo();

		if ( outOfAmmo || _timeSinceWeaponSwitch > _weaponSwitchInterval )
		{
			_timeSinceWeaponSwitch   = 0f;
			_weaponSwitchInterval    = Game.Random.Float( 8f, 15f );

			// Cycle to next slot, wrapping around
			var currentIndex = _pawn.Weapons.IndexOf( _pawn.ActiveWeapon );
			var nextIndex    = (currentIndex + 1) % _pawn.Weapons.Count;
			_pawn.SetActiveWeapon( _pawn.GetSlot( nextIndex ) );
		}
	}

	private void DetectStuck()
	{
		var pos = _pawn.WorldPosition;
		if ( pos.Distance( _lastPosition ) > 50f )
		{
			_lastPosition = pos;
			_timeSincePositionChanged = 0f;
			_isEscaping = false;
		}
		else if ( _timeSincePositionChanged > 1.8f )
		{
			// Haven't moved — pick an escape direction away from all nearby walls
			_isEscaping = true;
			_timeSincePositionChanged = 0f;
			_escapeDirection = FindEscapeDirection();
			PickNewPatrolPoint(); // reset patrol so we don't immediately try to re-enter the stuck area
		}
	}

	private Vector3 FindEscapeDirection()
	{
		var pos = _pawn.WorldPosition;
		var best = _pawn.WorldRotation.Forward;
		float bestClear = 0f;

		// Sample 12 directions in a hemisphere and pick the one with most clearance
		for ( int i = 0; i < 12; i++ )
		{
			var angle = i * (360f / 12f);
			var yaw   = MathF.Sin( angle.DegreeToRadian() );
			var pitch = (i % 3 == 0) ? Game.Random.Float( -0.4f, 0.4f ) : 0f;
			var dir   = Rotation.FromYaw( angle ).Forward.WithZ( pitch ).Normal;

			var tr = Scene.Trace.Ray( pos, pos + dir * WallAvoidDistance )
				.IgnoreGameObject( _pawn.GameObject )
				.WithoutTags( "player" )
				.Run();

			var clearance = tr.Hit ? tr.Distance : WallAvoidDistance;
			if ( clearance > bestClear )
			{
				bestClear = clearance;
				best = dir;
			}
		}

		return best;
	}

	// ── Target acquisition ────────────────────────────────────────────────────

	private void RefreshTarget()
	{
		if ( _timeSinceTargetSearch < 0.4f ) return;
		_timeSinceTargetSearch = 0f;

		PlayerPawn best = null;
		float bestDist = float.MaxValue;

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == _pawn ) continue;
			if ( !pawn.IsAlive ) continue;

			var dist = _pawn.WorldPosition.Distance( pawn.WorldPosition );
			if ( dist < bestDist )
			{
				bestDist = dist;
				best = pawn;
			}
		}

		// Track the latest observed candidate and only commit after ReactionTime.
		// This makes bot awareness feel less instant.
		if ( best != _candidateTarget )
		{
			_candidateTarget = best;
			_timeSinceCandidateChanged = 0f;
		}

		if ( _timeSinceCandidateChanged >= ReactionTime && _target != _candidateTarget )
		{
			_target = _candidateTarget;
			_timeSinceTargetAcquired = 0f;
		}

		// Reset the "no target" timer whenever we have someone in chase range
		if ( _target != null && bestDist < ChaseRange )
			_timeSinceHadTarget = 0f;
	}

	// ── State machine ─────────────────────────────────────────────────────────

	private void UpdateState()
	{
		var healthFraction = _pawn.MaxHealth > 0f ? _pawn.Health / _pawn.MaxHealth : 1f;
		bool recentlyHit   = _pawn.TimeSinceLastDamage < 1.0f;
		bool evadeReady    = _timeSinceLastEvade > 4f;
		bool counterOnHit  = ShouldCounterAttackOnHit( recentlyHit );

		switch ( State )
		{
			case BotState.Patrol:
				if ( _target != null && DistToTarget() < ChaseRange )
					SetState( BotState.Chase );
				else if ( _target != null && _timeSinceHadTarget > 6f )
				{
					// Haven't found anyone in range for a while — go hunt them down
					_timeSinceHadTarget = 0f;
					SetState( BotState.Chase );
				}
				break;

			case BotState.Chase:
				if ( _target == null || DistToTarget() > ChaseRange * 1.2f )
					SetState( BotState.Patrol );
				else if ( counterOnHit && _target != null )
					SetState( BotState.Attack );
				else if ( recentlyHit && evadeReady )
					SetState( BotState.Evade );
				else if ( _timeSinceLastFired > 12f && evadeReady )
					SetState( BotState.Evade );
				else if ( DistToTarget() < AttackRange )
					SetState( BotState.Attack );
				break;

			case BotState.Attack:
				if ( _target == null || !_target.IsAlive )
				{
					SetState( BotState.Patrol );
					break;
				}
				if ( counterOnHit && _target != null )
				{
					SetState( BotState.Attack );
					break;
				}
				if ( recentlyHit && evadeReady )
				{
					SetState( BotState.Evade );
					break;
				}
				if ( healthFraction < EvadeHealthFraction )
				{
					SetState( BotState.Evade );
					break;
				}
				if ( _timeSinceLastFired > 10f && evadeReady )
				{
					SetState( BotState.Evade );
					break;
				}
				if ( DistToTarget() > AttackRange * 1.5f )
					SetState( BotState.Chase );
				break;

			case BotState.Evade:
				if ( _timeSinceEvadeStart > EvadeDuration )
					SetState( _target != null ? BotState.Chase : BotState.Patrol );
				break;
		}
	}

	private bool ShouldCounterAttackOnHit( bool recentlyHit )
	{
		if ( !recentlyHit ) return false;

		// Roll once per hit window to avoid random flipping every frame.
		if ( _timeSinceLastHitReactionRoll < 1.0f ) return false;
		_timeSinceLastHitReactionRoll = 0f;

		return Game.Random.Float() < CounterAttackChanceOnHit;
	}

	private void SetState( BotState newState )
	{
		State = newState;

		if ( newState == BotState.Chase || newState == BotState.Attack )
		{
			_timeSinceHadTarget  = 0f;
			_timeSinceLastFired  = 0f;
		}

		if ( newState == BotState.Attack )
			PickAttackMode();

		if ( newState == BotState.Evade )
		{
			_timeSinceEvadeStart = 0f;
			_timeSinceLastEvade  = 0f;
			_timeSinceWeaveFlip  = 0f;
			_weaveSign           = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
			_weaveFreq           = Game.Random.Float( 0.25f, 0.55f ); // flip every 0.25–0.55s

			// Baseline: fly away from attacker with a random side kick
			var away = _target != null
				? (_pawn.WorldPosition - _target.WorldPosition).Normal
				: _pawn.WorldRotation.Forward;
			var perp = away.Cross( Vector3.Up ).Normal;
			_evadeBaseDirection = (away + perp * Game.Random.Float( -0.4f, 0.4f )).Normal;
			_evadeDirection     = _evadeBaseDirection;
		}

		if ( newState == BotState.Patrol )
			PickNewPatrolPoint();
	}

	// ── Steering ─────────────────────────────────────────────────────────────

	private void ApplySteering()
	{
		Vector3 desiredForward;
		Vector3 aimForward;      // where the ship LOOKS (weapons aim)
		float   inputX = 1f;     // 1 = throttle forward, 0 = coast

		// Escape overrides all other steering when stuck
		if ( _isEscaping )
		{
			desiredForward = ApplyWallAvoidance( _escapeDirection );
			_pawn.BotWantsBoost  = true;
			_pawn.BotFirePrimary = false;
			_pawn.ViewAngles     = Rotation.LookAt( desiredForward, Vector3.Up ).Angles();
			_pawn.InputDirection = Vector3.Forward;
			return;
		}

		switch ( State )
		{
			case BotState.Patrol:
				if ( _pawn.WorldPosition.Distance( _patrolPoint ) < 400f || _timeSincePatrolUpdate > 10f )
					PickNewPatrolPoint();
				desiredForward = (_patrolPoint - _pawn.WorldPosition).Normal;
				aimForward     = desiredForward;
				_pawn.BotWantsBoost  = false;
				_pawn.BotWantsBrake  = false;
				_pawn.BotFirePrimary = ShouldFire();
				_breakOffset = Vector3.Zero;
				inputX = 1f;
				break;

			case BotState.Chase:
				desiredForward = _target != null
					? InterceptTarget( _target )
					: _pawn.WorldRotation.Forward;
				desiredForward = ApplyBreakOffset( desiredForward );
				aimForward     = desiredForward;
				_pawn.BotWantsBoost  = false;
				_pawn.BotWantsBrake  = false;
				_pawn.BotFirePrimary = ShouldFire();
				inputX = 1f;
				break;

			case BotState.Attack:
			{
				var dist = DistToTarget();

				// Always aim at predicted target position for firing
				aimForward = _target != null
					? (PredictAimPosition( _target ) - _pawn.WorldPosition).Normal
					: _pawn.WorldRotation.Forward;

				// Switch attack mode on timer or when a strafe run passes through
				if ( _timeSinceAttackModeSwitch > _attackModeDuration )
					PickAttackMode();
				else if ( _attackMode == AttackMode.StrafeRun && dist < 350f )
					PickAttackMode(); // passed through — switch after close pass

				if ( _attackMode == AttackMode.StrafeRun )
				{
					// Full commitment run: fly straight at target, boost, no strafe
					desiredForward      = _target != null ? AttackPassDirection( _target ) : aimForward;
					_pawn.BotWantsBoost = UseBoostOnAttack;
					_pawn.BotWantsBrake = false;
					inputX = 1f;
				}
				else
				{
					// Hold-and-fire: coast at optimal distance, aim locked on target
					if ( dist > OptimalAttackDistance * 1.15f )
					{
						desiredForward      = _target != null ? AttackPassDirection( _target ) : aimForward;
						_pawn.BotWantsBoost = false;
						_pawn.BotWantsBrake = false;
						inputX = 1f;
					}
					else if ( dist < OptimalAttackDistance * 0.65f )
					{
						desiredForward      = _target != null
							? (_pawn.WorldPosition - _target.WorldPosition).Normal
							: aimForward;
						_pawn.BotWantsBoost = false;
						_pawn.BotWantsBrake = true;
						inputX = 0f;
					}
					else
					{
						desiredForward      = aimForward;
						_pawn.BotWantsBoost = false;
						_pawn.BotWantsBrake = false;
						inputX = 0f;
					}
				}

				_pawn.BotFirePrimary = ShouldFire();
				_breakOffset = Vector3.Zero;
				break;
			}

			case BotState.Evade:
				if ( _timeSinceWeaveFlip > _weaveFreq )
				{
					_timeSinceWeaveFlip = 0f;
					_weaveSign    = -_weaveSign;
					_weaveFreq    = Game.Random.Float( 0.25f, 0.55f );

					var right    = _evadeBaseDirection.Cross( Vector3.Up ).Normal;
					var up       = right.Cross( _evadeBaseDirection ).Normal;
					var lateral  = right  * _weaveSign  * Game.Random.Float( 0.6f, 1.0f );
					var vertical = up     * Game.Random.Float( -0.4f, 0.4f );
					_evadeDirection = (_evadeBaseDirection + lateral + vertical).Normal;
				}
				desiredForward = _evadeDirection;
				aimForward     = desiredForward;
				_pawn.BotWantsBoost  = true;
				_pawn.BotWantsBrake  = false;
				_pawn.BotFirePrimary = ShouldFire();
				_breakOffset = Vector3.Zero;
				inputX = 1f;
				break;

			default:
				desiredForward = _pawn.WorldRotation.Forward;
				aimForward     = desiredForward;
				_pawn.BotFirePrimary = false;
				_pawn.BotWantsBoost  = false;
				inputX = 1f;
				break;
		}

		// Movement direction passes through avoidance / bias / boundary / separation
		desiredForward = ApplyWallAvoidance( desiredForward );
		desiredForward = ApplyEnemyBias( desiredForward );
		desiredForward = ApplyBoundaryPull( desiredForward );
		desiredForward = ApplySeparation( desiredForward );

		// ViewAngles drives the ship's look direction (and therefore weapon aim).
		// In Attack we use the raw aim direction — not the movement direction.
		var lookDir    = (State == BotState.Attack) ? aimForward : desiredForward;
		var viewAngles = Rotation.LookAt( lookDir, Vector3.Up ).Angles();

		if ( State == BotState.Attack && _target != null )
			viewAngles = ApplyAimScatter( viewAngles );

		_pawn.ViewAngles     = viewAngles;
		_pawn.InputDirection = new Vector3( inputX, ComputeStrafe(), 0f );
	}

	/// <summary>
	/// Returns a -1..1 strafe value that periodically flips direction.
	/// Only active during Chase — Attack flies straight for a clean gun run.
	/// </summary>
	private float ComputeStrafe()
	{
		if ( State != BotState.Chase ) return 0f;

		if ( _timeSinceStrafFlip > _strafeFlipInterval )
		{
			_timeSinceStrafFlip = 0f;
			_strafeSign         = -_strafeSign;
			_strafeFlipInterval = Game.Random.Float( 0.8f, 2.0f );
		}

		return _strafeSign;
	}

	// ── Helpers ───────────────────────────────────────────────────────────────

	/// <summary>
	/// Directly corrects position when two ships overlap. Since ships pass through each
	/// other in the movement trace, this is the only hard guarantee they stay separated.
	/// </summary>
	private void ResolvePenetration()
	{
		const float MinDist = 200f;

		var pos = _pawn.WorldPosition;

		foreach ( var other in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( other == _pawn || !other.IsAlive ) continue;

			var toOther = other.WorldPosition - pos;
			var dist    = toOther.Length;

			if ( dist < MinDist && dist > 0.1f )
			{
				// Push both ships apart equally so neither teleports too far
				var correction = toOther.Normal * (MinDist - dist) * 0.5f;
				_pawn.WorldPosition  -= correction;
				other.WorldPosition  += correction;

				// Kill relative velocity toward each other to stop the spin
				var relVel = _pawn.Velocity - other.Velocity;
				var approaching = Vector3.Dot( relVel, toOther.Normal );
				if ( approaching > 0f )
				{
					var impulse = toOther.Normal * approaching * 0.5f;
					_pawn.Velocity  -= impulse;
					other.Velocity  += impulse;
				}
			}
		}
	}

	/// <summary>
	/// Pushes the desired direction away from any other ship within MinSeparationDistance.
	/// Prevents bots from steering into each other.
	/// </summary>
	private Vector3 ApplySeparation( Vector3 desired )
	{
		const float MinSep   = 300f;
		const float Strength = 4f;

		var pos        = _pawn.WorldPosition;
		var separation = Vector3.Zero;

		foreach ( var other in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( other == _pawn || !other.IsAlive ) continue;

			var toOther = other.WorldPosition - pos;
			var dist    = toOther.Length;

			if ( dist < MinSep && dist > 0.1f )
			{
				var t = 1f - (dist / MinSep);
				separation -= toOther.Normal * t;
			}
		}

		if ( separation.IsNearZeroLength ) return desired;
		return (desired + separation.Normal * Strength).Normal;
	}

	private float DistToTarget() =>
		_target != null ? _pawn.WorldPosition.Distance( _target.WorldPosition ) : float.MaxValue;

	/// <summary>
	/// Returns the world position the bot should aim at, accounting for projectile travel time.
	/// For hitscan weapons, returns the current target position (instant hit — no lead needed).
	/// For projectile weapons, leads the target based on InitialForceForward speed.
	/// </summary>
	private Vector3 PredictAimPosition( PlayerPawn target )
	{
		var shoot = _pawn.ActiveWeapon?.Components.Get<ShootComponent>( FindMode.EverythingInSelfAndDescendants );
		if ( shoot?.Data != null && shoot.Data.Mode == WeaponData.FiringMode.Projectile )
		{
			var projectileSpeed = MathF.Max( shoot.Data.InitialForceForward, 1f );
			var toTarget        = target.WorldPosition - _pawn.WorldPosition;
			var travelTime      = toTarget.Length / projectileSpeed;
			return target.WorldPosition + target.Velocity * travelTime;
		}

		// Hitscan — aim directly at current position
		return target.WorldPosition;
	}

	/// <summary>
	/// Randomly picks the next attack sub-mode and how long to stay in it.
	/// Strafe runs are shorter; hold-fire phases are longer.
	/// </summary>
	private void PickAttackMode()
	{
		_timeSinceAttackModeSwitch = 0f;
		// 60 % chance of a strafe run, 40 % hold-and-fire
		_attackMode         = Game.Random.Float() < 0.6f ? AttackMode.StrafeRun : AttackMode.HoldFire;
		_attackModeDuration = _attackMode == AttackMode.StrafeRun
			? Game.Random.Float( 2.5f, 5f )
			: Game.Random.Float( 3f, 7f );
	}

	/// <summary>
	/// Intercept-pursuit for Chase: aims where the target will be.
	/// No scatter — used for steering only.
	/// </summary>
	private Vector3 InterceptTarget( PlayerPawn target )
	{
		var toTarget   = target.WorldPosition - _pawn.WorldPosition;
		var mySpeed    = MathF.Max( _pawn.Speed, 1f );
		var travelTime = toTarget.Length / mySpeed;
		var predicted  = target.WorldPosition + target.Velocity * travelTime;
		return (predicted - _pawn.WorldPosition).Normal;
	}

	/// <summary>
	/// Attack steering: pure intercept toward predicted target position.
	/// No scatter here — scatter is applied separately to ViewAngles so it only
	/// affects weapon accuracy, not where the ship physically flies.
	/// </summary>
	private Vector3 AttackPassDirection( PlayerPawn target )
	{
		var toTarget   = target.WorldPosition - _pawn.WorldPosition;
		var mySpeed    = MathF.Max( _pawn.Speed, 1f );
		var travelTime = toTarget.Length / mySpeed;
		var predicted  = target.WorldPosition + target.Velocity * travelTime;
		return (predicted - _pawn.WorldPosition).Normal;
	}

	/// <summary>
	/// Returns a ViewAngles offset that adds persistent aim scatter.
	/// Refreshed every 0.3 s so it drifts rather than jittering every frame.
	/// </summary>
	private Angles ApplyAimScatter( Angles baseAngles )
	{
		if ( _timeSinceAimScatterUpdate > 0.3f )
		{
			_timeSinceAimScatterUpdate = 0f;
			_aimScatterOffset = AimError > 0f
				? new Vector3( Game.Random.Float( -AimError, AimError ), Game.Random.Float( -AimError, AimError ), 0f )
				: Vector3.Zero;
		}
		return new Angles(
			baseAngles.pitch + _aimScatterOffset.x,
			baseAngles.yaw   + _aimScatterOffset.y,
			baseAngles.roll );
	}

	/// <summary>
	/// Detects if we've been at the same distance for too long (circling) and
	/// injects a lateral+vertical juke offset to break out of the orbit.
	/// </summary>
	private Vector3 ApplyBreakOffset( Vector3 forward )
	{
		if ( _target == null ) return forward;

		var dist = DistToTarget();
		if ( MathF.Abs( dist - _lastTargetDist ) > 80f )
		{
			_lastTargetDist = dist;
			_timeSinceDistChanged = 0f;
		}

		// If we've been at the same range for >1.5s, generate a juke
		if ( _timeSinceDistChanged > 1.5f )
		{
			_timeSinceDistChanged = 0f;
			var right = forward.Cross( Vector3.Up ).Normal;
			var sign = Game.Random.Int( 0, 1 ) == 0 ? 1f : -1f;
			_breakOffset = right * sign * 0.8f + Vector3.Up * Game.Random.Float( -0.3f, 0.3f );
		}

		// Decay the offset over time
		_breakOffset = _breakOffset.LerpTo( Vector3.Zero, Time.Delta * 0.6f );

		return (forward + _breakOffset).Normal;
	}

	private bool ShouldFire()
	{
		// Reaction delay before engaging after target acquisition/change.
		if ( _target != null && _timeSinceTargetAcquired < ReactionTime )
			return false;

		var forward = _pawn.WorldRotation.Forward;
		var pos     = _pawn.WorldPosition;
		PlayerPawn bestCandidate = null;
		float bestAngle = float.MaxValue;

		foreach ( var pawn in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
		{
			if ( pawn == _pawn || !pawn.IsAlive ) continue;
			var aimPos  = (pawn == _target) ? PredictAimPosition( pawn ) : pawn.WorldPosition;
			var toEnemy = aimPos - pos;
			if ( toEnemy.Length > AttackRange ) continue;
			var angle = MathF.Acos( toEnemy.Normal.Dot( forward ).Clamp( -1f, 1f ) ).RadianToDegree();
			if ( angle < FireCone )
			{
				// Prefer shooting the active target; otherwise pick the closest-to-center candidate.
				if ( pawn == _target )
				{
					bestCandidate = pawn;
					break;
				}

				if ( angle < bestAngle )
				{
					bestAngle = angle;
					bestCandidate = pawn;
				}
			}
		}

		if ( bestCandidate == null )
		{
			_fireCandidate = null;
			return false;
		}

		// Per-shot reaction: when a candidate first enters the cone, wait ReactionTime before firing.
		if ( bestCandidate != _fireCandidate )
		{
			_fireCandidate = bestCandidate;
			_timeSinceFireCandidateSeen = 0f;
			return false;
		}

		if ( _timeSinceFireCandidateSeen < ReactionTime )
			return false;

		_timeSinceLastFired = 0f;
		return true;
	}

	/// <summary>
	/// Casts rays forward and to the sides. Any wall hit within WallAvoidDistance
	/// pushes the desired direction away proportional to proximity.
	/// </summary>
	/// <summary>
	/// Blends a small pull toward the nearest enemy into any steering direction.
	/// Stronger in Patrol (nothing else to do) and fades out in Attack (already aiming).
	/// </summary>
	private Vector3 ApplyEnemyBias( Vector3 forward )
	{
		if ( _target == null ) return forward;

		// No bias when already directly targeting (Attack/Chase handle aim themselves)
		float bias = State switch
		{
			BotState.Patrol => 0.35f,
			BotState.Chase  => 0.15f,
			BotState.Evade  => 0f,
			_               => 0f,
		};

		if ( bias <= 0f ) return forward;

		var toEnemy = (_target.WorldPosition - _pawn.WorldPosition).Normal;
		return (forward + toEnemy * bias).Normal;
	}

	private Vector3 ApplyWallAvoidance( Vector3 forward )
	{
		var pos   = _pawn.WorldPosition;
		var right = forward.Cross( Vector3.Up ).Normal;
		var up    = right.Cross( forward ).Normal;

		Span<Vector3> probes = stackalloc Vector3[]
		{
			forward,
			(forward + right * 0.7f).Normal,
			(forward - right * 0.7f).Normal,
			(forward + up    * 0.7f).Normal,
			(forward - up    * 0.7f).Normal,
		};

		var avoidance  = Vector3.Zero;
		int hitCount   = 0;

		foreach ( var dir in probes )
		{
			var tr = Scene.Trace.Ray( pos, pos + dir * WallAvoidDistance )
				.IgnoreGameObject( _pawn.GameObject )
				.WithoutTags( "player" )
				.Run();

			if ( tr.Hit )
			{
				var proximity = 1f - (tr.Distance / WallAvoidDistance);
				avoidance += tr.Normal * proximity;
				hitCount++;
			}
		}

		if ( avoidance.LengthSquared < 0.001f )
			return forward;

		// If most probes hit walls we're in a corner — escape wins over desired direction
		var strength = hitCount >= 4 ? WallAvoidStrength * 3f : WallAvoidStrength;
		return (forward + avoidance * strength).Normal;
	}

	/// <summary>
	/// Pulls the bot back toward the arena centre when it wanders too far.
	/// At the soft edge (80 % of MaxRoamDistance) a gentle blend begins;
	/// beyond MaxRoamDistance steering is overridden completely.
	/// </summary>
	private Vector3 ApplyBoundaryPull( Vector3 forward )
	{
		var dist = _pawn.WorldPosition.Distance( _arenaCenter );
		if ( dist < MaxRoamDistance * 0.8f ) return forward;

		var home  = (_arenaCenter - _pawn.WorldPosition).Normal;
		var t     = ((dist - MaxRoamDistance * 0.8f) / (MaxRoamDistance * 0.2f)).Clamp( 0f, 1f );

		// Past the hard limit — ignore all other steering and head straight home
		if ( dist >= MaxRoamDistance )
		{
			_pawn.BotFirePrimary = false;
			_pawn.BotWantsBoost  = true;
			if ( State != BotState.Patrol ) SetState( BotState.Patrol );
			return home;
		}

		return (forward + home * t * 2f).Normal;
	}

	private void PickNewPatrolPoint()
	{
		_timeSincePatrolUpdate = 0f;
		var offset = new Vector3(
			Game.Random.Float( -3000f, 3000f ),
			Game.Random.Float( -3000f, 3000f ),
			Game.Random.Float( -600f,   600f )
		);
		var candidate = _pawn.WorldPosition + offset;

		// Keep patrol points inside the roam boundary
		var toCandidate = candidate - _arenaCenter;
		if ( toCandidate.Length > MaxRoamDistance * 0.75f )
			candidate = _arenaCenter + toCandidate.Normal * MaxRoamDistance * 0.75f;

		_patrolPoint = candidate;
	}
}