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";
}
}