Player/Bot/PlayerBotController.Combat.cs
using System.Threading;

public partial class PlayerBotController
{
	private async Task<bool> Combat( CancellationToken token )
	{
		_currentTarget = FindAndUpdateTarget();

		// The only way combat can fail is if we don't have an initial target
		// As soon as we perfomed any action in here we consider the comhba a sucess even if it only lasted a couple of frames
		if ( !_currentTarget.IsValid() || NoTarget )
		{
			return false;
		}

		while ( !token.IsCancellationRequested )
		{
			_currentTarget = FindAndUpdateTarget();

			bool result = await RunParallel(
				token,
				ValidateTarget,
				UpdateAim,
				HandleMovement,
				HandleShooting
			);

			if ( !result )
			{
				// we still return true because we at least did some combat
				return true;
			}
		}

		return true;
	}

	private async Task<bool> ValidateTarget( CancellationToken token )
	{
		while ( !token.IsCancellationRequested )
		{
			if ( !IsCurrentTargetValid() )
			{
				_currentTarget = null;
				return false;
			}

			await Task.FixedUpdate();
		}

		return !token.IsCancellationRequested;
	}

	private async Task<bool> HandleShooting( CancellationToken token )
	{
		while ( !token.IsCancellationRequested )
		{
			// Can see target => shoot at it
			if ( _lastSeenEnemies.TryGetValue( _currentTarget, out TimeSince value ) && value < 0.1f )
			{
				// TODO I still hate this. This needs more work.
				// We either need to refactor our weapons to be more generic and have better entry points to trigger attacks,
				// Or we need a way of simulating input events,
				// Or we add some sor of interface to each weapon that takes over control of the bot when the weapon is equiped,
				// For now just hardcode a bunch of simpler weapons.
				var weaponsToTry = new List<Func<bool>>
				{
					() => TryShootWeapon<RpgWeapon>(w => w.Shoot(Player)),
					() => TryShootWeapon<CrossbowWeapon>(w => w.Shoot(Player)),
					() => TryShootWeapon<PythonWeapon>(w => w.ShootBullet(Player)),
					() => TryShootWeapon<HornetGunWeapon>(w => w.Shoot(Player, HornetProjectile.FireMode.Normal)),
					() => TryShootWeapon<Mp5Weapon>(w => w.ShootBullet(Player)),
					() => TryShootWeapon<ShotgunWeapon>(w => w.ShootBullet(Player)),
					() => TryShootWeapon<GlockWeapon>(w => w.ShootBullet(Player, false, w.PrimaryFireRate))
				};

				foreach ( var tryShoot in weaponsToTry )
				{
					if ( tryShoot() )
					{
						break; // Weapon fired successfully, exit the loop
					}
				}
			}

			await Task.FixedUpdate();
		}

		return !token.IsCancellationRequested;
	}

	private bool TryShootWeapon<TWeapon>( Action<TWeapon> shootAction ) where TWeapon : BaseWeapon
	{
		if ( Inventory.HasWeapon<TWeapon>() )
		{
			var weapon = Inventory.GetWeapon<TWeapon>();
			if ( weapon.HasAmmo( weapon.AmmoResource ) || weapon.UsesClips && weapon.ClipContents > 0 )
			{
				Inventory.SwitchWeapon( weapon );
				shootAction( weapon );
				return true;
			}
		}
		return false;
	}

	private async Task<bool> HandleMovement( CancellationToken token )
	{
		TimeSince lastCombatMovementUpdate = 0f;

		while ( !token.IsCancellationRequested )
		{
			if ( lastCombatMovementUpdate > 1f )
			{
				// Try to pick up nearby health or armor while in combat
				await TryDivertForConsumable(
					token,
					pickupFilter: pickup => pickup is HealthPickup or ArmourPickup
				);

				lastCombatMovementUpdate = 0f;

				// If target not visible, chase it
				if ( !_lastSeenEnemies.ContainsKey( _currentTarget ) || _lastSeenEnemies[_currentTarget] > 0.1f )
				{
					MeshAgent.MoveTo( _currentTarget.WorldPosition );
				}
				else
				{
					MeshAgent.MoveTo( _currentTarget.WorldPosition + Vector3.Random * 500f );
				}
			}

			await Task.FixedUpdate();
		}

		return !token.IsCancellationRequested;
	}

