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

/// <summary>
/// BOT 7G - lightweight sequence learning from confirmed player observations.
///
/// This deliberately avoids hidden transforms. Route transitions are learned only
/// while the opponent is visibly confirmed; push/repeek tendencies are derived
/// from those same observations. The output is a prior for search/utility, never
/// certainty about the live player position.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "BOT 7G / Player Prediction" )]
	public bool PlayerPredictionEnabled { get; set; } = true;

	[Property, Group( "BOT 7G / Player Prediction" ), Range( 0.08f, 0.50f )]
	public float PlayerPredictionSampleInterval { get; set; } = 0.16f;

	[Property, Group( "BOT 7G / Player Prediction" ), Range( 2, 12 )]
	public int PlayerPredictionMinimumSamples { get; set; } = 3;

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

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

	[Property, Sync( SyncFlags.FromHost )]
	public float PlayerRepeekTendency { get; set; } = 0.25f;

	[Property, Sync( SyncFlags.FromHost )]
	public float PlayerPushTendency { get; set; } = 0.50f;

	private readonly Dictionary<(int X, int Y), Dictionary<(int X, int Y), int>> _playerRouteBigram = new();
	private readonly Dictionary<(int AX, int AY, int BX, int BY), Dictionary<(int X, int Y), int>> _playerRouteTrigram = new();
	private (int X, int Y) _playerRoutePrevious;
	private (int X, int Y) _playerRouteCurrent;
	private bool _playerRouteHasPrevious;
	private bool _playerRouteHasCurrent;
	private int _playerRouteTransitionSamples;
	private float _nextPlayerPredictionSampleAt;
	private float _nextPlayerPredictionDiagnosticAt;
	private float _lastPredictionBeliefInjectAt = -999f;
	private bool _predictionHadVisual;
	private float _predictionLostVisualAt = -999f;
	private Vector3 _predictionLostVisualPosition;
	private float _predictionLastVisibleDistance = -1f;
	private float _lastPlayerPredictionVisualAt = -999f;

	private void UpdatePlayerPrediction()
	{
		if ( !UsesTacticalBehavior() || !PlayerPredictionEnabled )
		{
			PlayerPredictionStatus = "OFF";
			PlayerRoutePredictionConfidence = 0f;
			_predictionHadVisual = _hasConfirmedSight;
			return;
		}

		bool visual = _hasConfirmedSight && _perceptionTarget?.IsAlive == true;
		if ( _predictionHadVisual && !visual )
		{
			_predictionLostVisualAt = Time.Now;
			_predictionLostVisualPosition = LastKnownPosition;
		}
		else if ( !_predictionHadVisual && visual && _predictionLostVisualAt > 0f )
		{
			float elapsed = Time.Now - _predictionLostVisualAt;
			float distance = ( _observedTargetPosition - _predictionLostVisualPosition ).WithZ( 0f ).Length;
			if ( elapsed <= 3.2f )
			{
				float sample = elapsed <= 1.65f && distance <= 190f ? 1f : 0.12f;
				PlayerRepeekTendency = MathX.Lerp( PlayerRepeekTendency, sample, 0.12f );
			}
		}
		_predictionHadVisual = visual;

		if ( !visual || Time.Now < _nextPlayerPredictionSampleAt )
		{
			UpdatePlayerPredictionDiagnostics();
			return;
		}

		_nextPlayerPredictionSampleAt = Time.Now + MathF.Max( 0.08f, PlayerPredictionSampleInterval );
		_lastPlayerPredictionVisualAt = Time.Now;
		(int X, int Y) cell = GetSpatialMemoryKey( _observedTargetPosition );

		if ( _playerRouteHasCurrent && cell != _playerRouteCurrent )
		{
			AddTransition( _playerRouteBigram, _playerRouteCurrent, cell );
			if ( _playerRouteHasPrevious )
			{
				var context = ( _playerRoutePrevious.X, _playerRoutePrevious.Y, _playerRouteCurrent.X, _playerRouteCurrent.Y );
				AddTransition( _playerRouteTrigram, context, cell );
			}

			_playerRouteTransitionSamples++;
			_playerRoutePrevious = _playerRouteCurrent;
			_playerRouteHasPrevious = true;
			_playerRouteCurrent = cell;
		}
		else if ( !_playerRouteHasCurrent )
		{
			_playerRouteCurrent = cell;
			_playerRouteHasCurrent = true;
		}

		float currentDistance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
		if ( _predictionLastVisibleDistance >= 0f )
		{
			float delta = currentDistance - _predictionLastVisibleDistance;
			float sample = delta < -14f ? 1f : delta > 14f ? 0f : 0.5f;
			PlayerPushTendency = MathX.Lerp( PlayerPushTendency, sample, 0.075f );
		}
		_predictionLastVisibleDistance = currentDistance;

		TrimPlayerPredictionTables();
		UpdatePlayerPredictionDiagnostics();
	}

	private static void AddTransition<TContext>(
		Dictionary<TContext, Dictionary<(int X, int Y), int>> table,
		TContext context,
		(int X, int Y) next
	) where TContext : notnull
	{
		if ( !table.TryGetValue( context, out Dictionary<(int X, int Y), int> outcomes ) )
		{
			outcomes = new();
			table[context] = outcomes;
		}

		outcomes[next] = outcomes.TryGetValue( next, out int count ) ? count + 1 : 1;
		int total = outcomes.Values.Sum();
		if ( total <= 28 )
			return;

		foreach ( var key in outcomes.Keys.ToArray() )
			outcomes[key] = Math.Max( 1, outcomes[key] / 2 );
	}

	private void TrimPlayerPredictionTables()
	{
		const int maxContexts = 72;
		if ( _playerRouteBigram.Count > maxContexts )
		{
			foreach ( var key in _playerRouteBigram.Keys.Take( _playerRouteBigram.Count - maxContexts ).ToArray() )
				_playerRouteBigram.Remove( key );
		}
		if ( _playerRouteTrigram.Count > maxContexts )
		{
			foreach ( var key in _playerRouteTrigram.Keys.Take( _playerRouteTrigram.Count - maxContexts ).ToArray() )
				_playerRouteTrigram.Remove( key );
		}
	}

	private bool TryGetLearnedRoutePrediction( out Vector3 position, out float confidence )
	{
		position = Vector3.Zero;
		confidence = 0f;
		if ( !PlayerPredictionEnabled || !_playerRouteHasCurrent || Scene?.NavMesh is null )
			return false;

		Dictionary<(int X, int Y), int> outcomes = null;
		if ( _playerRouteHasPrevious )
		{
			var tri = ( _playerRoutePrevious.X, _playerRoutePrevious.Y, _playerRouteCurrent.X, _playerRouteCurrent.Y );
			_playerRouteTrigram.TryGetValue( tri, out outcomes );
		}
		if ( outcomes is null || outcomes.Count == 0 )
			_playerRouteBigram.TryGetValue( _playerRouteCurrent, out outcomes );
		if ( outcomes is null || outcomes.Count == 0 )
			return false;

		int total = outcomes.Values.Sum();
		var best = outcomes.OrderByDescending( x => x.Value ).First();
		int minimum = Math.Max( 2, PlayerPredictionMinimumSamples );
		if ( total < minimum )
			return false;

		float dominance = best.Value / (float)Math.Max( total, 1 );
		float evidence = MathX.Clamp( total / 8f, 0f, 1f );
		float age = MathF.Max( 0f, Time.Now - _lastPlayerPredictionVisualAt );
		float recency = age >= 8f ? 0f : MathF.Pow( 0.5f, age / 3.5f );
		confidence = MathX.Clamp( dominance * evidence * recency, 0f, 1f );
		if ( confidence < 0.12f )
			return false;

		Vector3 raw = GetSpatialMemoryCellCenter( best.Key, WorldPosition.z );
		Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, MathF.Max( 52f, SpatialMemoryCellSize * 0.75f ) );
		if ( projected is null )
			return false;

		position = projected.Value;
		return true;
	}

	private void InjectBeliefFromPlayerPrediction()
	{
		if ( _hasConfirmedSight || Time.Now - _lastPredictionBeliefInjectAt < 0.65f )
			return;
		if ( !TryGetLearnedRoutePrediction( out Vector3 prediction, out float confidence ) )
			return;

		_lastPredictionBeliefInjectAt = Time.Now;
		PlayerRoutePredictionConfidence = confidence;
		float mass = 0.07f + confidence * 0.24f;
		AddBeliefMass( GetSpatialMemoryKey( prediction ), mass );
		_beliefGlobalConfidence = MathF.Max( _beliefGlobalConfidence, 0.10f + confidence * 0.30f );
	}

	private void UpdatePlayerPredictionDiagnostics()
	{
		if ( Time.Now < _nextPlayerPredictionDiagnosticAt )
			return;
		_nextPlayerPredictionDiagnosticAt = Time.Now + 0.35f;

		if ( TryGetLearnedRoutePrediction( out Vector3 prediction, out float confidence ) )
		{
			PlayerRoutePredictionConfidence = confidence;
			float distance = ( prediction - WorldPosition ).WithZ( 0f ).Length;
			PlayerPredictionStatus = $"ROUTE {confidence * 100f:0}% · {distance:0}u · REPEEK {PlayerRepeekTendency * 100f:0}% · PUSH {PlayerPushTendency * 100f:0}%";
		}
		else
		{
			PlayerRoutePredictionConfidence = 0f;
			PlayerPredictionStatus = $"LEARNING {_playerRouteTransitionSamples} · REPEEK {PlayerRepeekTendency * 100f:0}% · PUSH {PlayerPushTendency * 100f:0}%";
		}
	}

	private void ResetPlayerPrediction( bool clearStyle )
	{
		_playerRouteBigram.Clear();
		_playerRouteTrigram.Clear();
		_playerRouteHasPrevious = false;
		_playerRouteHasCurrent = false;
		_playerRouteTransitionSamples = 0;
		_nextPlayerPredictionSampleAt = 0f;
		_nextPlayerPredictionDiagnosticAt = 0f;
		_lastPredictionBeliefInjectAt = -999f;
		_predictionHadVisual = false;
		_predictionLostVisualAt = -999f;
		_predictionLostVisualPosition = Vector3.Zero;
		_predictionLastVisibleDistance = -1f;
		_lastPlayerPredictionVisualAt = -999f;
		PlayerRoutePredictionConfidence = 0f;
		if ( clearStyle )
		{
			PlayerRepeekTendency = 0.25f;
			PlayerPushTendency = 0.50f;
		}
		PlayerPredictionStatus = PlayerPredictionEnabled ? "LEARNING" : "OFF";
	}
}