OMRBotController.RuntimePolish.cs
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// Post-BOT7 runtime polish driven by live testing.
///
/// This layer deliberately fixes player-facing behavior rather than adding a new
/// strategic architecture: active opening movement, short fair target retention
/// for automatic weapons, coarser blind-fire response, and wall-clearance probes.
/// All opponent-position inputs remain either observed or public arena knowledge.
/// </summary>
public sealed partial class OMRBotController
{
[Property, Group( "Runtime Polish / Initiative" )]
public bool ProactiveNoContactMovement { get; set; } = true;
[Property, Group( "Runtime Polish / Initiative" ), Range( 0.8f, 6f )]
public float NoContactReplanInterval { get; set; } = 2.1f;
[Property, Group( "Runtime Polish / Initiative" ), Range( 48f, 180f )]
public float NoContactArrivalDistance { get; set; } = 72f;
[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
public float AutomaticSightRetentionNormal { get; set; } = 0.28f;
[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
public float AutomaticSightRetentionHard { get; set; } = 0.38f;
[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 0f, 0.5f )]
public float AutomaticSightRetentionEasy { get; set; } = 0.16f;
[Property, Group( "Runtime Polish / Automatic Fire" ), Range( 1f, 2f )]
public float AutomaticFireToleranceScale { get; set; } = 1.28f;
[Property, Group( "Runtime Polish / Walls" ), Range( 32f, 160f )]
public float PreferredWallClearance { get; set; } = 70f;
[Property, Group( "Runtime Polish / Walls" ), Range( 16f, 80f )]
public float MinimumWallClearance { get; set; } = 30f;
[Property, Sync( SyncFlags.FromHost )]
public string InitiativeStatus { get; set; } = "READY";
private float _botLifeStartedAt = -999f;
private bool _noContactTargetValid;
private Vector3 _noContactTarget = Vector3.Zero;
private float _nextNoContactPlanAt;
private int _noContactPlanSide = 1;
private int _blindThreatHits;
private float _lastBlindThreatHitAt = -999f;
private void BeginNewBotLife()
{
_botLifeStartedAt = Time.Now;
_noContactTargetValid = false;
_noContactTarget = Vector3.Zero;
_nextNoContactPlanAt = 0f;
_noContactPlanSide = Game.Random.Float( 0f, 1f ) < 0.5f ? -1 : 1;
_blindThreatHits = 0;
_lastBlindThreatHitAt = -999f;
InitiativeStatus = "OPENING";
// Round resets keep the bot GameObject alive while inventory/weapons and
// positions are rebuilt. Clear transient intent so stale cover/search/path
// state from the previous life cannot leak into the new round.
_pathPoints.Clear();
_pathPointIndex = 0;
_nextRepathAt = 0f;
ResetFluidMovementState();
ResetCombatHumanization();
ResetCombatContinuity();
ResetPerformanceCaches();
SafeReleaseLastCombatWeapon();
_nextPerceptionScanAt = 0f;
_perceptionTarget = null;
_navigationTarget = null;
_rawVisibleSince = -1f;
_hasConfirmedSight = false;
_lastConfirmedSeenAt = -999f;
_observedTargetPosition = Vector3.Zero;
_observedTargetAimPoint = Vector3.Zero;
HasLastKnownPosition = false;
LastKnownPosition = Vector3.Zero;
ResetTacticalState();
ResetUtilityBrainState();
ResetOpponentBelief( "NEW LIFE" );
}
private void RegisterBlindThreatHit()
{
if ( _hasConfirmedSight )
{
_blindThreatHits = 0;
return;
}
if ( Time.Now - _lastBlindThreatHitAt > 1.15f )
_blindThreatHits = 0;
_lastBlindThreatHitAt = Time.Now;
_blindThreatHits = Math.Min( 4, _blindThreatHits + 1 );
}
private int GetRecentBlindThreatHits()
{
if ( Time.Now - _lastBlindThreatHitAt > 1.35f )
return 0;
return _blindThreatHits;
}
private float GetAutomaticSightRetentionDuration()
{
return Difficulty switch
{
OMRBotDifficulty.Easy => MathF.Max( 0f, AutomaticSightRetentionEasy ),
OMRBotDifficulty.Hard => MathF.Max( 0f, AutomaticSightRetentionHard ),
_ => MathF.Max( 0f, AutomaticSightRetentionNormal )
};
}
private bool TryHandleAutomaticTargetRetention( OneMoreRoundWeapon weapon )
{
if (
PerceptionStatus == "REACTING" ||
weapon?.IsValid != true ||
!weapon.IsAutomaticWeapon ||
!HasLastKnownPosition ||
_lastConfirmedSeenAt < 0f ||
_perceptionTarget?.IsAlive != true ||
weapon.IsReloading ||
weapon.Clip1 <= 0
)
return false;
float age = Time.Now - _lastConfirmedSeenAt;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float retention = GetAutomaticSightRetentionDuration() *
MathX.Lerp( 0.88f, 1.18f, MathX.Clamp( doctrine.ReacquirePersistence, 0f, 1f ) );
if ( age < 0f || age > retention )
return false;
// The target is no longer visible. Keep aiming/firing only at the stale
// observed sample for a tiny burst-continuity window. No live transform is
// consulted here, so a player who changes direction behind cover defeats it.
float distance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
UpdateCombatAim( _observedTargetAimPoint );
bool wantsAds = ShouldUseAds( weapon, distance );
CombatWantsAim = wantsAds;
weapon.SetBotAimIntent( wantsAds );
if ( UsesAdvancedMovement() )
{
UpdateFluidCombatMovement(
weapon,
distance,
GetCombatHoldDistance( weapon )
);
}
else
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
}
NavigationStatus = "CONTACT RETENTION";
NavigationTargetName = _perceptionTarget.DisplayName;
NavigationTargetDistance = MathF.Round( distance );
CurrentIntent = "COMBAT / AUTOMATIC RETENTION";
CombatStatus = $"RETENTION {MathF.Max( 0f, retention - age ):0.00}s";
UpdateVisibleWeaponAttack( weapon, distance, wantsAds );
return true;
}
private bool ShouldEvadeBlindThreatImmediately()
{
return GetRecentBlindThreatHits() >= 2 || GetOwnHealthFraction() <= 0.48f;
}
private void UpdateBlindThreatLocateReaction( OneMoreRoundWeapon weapon )
{
ReleaseBotWeaponIntent( weapon );
OrientTowardThreat();
// The first unseen hit gives only a rough directional clue. Do not instantly
// solve cover from the source position: make a small non-directional human-like
// adjustment while locating the attacker. Repeated hits may escalate to the
// normal evade/cover response.
float side = _strafeDirection >= 0f ? 1f : -1f;
Vector3 moveInput = new Vector3( 0f, side * 0.26f, 0f );
_movementInput?.SetBotInput(
moveInput,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
SetMovementDiagnostics( "UNDER FIRE / LOCATE" );
TacticalStatus = "UNDER FIRE / ROUGH BEARING";
CoverStatus = "NOT COMMITTED";
CombatStatus = "LOCATING ATTACKER";
CurrentIntent = "TACTICAL / LOCATE FIRE";
}
private bool TryHandleProactiveNoContact( OneMoreRoundWeapon weapon )
{
if ( !ProactiveNoContactMovement || !UsesTacticalBehavior() || Scene?.NavMesh is null )
return false;
if ( _roundManager?.Mode == SessionMode.Match && _roundManager.Phase != RoundPhase.Fighting )
return false;
if ( Scene.NavMesh.IsGenerating )
return false;
if (
!_noContactTargetValid ||
Time.Now >= _nextNoContactPlanAt ||
( _noContactTarget - WorldPosition ).WithZ( 0f ).Length <= MathF.Max( 36f, NoContactArrivalDistance )
)
{
if ( !TryChooseNoContactTarget( weapon, out _noContactTarget ) )
return false;
_noContactTargetValid = true;
_nextNoContactPlanAt = Time.Now + MathF.Max( 0.8f, NoContactReplanInterval );
_noContactPlanSide *= -1;
}
float distance = ( _noContactTarget - WorldPosition ).WithZ( 0f ).Length;
NavigateToWorldPosition(
_noContactTarget,
"TACTICAL PATROL",
MathF.Max( 36f, NoContactArrivalDistance ),
"NO CONTACT / PROACTIVE SEARCH",
allowSprint: distance > 150f,
faceMovement: true,
advancedMovement: true,
movementWeapon: weapon,
visibleCombatTarget: false
);
InitiativeStatus = $"PATROL · {distance:0}u";
TacticalStatus = "NO CONTACT / TAKE SPACE";
SearchStatus = "PROACTIVE PATROL";
CombatStatus = weapon?.IsReloading == true ? "RELOADING / PATROL" : "SEEKING CONTACT";
return true;
}
private bool TryChooseNoContactTarget( OneMoreRoundWeapon weapon, out Vector3 target )
{
target = Vector3.Zero;
if ( _roundManager?.IsValid != true || Scene?.NavMesh is null )
return false;
GameObject ownSpawn = _playerState?.TeamId == 0 ? _roundManager.SpawnA : _roundManager.SpawnB;
GameObject enemySpawn = _playerState?.TeamId == 0 ? _roundManager.SpawnB : _roundManager.SpawnA;
if ( enemySpawn is null )
return false;
Vector3 origin = ownSpawn is not null ? ownSpawn.WorldPosition : WorldPosition;
Vector3 lane = ( enemySpawn.WorldPosition - origin ).WithZ( 0f );
if ( lane.IsNearlyZero( 0.01f ) )
return false;
Vector3 forward = lane.Normal;
Vector3 lateral = new Vector3( -forward.y, forward.x, 0f ).Normal;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float pressure = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.ClosePressureBias;
float precision = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.PrecisionBias;
float fraction = MathX.Clamp( 0.50f + pressure * 0.12f - precision * 0.07f, 0.43f, 0.66f );
float sideOffset = MathX.Lerp( 155f, 235f, pressure ) * _noContactPlanSide;
List<Vector3> candidates = new()
{
origin + lane * fraction + lateral * sideOffset,
origin + lane * fraction - lateral * sideOffset * 0.65f,
origin + lane * MathX.Clamp( fraction + 0.10f, 0.48f, 0.74f ),
origin + lane * MathX.Clamp( fraction - 0.08f, 0.36f, 0.62f ) + lateral * sideOffset * 0.45f
};
float bestScore = float.MinValue;
foreach ( Vector3 raw in candidates )
{
Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 110f );
if ( projected is null )
continue;
if (
!TryEvaluateTacticalRoute(
projected.Value,
Vector3.Zero,
hasThreatReference: false,
out OMRBotRouteEvaluation route
)
)
continue;
Vector3 point = route.Destination;
float clearance = MeasureWallClearance01( point );
if ( clearance <= 0.04f )
continue;
float risk = MathX.Clamp( route.MemoryRisk / 8f, 0f, 1f );
float travel = route.PathLength;
float forwardProgress = Vector3.Dot( ( point - origin ).WithZ( 0f ), forward );
float score = clearance * 1.2f - risk * 0.8f - travel * 0.0008f + forwardProgress * 0.0011f;
if ( score <= bestScore )
continue;
bestScore = score;
target = point;
}
return bestScore != float.MinValue;
}
private float MeasureWallClearance01( Vector3 position )
{
if ( Scene is null )
return 1f;
float probe = MathF.Max( 40f, PreferredWallClearance );
Vector3 start = position + Vector3.Up * 34f;
float minimum = probe;
Vector3[] directions =
{
Vector3.Forward,
Vector3.Backward,
Vector3.Left,
Vector3.Right
};
foreach ( Vector3 direction in directions )
{
SceneTrace trace = Scene.Trace.Ray( start, start + direction * probe )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
trace = trace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
SceneTraceResult result = trace.Run();
if ( !result.Hit )
continue;
float distance = ( result.EndPosition - start ).Length;
minimum = MathF.Min( minimum, distance );
}
float minimumAllowed = MathF.Max( 8f, MinimumWallClearance );
if ( minimum <= minimumAllowed )
return 0f;
return MathX.Clamp( ( minimum - minimumAllowed ) / MathF.Max( 1f, probe - minimumAllowed ), 0f, 1f );
}
}