	/// <summary>
	/// Our Aim has two components
	/// 1. Which Local Aim Target to aim at (Head, Body, etc)
	/// 2. Track the global movement of the target ( WorldPosition )
	/// </summary>
	private async Task<bool> UpdateAim( CancellationToken token )
	{
		Vector3 localAimTarget = Vector3.Zero;
		Vector3 newLocalAimTarget = Vector3.Zero;
		TimeSince timeSinceLastLocalAimUpdate = 0;

		const float localAimUpdateInterval = 1.5f;

		while ( !token.IsCancellationRequested )
		{
			if ( timeSinceLastLocalAimUpdate > localAimUpdateInterval )
			{
				newLocalAimTarget = FindLocalAimTarget();
				timeSinceLastLocalAimUpdate = 0;
			}

			// Local aim is not as snappy and rather slow
			const float aimLerpFactor = 0.025f;
			localAimTarget = localAimTarget.LerpTo( newLocalAimTarget, aimLerpFactor );

			Vector3 worldAimTarget = _currentTarget.WorldPosition + localAimTarget;
			// Clamp so we don't aim too much in to the ground
			worldAimTarget = worldAimTarget.WithZ( MathF.Max( _currentTarget.WorldPosition.z + 24f, worldAimTarget.z ) );
			Vector3 direction = worldAimTarget - Controller.EyePosition;

			// If target hasn't been visible for a while stop tracking it
			// otherwhise it looks like the bot has wallhacks. It does but we don't want people to know.
			if ( _lastSeenEnemies.TryGetValue( _currentTarget, out var targetLastSeen ) && targetLastSeen > 1f )
			{
				worldAimTarget = MeshAgent.GetLookAhead( 30.0f ).WithZ( Controller.EyePosition.z );
			}

			if ( direction.LengthSquared > 0.01f * 0.01f )
			{
				Rotation targetRotation = Rotation.LookAt( direction.Normal );

				// Adjust eye angles towards the target rotation with reaction speed bias
				float lerpFactor = Random.Shared.Float( 0.1f, 0.25f ) * GameSettings.BotReactionSpeedBias;
				Controller.EyeAngles = Controller.EyeAngles.LerpTo( targetRotation, lerpFactor );
			}

			await Task.FixedUpdate();
		}

		return !token.IsCancellationRequested;
	}

	// Let's cache these
	private HitboxSet _cachedHitBoxSet;
	private Dictionary<string, float> _hitboxWeights;

	private Vector3 FindLocalAimTarget()
	{
		InitializeHitboxData();

		// Reapply headshot bias
		_hitboxWeights["HB_head"] = 0.6f * GameSettings.BotHeadShotBias;
		float totalWeight = _hitboxWeights.Values.Sum();

		float randomValue = Random.Shared.Float( 0, totalWeight );
		float cumulativeWeight = 0;

		foreach ( var hitbox in _cachedHitBoxSet.All )
		{
			cumulativeWeight += _hitboxWeights[hitbox.Name];

			if ( randomValue <= cumulativeWeight )
			{
				if ( !_currentTarget.Controller.Renderer.TryGetBoneTransform( hitbox.Bone, out var boneTransform ) )
					continue;

				Vector3 localAimTarget = boneTransform.Position
										 + GetRandomPointInHitbox( hitbox, 5f * (1f / MathF.Max( GameSettings.BotAccuracy, 0.1f ) ) )
										 - _currentTarget.Controller.Renderer.WorldPosition;

				return localAimTarget;
			}
		}

		Log.Warning( "Failed to find local aim target" );
		return Vector3.Zero;
	}

	private Vector3 GetRandomPointInHitbox( HitboxSet.Box hitbox, float scale )
	{
		var shape = hitbox.Shape;
		if ( shape is Sphere sphere )
		{
			var scaledSphere = new Sphere( sphere.Center, sphere.Radius * scale );
			return scaledSphere.RandomPointInside;
		}

		if ( shape is BBox box )
		{
			var scaledBox = box * scale;
			return scaledBox.RandomPointInside;
		}

		if ( shape is Capsule capsule )
		{
			var capsuleCenter = (capsule.CenterA + capsule.CenterB) * 0.5f; // Find the midpoint
			var direction = capsule.CenterB - capsule.CenterA; // Get the direction vector
			var halfLength = direction.Length * 0.5f; // Get half the length
			var normalizedDirection = direction.Normal; // Get the normalized direction

			// Scale both radius and length
			var scaledRadius = capsule.Radius * scale;
			var scaledHalfLength = halfLength * scale;

			// Calculate new center points
			var scaledCenterA = capsuleCenter - normalizedDirection * scaledHalfLength;
			var scaledCenterB = capsuleCenter + normalizedDirection * scaledHalfLength;

			// Create the scaled capsule
			var scaledCapsule = new Capsule( scaledCenterA, scaledCenterB, scaledRadius );

			return scaledCapsule.RandomPointInside;
		}

		throw new NotImplementedException( "Unsupported shape" );
	}

	private void InitializeHitboxData()
	{
		if ( _cachedHitBoxSet != null )
			return;

		_cachedHitBoxSet = _currentTarget.Controller.Renderer.Model.HitboxSet;

		_hitboxWeights = new Dictionary<string, float>
		{
			{ "HB_pelvis", 0.2f },
			{ "HB_spine_0", 0.3f },
			{ "HB_spine_1", 0.4f },
			{ "HB_spine_2", 0.5f },
			{ "HB_neck_0", 0.6f },
			{ "HB_head", 0.6f },
		};

		const float defaultWeight = 0.02f;

		// Initialize remaining hitboxes with default weight
		foreach ( var hitbox in _cachedHitBoxSet.All )
		{
			if ( !_hitboxWeights.ContainsKey( hitbox.Name ) )
			{
				_hitboxWeights[hitbox.Name] = defaultWeight;
			}
		}
	}
}