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