NextBot.cs
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

[Title( "Adaptive Horror Monster AI" )]
[Category( "AI" )]
public sealed class AdaptiveHorrorAI : Component, Component.ICollisionListener
{
	// ─────────────────────────────────────────────
	//  INSPECTOR PROPERTIES
	// ─────────────────────────────────────────────

	[Header( "Patrol" )]
	[Property] public float PatrolSpeed { get; set; } = 70f;
	[Property] public float PatrolRadius { get; set; } = 600f;
	[Property] public float WaitTime { get; set; } = 2.5f;

	[Header( "Chase" )]
	[Property] public float ChaseSpeed { get; set; } = 210f;
	[Property] public float ChaseRange { get; set; } = 480f;
	[Property] public float LoseRange { get; set; } = 680f;

	[Header( "Perception" )]
	[Property] public float HearingRange { get; set; } = 220f;
	[Property] public float SightRange { get; set; } = 400f;
	[Property, Range( 30f, 180f )] public float SightFovDegrees { get; set; } = 90f;

	[Header( "Adaptation" )]
	[Property] public float AdaptInterval { get; set; } = 15f;
	[Property] public float MaxSpeedBonus { get; set; } = 80f;
	[Property] public float MaxHearBonus { get; set; } = 120f;
	[Property] public int AdaptStepsMax { get; set; } = 5;

	[Header( "Horror" )]
	[Property] public float RetargetInterval { get; set; } = 0.8f;
	[Property] public float SearchDuration { get; set; } = 8f;
	[Property] public SoundEvent CatchSound { get; set; }
	[Property] public SoundEvent FootstepSound { get; set; }
	[Property] public SoundEvent AlertSound { get; set; }
	[Property] public SoundEvent AmbientBreathSound { get; set; }

	[Header( "Reset" )]
	[Property] public float ResetDelay { get; set; } = 4f;

	// ─────────────────────────────────────────────
	//  STATE MACHINE
	// ─────────────────────────────────────────────

	private enum AIState { Patrol, Alert, Hunt, Chase, Searching, Caught }
	private AIState _state = AIState.Patrol;

	// ─────────────────────────────────────────────
	//  INTERNAL FIELDS
	// ─────────────────────────────────────────────

	private NavMeshAgent _agent;
	private GameObject _target;

	private Vector3 _patrolTarget;
	private Vector3 _startPosition;
	private float _waitTimer;

	private Vector3 _lastKnownPosition;
	private float _searchTimer;
	private float _retargetTimer;

	private Vector3 _lastCheckedPos;
	private float _stuckTimer;

	private float _footstepTimer;
	private float _footstepInterval = 0.45f;

	private float _breathTimer;

	private float _adaptTimer;
	private int _adaptSteps;
	private float _currentChaseSpeed;
	private float _currentHearingRange;
	private int _timesPlayerEscaped;
	private int _timesPlayerCaught;
	private List<Vector3> _searchedAreas = new();

	private float _personalityAggression;
	private float _personalityPersistence;

	private bool _touched;

	// ─────────────────────────────────────────────
	//  LIFECYCLE
	// ─────────────────────────────────────────────

	protected override void OnStart()
	{
		_touched = false;
		_state = AIState.Patrol;

		if ( Networking.IsHost )
			GameObject.NetworkSpawn();
		else return;

		_agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelf );
		if ( _agent is null ) { Log.Error( "AdaptiveHorrorAI: NavMeshAgent missing!" ); return; }

		_startPosition = WorldPosition;
		_lastCheckedPos = WorldPosition;

		var rng = new Random( GameObject.Id.GetHashCode() );
		_personalityAggression = (float)rng.NextDouble();
		_personalityPersistence = (float)rng.NextDouble();

		_currentChaseSpeed = ChaseSpeed + _personalityAggression * 40f;
		_currentHearingRange = HearingRange + (1f - _personalityAggression) * 80f;

