OMRBotController.BeliefSearch.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// BOT 7E - Opponent Belief / Search.
///
/// This is deliberately not wall knowledge. The distribution is seeded only by
/// confirmed visual samples, already-noisy hearing, noisy incoming-fire bearing,
/// historical hotspots and the bot's own negative visual evidence. Once sight is
/// lost the distribution spreads over navigable neighbouring cells, giving search
/// an explicit representation of uncertainty instead of a fixed point list.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "BOT 7E / Belief Search" )]
	public bool BeliefSearchEnabled { get; set; } = true;

	[Property, Group( "BOT 7E / Belief Search" )]
	public float BeliefUpdateInterval { get; set; } = 0.24f;

	[Property, Group( "BOT 7E / Belief Search" )]
	public float BeliefConfidenceHalfLife { get; set; } = 4.8f;

	[Property, Group( "BOT 7E / Belief Search" ), Range( 0.05f, 0.65f )]
	public float BeliefSpreadFraction { get; set; } = 0.24f;

	[Property, Group( "BOT 7E / Belief Search" )]
	public int BeliefMaximumCells { get; set; } = 28;

	[Property, Group( "BOT 7E / Belief Search" ), Range( 0.01f, 0.30f )]
	public float BeliefMinimumCellProbability { get; set; } = 0.012f;

	[Property, Group( "BOT 7E / Belief Search" )]
	public float BeliefClearRadius { get; set; } = 150f;

	[Property, Group( "BOT 7E / Belief Search" )]
	public float BeliefSearchScoreWeight { get; set; } = 220f;

	[Property, Group( "BOT 7E / Belief Search" ), Range( 0.01f, 0.40f )]
	public float BeliefInterceptMinimumConfidence { get; set; } = 0.075f;

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

	[Property, Sync( SyncFlags.FromHost )]
	public float BeliefConfidence { get; set; }

	private readonly Dictionary<(int X, int Y), float> _opponentBelief = new();
	private float _beliefGlobalConfidence;
	private float _nextBeliefUpdateAt;
	private float _beliefLastAudioInjectedAt = -999f;
	private float _beliefLastThreatInjectedAt = -999f;
	private float _beliefLastVisualAt = -999f;
	private float _beliefLastHistoricalPriorAt = -999f;

	private void UpdateOpponentBelief()
	{
		if ( !UsesTacticalBehavior() || !BeliefSearchEnabled )
		{
			BeliefStatus = "OFF";
			BeliefConfidence = 0f;
			return;
		}

		PlayerState human = ResolveHumanTarget( _perceptionTarget );
		if ( human?.GameObject is not null && !human.IsAlive )
		{
			ResetOpponentBelief( "TARGET DOWN" );
			return;
		}

		if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
		{
			SeedBeliefFromConfirmedVisual();
			UpdateBeliefDiagnostics( "VISUAL COLLAPSE" );
			return;
		}

		if ( Time.Now < _nextBeliefUpdateAt )
			return;

		float step = MathF.Max( 0.10f, BeliefUpdateInterval );
		_nextBeliefUpdateAt = Time.Now + step;
		float halfLife = MathF.Max( 1.2f, BeliefConfidenceHalfLife );
		_beliefGlobalConfidence *= MathF.Pow( 0.5f, step / halfLife );
		if ( _beliefGlobalConfidence < 0.012f )
		{
			_opponentBelief.Clear();
			_beliefGlobalConfidence = 0f;
		}
		PropagateOpponentBelief();
		InjectBeliefFromPlayerPrediction();
		InjectBeliefFromFreshAudio();
		InjectBeliefFromThreatDirection();
		InjectHistoricalBeliefPrior();
		ApplyBeliefNegativeVisualEvidence();
		NormalizeAndTrimBelief();
		UpdateBeliefDiagnostics( "PREDICT" );
	}

	private void SeedBeliefFromConfirmedVisual()
	{
		_opponentBelief.Clear();
		(int X, int Y) key = GetSpatialMemoryKey( _observedTargetPosition );
		AddBeliefMass( key, 0.74f );

		Vector3 motion = _lastSeenDirection.WithZ( 0f );
		if ( motion.IsNearlyZero( 0.01f ) )
			motion = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Normal;

		if ( !motion.IsNearlyZero( 0.01f ) )
		{
			float size = MathF.Max( 48f, SpatialMemoryCellSize );
			(int X, int Y) forward = GetSpatialMemoryKey( _observedTargetPosition + motion.Normal * size );
			AddBeliefMass( forward, 0.16f );
			Vector3 lateral = new Vector3( -motion.y, motion.x, 0f ).Normal;
			AddBeliefMass( GetSpatialMemoryKey( _observedTargetPosition + lateral * size ), 0.05f );
			AddBeliefMass( GetSpatialMemoryKey( _observedTargetPosition - lateral * size ), 0.05f );
		}

		_beliefLastVisualAt = Time.Now;
		_beliefGlobalConfidence = 1f;
		NormalizeAndTrimBelief();
	}

	private void PropagateOpponentBelief()
	{
		if ( _opponentBelief.Count == 0 )
			return;

		float spread = MathX.Clamp( BeliefSpreadFraction, 0.05f, 0.65f );
		spread *= Difficulty switch
		{
			OMRBotDifficulty.Easy => 1.30f,
			OMRBotDifficulty.Hard => 0.82f,
			_ => 1f
		};
		spread = MathX.Clamp( spread, 0.05f, 0.65f );

		Dictionary<(int X, int Y), float> next = new();
		Vector3 learnedDirection = _lastSeenDirection.WithZ( 0f );
		bool useDirection =
			!learnedDirection.IsNearlyZero( 0.01f ) &&
			Time.Now - _beliefLastVisualAt <= 3.6f;
		if ( useDirection ) learnedDirection = learnedDirection.Normal;

		foreach ( KeyValuePair<(int X, int Y), float> entry in _opponentBelief )
		{
			float probability = MathF.Max( 0f, entry.Value );
			if ( probability <= 0.0001f )
				continue;

			AddBeliefMass( next, entry.Key, probability * ( 1f - spread ) );

			(int X, int Y)[] neighbors =
			{
				(entry.Key.X + 1, entry.Key.Y),
				(entry.Key.X - 1, entry.Key.Y),
				(entry.Key.X, entry.Key.Y + 1),
				(entry.Key.X, entry.Key.Y - 1)
			};

			float totalWeight = 0f;
			float[] weights = new float[neighbors.Length];
			Vector3 from = GetSpatialMemoryCellCenter( entry.Key, WorldPosition.z );

			for ( int i = 0; i < neighbors.Length; i++ )
			{
				if ( !IsBeliefCellNavigable( neighbors[i] ) )
					continue;

				float weight = 1f;
				if ( useDirection )
				{
					Vector3 to = GetSpatialMemoryCellCenter( neighbors[i], WorldPosition.z );
					Vector3 dir = ( to - from ).WithZ( 0f ).Normal;
					float dot = MathX.Clamp( dir.Dot( learnedDirection ), -1f, 1f );
					weight *= MathX.Lerp( 0.55f, 1.55f, ( dot + 1f ) * 0.5f );
				}

				weights[i] = weight;
				totalWeight += weight;
			}

			if ( totalWeight <= 0.001f )
			{
				AddBeliefMass( next, entry.Key, probability * spread );
				continue;
			}

			for ( int i = 0; i < neighbors.Length; i++ )
			{
				if ( weights[i] <= 0f )
					continue;
				AddBeliefMass( next, neighbors[i], probability * spread * ( weights[i] / totalWeight ) );
			}
		}

		_opponentBelief.Clear();
		foreach ( KeyValuePair<(int X, int Y), float> entry in next )
			_opponentBelief[entry.Key] = entry.Value;
	}

	private void InjectBeliefFromFreshAudio()
	{
		if ( !HasFreshHearing() || _lastHeardAt <= _beliefLastAudioInjectedAt )
			return;

		_beliefLastAudioInjectedAt = _lastHeardAt;
		float confidence = MathX.Clamp( _lastHeardConfidence, 0f, 1f );
		if ( confidence <= 0.05f )
			return;
		_beliefGlobalConfidence = MathF.Max( _beliefGlobalConfidence, 0.22f + confidence * 0.58f );

		(int X, int Y) key = GetSpatialMemoryKey( _lastHeardPosition );
		float mass = 0.18f + confidence * 0.42f;
		AddBeliefMass( key, mass * 0.55f );
		AddBeliefMass( (key.X + 1, key.Y), mass * 0.1125f );
		AddBeliefMass( (key.X - 1, key.Y), mass * 0.1125f );
		AddBeliefMass( (key.X, key.Y + 1), mass * 0.1125f );
		AddBeliefMass( (key.X, key.Y - 1), mass * 0.1125f );
	}

	private void InjectBeliefFromThreatDirection()
	{
		if ( !HasRecentThreat() || _lastThreatAt <= _beliefLastThreatInjectedAt )
			return;

		_beliefLastThreatInjectedAt = _lastThreatAt;
		Vector3 direction = _lastThreatDirection.WithZ( 0f );
		if ( direction.IsNearlyZero( 0.01f ) )
			return;
		direction = direction.Normal;
		_beliefGlobalConfidence = MathF.Max( _beliefGlobalConfidence, 0.34f );

		float[] distances = { 220f, 420f, 620f };
		float[] masses = { 0.09f, 0.14f, 0.07f };
		for ( int i = 0; i < distances.Length; i++ )
		{
			Vector3 point = WorldPosition + direction * distances[i];
			AddBeliefMass( GetSpatialMemoryKey( point ), masses[i] );
		}
	}

	private void InjectHistoricalBeliefPrior()
	{
		if ( Time.Now - _beliefLastHistoricalPriorAt < 3.5f )
			return;
		if ( _beliefGlobalConfidence >= 0.18f )
			return;

		_beliefLastHistoricalPriorAt = Time.Now;
		if (
			TryGetLearnedOpponentHotspot(
				WorldPosition,
				MathF.Max( 650f, SightRange ),
				out Vector3 hotspot,
				out float hotspotScore
			)
		)
		{
			float prior = MathX.Clamp( hotspotScore / 12f, 0.03f, 0.12f );
			AddBeliefMass( GetSpatialMemoryKey( hotspot ), prior );
			_beliefGlobalConfidence = MathF.Max( _beliefGlobalConfidence, 0.14f );
		}
	}

	private void ApplyBeliefNegativeVisualEvidence()
	{
		if (
			_controller?.IsValid != true ||
			_opponentBelief.Count == 0 ||
			PerceptionStatus == "REACTING"
		)
			return;

		bool clearedAny = false;
		List<(int X, int Y)> keys =
			_opponentBelief
				.OrderByDescending( entry => entry.Value )
				.Take( 12 )
				.Select( entry => entry.Key )
				.ToList();
		foreach ( (int X, int Y) key in keys )
		{
			if ( !_opponentBelief.TryGetValue( key, out float probability ) || probability <= 0.015f )
				continue;

			Vector3 center = GetSpatialMemoryCellCenter( key, WorldPosition.z );
			if ( CanVisuallyClearBeliefPosition( center ) )
			{
				_opponentBelief[key] = probability * 0.20f;
				clearedAny = true;
			}
		}

		if ( clearedAny )
			_beliefGlobalConfidence *= 0.94f;
	}

	private bool CanVisuallyClearBeliefPosition( Vector3 position )
	{
		if ( Scene is null || _controller?.IsValid != true )
			return false;

		Vector3 to = ( position - WorldPosition ).WithZ( 0f );
		float distance = to.Length;
		if ( distance <= 12f || distance > SightRange * 0.92f )
			return false;

		Vector3 forward = _controller.EyeTransform.Rotation.Forward.WithZ( 0f );
		if ( forward.IsNearlyZero( 0.01f ) )
			forward = WorldRotation.Forward.WithZ( 0f );
		forward = forward.Normal;
		float dot = forward.Dot( to.Normal );
		float threshold = MathF.Cos( MathF.Max( 1f, FieldOfView ) * 0.5f * MathF.PI / 180f );
		if ( dot < threshold )
			return false;

		Vector3 eye = _controller.EyePosition;
		Vector3 chest = position + Vector3.Up * 42f;
		Vector3 head = position + Vector3.Up * 66f;
		return IsBeliefRayOpen( eye, chest ) && IsBeliefRayOpen( eye, head );
	}

	private bool IsBeliefRayOpen( Vector3 from, Vector3 to )
	{
		SceneTrace trace =
			Scene.Trace.Ray( from, to )
				.IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "trigger" )
				.UseHitboxes( false );

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

		return !trace.Run().Hit;
	}

	private bool IsBeliefCellNavigable( (int X, int Y) key )
	{
		if ( Scene?.NavMesh is null || !Scene.NavMesh.IsEnabled || Scene.NavMesh.IsGenerating )
			return false;

		Vector3 center = GetSpatialMemoryCellCenter( key, WorldPosition.z );
		float radius = MathF.Max( 42f, SpatialMemoryCellSize * 0.58f );
		Vector3? projected = Scene.NavMesh.GetClosestPoint( center, radius );
		return projected is not null && ( projected.Value - center ).WithZ( 0f ).Length <= radius;
	}

	private void AddBeliefMass( (int X, int Y) key, float amount )
	{
		if ( amount <= 0f || !IsBeliefCellNavigable( key ) )
			return;
		AddBeliefMass( _opponentBelief, key, amount );
	}

	private static void AddBeliefMass(
		Dictionary<(int X, int Y), float> map,
		(int X, int Y) key,
		float amount
	)
	{
		if ( amount <= 0f )
			return;
		map.TryGetValue( key, out float previous );
		map[key] = previous + amount;
	}

	private void NormalizeAndTrimBelief()
	{
		if ( _opponentBelief.Count == 0 )
			return;

		float minimum = MathX.Clamp( BeliefMinimumCellProbability, 0.001f, 0.30f );
		List<KeyValuePair<(int X, int Y), float>> ordered =
			_opponentBelief
				.Where( entry => entry.Value >= minimum )
				.OrderByDescending( entry => entry.Value )
				.Take( Math.Clamp( BeliefMaximumCells, 6, 64 ) )
				.ToList();

		float total = ordered.Sum( entry => MathF.Max( 0f, entry.Value ) );
		_opponentBelief.Clear();
		if ( total <= 0.0001f )
			return;

		foreach ( KeyValuePair<(int X, int Y), float> entry in ordered )
			_opponentBelief[entry.Key] = entry.Value / total;
	}

	private float GetTotalBeliefMass()
	{
		float total = 0f;
		foreach ( float probability in _opponentBelief.Values )
			total += MathF.Max( 0f, probability );
		return total;
	}

	private bool HasOpponentBelief()
	{
		return BeliefSearchEnabled && _opponentBelief.Count > 0 && _beliefGlobalConfidence >= 0.025f && BeliefConfidence >= 0.012f;
	}

	private bool TryGetBestOpponentBelief( out Vector3 position, out float confidence )
	{
		position = Vector3.Zero;
		confidence = 0f;
		if ( !BeliefSearchEnabled || _opponentBelief.Count == 0 )
			return false;

		(int X, int Y) bestKey = default;
		bool found = false;
		foreach ( KeyValuePair<(int X, int Y), float> entry in _opponentBelief )
		{
			if ( entry.Value <= confidence )
				continue;
			bestKey = entry.Key;
			confidence = entry.Value;
			found = true;
		}

		if ( !found )
			return false;

		confidence *= MathX.Clamp( _beliefGlobalConfidence, 0f, 1f );
		if ( confidence <= 0.001f )
			return false;

		Vector3 center = GetSpatialMemoryCellCenter( bestKey, WorldPosition.z );
		Vector3? projected = Scene?.NavMesh?.GetClosestPoint( center, MathF.Max( 48f, SpatialMemoryCellSize * 0.72f ) );
		position = projected ?? center;
		return true;
	}

	private float GetOpponentBeliefAt( Vector3 position )
	{
		(int X, int Y) key = GetSpatialMemoryKey( position );
		float total = 0f;
		for ( int x = -1; x <= 1; x++ )
		{
			for ( int y = -1; y <= 1; y++ )
			{
				if ( !_opponentBelief.TryGetValue( (key.X + x, key.Y + y), out float probability ) )
					continue;
				float weight = x == 0 && y == 0 ? 1f : ( x == 0 || y == 0 ? 0.45f : 0.22f );
				total += probability * weight;
			}
		}
		return MathX.Clamp( total * _beliefGlobalConfidence, 0f, 1f );
	}

	private void AddBeliefSearchCandidates( int maximum )
	{
		if ( _opponentBelief.Count == 0 || maximum <= 0 )
			return;

		foreach ( KeyValuePair<(int X, int Y), float> entry in _opponentBelief.OrderByDescending( item => item.Value ).Take( maximum ) )
		{
			if ( entry.Value < MathF.Max( 0.02f, BeliefMinimumCellProbability ) )
				continue;
			Vector3 center = GetSpatialMemoryCellCenter( entry.Key, WorldPosition.z );
			TryAddSearchPoint( center );
		}
	}

	private bool TryGetBeliefInterceptTarget( out Vector3 target, out float confidence )
	{
		target = Vector3.Zero;
		confidence = 0f;
		if ( _opponentBelief.Count == 0 )
			return false;

		Vector3 lastDirection = _lastSeenDirection.WithZ( 0f );
		if ( !HasLastKnownPosition || lastDirection.IsNearlyZero( 0.01f ) )
			return TryGetBestOpponentBelief( out target, out confidence );
		lastDirection = lastDirection.Normal;

		float bestScore = float.MinValue;
		foreach ( KeyValuePair<(int X, int Y), float> entry in _opponentBelief )
		{
			Vector3 center = GetSpatialMemoryCellCenter( entry.Key, WorldPosition.z );
			Vector3 fromLast = ( center - LastKnownPosition ).WithZ( 0f );
			if ( fromLast.Length < 70f )
				continue;

			float directional = fromLast.IsNearlyZero( 0.01f )
				? 0f
				: MathX.Clamp( ( fromLast.Normal.Dot( lastDirection ) + 1f ) * 0.5f, 0f, 1f );
			float routeRisk = MathX.Clamp( GetSpatialMemoryRiskAt( center ) / 8f, 0f, 1f );
			float score = entry.Value * 1.4f + directional * 0.34f - routeRisk * 0.18f;
			if ( score <= bestScore )
				continue;
			bestScore = score;
			confidence = entry.Value;
			target = center;
		}

		if ( bestScore == float.MinValue || confidence < MathF.Max( 0.02f, BeliefInterceptMinimumConfidence ) )
			return false;

		Vector3? projected = Scene?.NavMesh?.GetClosestPoint( target, MathF.Max( 48f, SpatialMemoryCellSize * 0.72f ) );
		if ( projected is not null ) target = projected.Value;
		return true;
	}

	private void MarkOpponentBeliefAreaCleared( Vector3 position )
	{
		if ( _opponentBelief.Count == 0 )
			return;

		float radius = MathF.Max( 70f, BeliefClearRadius );
		List<(int X, int Y)> keys = _opponentBelief.Keys.ToList();
		foreach ( (int X, int Y) key in keys )
		{
			Vector3 center = GetSpatialMemoryCellCenter( key, position.z );
			float distance = ( center - position ).WithZ( 0f ).Length;
			if ( distance > radius )
				continue;
			float fraction = 1f - MathX.Clamp( distance / radius, 0f, 1f );
			float retain = MathX.Lerp( 0.62f, 0.08f, fraction );
			_opponentBelief[key] *= retain;
		}
		_beliefGlobalConfidence *= 0.86f;
		NormalizeAndTrimBelief();
	}

	private bool TryHandleBeliefDrivenSearch( OneMoreRoundWeapon weapon )
	{
		if ( _hasConfirmedSight )
			return false;

		return TryHandleIntelligentSearch( weapon );
	}

	private void UpdateBeliefDiagnostics( string state )
	{
		if ( TryGetBestOpponentBelief( out Vector3 position, out float confidence ) )
		{
			BeliefConfidence = confidence;
			float distance = ( position - WorldPosition ).WithZ( 0f ).Length;
			BeliefStatus = $"{state} · CELLS {_opponentBelief.Count} · PEAK {confidence * 100f:0}% · CONF {_beliefGlobalConfidence * 100f:0}% · {distance:0}u";
		}
		else
		{
			BeliefConfidence = 0f;
			BeliefStatus = $"{state} · EMPTY";
		}
	}

	private void ResetOpponentBelief( string status = "RESET" )
	{
		_opponentBelief.Clear();
		_beliefGlobalConfidence = 0f;
		_nextBeliefUpdateAt = 0f;
		_beliefLastAudioInjectedAt = -999f;
		_beliefLastThreatInjectedAt = -999f;
		_beliefLastVisualAt = -999f;
		_beliefLastHistoricalPriorAt = -999f;
		BeliefConfidence = 0f;
		BeliefStatus = BeliefSearchEnabled ? status : "OFF";
	}
}