OMRBotController.CombatHumanization.cs
using Sandbox;
using System;
public enum OMRBotCombatSequenceKind
{
None,
AutomaticPressure,
AutomaticReset,
PrecisionReset,
ShotgunCommit,
PistolRhythm
}
/// <summary>
/// BOT 8A-8C combat execution and movement humanization.
///
/// This layer deliberately sits underneath BOT 7D's strategic choice. It does
/// not invent hidden information and it does not bypass OMR locomotion/weapons.
/// It converts doctrine into short combat rhythms: committed automatic bursts,
/// post-shot precision resets, close-range shotgun commits and less robotic path
/// steering. All physical movement still goes through PlayerMovementInput.
/// </summary>
public sealed partial class OMRBotController
{
[Property, Group( "BOT 8 / Combat" )]
public bool WeaponCombatSequencesEnabled { get; set; } = true;
[Property, Group( "BOT 8 / Movement" )]
public bool HumanizedMovementEnabled { get; set; } = true;
[Property, Group( "BOT 8 / Automatic Fire" ), Range( 0.02f, 0.30f )]
public float AutomaticBurstPauseMin { get; set; } = 0.065f;
[Property, Group( "BOT 8 / Automatic Fire" ), Range( 0.04f, 0.45f )]
public float AutomaticBurstPauseMax { get; set; } = 0.18f;
[Property, Group( "BOT 8 / Precision" ), Range( 0.10f, 1.25f )]
public float PrecisionPostShotResetScale { get; set; } = 0.78f;
[Property, Group( "BOT 8 / Shotgun" ), Range( 0.20f, 1.5f )]
public float ShotgunCommitDuration { get; set; } = 0.72f;
[Property, Group( "BOT 8 / Movement" ), Range( 0f, 0.8f )]
public float CombatInputReversalPenalty { get; set; } = 0.30f;
[Property, Group( "BOT 8 / Movement" ), Range( 36f, 140f )]
public float CorridorCenteringProbeDistance { get; set; } = 82f;
[Property, Group( "BOT 8 / Movement" ), Range( 0f, 0.8f )]
public float CorridorCenteringStrength { get; set; } = 0.34f;
[Property, Group( "BOT 8 / Movement" ), Range( 0.04f, 0.35f )]
public float CorridorProbeInterval { get; set; } = 0.12f;
[Property, Sync( SyncFlags.FromHost )]
public string CombatSequenceStatus { get; set; } = "IDLE";
[Property, Sync( SyncFlags.FromHost )]
public string HumanizationStatus { get; set; } = "READY";
private OMRBotCombatSequenceKind _combatSequence = OMRBotCombatSequenceKind.None;
private OneMoreRoundWeapon _combatSequenceWeapon;
private int _automaticBurstShotsRemaining;
private float _automaticBurstPauseUntil = -999f;
private float _precisionResetUntil = -999f;
private float _shotgunCommitUntil = -999f;
private float _pistolRhythmUntil = -999f;
private float _lastSequenceShotAt = -999f;
private float _nextCorridorProbeAt;
private float _corridorSteeringBias;
private void ResetCombatHumanization()
{
_combatSequence = OMRBotCombatSequenceKind.None;
_combatSequenceWeapon = null;
_automaticBurstShotsRemaining = 0;
_automaticBurstPauseUntil = -999f;
_precisionResetUntil = -999f;
_shotgunCommitUntil = -999f;
_pistolRhythmUntil = -999f;
_lastSequenceShotAt = -999f;
_nextCorridorProbeAt = 0f;
_corridorSteeringBias = 0f;
CombatSequenceStatus = "IDLE";
HumanizationStatus = "READY";
}
private void RefreshCombatSequenceContext( OneMoreRoundWeapon weapon )
{
if ( !WeaponCombatSequencesEnabled || weapon?.IsValid != true )
{
_combatSequence = OMRBotCombatSequenceKind.None;
_combatSequenceWeapon = weapon;
_automaticBurstShotsRemaining = 0;
CombatSequenceStatus = WeaponCombatSequencesEnabled ? "UNARMED" : "OFF";
return;
}
if ( _combatSequenceWeapon != weapon )
{
_combatSequenceWeapon = weapon;
_automaticBurstShotsRemaining = 0;
_automaticBurstPauseUntil = -999f;
_precisionResetUntil = -999f;
_shotgunCommitUntil = -999f;
_pistolRhythmUntil = -999f;
_combatSequence = OMRBotCombatSequenceKind.None;
}
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float distance = _hasConfirmedSight
? ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length
: float.PositiveInfinity;
if (
doctrine.Archetype == OMRWeaponArchetype.Shotgun &&
_hasConfirmedSight &&
distance > doctrine.LegacyHoldDistance * 0.92f &&
distance <= doctrine.LegacyMaximumFireDistance * 1.05f
)
{
_shotgunCommitUntil = MathF.Max(
_shotgunCommitUntil,
Time.Now + MathF.Max( 0.20f, ShotgunCommitDuration ) * 0.45f
);
}
if ( Time.Now < _precisionResetUntil )
{
_combatSequence = OMRBotCombatSequenceKind.PrecisionReset;
CombatSequenceStatus = $"PRECISION RESET · {MathF.Max( 0f, _precisionResetUntil - Time.Now ):0.00}s";
}
else if ( Time.Now < _automaticBurstPauseUntil )
{
_combatSequence = OMRBotCombatSequenceKind.AutomaticReset;
CombatSequenceStatus = $"BURST RESET · {MathF.Max( 0f, _automaticBurstPauseUntil - Time.Now ):0.00}s";
}
else if ( doctrine.IsAutomatic && _automaticBurstShotsRemaining > 0 )
{
_combatSequence = OMRBotCombatSequenceKind.AutomaticPressure;
CombatSequenceStatus = $"AUTO PRESSURE · {_automaticBurstShotsRemaining} SHOTS";
}
else if ( Time.Now < _shotgunCommitUntil )
{
_combatSequence = OMRBotCombatSequenceKind.ShotgunCommit;
CombatSequenceStatus = "SHOTGUN COMMIT";
}
else if ( Time.Now < _pistolRhythmUntil )
{
_combatSequence = OMRBotCombatSequenceKind.PistolRhythm;
CombatSequenceStatus = "PRECISION RHYTHM";
}
else
{
_combatSequence = OMRBotCombatSequenceKind.None;
CombatSequenceStatus = doctrine.DisplayName;
}
}
private bool TryAllowCombatSequenceShot(
OneMoreRoundWeapon weapon,
float distance,
out string holdReason
)
{
holdReason = string.Empty;
if ( !WeaponCombatSequencesEnabled || weapon?.IsValid != true )
return true;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
if ( !doctrine.IsAutomatic )
return true;
if ( Time.Now < _automaticBurstPauseUntil )
{
holdReason = "BURST RESET";
return false;
}
if ( _automaticBurstShotsRemaining <= 0 )
BeginAutomaticBurst( doctrine, distance );
return _automaticBurstShotsRemaining > 0;
}
private void BeginAutomaticBurst( OMRBotWeaponDoctrine doctrine, float distance )
{
int minShots;
int maxShots;
if ( doctrine.WeaponId == "mp5" )
{
minShots = 7;
maxShots = 13;
}
else if ( doctrine.WeaponId == "m4a1" )
{
minShots = distance > doctrine.LegacyHoldDistance * 1.25f ? 3 : 5;
maxShots = distance > doctrine.LegacyHoldDistance * 1.25f ? 6 : 9;
}
else
{
minShots = 4;
maxShots = 8;
}
if ( _hasConfirmedSight && Difficulty != OMRBotDifficulty.Easy &&
distance <= doctrine.LegacyHoldDistance * 1.25f && CombatAimError <= GetFireToleranceDegrees() * 1.25f )
{
minShots += 3;
maxShots += 5;
}
if ( Difficulty == OMRBotDifficulty.Easy )
{
minShots = Math.Max( 2, minShots - 2 );
maxShots = Math.Max( minShots, maxShots - 2 );
}
else if ( Difficulty == OMRBotDifficulty.Hard )
{
maxShots += 2;
}
float sustained = MathX.Clamp( doctrine.SustainedFireBias, 0f, 1f );
int weightedMax = Math.Max(
minShots,
(int)MathF.Round( MathX.Lerp( minShots, maxShots, sustained ) )
);
_automaticBurstShotsRemaining = Game.Random.Int( minShots, weightedMax );
_combatSequence = OMRBotCombatSequenceKind.AutomaticPressure;
CombatSequenceStatus = $"AUTO PRESSURE · {_automaticBurstShotsRemaining} SHOTS";
}
private float GetCombatSequenceTriggerInterval( OneMoreRoundWeapon weapon )
{
if ( weapon?.IsValid == true && weapon.IsAutomaticWeapon ) return 0f;
return GetBotTriggerInterval( weapon );
}
private void OnCombatSequenceShotFired( OneMoreRoundWeapon weapon, float distance )
{
if ( !WeaponCombatSequencesEnabled || weapon?.IsValid != true )
return;
_lastSequenceShotAt = Time.Now;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
if ( doctrine.IsAutomatic )
{
_automaticBurstShotsRemaining = Math.Max( 0, _automaticBurstShotsRemaining - 1 );
if ( _automaticBurstShotsRemaining <= 0 )
{
float minPause = MathF.Max( 0.02f, AutomaticBurstPauseMin );
float maxPause = MathF.Max( minPause, AutomaticBurstPauseMax );
float discipline = MathX.Clamp( doctrine.BurstDisciplineBias, 0f, 1f );
float pause = Game.Random.Float( minPause, maxPause );
pause *= MathX.Lerp( 0.65f, 1.25f, discipline );
if ( Difficulty == OMRBotDifficulty.Hard ) pause *= 0.82f;
if ( Difficulty == OMRBotDifficulty.Easy ) pause *= 1.18f;
_automaticBurstPauseUntil = Time.Now + pause;
_combatSequence = OMRBotCombatSequenceKind.AutomaticReset;
}
else
{
_combatSequence = OMRBotCombatSequenceKind.AutomaticPressure;
}
// Do not let a healthy automatic-fire engagement get interrupted by a
// trivially different utility score halfway through a committed burst.
if (
_utilitySelectedIntent == OMRBotIntentKind.Engage ||
_utilitySelectedIntent == OMRBotIntentKind.Press
)
{
_utilityCommitUntil = MathF.Max( _utilityCommitUntil, Time.Now + 0.26f );
}
return;
}
if ( doctrine.PostShotResetBias >= 0.72f )
{
float reset = MathF.Max(
0.18f,
doctrine.FireInterval * MathF.Max( 0.25f, PrecisionPostShotResetScale )
);
reset *= MathX.Lerp( 0.70f, 1.10f, doctrine.PostShotResetBias );
_precisionResetUntil = Time.Now + reset;
_combatSequence = OMRBotCombatSequenceKind.PrecisionReset;
// If the shot came from an authored cover peek, immediately begin the
// existing return-to-anchor phase. The next peek naturally alternates.
if ( _coverPeekActive && !_coverPeekReturning )
{
_coverPeekReturning = true;
_coverPeekUntil = -1f;
}
_nextUtilityDecisionAt = 0f;
return;
}
if ( doctrine.Archetype == OMRWeaponArchetype.Shotgun )
{
_shotgunCommitUntil = Time.Now + MathF.Max( 0.20f, ShotgunCommitDuration );
_combatSequence = OMRBotCombatSequenceKind.ShotgunCommit;
_nextUtilityDecisionAt = 0f;
return;
}
if ( doctrine.Archetype == OMRWeaponArchetype.Pistol )
{
_pistolRhythmUntil = Time.Now + MathF.Max( 0.12f, doctrine.FireInterval * 0.65f );
_combatSequence = OMRBotCombatSequenceKind.PistolRhythm;
}
}
private float GetCombatSequenceUtilityAdjustment( OMRBotIntentKind intent )
{
if ( !WeaponCombatSequencesEnabled )
return 0f;
OMRBotWeaponDoctrine doctrine = _brainWeaponDoctrine ?? OMRBotWeaponDoctrine.Empty;
float adjustment = 0f;
bool autoPressure =
_combatSequence == OMRBotCombatSequenceKind.AutomaticPressure &&
_automaticBurstShotsRemaining > 0;
bool precisionReset = Time.Now < _precisionResetUntil;
bool shotgunCommit = Time.Now < _shotgunCommitUntil;
bool pistolRhythm = Time.Now < _pistolRhythmUntil;
if ( autoPressure )
{
if ( intent == OMRBotIntentKind.Engage ) adjustment += 0.20f;
if ( intent == OMRBotIntentKind.Press ) adjustment += 0.10f + doctrine.AggressiveCommitBias * 0.05f;
if ( intent == OMRBotIntentKind.Reposition ) adjustment -= 0.09f;
if ( intent == OMRBotIntentKind.TakeCover ) adjustment -= 0.10f;
if ( intent == OMRBotIntentKind.FlankLeft || intent == OMRBotIntentKind.FlankRight ) adjustment -= 0.05f;
}
if ( precisionReset )
{
if ( intent == OMRBotIntentKind.TakeCover ) adjustment += 0.42f * doctrine.PostShotResetBias;
if ( intent == OMRBotIntentKind.Reposition ) adjustment += 0.28f * doctrine.PostShotResetBias;
if ( intent == OMRBotIntentKind.OpenDistance ) adjustment += 0.18f * doctrine.PostShotResetBias;
if ( intent == OMRBotIntentKind.Engage ) adjustment -= 0.32f * doctrine.PostShotResetBias;
if ( intent == OMRBotIntentKind.Press ) adjustment -= 0.38f * doctrine.PostShotResetBias;
}
if ( shotgunCommit )
{
if ( intent == OMRBotIntentKind.Press ) adjustment += 0.20f + doctrine.AggressiveCommitBias * 0.16f;
if ( intent == OMRBotIntentKind.Engage ) adjustment += 0.10f + doctrine.AggressiveCommitBias * 0.05f;
if ( intent == OMRBotIntentKind.FlankLeft || intent == OMRBotIntentKind.FlankRight ) adjustment += 0.07f;
if ( intent == OMRBotIntentKind.OpenDistance ) adjustment -= 0.32f;
if ( intent == OMRBotIntentKind.TakeCover ) adjustment -= 0.10f;
}
if ( pistolRhythm )
{
if ( intent == OMRBotIntentKind.Engage ) adjustment += 0.05f;
if ( intent == OMRBotIntentKind.Reposition ) adjustment += 0.035f;
}
return adjustment;
}
private void ApplyCombatSequenceMovementScore(
Vector3 localInput,
OMRBotWeaponDoctrine doctrine,
float currentDistance,
float holdDistance,
ref float score
)
{
if ( !WeaponCombatSequencesEnabled || doctrine is null )
return;
float forward = localInput.x;
float lateral = MathF.Abs( localInput.y );
float amount = localInput.WithZ( 0f ).Length;
if ( _combatSequence == OMRBotCombatSequenceKind.AutomaticPressure )
{
score += lateral * doctrine.SustainedFireBias * 0.12f;
if ( amount <= 0.01f ) score += doctrine.CombatStabilityBias * 0.06f;
}
if ( Time.Now < _precisionResetUntil )
{
if ( forward < -0.05f ) score += 0.32f * doctrine.PostShotResetBias;
if ( lateral > 0.30f ) score += 0.18f * doctrine.PostShotResetBias;
if ( forward > 0.05f ) score -= 0.34f * doctrine.PostShotResetBias;
}
if ( Time.Now < _shotgunCommitUntil )
{
if ( currentDistance > holdDistance * 0.82f && forward > 0.05f ) score += 0.38f;
if ( currentDistance > holdDistance && forward < -0.05f ) score -= 0.42f;
score += lateral * 0.06f;
}
}
private bool TryHoldPrecisionResetInCover( OneMoreRoundWeapon weapon )
{
if (
!WeaponCombatSequencesEnabled ||
weapon?.IsValid != true ||
Time.Now >= _precisionResetUntil ||
!_hasCoverTarget ||
_coverPeekActive
)
return false;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
if ( doctrine.PostShotResetBias < 0.72f )
return false;
EnsureBotCrouchState( _coverPosture == OMRCoverPosture.Crouch );
StopMovementOnly();
ReleaseBotWeaponIntent( weapon );
CombatWantsAim = false;
CoverStatus = weapon.IsCyclePendingOrActive
? "RESET / CYCLING IN COVER"
: "RESET / HIDDEN";
TacticalStatus = "POST-SHOT RESET";
CombatStatus = weapon.IsCyclePendingOrActive ? "CYCLING IN COVER" : "RESETTING";
return true;
}
private float GetHumanizedCombatDecisionIntervalScale( OMRBotWeaponDoctrine doctrine )
{
if ( !HumanizedMovementEnabled || doctrine is null )
return 1f;
float scale = MathX.Lerp( 0.94f, 1.16f, doctrine.CombatStabilityBias );
if ( _combatSequence == OMRBotCombatSequenceKind.AutomaticPressure )
scale *= 0.92f;
if ( Time.Now < _precisionResetUntil )
scale *= 1.08f;
return scale;
}
private bool ApplyHumanizedCombatCandidateScore(
Vector3 localInput,
float wallClearance,
ref float score
)
{
if ( !HumanizedMovementEnabled )
return true;
Vector3 previous = _combatCommittedInput.WithZ( 0f );
Vector3 proposed = localInput.WithZ( 0f );
if ( !previous.IsNearlyZero( 0.01f ) && !proposed.IsNearlyZero( 0.01f ) )
{
float directionalDot = previous.Normal.Dot( proposed.Normal );
if ( directionalDot < -0.20f )
score -= MathF.Max( 0f, CombatInputReversalPenalty ) * MathF.Abs( directionalDot );
else if ( directionalDot > 0.72f )
score += 0.055f;
}
// The existing four-direction clearance probe is already paid for by BOT
// 7B. Use it more decisively instead of adding another per-candidate trace
// batch. This rejects the classic AI behavior of deliberately walking with
// its shoulder almost scraping arena walls.
if ( !proposed.IsNearlyZero( 0.01f ) )
{
float clearance = wallClearance;
float minimum = Difficulty switch
{
OMRBotDifficulty.Easy => 0.09f,
OMRBotDifficulty.Hard => 0.15f,
_ => 0.12f
};
if ( clearance < minimum )
return false;
score += clearance * 0.10f;
}
return true;
}
private Vector3 ApplyHumanizedPathSteering( Vector3 desired )
{
if (
!HumanizedMovementEnabled ||
Scene is null ||
desired.WithZ( 0f ).IsNearlyZero( 0.01f )
)
{
HumanizationStatus = HumanizedMovementEnabled ? "READY" : "OFF";
return desired;
}
Vector3 forward = desired.WithZ( 0f ).Normal;
Vector3 lateral = new Vector3( -forward.y, forward.x, 0f ).Normal;
if ( Time.Now >= _nextCorridorProbeAt )
{
_nextCorridorProbeAt = Time.Now + MathF.Max( 0.04f, CorridorProbeInterval );
float probe = MathF.Max( 36f, CorridorCenteringProbeDistance );
float leftDistance = ProbeHumanizedWallDistance( lateral, probe );
float rightDistance = ProbeHumanizedWallDistance( -lateral, probe );
float leftPressure = 1f - MathX.Clamp( leftDistance / probe, 0f, 1f );
float rightPressure = 1f - MathX.Clamp( rightDistance / probe, 0f, 1f );
_corridorSteeringBias = MathX.Clamp( rightPressure - leftPressure, -1f, 1f );
}
float strength = MathF.Max( 0f, CorridorCenteringStrength );
if ( MathF.Abs( _corridorSteeringBias ) <= 0.025f || strength <= 0f )
{
HumanizationStatus = "CENTERED";
return forward;
}
Vector3 adjusted = ( forward + lateral * _corridorSteeringBias * strength ).WithZ( 0f );
if ( adjusted.IsNearlyZero( 0.01f ) )
return forward;
HumanizationStatus = _corridorSteeringBias > 0f ? "CENTER / LEFT" : "CENTER / RIGHT";
return adjusted.Normal;
}
private float ProbeHumanizedWallDistance( Vector3 direction, float maximum )
{
Vector3 start = WorldPosition + Vector3.Up * 34f;
SceneTrace trace =
Scene.Trace.Ray( start, start + direction.WithZ( 0f ).Normal * maximum )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
trace = trace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
SceneTraceResult result = trace.Run();
if ( !result.Hit )
return maximum;
return MathF.Min( maximum, ( result.EndPosition - start ).Length );
}
}