		_agent.MaxSpeed = PatrolSpeed;
		PickNewPatrolTarget();
	}

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost ) return;
		if ( _agent is null || _touched ) return;

		TickRetarget();
		TickAdaptation();
		TickFootstep();
		TickBreathing();
		TickStuckDetection();
		TickStateMachine();
	}

	// ─────────────────────────────────────────────
	//  STATE MACHINE
	// ─────────────────────────────────────────────

	private void TickStateMachine()
	{
		switch ( _state )
		{
			case AIState.Patrol: StatePatrol(); break;
			case AIState.Alert: StateAlert(); break;
			case AIState.Hunt: StateHunt(); break;
			case AIState.Chase: StateChase(); break;
			case AIState.Searching: StateSearching(); break;
		}
	}

	private void StatePatrol()
	{
		_agent.MaxSpeed = PatrolSpeed;

		if ( _waitTimer > 0f )
		{
			_waitTimer -= Time.Delta;
			if ( _waitTimer <= 0f ) PickNewPatrolTarget();
			return;
		}

		if ( WorldPosition.Distance( _patrolTarget ) < 60f )
			_waitTimer = WaitTime;

		if ( CanHearTarget() || CanSeeTarget() )
			TransitionTo( AIState.Alert );
	}

	private void StateAlert()
	{
		_agent.MaxSpeed = PatrolSpeed * 1.5f;

		if ( _target is not null )
			_agent.MoveTo( _target.WorldPosition );

		if ( _target is not null && WorldPosition.Distance( _target.WorldPosition ) <= ChaseRange )
			TransitionTo( AIState.Chase );
		else if ( !CanHearTarget() && !CanSeeTarget() )
			TransitionTo( AIState.Searching );
	}

	private void StateHunt()
	{
		_agent.MaxSpeed = _currentChaseSpeed * 0.75f;
		_agent.MoveTo( _lastKnownPosition );

		if ( CanSeeTarget() || (CanHearTarget() && _target is not null &&
			WorldPosition.Distance( _target.WorldPosition ) <= ChaseRange) )
			TransitionTo( AIState.Chase );

		if ( WorldPosition.Distance( _lastKnownPosition ) < 80f )
			TransitionTo( AIState.Searching );
	}

	private void StateChase()
	{
		_agent.MaxSpeed = _currentChaseSpeed;

		if ( _target is not null )
		{
			_lastKnownPosition = _target.WorldPosition;
			_agent.MoveTo( _target.WorldPosition );
		}

		bool lostSight = !CanSeeTarget();
		bool tooFar = _target is null || WorldPosition.Distance( _target.WorldPosition ) > LoseRange + (_personalityPersistence * 100f);

		if ( lostSight && tooFar )
		{
			_timesPlayerEscaped++;
			TransitionTo( AIState.Hunt );
		}
	}

	private void StateSearching()
	{
		_searchTimer -= Time.Delta;
		_agent.MaxSpeed = PatrolSpeed * 1.2f;

		if ( WorldPosition.Distance( _patrolTarget ) < 60f )
		{
			var pt = Scene.NavMesh.GetRandomPoint( _lastKnownPosition, 200f );
			if ( pt.HasValue )
			{
				_patrolTarget = pt.Value;
				_agent.MoveTo( _patrolTarget );
			}
		}

		if ( CanSeeTarget() || CanHearTarget() )
			TransitionTo( AIState.Chase );

		if ( _searchTimer <= 0f )
		{
			_searchedAreas.Add( _lastKnownPosition );
			if ( _searchedAreas.Count > 10 ) _searchedAreas.RemoveAt( 0 );
			TransitionTo( AIState.Patrol );
		}
	}

	private void TransitionTo( AIState next )
	{
		if ( _state == next ) return;

		switch ( next )
		{
			case AIState.Alert:
				BroadcastAlertSound();
				break;
			case AIState.Chase:
				_footstepInterval = 0.25f;
				break;
			case AIState.Patrol:
				_footstepInterval = 0.5f;
				PickNewPatrolTarget();
				break;
			case AIState.Searching:
				_searchTimer = SearchDuration * (0.5f + _personalityPersistence * 0.8f);
				break;
			case AIState.Hunt:
				_footstepInterval = 0.35f;
				_agent.MoveTo( _lastKnownPosition );
				break;
		}

		_state = next;
	}

	// ─────────────────────────────────────────────
	//  PERCEPTION
	// ─────────────────────────────────────────────

	private bool CanSeeTarget()
	{
		if ( _target is null ) return false;

		float dist = WorldPosition.Distance( _target.WorldPosition );
		if ( dist > SightRange ) return false;

		Vector3 toTarget = (_target.WorldPosition - WorldPosition).Normal;
		float dot = WorldRotation.Forward.Dot( toTarget );
		float halfFov = MathF.Cos( MathF.PI * SightFovDegrees / 360f );
		if ( dot < halfFov ) return false;

		Vector3 eyePos = WorldPosition + Vector3.Up * 60f;
		Vector3 targetPos = _target.WorldPosition + Vector3.Up * 60f;
		var tr = Scene.Trace
			.Ray( new Ray( eyePos, (targetPos - eyePos).Normal ), dist + 10f )
			.WithoutTags( "monster" )
			.Run();

		return !tr.Hit || tr.GameObject == _target;
	}

	private bool CanHearTarget()
	{
		if ( _target is null ) return false;
		float dist = WorldPosition.Distance( _target.WorldPosition );
		float noise = EstimatePlayerNoise();
		return dist <= _currentHearingRange * noise;
	}

	private float EstimatePlayerNoise()
	{
		if ( _target is null ) return 0f;
		var pc = _target.Components.Get<PlayerController>( FindMode.EverythingInSelf );
		if ( pc is null ) return 0.6f;
		// Hook into pc.Velocity magnitude here for crouching/sprinting awareness
		return 1.0f;
	}

	// ─────────────────────────────────────────────
	//  ADAPTATION
	// ─────────────────────────────────────────────

	private void TickAdaptation()
	{
		if ( _adaptSteps >= AdaptStepsMax ) return;

		_adaptTimer -= Time.Delta;
		if ( _adaptTimer > 0f ) return;
		_adaptTimer = AdaptInterval;

		if ( _timesPlayerEscaped == 0 ) return;

		_adaptSteps++;
		_timesPlayerEscaped = 0;

		float t = (float)_adaptSteps / AdaptStepsMax;
		_currentChaseSpeed = ChaseSpeed + _personalityAggression * 40f + t * MaxSpeedBonus;
		_currentHearingRange = HearingRange + (1f - _personalityAggression) * 80f + t * MaxHearBonus;

		Log.Info( $"[HorrorAI] Adapted! Step {_adaptSteps}/{AdaptStepsMax} | Speed={_currentChaseSpeed:F0} | Hearing={_currentHearingRange:F0}" );
		BroadcastAdaptEvent( _adaptSteps );
	}

	// ─────────────────────────────────────────────
	//  PATROL HELPERS
	// ─────────────────────────────────────────────

	private void PickNewPatrolTarget()
	{
		if ( _agent is null ) return;

		for ( int attempts = 0; attempts < 5; attempts++ )
		{
			var pt = Scene.NavMesh.GetRandomPoint( _startPosition, PatrolRadius );
			if ( !pt.HasValue ) break;

			bool tooClose = false;
			foreach ( var area in _searchedAreas )
			{
				if ( pt.Value.Distance( area ) < 150f ) { tooClose = true; break; }
			}

			if ( !tooClose )
			{
				_patrolTarget = pt.Value;
				_agent.MoveTo( _patrolTarget );
				return;
			}
		}

		var fallback = Scene.NavMesh.GetClosestPoint( _startPosition );
		if ( fallback.HasValue )
		{
			_patrolTarget = fallback.Value;
			_agent.MoveTo( _patrolTarget );
		}
	}

	private void TickRetarget()
	{
		_retargetTimer -= Time.Delta;
		if ( _retargetTimer > 0f ) return;
		_retargetTimer = RetargetInterval;
		_target = FindNearestPlayer();
	}

	private GameObject FindNearestPlayer()
	{
		GameObject nearest = null;
		float nearestDist = float.MaxValue;
		foreach ( var pc in Scene.GetAllComponents<PlayerController>() )
		{
			if ( pc.GameObject is null || !pc.GameObject.IsValid() ) continue;
			float d = WorldPosition.Distance( pc.WorldPosition );
			if ( d < nearestDist ) { nearestDist = d; nearest = pc.GameObject; }
		}
		return nearest;
	}

	// ─────────────────────────────────────────────
	//  STUCK DETECTION
	// ─────────────────────────────────────────────

	private void TickStuckDetection()
	{
		_stuckTimer += Time.Delta;
		if ( _stuckTimer < 2.5f ) return;
		_stuckTimer = 0f;

		if ( (_state == AIState.Patrol || _state == AIState.Searching) &&
			WorldPosition.Distance( _lastCheckedPos ) < 15f )
			PickNewPatrolTarget();

		_lastCheckedPos = WorldPosition;
	}

	// ─────────────────────────────────────────────
	//  AUDIO
	// ─────────────────────────────────────────────

	private void TickFootstep()
	{
		_footstepTimer -= Time.Delta;
		if ( _footstepTimer > 0f || FootstepSound is null ) return;

		_footstepTimer = _footstepInterval;
		if ( WorldPosition.Distance( _lastCheckedPos ) > 1f )
			BroadcastFootstep();
	}

	private void TickBreathing()
	{
		_breathTimer -= Time.Delta;
		if ( _breathTimer > 0f || AmbientBreathSound is null ) return;

		_breathTimer = _state == AIState.Chase ? 2f : 5f;
		BroadcastBreath();
	}

	// ─────────────────────────────────────────────
	//  COLLISION / CATCH
	// ─────────────────────────────────────────────

	public void OnCollisionStart( Collision collision )
	{
		if ( !Networking.IsHost || _touched ) return;

		var hit = collision.Other.GameObject;
		var pc = hit.Components.Get<PlayerController>( FindMode.EverythingInSelf )
			   ?? hit.Parent?.Components.Get<PlayerController>( FindMode.EverythingInSelf );
		if ( pc is null ) return;

		_touched = true;
		_timesPlayerCaught++;
		_state = AIState.Caught;

		if ( CatchSound is not null ) BroadcastCatchSound();

		var owner = pc.GameObject.Network.Owner;
		if ( owner is not null )
			KickPlayer( owner.SteamId );
		else
			Log.Warning( "AdaptiveHorrorAI: Caught player has no network owner." );

		_ = ResetAfterCatch();
	}

	private async Task ResetAfterCatch()
	{
		await Task.DelaySeconds( ResetDelay );
		if ( !this.IsValid() ) return;

		_touched = false;
		_target = null;
		_state = AIState.Patrol;
		_agent.MaxSpeed = PatrolSpeed;
		PickNewPatrolTarget();
	}

	// ─────────────────────────────────────────────
	//  RPCs
	// ─────────────────────────────────────────────

	[Rpc.Broadcast]
	private void KickPlayer( ulong targetId )
	{
		if ( Connection.Local.SteamId != targetId ) return;
		Log.Info( "You were caught by the monster!" );
		Game.Disconnect();
	}

	[Rpc.Broadcast]
	private void BroadcastCatchSound() => Sound.Play( CatchSound, WorldPosition );

	[Rpc.Broadcast]
	private void BroadcastAlertSound()
	{
		if ( AlertSound is not null ) Sound.Play( AlertSound, WorldPosition );
	}

	[Rpc.Broadcast]
	private void BroadcastFootstep()
	{
		if ( FootstepSound is not null ) Sound.Play( FootstepSound, WorldPosition );
	}

	[Rpc.Broadcast]
	private void BroadcastBreath()
	{
		if ( AmbientBreathSound is not null ) Sound.Play( AmbientBreathSound, WorldPosition );
	}

	[Rpc.Broadcast]
	private void BroadcastAdaptEvent( int step )
	{
		Log.Info( $"[HorrorAI] Monster adapted! (step {step}) — it's getting smarter..." );
	}

	public void OnCollisionUpdate( Collision collision ) { }
	public void OnCollisionStop( CollisionStop collision ) { }

	// ─────────────────────────────────────────────
	//  GIZMOS
	// ─────────────────────────────────────────────

	protected override void DrawGizmos()
	{
		Gizmo.Draw.Color = Color.Red.WithAlpha( 0.12f );
		Gizmo.Draw.LineSphere( Vector3.Zero, ChaseRange );

		Gizmo.Draw.Color = Color.Orange.WithAlpha( 0.10f );
		Gizmo.Draw.LineSphere( Vector3.Zero, SightRange );

		Gizmo.Draw.Color = Color.Yellow.WithAlpha( 0.10f );
		Gizmo.Draw.LineSphere( Vector3.Zero, _currentHearingRange );

		Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.08f );
		Gizmo.Draw.LineSphere( Vector3.Zero, PatrolRadius );

		string adaptLabel = _adaptSteps >= AdaptStepsMax ? "FULLY ADAPTED" : $"Adapt {_adaptSteps}/{AdaptStepsMax}";
		string stateLabel = _state.ToString().ToUpper();
		string targetName = _target is not null ? _target.Name : "none";
		string personality = _personalityAggression > 0.6f ? "RUSHER" : (_personalityAggression < 0.4f ? "STALKER" : "BALANCED");

		Gizmo.Draw.Color = Color.White;
		Gizmo.Draw.ScreenText(
			$"[ {stateLabel} ] → {targetName}\n" +
			$"Type: {personality} | Spd: {_currentChaseSpeed:F0} | Hear: {_currentHearingRange:F0}\n" +
			$"{adaptLabel} | Escapes tracked: {_timesPlayerEscaped}",
			new Vector2( 10, 10 )
		);
	}
}