OMRBotController.cs
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
public enum OMRBotDifficulty
{
Easy,
Normal,
Hard
}
public enum OMRBotBehaviorMode
{
/// <summary>
/// Foundation validation mode. The bot participates as a real OMR player but
/// deliberately supplies no movement/combat decisions.
/// </summary>
Stationary,
/// <summary>
/// BOT 1 navigation test. The bot is allowed to know the human player's
/// position directly so navigation can be validated in isolation.
/// </summary>
NavigateToOpponent,
/// <summary>
/// BOT 2 perception test. The bot only gains a usable target after a field-of-
/// view + line-of-sight check and a human-like reaction delay. When sight is
/// broken it navigates to the last observed position, searches briefly, then
/// forgets the stale information.
/// </summary>
PerceptionSearch,
/// <summary>
/// BOT 3 combat test. Uses the validated BOT 2 perception data to aim, ADS,
/// fire and reload a real OneMoreRoundWeapon. It never fires at memory-only
/// positions and never reads hidden opponent coordinates for combat.
/// </summary>
Combat,
/// <summary>
/// BOT 4 movement-combat test. Keeps BOT 3's fair perception and real weapon
/// handling, then layers player-equivalent sprint, strafe, slide, crouch and
/// jump decisions through PlayerMovementInput. No transform movement cheats.
/// </summary>
CombatMovement,
/// <summary>
/// BOT 5A+5B tactical combat stack. Adds action arbitration, combat-usable
/// cover, peeking, suppressive fire, flanking, hearing, intelligent mobile
/// search, stronger under-fire reactions and traversal anti-loop safeguards
/// on top of the validated BOT 4 movement/combat stack.
/// </summary>
TacticalCombat
}
public enum OMRCoverPosture
{
None,
Full,
Crouch,
Firing
}
public readonly struct OMRCoverChoice
{
public Vector3 Anchor { get; }
public OMRCoverPosture Posture { get; }
public Vector3 LeftPeek { get; }
public Vector3 RightPeek { get; }
public bool HasLeftPeek { get; }
public bool HasRightPeek { get; }
public float Score { get; }
public OMRCoverChoice(
Vector3 anchor,
OMRCoverPosture posture,
Vector3 leftPeek,
Vector3 rightPeek,
bool hasLeftPeek,
bool hasRightPeek,
float score
)
{
Anchor = anchor;
Posture = posture;
LeftPeek = leftPeek;
RightPeek = rightPeek;
HasLeftPeek = hasLeftPeek;
HasRightPeek = hasRightPeek;
Score = score;
}
}
/// <summary>
/// Host-side brain for an OMR player bot.
///
/// The bot is a normal player prefab. AI writes synthetic commands into
/// PlayerMovementInput and therefore travels through the exact same
/// PlayerLocomotion / PlayerController pipeline as a human.
///
/// BOT 1 uses Scene.NavMesh.CalculatePath for route finding and consumes the
/// returned straight-path points directly. BOT 2 layers fair perception and
/// memory on top. BOT 3 consumes only that perceived information to drive the
/// real OMR weapon systems for aiming, ADS, firing and reloading. BOT 4 keeps
/// those systems intact and adds contextual movement through the exact same
/// synthetic input contract used by human locomotion. BOT 5A.2 adds a tactical
/// layer that treats incoming damage as directional evidence, scores nearby
/// combat cover, peeks, suppresses last-seen positions, flanks, hears noisy
/// gameplay stimuli and actively searches likely sectors without ever revealing
/// a hidden opponent's live transform.
/// </summary>
public sealed partial class OMRBotController : Component
{
[Property, Sync( SyncFlags.FromHost )]
public int BotSlot { get; set; } = 0;
[Property, Sync( SyncFlags.FromHost )]
public OMRBotDifficulty Difficulty { get; set; } =
OMRBotDifficulty.Normal;
[Property, Sync( SyncFlags.FromHost )]
public OMRBotBehaviorMode BehaviorMode { get; set; } =
OMRBotBehaviorMode.Stationary;
[Property, Sync( SyncFlags.FromHost )]
public string CurrentIntent { get; set; } =
"STATIONARY / FOUNDATION TEST";
[Property, Sync( SyncFlags.FromHost )]
public string NavigationStatus { get; set; } = "OFF";
[Property, Sync( SyncFlags.FromHost )]
public string NavigationTargetName { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public float NavigationTargetDistance { get; set; } = 0f;
// =========================================================
// BOT 2 PERCEPTION DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string PerceptionStatus { get; set; } = "OFF";
[Property, Sync( SyncFlags.FromHost )]
public string PerceptionTargetName { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public float PerceptionTargetDistance { get; set; } = 0f;
[Property, Sync( SyncFlags.FromHost )]
public float PerceptionLastSeenSeconds { get; set; } = -1f;
[Property, Sync( SyncFlags.FromHost )]
public float PerceptionReactionRemaining { get; set; } = 0f;
[Property, Sync( SyncFlags.FromHost )]
public bool HasLastKnownPosition { get; set; } = false;
[Property, Sync( SyncFlags.FromHost )]
public Vector3 LastKnownPosition { get; set; } = Vector3.Zero;
// =========================================================
// BOT 3 COMBAT DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string CombatStatus { get; set; } = "OFF";
[Property, Sync( SyncFlags.FromHost )]
public float CombatAimError { get; set; } = 0f;
[Property, Sync( SyncFlags.FromHost )]
public bool CombatWantsAim { get; set; } = false;
[Property, Sync( SyncFlags.FromHost )]
public int CombatClip { get; set; } = -1;
[Property, Sync( SyncFlags.FromHost )]
public int CombatReserve { get; set; } = -1;
// =========================================================
// BOT 4 MOVEMENT DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string MovementStatus { get; set; } = "OFF";
[Property, Sync( SyncFlags.FromHost )]
public float MovementSpeed { get; set; } = 0f;
// =========================================================
// BOT 5A.2 TACTICAL DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string TacticalStatus { get; set; } = "OFF";
[Property, Sync( SyncFlags.FromHost )]
public string ThreatStatus { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public string ThreatBearing { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public float ThreatAgeSeconds { get; set; } = -1f;
[Property, Sync( SyncFlags.FromHost )]
public string CoverStatus { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public string TacticalContextStatus { get; set; } = "NEUTRAL";
[Property, Sync( SyncFlags.FromHost )]
public float TacticalAggression { get; set; } = 0.50f;
// =========================================================
// BOT 5A + 5B ADVANCED TACTICAL DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string HearingStatus { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public string HearingBearing { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public float HearingAgeSeconds { get; set; } = -1f;
[Property, Sync( SyncFlags.FromHost )]
public string SearchStatus { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public string FlankStatus { get; set; } = "NONE";
[Property, Sync( SyncFlags.FromHost )]
public string SuppressionStatus { get; set; } = "NONE";
// =========================================================
// BOT 6 MODIFIER / ADAPTIVE DIAGNOSTICS
// =========================================================
[Property, Sync( SyncFlags.FromHost )]
public string ModifierStatus { get; set; } = "STANDARD";
[Property, Sync( SyncFlags.FromHost )]
public string AdaptationStatus { get; set; } = "LEARNING";
[Property, Sync( SyncFlags.FromHost )]
public float AdaptationConfidence { get; set; } = 0f;
[Property, Sync( SyncFlags.FromHost )]
public string SpatialMemoryStatus { get; set; } = "LEARNING";
[Property, Sync( SyncFlags.FromHost )]
public string AwarenessStatus { get; set; } = "FAIR";
// =========================================================
// NAVIGATION
// =========================================================
[Property, Group( "Navigation" )]
public float RepathInterval { get; set; } = 0.25f;
[Property, Group( "Navigation" )]
public float LookAheadDistance { get; set; } = 72f;
[Property, Group( "Navigation" )]
public float StopDistance { get; set; } = 105f;
[Property, Group( "Navigation" )]
public float SprintDistance { get; set; } = 360f;
// =========================================================
// PERCEPTION
// =========================================================
[Property, Group( "Perception" )]
public float PerceptionScanInterval { get; set; } = 0.10f;
[Property, Group( "Perception" )]
public float SightRange { get; set; } = 1600f;
[Property, Group( "Perception" )]
public float FieldOfView { get; set; } = 110f;
[Property, Group( "Perception" )]
public float PersonalSpace { get; set; } = 42f;
[Property, Group( "Perception" )]
public float SearchStopDistance { get; set; } = 52f;
[Property, Group( "Perception" )]
public float SearchSweepSpeed { get; set; } = 48f;
[Property, Group( "Perception" )]
public float IdleSweepSpeed { get; set; } = 26f;
[Property, Group( "Perception / Difficulty" )]
public float EasyReactionDelay { get; set; } = 0.55f;
[Property, Group( "Perception / Difficulty" )]
public float NormalReactionDelay { get; set; } = 0.32f;
[Property, Group( "Perception / Difficulty" )]
public float HardReactionDelay { get; set; } = 0.18f;
[Property, Group( "Perception / Difficulty" )]
public float EasyMemoryDuration { get; set; } = 3.5f;
[Property, Group( "Perception / Difficulty" )]
public float NormalMemoryDuration { get; set; } = 5.0f;
[Property, Group( "Perception / Difficulty" )]
public float HardMemoryDuration { get; set; } = 6.5f;
// =========================================================
// COMBAT
// =========================================================
[Property, Group( "Combat / Aim" )]
public float EasyAimFollowSpeed { get; set; } = 3.6f;
[Property, Group( "Combat / Aim" )]
public float NormalAimFollowSpeed { get; set; } = 6.2f;
[Property, Group( "Combat / Aim" )]
public float HardAimFollowSpeed { get; set; } = 9.5f;
[Property, Group( "Combat / Aim" )]
public float EasyAimNoiseDegrees { get; set; } = 3.6f;
[Property, Group( "Combat / Aim" )]
public float NormalAimNoiseDegrees { get; set; } = 1.8f;
[Property, Group( "Combat / Aim" )]
public float HardAimNoiseDegrees { get; set; } = 0.8f;
[Property, Group( "Combat / Aim" )]
public float EasyFireToleranceDegrees { get; set; } = 6.5f;
[Property, Group( "Combat / Aim" )]
public float NormalFireToleranceDegrees { get; set; } = 4.0f;
[Property, Group( "Combat / Aim" )]
public float HardFireToleranceDegrees { get; set; } = 2.4f;
[Property, Group( "Combat" )]
public float MinimumAdsReadyFraction { get; set; } = 0.72f;
[Property, Group( "Combat" )]
public float MinimumTriggerReset { get; set; } = 0.035f;
// =========================================================
// BOT 4 PLAYER-EQUIVALENT MOVEMENT
// =========================================================
[Property, Group( "Movement / Combat" )]
public float StrafeSwitchInterval { get; set; } = 1.15f;
[Property, Group( "Movement / Combat" )]
public float StrafeStrength { get; set; } = 0.88f;
[Property, Group( "Movement / Combat" )]
public float RetreatDistanceScale { get; set; } = 0.66f;
[Property, Group( "Movement / Slide" )]
public float SlideMinimumTargetDistance { get; set; } = 300f;
[Property, Group( "Movement / Slide" )]
public float SlideMaximumTargetDistance { get; set; } = 720f;
[Property, Group( "Movement / Slide" )]
public float SlideCooldown { get; set; } = 3.25f;
[Property, Group( "Movement / Jump" )]
public float JumpMinimumTargetDistance { get; set; } = 170f;
[Property, Group( "Movement / Jump" )]
public float JumpMaximumTargetDistance { get; set; } = 430f;
[Property, Group( "Movement / Jump" )]
public float JumpCooldown { get; set; } = 4.25f;
[Property, Group( "Movement / Crouch" )]
public float CrouchDodgeDuration { get; set; } = 0.62f;
[Property, Group( "Movement / Crouch" )]
public float CrouchDodgeCooldown { get; set; } = 5.0f;
// =========================================================
// BOT 5A.2 TACTICAL COVER / THREAT RESPONSE
// =========================================================
[Property, Group( "Tactics / Threat" )]
public float ThreatMemoryDuration { get; set; } = 2.75f;
[Property, Group( "Tactics / Threat" )]
public float ThreatProbeDistance { get; set; } = 1100f;
[Property, Group( "Tactics / Cover" )]
public float CoverDecisionCooldown { get; set; } = 1.35f;
[Property, Group( "Tactics / Cover" )]
public float CoverHoldDuration { get; set; } = 1.85f;
[Property, Group( "Tactics / Cover" )]
public float CoverArrivalDistance { get; set; } = 46f;
[Property, Group( "Tactics / Cover" )]
public float CoverCandidateMinDistance { get; set; } = 145f;
[Property, Group( "Tactics / Cover" )]
public float CoverCandidateMaxDistance { get; set; } = 365f;
[Property, Group( "Tactics / Cover" )]
public float LowHealthCoverFraction { get; set; } = 0.38f;
[Property, Group( "Tactics / Cover" )]
public float LowHealthDecisionCooldown { get; set; } = 4.5f;
[Property, Group( "Tactics / Cover Quality" )]
public float CoverRouteSampleSpacing { get; set; } = 72f;
[Property, Group( "Tactics / Cover Quality" )]
public float CoverSideProbeDistance { get; set; } = 78f;
[Property, Group( "Tactics / Cover Quality" )]
public float CoverRouteExposurePenalty { get; set; } = 145f;
[Property, Group( "Tactics / Arbitration" )]
public float CoverEngageInterruptMargin { get; set; } = 14f;
[Property, Group( "Tactics / Arbitration" )]
public float UnderFireEvadeStrength { get; set; } = 0.90f;
[Property, Group( "Tactics / Cover Combat" )]
public float CoverPeekDuration { get; set; } = 0.82f;
[Property, Group( "Tactics / Cover Combat" )]
public float CoverPeekCooldown { get; set; } = 0.52f;
[Property, Group( "Tactics / Cover Combat" )]
public float CoverPeekArrivalDistance { get; set; } = 26f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalProbeDistance { get; set; } = 46f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalLowProbeHeight { get; set; } = 20f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalHighProbeHeight { get; set; } = 54f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalBypassDuration { get; set; } = 0.85f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalJumpCooldown { get; set; } = 1.25f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalNoProgressTimeout { get; set; } = 0.62f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalMinimumProgress { get; set; } = 14f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalOrbitWindow { get; set; } = 0.72f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalOrbitTurnThreshold { get; set; } = 185f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalOrbitMinimumTravel { get; set; } = 86f;
[Property, Group( "Tactics / Traversal" )]
public float TraversalRecoveryDuration { get; set; } = 0.58f;
// =========================================================
// BOT 5B SEARCH / HEARING / SUPPRESSION / FLANKING
// =========================================================
[Property, Group( "Tactics / Hearing" )]
public float GunshotHearingRange { get; set; } = 1250f;
[Property, Group( "Tactics / Hearing" )]
public float SprintHearingRange { get; set; } = 520f;
[Property, Group( "Tactics / Hearing" )]
public float WalkHearingRange { get; set; } = 260f;
[Property, Group( "Tactics / Hearing" )]
public float SlideHearingRange { get; set; } = 680f;
[Property, Group( "Tactics / Hearing" )]
public float HearingMemoryDuration { get; set; } = 4.25f;
[Property, Group( "Tactics / Hearing" )]
public float HearingOcclusionRangeScale { get; set; } = 0.58f;
[Property, Group( "Tactics / Search" )]
public float SearchPredictionSeconds { get; set; } = 0.85f;
[Property, Group( "Tactics / Search" )]
public float SearchLateralDistance { get; set; } = 155f;
[Property, Group( "Tactics / Search" )]
public float SearchForwardDistance { get; set; } = 210f;
[Property, Group( "Tactics / Search" )]
public float SearchPointPause { get; set; } = 0.42f;
[Property, Group( "Tactics / Search" )]
public float SearchPlanLifetime { get; set; } = 6.5f;
[Property, Group( "Tactics / Suppression" )]
public float SuppressionMemoryWindow { get; set; } = 1.15f;
[Property, Group( "Tactics / Suppression" )]
public float SuppressionCooldown { get; set; } = 2.6f;
[Property, Group( "Tactics / Suppression" )]
public int SuppressionBurstShots { get; set; } = 4;
[Property, Group( "Tactics / Flank" )]
public float FlankDecisionCooldown { get; set; } = 4.25f;
[Property, Group( "Tactics / Flank" )]
public float FlankMinimumContactTime { get; set; } = 1.65f;
[Property, Group( "Tactics / Flank" )]
public float FlankMinimumDistance { get; set; } = 210f;
[Property, Group( "Tactics / Flank" )]
public float FlankMaximumDistance { get; set; } = 430f;
// =========================================================
// BOT 6 MODIFIER AWARENESS / ADAPTIVE OPPONENT MODEL
// =========================================================
[Property, Group( "BOT 6 / Adaptation" )]
public bool AdaptiveLearningEnabled { get; set; } = true;
[Property, Group( "BOT 6 / Adaptation" ), Range( 0.01f, 0.35f )]
public float AdaptiveLearningRate { get; set; } = 0.09f;
[Property, Group( "BOT 6 / Adaptation" ), Range( 4, 80 )]
public int AdaptiveMinimumSamples { get; set; } = 14;
[Property, Group( "BOT 6 / Variation" ), Range( 0f, 0.35f )]
public float DynamicVariationStrength { get; set; } = 0.16f;
[Property, Group( "BOT 6 / Variation" )]
public float DynamicDecisionIntervalMin { get; set; } = 2.0f;
[Property, Group( "BOT 6 / Variation" )]
public float DynamicDecisionIntervalMax { get; set; } = 4.8f;
[Property, Group( "BOT 6 / Mobility" ), Range( 0f, 0.65f )]
public float SearchClearMoveStrength { get; set; } = 0.28f;
[Property, Group( "BOT 6 / Mobility" ), Range( 0f, 0.65f )]
public float SuppressionMoveStrength { get; set; } = 0.22f;
[Property, Group( "BOT 6 / Mobility" )]
public float RepathFailureGrace { get; set; } = 0.70f;
// =========================================================
// BOT 6.1 - FAIR AWARENESS / SPATIAL EPISODIC MEMORY
// =========================================================
[Property, Group( "BOT 6.1 / Fair Awareness" ), Range( 0f, 0.5f )]
public float CrouchHearingRangeScale { get; set; } = 0.08f;
[Property, Group( "BOT 6.1 / Fair Awareness" ), Range( 0f, 1f )]
public float RearHearingConfidenceThreshold { get; set; } = 0.52f;
[Property, Group( "BOT 6.1 / Fair Awareness" ), Range( 0f, 1f )]
public float SideHearingConfidenceThreshold { get; set; } = 0.30f;
[Property, Group( "BOT 6.1 / Memory" )]
public float SpatialMemoryCellSize { get; set; } = 128f;
[Property, Group( "BOT 6.1 / Memory" )]
public float SpatialMemoryHalfLife { get; set; } = 55f;
[Property, Group( "BOT 6.1 / Memory" )]
public float RouteMemoryHalfLife { get; set; } = 24f;
[Property, Group( "BOT 6.1 / Memory" )]
public float LearnedHotspotMinimumScore { get; set; } = 2.40f;
[Property, Group( "BOT 6.1 / Memory" )]
public float DeathRoutePenalty { get; set; } = 2.25f;
[Property, Group( "BOT 6.1 / Memory" )]
public float RouteSampleInterval { get; set; } = 0.24f;
[Property, Group( "Diagnostics" )]
public bool LogState { get; set; } = false;
private sealed class SpatialMemoryCell
{
public float OpponentPresence;
public float OpponentStationaryPresence;
public float Danger;
public float DeathDanger;
public float RouteUse;
public float LastUpdatedAt;
public float LastOpponentSeenAt = -999f;
public float LastCheckedAt = -999f;
}
private readonly struct RecentRouteSample
{
public Vector3 Position { get; }
public float Time { get; }
public RecentRouteSample( Vector3 position, float time )
{
Position = position;
Time = time;
}
}
private PlayerState _playerState;
private PlayerController _controller;
private PlayerMovementInput _movementInput;
private PlayerSprint _sprint;
private PlayerCrouch _crouch;
private PlayerSlide _slide;
private BaseInventoryComponent _inventory;
private PlayerHealth _health;
private PlayerState _navigationTarget;
private PlayerState _perceptionTarget;
private readonly List<Vector3> _pathPoints = new();
private int _pathPointIndex;
private string _pathStatus = "NO PATH";
private float _nextRepathAt;
private float _nextDiagnosticAt;
private float _nextPerceptionScanAt;
private float _rawVisibleSince = -1f;
private float _lastConfirmedSeenAt = -999f;
private bool _hasConfirmedSight;
private Vector3 _observedTargetPosition;
private Vector3 _observedTargetAimPoint;
private float _nextSearchTurnAt;
private float _searchTurnDirection = 1f;
private float _nextBotTriggerAt;
private OneMoreRoundWeapon _lastCombatWeapon;
// BOT 4 movement state. These timers decide when to press the same synthetic
// buttons a human would press; PlayerLocomotion still owns the physical result.
private float _nextStrafeSwitchAt;
private float _strafeDirection = 1f;
private float _nextSlideAt;
private float _nextJumpAt;
private bool _airJumpAttemptPending;
private float _airJumpAttemptAt;
private float _nextCrouchDodgeAt;
private float _crouchDodgeEndAt = -1f;
private float _crouchButtonHeldUntil = -1f;
private bool _wasSliding;
// BOT 5A.2 tactical state. Damage only produces a noisy direction vector, not
// the attacker's hidden live position. Cover points are projected onto the
// same runtime navmesh used by BOT 1 and reached through normal locomotion.
private int _lastProcessedDamageSequence = -1;
private float _lastThreatAt = -999f;
private Vector3 _lastThreatDirection = Vector3.Zero;
private float _threatOrientUntil = -1f;
private float _nextCoverDecisionAt;
private float _nextLowHealthCoverAt;
private bool _hasCoverTarget;
private Vector3 _coverTarget = Vector3.Zero;
private string _coverReason = string.Empty;
private float _coverHoldUntil = -1f;
private OMRCoverPosture _coverPosture = OMRCoverPosture.None;
private Vector3 _coverLeftPeek = Vector3.Zero;
private Vector3 _coverRightPeek = Vector3.Zero;
private bool _coverHasLeftPeek;
private bool _coverHasRightPeek;
private bool _coverPeekActive;
private bool _coverPeekReturning;
private Vector3 _coverPeekTarget = Vector3.Zero;
private float _coverPeekUntil = -1f;
private float _nextCoverPeekAt;
private int _coverPeekSide = 1;
private float _nextTraversalJumpAt;
private float _traversalBypassUntil = -1f;
private Vector3 _traversalBypassDirection = Vector3.Zero;
private string _traversalFrameStatus = string.Empty;
private float _traversalProgressStartedAt = -1f;
private float _traversalBestDistance = float.MaxValue;
private Vector3 _traversalProgressTarget = Vector3.Zero;
private int _traversalPreferredBypassSign = 1;
private float _traversalOrbitWindowStartedAt = -1f;
private Vector3 _traversalOrbitStartPosition = Vector3.Zero;
private Vector3 _traversalOrbitTarget = Vector3.Zero;
private Vector3 _traversalOrbitLastPosition = Vector3.Zero;
private Vector3 _traversalOrbitPreviousDirection = Vector3.Zero;
private float _traversalOrbitStartDistance = float.MaxValue;
private float _traversalOrbitTravelDistance;
private float _traversalOrbitTurnDegrees;
private float _traversalRecoveryUntil = -1f;
private Vector3 _traversalRecoveryDirection = Vector3.Zero;
// BOT 5B sensory/search/tactical state. These fields never grant hidden live
// target coordinates to combat. They cache only visual memory, noisy auditory
// evidence, or deliberate tactical destinations derived from those facts.
private Vector3 _lastSeenVelocity = Vector3.Zero;
private Vector3 _lastSeenDirection = Vector3.Zero;
private Vector3 _previousObservedPosition = Vector3.Zero;
private float _previousObservedAt = -1f;
private float _confirmedSightStartedAt = -1f;
private bool _sightWasConfirmedLastFrame;
private float _lostSightAt = -999f;
private OneMoreRoundWeapon _heardOpponentWeapon;
private int _heardOpponentWeaponEventCount = -1;
private float _nextMovementHearingAt;
private Vector3 _lastHeardPosition = Vector3.Zero;
private float _lastHeardAt = -999f;
private float _lastHeardConfidence;
private string _lastHeardKind = string.Empty;
private readonly List<Vector3> _searchPoints = new();
private int _searchPointIndex;
private float _searchPlanCreatedAt = -999f;
private float _searchPauseUntil = -1f;
private Vector3 _searchFacingDirection = Vector3.Zero;
private string _searchEvidence = string.Empty;
private bool _flankActive;
private Vector3 _flankTarget = Vector3.Zero;
private float _nextFlankDecisionAt;
private int _flankSide = 1;
private float _suppressionUntil = -1f;
private float _nextSuppressionAt;
private int _suppressionShotsRemaining;
private Vector3 _suppressionAimPoint = Vector3.Zero;
// BOT 6 modifier-awareness and lightweight online opponent model. This is not
// an opaque neural network: it is a bounded, inspectable set of exponentially
// weighted observations gathered only from information a human could perceive.
private RoundManager _roundManager;
private OMRRoundRuleDirector _roundRuleDirector;
private OMRRoundRuleProfile _activeRoundRule;
private RoundModifier _activeModifier = RoundModifier.Standard;
private HighNoonStage _lastHighNoonStage = HighNoonStage.Inactive;
private float _highNoonFireAt = -1f;
private int _adaptiveSampleCount;
private float _nextAdaptiveSampleAt;
private float _adaptiveOpponentAggression = 0.50f;
private float _adaptiveOpponentMobility = 0.50f;
private float _adaptiveOpponentAirUse = 0.10f;
private float _adaptiveOpponentCrouchUse = 0.10f;
private float _adaptivePreferredDistance = 0.50f;
private float _adaptivePreviousDistance = -1f;
private float _adaptivePreviousSampleAt = -1f;
private float _nextDynamicVariationAt;
private float _dynamicPressureBias;
private float _dynamicCoverBias;
private float _dynamicFlankBias;
private float _dynamicMovementBias;
private int _dynamicDecisionSerial;
// BOT 6.1 persistent-within-session spatial memory. Values are learned only
// from confirmed sight, received damage, the bot's own movement and its own
// deaths. The bot never writes the opponent's hidden live transform here.
private readonly Dictionary<(int X, int Y), SpatialMemoryCell> _spatialMemory = new();
private readonly List<RecentRouteSample> _recentRouteSamples = new();
private float _nextRouteMemorySampleAt;
private float _nextSpatialMemoryDiagnosticAt;
private bool _memoryWasAlive;
private bool _memoryLifeInitialized;
private int _spatialDeathCount;
private int _learnedHotspotChecks;
private ArenaController _memoryArena;
private ArenaLayoutState _memoryLayoutState = ArenaLayoutState.Invalid;
private bool _memoryLayoutInitialized;
private float _lastSuccessfulPathAt = -999f;
protected override void OnStart()
{
ResolveReferences();
SuppressHumanControl();
if ( LogState && _playerState?.IsBot == true )
{
Log.Info(
$"[OMR BOT] Started | {_playerState.DisplayName} | Team:{_playerState.TeamId} | Difficulty:{Difficulty}"
);
}
}
private float _nextUpdateExceptionLogAt;
private string _lastUpdateException = string.Empty;
protected override void OnUpdate()
{
// The bot object is host-controlled. Remote peers only render the
// replicated result and must never make AI decisions locally.
if ( !Networking.IsHost || IsProxy )
return;
try
{
UpdateHostBrain();
}
catch ( Exception exception )
{
HandleUpdateException( exception );
}
}
private void UpdateHostBrain()
{
ResolveReferences();
if ( _playerState?.IsValid != true || _playerState.IsBot != true )
return;
SuppressHumanControl();
UpdateSpatialMemoryLifecycle();
UpdatePerformanceCounters();
if ( !PrepareBotSessionLifecycle() ) return;
switch ( BehaviorMode )
{
case OMRBotBehaviorMode.TacticalCombat:
UpdateCombat();
break;
case OMRBotBehaviorMode.CombatMovement:
UpdateCombat();
break;
case OMRBotBehaviorMode.Combat:
UpdateCombat();
break;
case OMRBotBehaviorMode.PerceptionSearch:
ResetCombatDiagnostics( "OFF" );
UpdatePerceptionSearch();
break;
case OMRBotBehaviorMode.NavigateToOpponent:
ResetCombatDiagnostics( "OFF" );
UpdateNavigationToOpponent();
break;
case OMRBotBehaviorMode.Stationary:
default:
ReleaseBotWeaponIntent();
ResetCombatDiagnostics( "OFF" );
ResetPerceptionDiagnostics( "OFF" );
StopNavigation(
"STATIONARY / FOUNDATION TEST",
"OFF"
);
break;
}
}
private void HandleUpdateException( Exception exception )
{
// Do not leave stale movement or ADS held if a transient component is
// invalidated during sandbox respawn/hotload/teardown. S&box otherwise
// reports only the generic "Exception when calling Update" line, which
// is wonderfully unhelpful when debugging an AI made of many systems.
StopMovementOnly();
SafeReleaseLastCombatWeapon();
CurrentIntent = "AI UPDATE RECOVERY";
NavigationStatus = "RECOVERING";
CombatStatus = "UPDATE ERROR";
string signature =
$"{exception.GetType().FullName}: {exception.Message}";
if ( signature != _lastUpdateException || Time.Now >= _nextUpdateExceptionLogAt )
{
_lastUpdateException = signature;
_nextUpdateExceptionLogAt = Time.Now + 1.0f;
Log.Error(
$"[OMR BOT] Update exception in {BehaviorMode}: {exception}"
);
}
// Force reference reacquisition on the next frame. This covers objects
// replaced during Sandbox respawn and hotload without treating a stale
// Component reference as a valid non-null object forever.
_playerState = null;
_controller = null;
_movementInput = null;
_sprint = null;
_crouch = null;
_slide = null;
_inventory = null;
MovementStatus = "RECOVERING";
MovementSpeed = 0f;
}
public void Configure(
int slot,
OMRBotDifficulty difficulty,
OMRBotBehaviorMode behaviorMode
)
{
if ( !Networking.IsHost )
return;
BotSlot = slot;
Difficulty = difficulty;
if ( BehaviorMode != behaviorMode )
{
BehaviorMode = behaviorMode;
ResetRuntimeBehaviorState();
}
}
private void ResolveReferences()
{
if ( _playerState?.IsValid != true )
_playerState = Components.Get<PlayerState>();
if ( _controller?.IsValid != true )
_controller = Components.Get<PlayerController>();
if ( _movementInput?.IsValid != true )
_movementInput = Components.Get<PlayerMovementInput>();
if ( _sprint?.IsValid != true )
_sprint = Components.Get<PlayerSprint>();
if ( _crouch?.IsValid != true )
_crouch = Components.Get<PlayerCrouch>();
if ( _slide?.IsValid != true )
_slide = Components.Get<PlayerSlide>();
if ( _inventory?.IsValid != true )
_inventory = Components.Get<BaseInventoryComponent>();
if ( _health?.IsValid != true )
_health = Components.Get<PlayerHealth>();
}
// =========================================================
// BOT 1 - OMNISCIENT NAVIGATION TEST
// =========================================================
private void UpdateNavigationToOpponent()
{
ResetPerceptionDiagnostics( "BOT 1 OMNISCIENT" );
if ( !CanUseNavigation() )
return;
_navigationTarget =
ResolveHumanTarget( _navigationTarget );
if (
_navigationTarget?.GameObject is null ||
!_navigationTarget.IsAlive
)
{
StopNavigation(
"NO LIVE TARGET",
"IDLE"
);
return;
}
NavigateToWorldPosition(
_navigationTarget.WorldPosition,
_navigationTarget.DisplayName,
StopDistance,
"NAVIGATING"
);
}
// =========================================================
// BOT 2 - PERCEPTION + MEMORY + SEARCH
// =========================================================
private void UpdatePerceptionSearch()
{
if ( !CanUseNavigation() )
{
PerceptionStatus = "WAITING";
return;
}
UpdatePerception();
UpdatePerceptionTimers();
if ( PerceptionStatus == "REACTING" )
{
// Do not instantly turn or move toward a contact the bot has not
// consciously reacted to yet. Holding its current look direction also
// prevents the idle scan from sweeping away during the reaction window.
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "REACTING";
NavigationTargetName = "NONE";
NavigationTargetDistance = 0f;
CurrentIntent = "REACTION DELAY / HOLD";
return;
}
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
{
NavigateToWorldPosition(
_observedTargetPosition,
_perceptionTarget.DisplayName,
StopDistance,
"TRACKING VISIBLE TARGET"
);
return;
}
if ( HasFreshMemory() )
{
Vector3 toLastKnown =
( LastKnownPosition - WorldPosition )
.WithZ( 0f );
if ( toLastKnown.Length <= SearchStopDistance )
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "SEARCHING";
NavigationTargetName = "LAST KNOWN";
NavigationTargetDistance =
MathF.Round( toLastKnown.Length );
CurrentIntent = "SEARCHING LAST KNOWN POSITION";
SweepLook( SearchSweepSpeed );
return;
}
NavigateToWorldPosition(
LastKnownPosition,
"LAST KNOWN",
SearchStopDistance,
"SEARCHING LAST KNOWN"
);
return;
}
// No valid visual contact and no recent memory. Do not use the actual
// opponent position. Slowly scan the arena until the FOV/LOS system sees
// something again.
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "IDLE";
NavigationTargetName = "NONE";
NavigationTargetDistance = 0f;
CurrentIntent = "NO CONTACT / SCANNING";
SweepLook( IdleSweepSpeed );
}
private void UpdatePerception()
{
_perceptionTarget =
ResolveHumanTarget( _perceptionTarget );
if ( _perceptionTarget?.GameObject is null || !_perceptionTarget.IsAlive )
{
_hasConfirmedSight = false;
_rawVisibleSince = -1f;
PerceptionTargetName = "NONE";
PerceptionTargetDistance = 0f;
PerceptionReactionRemaining = 0f;
PerceptionStatus = HasFreshMemory()
? "MEMORY / TARGET DOWN"
: "NO TARGET";
return;
}
if ( Time.Now < _nextPerceptionScanAt )
return;
_nextPerceptionScanAt =
Time.Now + MathF.Max( 0.02f, PerceptionScanInterval );
Vector3 toTarget =
( _perceptionTarget.WorldPosition - WorldPosition );
float targetDistance =
toTarget.Length;
PerceptionTargetName =
_perceptionTarget.DisplayName;
PerceptionTargetDistance =
MathF.Round( targetDistance );
bool withinRange =
targetDistance <= SightRange;
bool inViewCone =
withinRange &&
IsInViewCone( _perceptionTarget, targetDistance );
bool hasLineOfSight =
inViewCone &&
HasLineOfSight( _perceptionTarget );
bool rawVisible =
withinRange &&
inViewCone &&
hasLineOfSight;
if ( rawVisible )
{
if ( _rawVisibleSince < 0f )
_rawVisibleSince = Time.Now;
float reactionDelay =
GetReactionDelay();
float visibleFor =
MathF.Max( 0f, Time.Now - _rawVisibleSince );
PerceptionReactionRemaining =
MathF.Max( 0f, reactionDelay - visibleFor );
if ( visibleFor >= reactionDelay )
{
Vector3 newlyObservedPosition = _perceptionTarget.WorldPosition;
if ( !_hasConfirmedSight )
{
_confirmedSightStartedAt = Time.Now;
}
if ( _previousObservedAt > 0f && Time.Now - _previousObservedAt <= 0.30f &&
( newlyObservedPosition - _previousObservedPosition ).Length <= 160f )
{
float sampleDelta = MathF.Max( 0.02f, Time.Now - _previousObservedAt );
Vector3 observedVelocity =
( newlyObservedPosition - _previousObservedPosition ) / sampleDelta;
_lastSeenVelocity = Vector3.Lerp( _lastSeenVelocity, observedVelocity, 0.45f );
Vector3 planarVelocity = _lastSeenVelocity.WithZ( 0f );
if ( !planarVelocity.IsNearlyZero( 1f ) )
_lastSeenDirection = planarVelocity.Normal;
}
else _lastSeenVelocity = Vector3.Zero;
_previousObservedPosition = newlyObservedPosition;
_previousObservedAt = Time.Now;
_sightWasConfirmedLastFrame = true;
_hasConfirmedSight = true;
_lastConfirmedSeenAt = Time.Now;
_observedTargetPosition = newlyObservedPosition;
_observedTargetAimPoint = ResolveCombatAimPoint( _perceptionTarget );
HasLastKnownPosition = true;
LastKnownPosition = _observedTargetPosition;
PerceptionStatus = "VISIBLE";
}
else
{
// The physical ray can already see the opponent, but the AI is not
// allowed to use that information until its reaction delay elapses.
_hasConfirmedSight = false;
PerceptionStatus = "REACTING";
}
return;
}
_rawVisibleSince = -1f;
PerceptionReactionRemaining = 0f;
if ( _hasConfirmedSight || _sightWasConfirmedLastFrame )
{
_lostSightAt = Time.Now;
_sightWasConfirmedLastFrame = false;
}
_hasConfirmedSight = false;
if ( HasFreshMemory() )
{
PerceptionStatus = "MEMORY";
}
else
{
HasLastKnownPosition = false;
PerceptionStatus = !withinRange
? "OUT OF RANGE"
: !inViewCone
? "OUT OF FOV"
: "OCCLUDED";
}
}
private bool IsInViewCone(
PlayerState target,
float distance
)
{
if ( target?.GameObject is null || _controller is null )
return false;
Vector3 toTarget =
( target.WorldPosition - WorldPosition )
.WithZ( 0f );
if ( toTarget.IsNearlyZero( 0.01f ) )
return true;
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( toTarget.Normal );
// Nearby opponents are easier to notice in peripheral vision, but never
// become magically visible behind the bot. The old 360-degree PersonalSpace
// shortcut was the main source of uncanny close-range 180-degree snaps.
float effectiveFieldOfView =
distance <= MathF.Max( 12f, PersonalSpace )
? MathF.Max( FieldOfView, 155f )
: FieldOfView;
float halfAngleRadians =
MathF.Max( 1f, effectiveFieldOfView ) *
0.5f *
MathF.PI /
180f;
float threshold =
MathF.Cos( halfAngleRadians );
return dot >= threshold;
}
private bool HasLineOfSight(
PlayerState target
)
{
if (
Scene is null ||
target?.GameObject is null ||
_controller is null
)
return false;
Vector3 eyePosition =
_controller.EyePosition;
PlayerController targetController =
target.Components.Get<PlayerController>();
Vector3 targetEye =
targetController is not null
? targetController.EyePosition
: target.WorldPosition + Vector3.Up * 60f;
Vector3 targetTorso =
target.WorldPosition + Vector3.Up * 34f;
// Two samples make peeking/partial cover behave more naturally: seeing the
// head OR a central torso point is enough to notice the opponent.
return
TraceCanSeeTarget( target, eyePosition, targetEye ) ||
TraceCanSeeTarget( target, eyePosition, targetTorso );
}
private bool TraceCanSeeTarget(
PlayerState target,
Vector3 from,
Vector3 to
)
{
SceneTraceResult trace =
Scene
.Trace
.Ray( from, to )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( true )
.Run();
if ( !trace.Hit )
return true;
if ( trace.GameObject == target.GameObject )
return true;
// Player collision/hitboxes can live below the PlayerState root. This is
// the same hierarchy-aware pattern used by Facepunch's current SensesLayer.
return
trace.GameObject is not null &&
target.GameObject.IsDescendant( trace.GameObject );
}
private bool HasFreshMemory()
{
if ( !HasLastKnownPosition )
return false;
float age =
Time.Now - _lastConfirmedSeenAt;
return
age >= 0f &&
age <= GetMemoryDuration();
}
private void UpdatePerceptionTimers()
{
if ( HasLastKnownPosition )
{
PerceptionLastSeenSeconds =
MathF.Round(
MathF.Max( 0f, Time.Now - _lastConfirmedSeenAt ) * 10f
) / 10f;
}
else
{
PerceptionLastSeenSeconds = -1f;
}
if ( _rawVisibleSince >= 0f && !_hasConfirmedSight )
{
PerceptionReactionRemaining =
MathF.Max(
0f,
GetReactionDelay() -
( Time.Now - _rawVisibleSince )
);
}
}
private float GetReactionDelay() =>
Difficulty switch
{
OMRBotDifficulty.Easy => EasyReactionDelay,
OMRBotDifficulty.Hard => HardReactionDelay,
_ => NormalReactionDelay
};
private float GetMemoryDuration() =>
Difficulty switch
{
OMRBotDifficulty.Easy => EasyMemoryDuration,
OMRBotDifficulty.Hard => HardMemoryDuration,
_ => NormalMemoryDuration
};
private void SweepLook(
float degreesPerSecond
)
{
if ( _controller is null || degreesPerSecond <= 0f )
return;
if ( Time.Now >= _nextSearchTurnAt )
{
// Sweep far enough that an opponent who moved behind the bot can
// eventually re-enter the view cone without the AI consulting their
// actual position. Faster search modes cover the same arc sooner.
float sweepArc = 145f;
float sweepSeconds =
MathF.Max( 1.0f, sweepArc / degreesPerSecond );
_nextSearchTurnAt = Time.Now + sweepSeconds;
_searchTurnDirection *= -1f;
}
Angles eyeAngles =
_controller.EyeAngles;
_controller.EyeAngles =
new Angles(
0f,
eyeAngles.yaw +
degreesPerSecond *
_searchTurnDirection *
Time.Delta,
0f
);
}
// =========================================================
// BOT 5B - HEARING / EVIDENCE
// =========================================================
private void UpdateHearing()
{
if ( !UsesTacticalBehavior() )
{
HearingStatus = "OFF";
HearingBearing = "NONE";
HearingAgeSeconds = -1f;
return;
}
PlayerState human = ResolveHumanTarget( _perceptionTarget );
if ( human?.GameObject is null || !human.IsAlive )
{
UpdateHearingDiagnostics();
return;
}
OneMoreRoundWeapon humanWeapon = ResolveActiveWeaponFor( human );
if ( humanWeapon?.IsValid == true )
{
if ( _heardOpponentWeapon != humanWeapon )
{
_heardOpponentWeapon = humanWeapon;
_heardOpponentWeaponEventCount = humanWeapon.PresentationEventCount;
}
else if ( humanWeapon.PresentationEventCount != _heardOpponentWeaponEventCount )
{
_heardOpponentWeaponEventCount = humanWeapon.PresentationEventCount;
if ( humanWeapon.LastPresentationEvent == WeaponPresentationEventKind.ShotFired )
{
float range = GetGunshotHearingRange( humanWeapon );
RegisterHeardStimulus(
human.WorldPosition + Vector3.Up * 42f,
range,
"GUNSHOT",
1.0f
);
}
else if ( humanWeapon.LastPresentationEvent == WeaponPresentationEventKind.ReloadStarted )
{
RegisterHeardStimulus(
human.WorldPosition + Vector3.Up * 36f,
240f,
"RELOAD",
0.35f
);
}
}
}
else
{
_heardOpponentWeapon = null;
_heardOpponentWeaponEventCount = -1;
}
if ( Time.Now >= _nextMovementHearingAt )
{
_nextMovementHearingAt = Time.Now + 0.16f;
RegisterMovementHearing( human );
}
UpdateHearingDiagnostics();
}
private OneMoreRoundWeapon ResolveActiveWeaponFor( PlayerState player )
{
if ( player?.GameObject is null )
return null;
BaseInventoryComponent inventory =
player.GameObject.Components.Get<BaseInventoryComponent>();
OneMoreRoundWeapon weapon = inventory?.ActiveItem as OneMoreRoundWeapon;
return weapon?.IsValid == true ? weapon : null;
}
private float GetGunshotHearingRange( OneMoreRoundWeapon weapon )
{
string id = ( weapon?.WeaponId ?? string.Empty ).Trim().ToLowerInvariant();
float baseRange = MathF.Max( 400f, GunshotHearingRange );
return id switch
{
"m700" => baseRange * 1.28f,
"spm4" => baseRange * 1.08f,
"m4a1" => baseRange,
"revolver" => baseRange * 0.92f,
"mp5" => baseRange * 0.82f,
"usp" => baseRange * 0.72f,
_ => baseRange * 0.85f
};
}
private void RegisterMovementHearing( PlayerState human )
{
if ( human?.GameObject is null )
return;
PlayerController controller = human.GameObject.Components.Get<PlayerController>();
if ( controller?.IsValid != true )
return;
float speed = controller.Velocity.WithZ( 0f ).Length;
PlayerSprint sprint = human.GameObject.Components.Get<PlayerSprint>();
PlayerSlide slide = human.GameObject.Components.Get<PlayerSlide>();
PlayerCrouch crouch = human.GameObject.Components.Get<PlayerCrouch>();
float range = 0f;
string kind = string.Empty;
float confidence = 0f;
if ( slide?.IsValid == true && slide.IsSliding )
{
range = SlideHearingRange;
kind = "SLIDE";
confidence = 0.72f;
}
else if ( sprint?.IsValid == true && sprint.IsSprinting && speed > 180f )
{
range = SprintHearingRange;
kind = "SPRINT";
confidence = 0.58f;
}
else if ( crouch?.IsValid == true && crouch.WantsCrouch )
{
// Crouch walking is intentionally close to silent. This is a gameplay
// fairness rule, not a simulation claim: a human who deliberately sneaks
// behind ROOK should not trip a supernatural rear-facing motion sensor.
if ( speed <= 34f )
return;
range =
WalkHearingRange *
MathX.Clamp( CrouchHearingRangeScale, 0f, 0.5f );
kind = "CROUCH STEPS";
confidence = 0.08f;
}
else if ( speed > 85f )
{
range = WalkHearingRange * MathX.Clamp( speed / 230f, 0.45f, 1.15f );
kind = "FOOTSTEPS";
confidence = 0.38f;
}
if ( range <= 1f )
return;
RegisterHeardStimulus(
human.WorldPosition + Vector3.Up * 24f,
range,
kind,
confidence
);
}
private void RegisterHeardStimulus(
Vector3 sourcePosition,
float baseRange,
string kind,
float baseConfidence
)
{
if ( _controller?.IsValid != true || Scene is null )
return;
Vector3 ear = _controller.EyePosition;
float distance = ( sourcePosition - ear ).Length;
bool occluded = IsAuditoryPathOccluded( sourcePosition );
float effectiveRange = MathF.Max( 40f, baseRange );
if ( occluded )
effectiveRange *= MathX.Clamp( HearingOcclusionRangeScale, 0.25f, 0.95f );
if ( distance > effectiveRange )
return;
float distanceFraction = MathX.Clamp( distance / effectiveRange, 0f, 1f );
float difficultyError = Difficulty switch
{
OMRBotDifficulty.Easy => 1.40f,
OMRBotDifficulty.Hard => 0.62f,
_ => 1.0f
};
float uncertainty =
MathX.Lerp( 24f, 130f, distanceFraction ) *
difficultyError *
( occluded ? 1.65f : 1f ) *
MathX.Lerp( 1.35f, 0.72f, MathX.Clamp( baseConfidence, 0f, 1f ) );
float errorYaw = Game.Random.Float( -180f, 180f );
float errorRadius = Game.Random.Float( uncertainty * 0.25f, uncertainty );
Vector3 noisyOffset =
Rotation.FromYaw( errorYaw ).Forward.WithZ( 0f ).Normal * errorRadius;
_lastHeardPosition = sourcePosition + noisyOffset;
_lastHeardAt = Time.Now;
_lastHeardKind = string.IsNullOrWhiteSpace( kind ) ? "SOUND" : kind;
_lastHeardConfidence =
MathX.Clamp(
baseConfidence * ( 1f - distanceFraction * 0.45f ) * ( occluded ? 0.72f : 1f ),
0.08f,
1f
);
}
private bool IsAuditoryPathOccluded( Vector3 sourcePosition )
{
if ( Scene is null || _controller?.IsValid != true )
return false;
SceneTrace trace =
Scene.Trace
.Ray( _controller.EyePosition, sourcePosition )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
trace = trace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
return trace.Run().Hit;
}
private bool HasFreshHearing() =>
_lastHeardAt > 0f &&
Time.Now - _lastHeardAt <= MathF.Max( 0.75f, HearingMemoryDuration );
private bool HasActionableHearing()
{
if ( !HasFreshHearing() || _controller?.IsValid != true )
return false;
Vector3 planar =
( _lastHeardPosition - WorldPosition )
.WithZ( 0f );
if ( planar.IsNearlyZero( 0.01f ) )
return true;
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( planar.Normal );
// Rear sounds require much stronger evidence before the bot commits to an
// investigation. Gunshots/slides/sprints can still trigger it; quiet steps
// cannot cause an uncanny instant 180-degree response.
float threshold =
dot <= -0.35f
? MathX.Clamp( RearHearingConfidenceThreshold, 0f, 1f )
: dot <= 0.35f
? MathX.Clamp( SideHearingConfidenceThreshold, 0f, 1f )
: 0.16f;
if ( string.Equals( _lastHeardKind, "CROUCH STEPS", StringComparison.OrdinalIgnoreCase ) )
threshold = MathF.Max( threshold, 0.65f );
return _lastHeardConfidence >= threshold;
}
private void UpdateHearingDiagnostics()
{
if ( HasFreshHearing() )
{
string action = HasActionableHearing() ? "ACTION" : "NOTICE ONLY";
HearingStatus = $"{_lastHeardKind} · CONF {_lastHeardConfidence * 100f:0}% · {action}";
HearingAgeSeconds = MathF.Round( MathF.Max( 0f, Time.Now - _lastHeardAt ) * 10f ) / 10f;
HearingBearing = DescribeWorldBearing( _lastHeardPosition - WorldPosition );
}
else
{
HearingStatus = "NONE";
HearingBearing = "NONE";
HearingAgeSeconds = -1f;
}
}
private string DescribeWorldBearing( Vector3 direction )
{
if ( _controller?.IsValid != true || direction.WithZ( 0f ).IsNearlyZero( 0.01f ) )
return "NONE";
float targetYaw = Rotation.LookAt( direction.WithZ( 0f ).Normal, Vector3.Up ).Angles().yaw;
float delta = MathX.DeltaDegrees( _controller.EyeAngles.yaw, targetYaw );
float abs = MathF.Abs( delta );
if ( abs <= 22.5f ) return "FRONT";
if ( abs >= 157.5f ) return "REAR";
if ( delta > 0f )
return abs <= 67.5f ? "RIGHT" : abs <= 112.5f ? "REAR-RIGHT" : "REAR-RIGHT";
return abs <= 67.5f ? "LEFT" : abs <= 112.5f ? "REAR-LEFT" : "REAR-LEFT";
}
// =========================================================
// BOT 3 - COMBAT
// =========================================================
private void UpdateCombat()
{
if ( !CanUseNavigation() )
{
PerceptionStatus = "WAITING";
ReleaseBotWeaponIntent();
ResetCombatDiagnostics( "WAITING" );
return;
}
UpdateModifierAwareness();
UpdateDynamicTacticalVariation();
UpdatePerception();
UpdatePerceptionTimers();
UpdateSpatialOpponentMemory();
UpdateHearing();
UpdateThreatMemory();
OneMoreRoundWeapon weapon = ResolveActiveWeapon();
RefreshCombatSequenceContext( weapon );
if ( ShouldSampleStrategy( weapon ) )
{
UpdatePlayerPrediction();
UpdateOpponentBelief();
UpdateOpponentLearning();
UpdateTacticalContext();
RefreshBrainArchitecture( weapon );
}
UpdateTacticalMapContext( weapon );
UpdateCombatAmmoDiagnostics( weapon );
if ( _hasConfirmedSight )
InitiativeStatus = "CONTACT";
else if ( HasFreshMemory() || HasActionableHearing() || HasRecentThreat() )
InitiativeStatus = "EVIDENCE";
if ( UsesTacticalBehavior() && TryHandleHighNoonBehavior( weapon ) )
return;
// Automatic weapons get a tiny, fair stale-contact retention window before
// strategic search takes over. This uses only the last observed aim sample.
if ( !_hasConfirmedSight && TryHandleAutomaticTargetRetention( weapon ) )
return;
if ( UsesTacticalBehavior() && TryHandleTacticalPriority( weapon ) )
return;
if ( PerceptionStatus == "REACTING" )
{
ReleaseBotWeaponIntent( weapon );
_pathPoints.Clear();
_pathPointIndex = 0;
if ( UsesTacticalBehavior() )
MaintainReactionMomentum();
else
StopMovementOnly();
NavigationStatus = "REACTING";
NavigationTargetName = "NONE";
NavigationTargetDistance = 0f;
CurrentIntent = "REACTION DELAY / HOLD";
CombatStatus = "REACTION DELAY";
return;
}
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
{
UpdateVisibleCombat( weapon );
return;
}
ReleaseBotWeaponIntent( weapon );
TryReloadWhileSafe( weapon );
CombatAimError = 0f;
CombatWantsAim = false;
if ( HasFreshMemory() )
{
Vector3 toLastKnown =
( LastKnownPosition - WorldPosition )
.WithZ( 0f );
CombatStatus = weapon?.IsReloading == true
? "RELOADING IN COVER"
: "NO VISUAL / MEMORY";
if ( toLastKnown.Length <= SearchStopDistance )
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "SEARCHING";
NavigationTargetName = "LAST KNOWN";
NavigationTargetDistance = MathF.Round( toLastKnown.Length );
CurrentIntent = "COMBAT SEARCH / LAST KNOWN";
SweepLook( SearchSweepSpeed );
return;
}
NavigateToWorldPosition(
LastKnownPosition,
"LAST KNOWN",
SearchStopDistance,
"COMBAT SEARCHING LAST KNOWN",
allowSprint: true,
faceMovement: true,
advancedMovement: UsesAdvancedMovement(),
movementWeapon: weapon,
visibleCombatTarget: false
);
return;
}
if ( TryHandleProactiveNoContact( weapon ) )
return;
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "IDLE";
NavigationTargetName = "NONE";
NavigationTargetDistance = 0f;
CurrentIntent = "COMBAT / NO CONTACT / SCANNING";
CombatStatus = weapon?.IsReloading == true
? "RELOADING / NO CONTACT"
: "NO CONTACT";
SweepLook( IdleSweepSpeed );
}
private void UpdateVisibleCombat( OneMoreRoundWeapon weapon )
{
if ( weapon?.IsValid != true )
{
CombatStatus = "NO ACTIVE WEAPON";
CombatAimError = 0f;
CombatWantsAim = false;
NavigateToWorldPosition(
_observedTargetPosition,
_perceptionTarget.DisplayName,
StopDistance,
"COMBAT / UNARMED APPROACH",
allowSprint: false
);
return;
}
if ( _lastCombatWeapon != weapon )
{
if ( _lastCombatWeapon?.IsValid == true )
_lastCombatWeapon.SetBotAimIntent( false );
_lastCombatWeapon = weapon;
_nextBotTriggerAt = 0f;
}
float distance =
( _observedTargetPosition - WorldPosition )
.WithZ( 0f )
.Length;
float holdDistance = GetCombatHoldDistance( weapon );
if ( UsesTacticalBehavior() )
{
// Health/context changes *how strongly* the bot presses without changing
// the weapon's underlying preferred role. High aggression closes the
// distance modestly; low aggression gives the weapon a little more room.
holdDistance *=
MathX.Lerp(
1.12f,
0.88f,
MathX.Clamp( TacticalAggression, 0f, 1f )
);
}
// BOT 7D may deliberately press closer or preserve more space. The actual
// movement/weapon execution below remains the same shared player-equivalent
// pipeline.
holdDistance *= GetUtilityRangeScale();
bool shouldAdvance = distance > holdDistance;
// Aim at the most recent *observed* sample from BOT 2. Do not read the
// target's live transform here; if they duck behind cover between
// perception scans, combat keeps aiming at the stale seen point until the
// next scan revokes visibility.
UpdateCombatAim( _observedTargetAimPoint );
bool advancedMovement =
UsesAdvancedMovement();
bool wantsAds = ShouldUseAds( weapon, distance );
CombatWantsAim = wantsAds;
weapon.SetBotAimIntent( wantsAds );
if ( shouldAdvance )
{
NavigateToWorldPosition(
_observedTargetPosition,
_perceptionTarget.DisplayName,
holdDistance,
"COMBAT APPROACH",
allowSprint: advancedMovement && !_hasConfirmedSight,
faceMovement: false,
advancedMovement: advancedMovement,
movementWeapon: weapon,
visibleCombatTarget: true
);
}
else if ( advancedMovement )
{
UpdateCombatStrafe( weapon, distance, holdDistance );
}
else
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
NavigationStatus = "COMBAT HOLD";
NavigationTargetName = _perceptionTarget.DisplayName;
NavigationTargetDistance = MathF.Round( distance );
CurrentIntent = "COMBAT / HOLDING RANGE";
}
UpdateVisibleWeaponAttack( weapon, distance, wantsAds );
}
private void UpdateVisibleWeaponAttack(
OneMoreRoundWeapon weapon,
float distance,
bool wantsAds
)
{
UpdateCombatAmmoDiagnostics( weapon );
if ( weapon?.IsValid != true )
{
CombatStatus = "NO ACTIVE WEAPON";
return;
}
if ( weapon.IsReloading )
{
CombatStatus = "RELOADING";
return;
}
if ( weapon.Clip1 == 0 )
{
weapon.SetBotAimIntent( false );
CombatWantsAim = false;
if ( weapon.BotTryReload() )
CombatStatus = "STARTING RELOAD";
else
CombatStatus = "EMPTY / CANNOT RELOAD";
return;
}
if ( weapon.IsCyclePendingOrActive )
{
CombatStatus = "CYCLING";
return;
}
float maximumFireDistance = GetMaximumFireDistance( weapon );
if ( distance > maximumFireDistance )
{
CombatStatus = "CLOSING TO FIRE RANGE";
return;
}
if (
wantsAds &&
weapon.IsAiming &&
weapon.AimFraction < MathX.Clamp( MinimumAdsReadyFraction, 0f, 1f )
)
{
CombatStatus = "SETTLING ADS";
return;
}
float tolerance = GetFireToleranceDegrees();
if ( weapon.IsAutomaticWeapon )
tolerance *= MathF.Max( 1f, AutomaticFireToleranceScale );
if ( weapon.IsAutomaticWeapon
? !AllowAutomaticTrackingFire( weapon, tolerance )
: CombatAimError > tolerance )
{
CombatStatus = $"ALIGNING ({CombatAimError:0.0}°)";
return;
}
if ( Time.Now < _nextBotTriggerAt )
{
CombatStatus = "TRIGGER RESET";
return;
}
if ( !TryAllowCombatSequenceShot( weapon, distance, out string sequenceHoldReason ) )
{
CombatStatus = sequenceHoldReason;
return;
}
if ( weapon.BotTryPrimaryFire() )
{
OnCombatSequenceShotFired( weapon, distance );
CombatStatus = weapon.IsAutomaticWeapon ? "AUTOMATIC FIRE" : "FIRED";
_nextBotTriggerAt = Time.Now + GetCombatSequenceTriggerInterval( weapon );
UpdateCombatAmmoDiagnostics( weapon );
return;
}
CombatStatus = weapon.CanPrimaryAttack()
? "HOLDING FIRE"
: weapon.ActionState.ToString().ToUpperInvariant();
}
private OneMoreRoundWeapon ResolveActiveWeapon()
{
if ( _inventory?.IsValid != true )
_inventory = Components.Get<BaseInventoryComponent>();
if ( _inventory?.IsValid != true )
return null;
OneMoreRoundWeapon weapon =
_inventory.ActiveItem as OneMoreRoundWeapon;
return weapon?.IsValid == true
? weapon
: null;
}
private void UpdateCombatAmmoDiagnostics( OneMoreRoundWeapon weapon )
{
if ( weapon?.IsValid != true )
{
CombatClip = -1;
CombatReserve = -1;
return;
}
CombatClip = weapon.Clip1;
CombatReserve = weapon.Ammo1;
}
private void ReleaseBotWeaponIntent( OneMoreRoundWeapon weapon = null )
{
if ( weapon?.IsValid != true )
weapon = ResolveActiveWeapon();
if ( weapon?.IsValid == true )
weapon.SetBotAimIntent( false );
CombatWantsAim = false;
}
private void SafeReleaseLastCombatWeapon()
{
if ( _lastCombatWeapon?.IsValid == true )
{
try
{
_lastCombatWeapon.SetBotAimIntent( false );
}
catch
{
// Lifecycle cleanup must never generate a second exception while
// handling the original invalidation.
}
}
_lastCombatWeapon = null;
CombatWantsAim = false;
}
private void TryReloadWhileSafe( OneMoreRoundWeapon weapon )
{
if ( weapon?.IsValid != true || weapon.IsReloading )
return;
bool empty = weapon.Clip1 == 0;
bool tacticalLargeCaliberReload =
ActiveModifierIs( RoundModifier.LargeCaliber ) &&
weapon.UsesPrimaryClip &&
weapon.PrimaryClipSize > 0 &&
weapon.Ammo1 > 0 &&
weapon.Clip1 > 0 &&
weapon.Clip1 <= Math.Max( 1, (int)MathF.Floor( weapon.PrimaryClipSize * 0.28f ) );
if ( !empty && !tacticalLargeCaliberReload )
return;
weapon.BotTryReload();
UpdateCombatAmmoDiagnostics( weapon );
}
private Vector3 ResolveCombatAimPoint( PlayerState target )
{
if ( target?.GameObject is null )
return _observedTargetPosition + Vector3.Up * 48f;
PlayerController targetController =
target.Components.Get<PlayerController>();
Vector3 torso = target.WorldPosition + Vector3.Up * 34f;
Vector3 eye = targetController is not null
? targetController.EyePosition
: target.WorldPosition + Vector3.Up * 60f;
if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
{
// The target is currently confirmed visible, so using the observed eye/head
// sample is fair. Difficulty still matters through aim noise/follow speed
// and fire tolerance rather than by deliberately aiming at the torso.
return eye - Vector3.Up * 2f;
}
float eyeBias = Difficulty switch
{
OMRBotDifficulty.Easy => 0.30f,
OMRBotDifficulty.Hard => 0.72f,
_ => 0.52f
};
return torso + ( eye - torso ) * eyeBias;
}
private void UpdateCombatAim( Vector3 aimPoint )
{
if ( _controller is null )
return;
Vector3 from = _controller.EyePosition;
Vector3 trueDirection = ResolveTrackedAimPoint( aimPoint ) - from;
if ( trueDirection.IsNearlyZero( 0.001f ) )
return;
Angles trueAngles =
Rotation.LookAt( trueDirection.Normal, Vector3.Up ).Angles();
float noise = GetAimNoiseDegrees();
float phase = Time.Now + BotSlot * 0.71f;
float yawNoise = MathF.Sin( phase * 1.73f ) * noise;
float pitchNoise = MathF.Sin( phase * 1.19f + 1.4f ) * noise * 0.55f;
Angles noisyAngles = new(
trueAngles.pitch + pitchNoise,
trueAngles.yaw + yawNoise,
0f
);
float followSpeed = GetAimFollowSpeed();
float follow = 1f - MathF.Exp(
-MathF.Max( followSpeed, 0.01f ) * MathF.Max( Time.Delta, 0f )
);
Rotation smoothed = Rotation.Slerp(
_controller.EyeAngles.ToRotation(),
noisyAngles.ToRotation(),
MathX.Clamp( follow, 0f, 1f )
);
Angles resolved = smoothed.Angles();
_controller.EyeAngles = new Angles(
MathX.Clamp( resolved.pitch, -89f, 89f ),
resolved.yaw,
0f
);
float pitchError = MathF.Abs(
MathX.DeltaDegrees( _controller.EyeAngles.pitch, trueAngles.pitch )
);
float yawError = MathF.Abs(
MathX.DeltaDegrees( _controller.EyeAngles.yaw, trueAngles.yaw )
);
CombatAimError = MathF.Sqrt(
pitchError * pitchError + yawError * yawError
);
}
private bool ShouldUseAds( OneMoreRoundWeapon weapon, float distance )
{
if ( weapon is null || !weapon.AllowAim || !weapon.RoundAllowsAim )
return false;
if ( ActiveModifierIs( RoundModifier.HighNoon ) )
return false;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
if ( doctrine.LegacyDisableAds )
return false;
float threshold =
ActiveModifierIs( RoundModifier.HeadshotOnly )
? doctrine.LegacyHeadshotAdsStartDistance
: doctrine.LegacyAdsStartDistance;
return distance >= threshold;
}
private float GetCombatHoldDistance( OneMoreRoundWeapon weapon )
{
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float distance = doctrine.LegacyHoldDistance;
if ( ActiveModifierIs( RoundModifier.HipfireOnly ) )
distance *= 0.78f;
else if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
distance *= 1.10f;
else if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
distance *= 1.06f;
distance *= GetAdaptiveHoldDistanceScale( weapon );
return distance;
}
private float GetMaximumFireDistance( OneMoreRoundWeapon weapon )
{
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float distance = doctrine.LegacyMaximumFireDistance;
if ( ActiveModifierIs( RoundModifier.HipfireOnly ) )
distance *= 0.72f;
return distance;
}
private float GetAimFollowSpeed() =>
Difficulty switch
{
OMRBotDifficulty.Easy => EasyAimFollowSpeed,
OMRBotDifficulty.Hard => HardAimFollowSpeed,
_ => NormalAimFollowSpeed
};
private float GetAimNoiseDegrees() =>
Difficulty switch
{
OMRBotDifficulty.Easy => EasyAimNoiseDegrees,
OMRBotDifficulty.Hard => HardAimNoiseDegrees,
_ => NormalAimNoiseDegrees
};
private float GetFireToleranceDegrees()
{
float tolerance = Difficulty switch
{
OMRBotDifficulty.Easy => EasyFireToleranceDegrees,
OMRBotDifficulty.Hard => HardFireToleranceDegrees,
_ => NormalFireToleranceDegrees
};
if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
tolerance *= 0.78f;
else if ( ActiveModifierIs( RoundModifier.HipfireOnly ) )
tolerance *= 1.08f;
return tolerance;
}
private float GetBotTriggerInterval( OneMoreRoundWeapon weapon )
{
float authored = MathF.Max( weapon?.PrimaryDelay ?? 0.1f, 0.01f );
float humanDelay = weapon?.IsAutomaticWeapon == true
? Difficulty switch
{
OMRBotDifficulty.Easy => 0.020f,
OMRBotDifficulty.Hard => 0.003f,
_ => 0.008f
}
: Difficulty switch
{
OMRBotDifficulty.Easy => 0.075f,
OMRBotDifficulty.Hard => 0.015f,
_ => 0.035f
};
float interval = authored + humanDelay;
if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
interval += 0.045f;
else if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
interval += 0.030f;
else if ( ActiveModifierIs( RoundModifier.Blitz ) )
interval -= 0.010f;
return MathF.Max(
interval,
MathF.Max( MinimumTriggerReset, 0.01f )
);
}
private void ResetCombatDiagnostics( string status )
{
CombatStatus = status;
CombatAimError = 0f;
CombatWantsAim = false;
CombatClip = -1;
CombatReserve = -1;
}
// =========================================================
// BOT 4 - PLAYER-EQUIVALENT MOVEMENT
// =========================================================
private void UpdateCombatStrafe(
OneMoreRoundWeapon weapon,
float distance,
float holdDistance
)
{
if ( FluidMovementEnabled )
{
UpdateFluidCombatMovement( weapon, distance, holdDistance );
return;
}
UpdateLegacyCombatStrafe( weapon, distance, holdDistance );
}
private void UpdateLegacyCombatStrafe(
OneMoreRoundWeapon weapon,
float distance,
float holdDistance
)
{
_pathPoints.Clear();
_pathPointIndex = 0;
NavigationStatus = "COMBAT STRAFE";
NavigationTargetName = _perceptionTarget?.DisplayName ?? "PLAYER";
NavigationTargetDistance = MathF.Round( distance );
if ( Time.Now >= _nextStrafeSwitchAt )
{
// Usually reverse, occasionally keep the current side for one more beat.
// The latter prevents a perfectly learnable left/right metronome.
float keepChance =
MathX.Clamp(
0.12f + MathF.Max( 0f, _dynamicMovementBias ) * 0.35f,
0.08f,
0.28f
);
if ( Game.Random.Float( 0f, 1f ) > keepChance )
_strafeDirection *= -1f;
_nextStrafeSwitchAt =
Time.Now + MathF.Max( 0.38f, GetStrafeSwitchInterval() );
}
float forward = 0f;
float retreatDistance =
holdDistance * MathX.Clamp( RetreatDistanceScale, 0.35f, 0.9f );
if ( distance < retreatDistance )
forward = -0.62f;
else if ( distance > holdDistance * 0.92f )
forward = 0.22f;
float lateral =
MathX.Clamp( StrafeStrength, 0.2f, 1f ) * _strafeDirection;
Vector3 moveInput =
new Vector3( forward, lateral, 0f )
.ClampLength( 1f );
bool crouchDown = false;
bool jumpPressed = false;
bool sprint = false;
ApplyCombatEvasionActions(
weapon,
distance,
ref crouchDown,
ref jumpPressed
);
_movementInput.SetBotInput(
moveInput,
sprintDown: sprint,
crouchDown: crouchDown,
jumpPressed: jumpPressed
);
string direction = _strafeDirection >= 0f ? "RIGHT" : "LEFT";
if ( _slide?.IsSliding == true )
{
SetMovementDiagnostics( "SLIDE" );
CurrentIntent = "COMBAT / SLIDING";
}
else if ( jumpPressed )
{
SetMovementDiagnostics( _controller.IsAirborne ? "AIR JUMP REQUEST" : "JUMP REQUEST" );
CurrentIntent = $"COMBAT / STRAFE {direction} / JUMP";
}
else if ( _controller?.IsDucking == true || _crouch?.WantsCrouch == true )
{
SetMovementDiagnostics( $"CROUCH STRAFE {direction}" );
CurrentIntent = $"COMBAT / CROUCH STRAFE {direction}";
}
else if ( forward < -0.01f )
{
SetMovementDiagnostics( $"RETREAT + STRAFE {direction}" );
CurrentIntent = $"COMBAT / RETREAT + STRAFE {direction}";
}
else
{
SetMovementDiagnostics( $"STRAFE {direction}" );
CurrentIntent = $"COMBAT / STRAFE {direction}";
}
}
private void ApplyAdvancedTraversalActions(
OneMoreRoundWeapon weapon,
float targetDistance,
bool visibleCombatTarget,
ref bool sprint,
ref bool crouchDown,
ref bool jumpPressed
)
{
if ( _controller?.IsValid != true )
return;
UpdateScheduledCrouchRelease();
// If a round grants an extra air jump, this synthetic second press will be
// accepted by PlayerLocomotion. Standard rounds simply ignore it. This keeps
// BOT 4 compatible with Double Jump without reading or bypassing the rule.
if (
_airJumpAttemptPending &&
_controller.IsAirborne &&
Time.Now >= _airJumpAttemptAt
)
{
jumpPressed = true;
_airJumpAttemptPending = false;
}
bool canSlide =
visibleCombatTarget &&
CanUseSlideWithWeapon( weapon ) &&
targetDistance >= SlideMinimumTargetDistance &&
targetDistance <= SlideMaximumTargetDistance &&
Time.Now >= _nextSlideAt &&
_controller.IsOnGround &&
_slide?.IsValid == true &&
!_slide.IsSliding &&
_sprint?.IsSprinting == true &&
_controller.Velocity.WithZ( 0f ).Length >= _slide.MinimumStartSpeed;
if ( canSlide )
{
PulseCrouchButton();
_nextSlideAt = Time.Now + MathF.Max( 1.5f, GetSlideCooldown() );
}
crouchDown = GetBotCrouchButtonDown();
}
private void ApplyCombatEvasionActions(
OneMoreRoundWeapon weapon,
float targetDistance,
ref bool crouchDown,
ref bool jumpPressed
)
{
if ( _controller?.IsValid != true )
return;
UpdateScheduledCrouchRelease();
if (
_airJumpAttemptPending &&
_controller.IsAirborne &&
Time.Now >= _airJumpAttemptAt
)
{
jumpPressed = true;
_airJumpAttemptPending = false;
}
float jumpMinDistance = JumpMinimumTargetDistance;
float jumpMaxDistance = JumpMaximumTargetDistance;
if ( ActiveModifierIs( RoundModifier.DoubleJump ) )
{
jumpMinDistance *= 0.72f;
jumpMaxDistance *= 1.42f;
}
else if ( ActiveModifierIs( RoundModifier.LowGravity ) )
{
jumpMinDistance *= 0.82f;
jumpMaxDistance *= 1.28f;
}
bool canGroundJump =
!jumpPressed &&
_controller.IsOnGround &&
Time.Now >= _nextJumpAt &&
targetDistance >= jumpMinDistance &&
targetDistance <= jumpMaxDistance &&
!IsPrecisionMovementWeapon( weapon ) &&
_slide?.IsSliding != true &&
_crouch?.WantsCrouch != true;
if ( canGroundJump )
{
float jumpChance =
MathX.Clamp(
0.72f + _dynamicMovementBias * 0.75f,
0.38f,
0.92f
);
if ( ActiveModifierIs( RoundModifier.DoubleJump ) )
jumpChance = MathF.Min( 0.96f, jumpChance + 0.16f );
else if ( ActiveModifierIs( RoundModifier.LowGravity ) )
jumpChance = MathF.Min( 0.92f, jumpChance + 0.10f );
if ( Game.Random.Float( 0f, 1f ) <= jumpChance )
{
jumpPressed = true;
_nextJumpAt = Time.Now + MathF.Max( 1.25f, GetJumpCooldown() );
bool hasExtraAirJump =
ActiveModifierIs( RoundModifier.DoubleJump ) ||
( _activeRoundRule?.AdditionalAirJumps ?? 0 ) > 0;
_airJumpAttemptPending = hasExtraAirJump;
_airJumpAttemptAt =
Time.Now + Game.Random.Float( 0.20f, 0.31f );
}
else
{
_nextJumpAt = Time.Now + Game.Random.Float( 0.45f, 0.85f );
}
}
bool canStartCrouchDodge =
!jumpPressed &&
_controller.IsOnGround &&
_slide?.IsSliding != true &&
_crouch?.IsValid == true &&
!_crouch.WantsCrouch &&
Time.Now >= _nextCrouchDodgeAt &&
targetDistance <= MathF.Max( 520f, GetCombatHoldDistance( weapon ) * 1.25f );
if ( canStartCrouchDodge )
{
PulseCrouchButton();
_crouchDodgeEndAt =
Time.Now + MathF.Max( 0.25f, CrouchDodgeDuration );
_nextCrouchDodgeAt =
Time.Now + MathF.Max( 2.5f, GetCrouchDodgeCooldown() );
}
crouchDown = GetBotCrouchButtonDown();
}
private void UpdateScheduledCrouchRelease()
{
bool slidingNow =
_slide?.IsValid == true && _slide.IsSliding;
// A slide is entered through the same crouch press as a human. If sprint
// stops before locomotion has a chance to clear that crouch intent, send the
// matching release-toggle after the slide so ROOK does not stay crouched by
// accident.
if (
_wasSliding &&
!slidingNow &&
_crouchDodgeEndAt < 0f &&
_crouch?.IsValid == true &&
_crouch.WantsCrouch
)
{
PulseCrouchButton();
}
_wasSliding = slidingNow;
if (
_airJumpAttemptPending &&
_controller?.IsOnGround == true &&
Time.Now >= _airJumpAttemptAt + 0.35f
)
{
_airJumpAttemptPending = false;
}
if (
_crouchDodgeEndAt < 0f ||
Time.Now < _crouchDodgeEndAt
)
return;
_crouchDodgeEndAt = -1f;
if ( _crouch?.IsValid == true && _crouch.WantsCrouch )
PulseCrouchButton();
}
private void PulseCrouchButton()
{
_crouchButtonHeldUntil =
MathF.Max( _crouchButtonHeldUntil, Time.Now + 0.08f );
}
private bool GetBotCrouchButtonDown() =>
Time.Now < _crouchButtonHeldUntil;
private bool CanUseSlideWithWeapon( OneMoreRoundWeapon weapon ) =>
GetWeaponDoctrine( weapon ).CanUseCombatSlide;
private bool IsPrecisionMovementWeapon( OneMoreRoundWeapon weapon ) =>
GetWeaponDoctrine( weapon ).IsPrecisionMovementWeapon;
private float GetStrafeSwitchInterval()
{
float interval = Difficulty switch
{
OMRBotDifficulty.Easy => StrafeSwitchInterval * 1.35f,
OMRBotDifficulty.Hard => StrafeSwitchInterval * 0.78f,
_ => StrafeSwitchInterval
};
interval *=
MathX.Clamp( 1f + _dynamicMovementBias * 0.75f, 0.68f, 1.30f );
if ( ActiveModifierIs( RoundModifier.Blitz ) )
interval *= 0.82f;
else if ( ActiveModifierIs( RoundModifier.HipfireOnly ) )
interval *= 0.88f;
// Randomize each committed strafe window, not every frame. This preserves
// readable movement while removing the metronomic left/right rhythm.
interval *= Game.Random.Float( 0.82f, 1.18f );
return MathF.Max( 0.38f, interval );
}
private float GetSlideCooldown() =>
Difficulty switch
{
OMRBotDifficulty.Easy => SlideCooldown * 1.35f,
OMRBotDifficulty.Hard => SlideCooldown * 0.82f,
_ => SlideCooldown
};
private float GetJumpCooldown()
{
float cooldown = Difficulty switch
{
OMRBotDifficulty.Easy => JumpCooldown * 1.35f,
OMRBotDifficulty.Hard => JumpCooldown * 0.80f,
_ => JumpCooldown
};
if ( ActiveModifierIs( RoundModifier.DoubleJump ) )
cooldown *= 0.58f;
else if ( ActiveModifierIs( RoundModifier.LowGravity ) )
cooldown *= 0.72f;
else if ( ActiveModifierIs( RoundModifier.Blitz ) )
cooldown *= 0.82f;
cooldown *=
MathX.Clamp( 1f - _dynamicMovementBias * 0.55f, 0.72f, 1.28f );
return MathF.Max( 1.25f, cooldown );
}
private float GetCrouchDodgeCooldown() =>
Difficulty switch
{
OMRBotDifficulty.Easy => CrouchDodgeCooldown * 1.35f,
OMRBotDifficulty.Hard => CrouchDodgeCooldown * 0.82f,
_ => CrouchDodgeCooldown
};
private void SetMovementDiagnostics( string status )
{
MovementStatus = status;
MovementSpeed =
_controller?.IsValid == true
? MathF.Round( _controller.Velocity.WithZ( 0f ).Length )
: 0f;
}
// =========================================================
// BOT 5A + 5B - TACTICAL COMBAT / SEARCH / HEARING
// =========================================================
private bool UsesAdvancedMovement() =>
BehaviorMode == OMRBotBehaviorMode.CombatMovement ||
BehaviorMode == OMRBotBehaviorMode.TacticalCombat;
private bool UsesTacticalBehavior() =>
BehaviorMode == OMRBotBehaviorMode.TacticalCombat;
private bool TryHandleSuppressiveFire( OneMoreRoundWeapon weapon )
{
if ( !UsesTacticalBehavior() || weapon?.IsValid != true )
return false;
if ( _hasConfirmedSight )
{
_suppressionUntil = -1f;
_suppressionShotsRemaining = 0;
SuppressionStatus = "NONE";
return false;
}
if ( Time.Now < _suppressionUntil && _suppressionShotsRemaining > 0 )
{
if ( weapon.IsReloading || weapon.Clip1 <= 0 )
{
_suppressionUntil = -1f;
_suppressionShotsRemaining = 0;
SuppressionStatus = "ABORT / AMMO";
return false;
}
UpdateSuppressiveMovement();
UpdateCombatAim( _suppressionAimPoint );
float distance = ( _suppressionAimPoint - WorldPosition ).WithZ( 0f ).Length;
bool wantsAds = ShouldUseAds( weapon, distance );
CombatWantsAim = wantsAds;
weapon.SetBotAimIntent( wantsAds );
if ( Time.Now >= _nextBotTriggerAt && CombatAimError <= GetFireToleranceDegrees() * 1.55f )
{
if ( weapon.BotTryPrimaryFire() )
{
_suppressionShotsRemaining--;
_nextBotTriggerAt = Time.Now + GetBotTriggerInterval( weapon );
}
}
SuppressionStatus = $"BURST · {_suppressionShotsRemaining} LEFT";
TacticalStatus = "SUPPRESS LAST SEEN";
CombatStatus = "SUPPRESSIVE FIRE";
NavigationStatus = "SUPPRESSING";
NavigationTargetName = "LAST SEEN";
NavigationTargetDistance = MathF.Round( distance );
CurrentIntent = "TACTICAL / SUPPRESSIVE FIRE";
if ( _suppressionShotsRemaining <= 0 || Time.Now >= _suppressionUntil )
{
_suppressionUntil = -1f;
_suppressionShotsRemaining = 0;
SuppressionStatus = "COMPLETE";
}
return true;
}
SuppressionStatus = "NONE";
if (
!CanUseSuppressiveFireWithWeapon( weapon ) ||
weapon.Clip1 < 3 ||
Time.Now < _nextSuppressionAt ||
!HasFreshMemory() ||
Time.Now - _lastConfirmedSeenAt > MathF.Max( 0.35f, SuppressionMemoryWindow ) ||
HasRecentThreat() ||
TacticalAggression < 0.38f
)
return false;
float chance = Difficulty switch
{
OMRBotDifficulty.Easy => 0.32f,
OMRBotDifficulty.Hard => 0.72f,
_ => 0.52f
};
chance *= MathX.Lerp( 0.65f, 1.20f, TacticalAggression );
chance *= MathX.Clamp( 1f + _dynamicPressureBias * 0.85f, 0.65f, 1.30f );
if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
chance *= 0.32f;
if ( ActiveModifierIs( RoundModifier.Blitz ) )
chance *= 0.72f;
if ( Game.Random.Float( 0f, 1f ) > MathX.Clamp( chance, 0.05f, 0.92f ) )
{
_nextSuppressionAt = Time.Now + MathF.Max( 1.2f, SuppressionCooldown * 0.55f );
return false;
}
Vector3 lateral = _lastSeenDirection.IsNearlyZero( 0.01f )
? Vector3.Zero
: new Vector3( -_lastSeenDirection.y, _lastSeenDirection.x, 0f );
float sideNoise = Game.Random.Float( -22f, 22f );
_suppressionAimPoint = _observedTargetAimPoint + lateral * sideNoise;
int desiredSuppressionShots =
ActiveModifierIs( RoundModifier.LargeCaliber )
? 2
: Math.Max( 2, SuppressionBurstShots );
_suppressionShotsRemaining =
Math.Min(
desiredSuppressionShots,
Math.Max( 2, weapon.Clip1 - 1 )
);
_suppressionUntil = Time.Now + 1.25f;
_nextSuppressionAt = Time.Now + MathF.Max( 1.4f, SuppressionCooldown );
SuppressionStatus = $"START · {_suppressionShotsRemaining} SHOTS";
return TryHandleSuppressiveFire( weapon );
}
private bool CanUseSuppressiveFireWithWeapon( OneMoreRoundWeapon weapon )
{
if (
ActiveModifierIs( RoundModifier.HeadshotOnly ) ||
ActiveModifierIs( RoundModifier.HighNoon )
)
{
return false;
}
return GetWeaponDoctrine( weapon ).SupportsSuppressiveFire;
}
private bool TryHandleFlankPlan( OneMoreRoundWeapon weapon )
{
if ( !_flankActive )
return false;
if ( _health?.IsValid == true && _health.Health <= 0f )
{
CancelFlank( "CANCEL / DEAD" );
return false;
}
float distance = ( _flankTarget - WorldPosition ).WithZ( 0f ).Length;
if ( distance <= 42f )
{
_flankActive = false;
FlankStatus = "COMPLETE";
TacticalStatus = "FLANK COMPLETE / REACQUIRE";
_pathPoints.Clear();
_pathPointIndex = 0;
return false;
}
bool stillHasEvidence = _hasConfirmedSight || HasFreshMemory() || HasActionableHearing();
if ( !stillHasEvidence )
{
CancelFlank( "CANCEL / LOST INTEL" );
return false;
}
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
{
UpdateCombatAim( _observedTargetAimPoint );
if ( weapon?.IsValid == true && weapon.Clip1 > 0 && !weapon.IsReloading )
{
float targetDistance = ( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
bool wantsAds = ShouldUseAds( weapon, targetDistance ) && targetDistance < 700f;
CombatWantsAim = wantsAds;
weapon.SetBotAimIntent( wantsAds );
UpdateVisibleWeaponAttack( weapon, targetDistance, wantsAds );
}
}
else
{
ReleaseBotWeaponIntent( weapon );
}
NavigateToWorldPosition(
_flankTarget,
"FLANK POSITION",
38f,
"TACTICAL FLANK",
allowSprint: !_hasConfirmedSight && distance > 260f,
faceMovement: !_hasConfirmedSight,
advancedMovement: true,
movementWeapon: weapon,
visibleCombatTarget: _hasConfirmedSight
);
FlankStatus = $"MOVING · {distance:0}u";
TacticalStatus = "FLANK / CHANGE ANGLE";
CurrentIntent = "TACTICAL / FLANKING";
return true;
}
private bool TryStartFlank( OneMoreRoundWeapon weapon )
{
if (
_flankActive ||
!_hasConfirmedSight ||
_perceptionTarget?.IsAlive != true ||
weapon?.IsValid != true ||
weapon.IsReloading ||
weapon.Clip1 <= 0 ||
HasRecentThreat() ||
Time.Now < _nextFlankDecisionAt ||
_confirmedSightStartedAt < 0f ||
Time.Now - _confirmedSightStartedAt < MathF.Max( 0.8f, FlankMinimumContactTime ) ||
TacticalAggression < 0.34f
)
return false;
float chance = Difficulty switch
{
OMRBotDifficulty.Easy => 0.20f,
OMRBotDifficulty.Hard => 0.62f,
_ => 0.40f
};
chance *= MathX.Lerp( 0.75f, 1.20f, TacticalAggression );
chance *= MathX.Clamp( 1f + _dynamicFlankBias * 1.15f, 0.55f, 1.45f );
float adaptiveConfidence = GetAdaptiveConfidenceWeight();
if ( adaptiveConfidence > 0f )
{
// Static/passive opponents are more flankable; highly mobile players make
// long committed rotations less attractive.
float adaptiveFlankScale =
MathX.Lerp(
1.18f,
0.82f,
_adaptiveOpponentMobility
);
chance *= MathX.Lerp( 1f, adaptiveFlankScale, adaptiveConfidence );
}
if ( ActiveModifierIs( RoundModifier.Blitz ) )
chance *= 1.18f;
else if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
chance *= 0.88f;
_nextFlankDecisionAt = Time.Now + MathF.Max( 2.1f, FlankDecisionCooldown );
if ( Game.Random.Float( 0f, 1f ) > MathX.Clamp( chance, 0.05f, 0.92f ) )
return false;
if ( !TryChooseFlankPosition( weapon, out Vector3 flank ) )
{
FlankStatus = "NO ROUTE";
return false;
}
_flankActive = true;
_flankTarget = flank;
_flankSide *= -1;
FlankStatus = "START / ANGLE CHANGE";
return TryHandleFlankPlan( weapon );
}
private bool TryChooseFlankPosition( OneMoreRoundWeapon weapon, out Vector3 best )
{
best = Vector3.Zero;
if ( Scene?.NavMesh is null || _perceptionTarget?.IsAlive != true )
return false;
Vector3 toTarget = ( _observedTargetPosition - WorldPosition ).WithZ( 0f );
if ( toTarget.IsNearlyZero( 0.01f ) )
return false;
Vector3 forward = toTarget.Normal;
Vector3 lateral = new Vector3( -forward.y, forward.x, 0f ).Normal;
float hold = GetCombatHoldDistance( weapon );
float minDistance = MathF.Max( 140f, FlankMinimumDistance );
float maxDistance = MathF.Max( minDistance + 60f, FlankMaximumDistance );
float[] distances = { minDistance, ( minDistance + maxDistance ) * 0.5f, maxDistance };
int[] sides = { _flankSide, -_flankSide };
List<Vector3> candidates = new();
foreach ( int side in sides )
{
foreach ( float distance in distances )
{
Vector3 raw =
WorldPosition +
lateral * side * distance +
forward * MathF.Min( 90f, distance * 0.22f );
candidates.Add( raw );
}
}
// Optional authored anchors are additional candidates, never commands. They
// still have to survive the same route/exposure/range evaluation as procedural
// positions.
AddAuthoredTacticalCandidates(
candidates,
OMRBotTacticalQueryPurpose.Flank,
WorldPosition,
maxDistance * 1.55f
);
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
Vector3 threatReference =
_observedTargetAimPoint.IsNearlyZero( 0.01f )
? _observedTargetPosition + Vector3.Up * 46f
: _observedTargetAimPoint;
float bestScore = float.MinValue;
OMRBotTacticalPositionEvaluation bestEvaluation = default;
foreach ( Vector3 raw in candidates )
{
if (
!TryEvaluateTacticalPosition(
raw,
OMRBotTacticalQueryPurpose.Flank,
doctrine,
threatReference,
true,
_observedTargetPosition,
hold,
out OMRBotTacticalPositionEvaluation evaluation
)
)
continue;
// Small commitment bias prevents left/right candidates with nearly identical
// tactical value from oscillating solely because traces moved a fraction.
Vector3 candidateDirection =
( evaluation.Position - WorldPosition ).WithZ( 0f );
float preferredSideBonus = 0f;
if ( !candidateDirection.IsNearlyZero( 0.01f ) )
{
float sideDot = candidateDirection.Normal.Dot( lateral ) * _flankSide;
preferredSideBonus = MathF.Max( 0f, sideDot ) * 14f;
}
float score = evaluation.Score + preferredSideBonus;
if ( score <= bestScore )
continue;
bestScore = score;
best = evaluation.Position;
bestEvaluation = evaluation;
}
if ( bestScore <= float.MinValue )
return false;
SetTacticalQueryDiagnostic( OMRBotTacticalQueryPurpose.Flank, bestEvaluation );
return true;
}
private void CancelFlank( string status )
{
_flankActive = false;
_flankTarget = Vector3.Zero;
FlankStatus = status;
}
private bool TryHandleIntelligentSearch( OneMoreRoundWeapon weapon )
{
if ( _hasConfirmedSight )
{
ClearSearchPlan( "CONTACT" );
return false;
}
bool visualEvidence = HasFreshMemory();
bool hearingEvidence = HasActionableHearing();
bool hearingIsNewer = hearingEvidence && ( !visualEvidence || _lastHeardAt > _lastConfirmedSeenAt + 0.18f );
bool evidenceChangedSincePlan =
( hearingIsNewer && _lastHeardAt > _searchPlanCreatedAt + 0.10f ) ||
( visualEvidence && _lastConfirmedSeenAt > _searchPlanCreatedAt + 0.10f );
float effectiveSearchLifetime =
MathF.Max(
1.5f,
SearchPlanLifetime *
( ActiveModifierIs( RoundModifier.Blitz ) ? 0.62f : 1f )
);
if (
_searchPoints.Count == 0 ||
_searchPointIndex >= _searchPoints.Count ||
evidenceChangedSincePlan ||
Time.Now - _searchPlanCreatedAt > effectiveSearchLifetime
)
{
Vector3 origin;
Vector3 direction;
string evidence;
if ( hearingIsNewer )
{
origin = _lastHeardPosition;
direction = ( origin - WorldPosition ).WithZ( 0f ).Normal;
evidence = $"HEARD {_lastHeardKind}";
}
else if ( visualEvidence )
{
origin = LastKnownPosition;
direction = !_lastSeenDirection.IsNearlyZero( 0.01f )
? _lastSeenDirection
: ( origin - WorldPosition ).WithZ( 0f ).Normal;
evidence = "LAST SEEN";
}
else if ( hearingEvidence )
{
origin = _lastHeardPosition;
direction = ( origin - WorldPosition ).WithZ( 0f ).Normal;
evidence = $"HEARD {_lastHeardKind}";
}
else if ( HasRecentThreat() )
{
direction = _lastThreatDirection;
origin = WorldPosition + direction * 360f;
evidence = "FIRE DIRECTION";
}
else
{
return TryHandleNoContactPatrol( weapon );
}
if ( !BuildSearchPlan( origin, direction, evidence ) )
return TryHandleNoContactPatrol( weapon );
}
if ( _searchPointIndex >= _searchPoints.Count )
return false;
Vector3 point = _searchPoints[_searchPointIndex];
float distance = ( point - WorldPosition ).WithZ( 0f ).Length;
float arrival = 48f;
if ( distance > arrival )
{
ReleaseBotWeaponIntent( weapon );
NavigateToWorldPosition(
point,
$"SEARCH {_searchPointIndex + 1}/{_searchPoints.Count}",
arrival,
"TACTICAL SEARCH / CLEARING",
allowSprint: distance > 260f && _lastHeardConfidence < 0.8f,
faceMovement: true,
advancedMovement: true,
movementWeapon: weapon,
visibleCombatTarget: false
);
SearchStatus = $"MOVING {_searchPointIndex + 1}/{_searchPoints.Count} · {_searchEvidence}";
TacticalStatus = "SEARCH / CLEAR SECTOR";
CombatStatus = "SEARCHING";
return true;
}
UpdateSearchClearMovement();
ReleaseBotWeaponIntent( weapon );
NavigationStatus = "SEARCH CLEAR";
NavigationTargetName = $"SEARCH {_searchPointIndex + 1}";
NavigationTargetDistance = MathF.Round( distance );
if ( _searchPauseUntil < 0f )
_searchPauseUntil = Time.Now + GetEffectiveSearchPause();
UpdateSearchLook();
SearchStatus = $"CLEARING {_searchPointIndex + 1}/{_searchPoints.Count} · {_searchEvidence}";
TacticalStatus = "SEARCH / CHECK ANGLES";
CombatStatus = "CLEARING CORNER";
if ( Time.Now >= _searchPauseUntil )
{
MarkOpponentBeliefAreaCleared( point );
_searchPointIndex++;
_searchPauseUntil = -1f;
if ( _searchPointIndex < _searchPoints.Count )
{
Vector3 next = ( _searchPoints[_searchPointIndex] - WorldPosition ).WithZ( 0f );
if ( !next.IsNearlyZero( 0.01f ) )
_searchFacingDirection = next.Normal;
}
}
return true;
}
private bool BuildSearchPlan( Vector3 origin, Vector3 direction, string evidence )
{
_searchPoints.Clear();
_searchPointIndex = 0;
_searchPauseUntil = -1f;
_searchPlanCreatedAt = Time.Now;
_searchEvidence = evidence;
Vector3 planarDirection = direction.WithZ( 0f );
if ( planarDirection.IsNearlyZero( 0.01f ) )
planarDirection = WorldRotation.Forward.WithZ( 0f );
planarDirection = planarDirection.Normal;
Vector3 lateral = new Vector3( -planarDirection.y, planarDirection.x, 0f ).Normal;
Vector3 predicted = origin;
if ( evidence == "LAST SEEN" && !_lastSeenVelocity.WithZ( 0f ).IsNearlyZero( 1f ) )
{
Vector3 prediction =
_lastSeenVelocity.WithZ( 0f ) *
MathF.Max( 0.15f, SearchPredictionSeconds * GetModifierSearchPredictionScale() );
prediction = prediction.ClampLength( MathF.Max( 80f, SearchForwardDistance * 1.15f ) );
predicted += prediction;
}
TryAddSearchPoint( predicted );
TryAddSearchPoint( predicted + planarDirection * MathF.Max( 90f, SearchForwardDistance ) );
float sideDistance = MathF.Max( 70f, SearchLateralDistance );
int firstSide = _flankSide >= 0 ? 1 : -1;
TryAddSearchPoint( predicted + lateral * sideDistance * firstSide );
TryAddSearchPoint( predicted - lateral * sideDistance * firstSide );
// BOT 7E adds the strongest *inferred* current locations. These cells are
// produced only from fair evidence and uncertainty propagation, not from the
// opponent's hidden transform.
AddBeliefSearchCandidates( 4 );
// If repeated confirmed observations say the player favors a nearby spot,
// include that *historical* location as another search hypothesis. This is
// memory, not wall vision: it is only populated while the opponent was seen.
if (
TryGetLearnedOpponentHotspot(
predicted,
MathF.Max( 260f, SearchForwardDistance * 2.0f ),
out Vector3 nearbyHotspot,
out _
)
)
{
TryAddSearchPoint( nearbyHotspot );
}
// Authored search/choke/ambush hints are optional hypotheses. They do not
// reveal the player and remain subject to reachability plus tactical scoring.
List<Vector3> authored = new();
AddAuthoredTacticalCandidates(
authored,
OMRBotTacticalQueryPurpose.Search,
predicted,
MathF.Max( 300f, SearchForwardDistance * 2.25f )
);
foreach ( Vector3 point in authored )
TryAddSearchPoint( point );
ReorderSearchPointsByMemoryRisk( predicted, evidence );
_searchFacingDirection = planarDirection;
SearchStatus = _searchPoints.Count > 0 ? $"PLAN {_searchPoints.Count} · {evidence}" : "NO PLAN";
return _searchPoints.Count > 0;
}
private void TryAddSearchPoint( Vector3 raw )
{
if ( Scene?.NavMesh is null )
return;
Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 130f );
if ( projected is null )
return;
foreach ( Vector3 existing in _searchPoints )
{
if ( ( existing - projected.Value ).WithZ( 0f ).Length < 70f )
return;
}
if ( !TryEvaluateTacticalRoute( projected.Value, Vector3.Zero, false, out _ ) ) return;
_searchPoints.Add( projected.Value );
}
private void ReorderSearchPointsByMemoryRisk(
Vector3 evidenceOrigin,
string evidence
)
{
if ( _searchPoints.Count <= 1 || Scene?.NavMesh is null )
return;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( ResolveActiveWeapon() );
float desiredRange = MathF.Max( 90f, doctrine.LegacyHoldDistance );
Vector3 threatReference = evidenceOrigin + Vector3.Up * 34f;
List<(Vector3 Point, float Score, OMRBotTacticalPositionEvaluation Evaluation)> scored = new();
foreach ( Vector3 point in _searchPoints )
{
if (
!TryEvaluateTacticalPosition(
point,
OMRBotTacticalQueryPurpose.Search,
doctrine,
threatReference,
true,
evidenceOrigin,
desiredRange,
out OMRBotTacticalPositionEvaluation evaluation
)
)
continue;
float score = evaluation.Score;
score += GetOpponentBeliefAt( evaluation.Position ) * MathF.Max( 0f, BeliefSearchScoreWeight );
if ( string.Equals( evidence, "LEARNED HOTSPOT", StringComparison.OrdinalIgnoreCase ) )
{
// Historical evidence is intentionally weaker than fresh visual/audio
// evidence. Route quality therefore has more room to influence the order.
score -= ( 1f - evaluation.EvidenceRelevance ) * 24f;
}
scored.Add( ( evaluation.Position, score, evaluation ) );
}
if ( scored.Count == 0 )
return;
scored.Sort( ( a, b ) => b.Score.CompareTo( a.Score ) );
_searchPoints.Clear();
foreach ( var item in scored )
_searchPoints.Add( item.Point );
SetTacticalQueryDiagnostic( OMRBotTacticalQueryPurpose.Search, scored[0].Evaluation );
}
private void UpdateSearchLook()
{
if ( _controller?.IsValid != true )
return;
Vector3 direction = _searchFacingDirection;
if ( direction.IsNearlyZero( 0.01f ) )
direction = WorldRotation.Forward.WithZ( 0f );
if ( TryGetBestOpponentBelief( out Vector3 beliefLook, out float beliefLookConfidence ) )
{
Vector3 toBelief = ( beliefLook - WorldPosition ).WithZ( 0f );
if ( !toBelief.IsNearlyZero( 0.01f ) )
{
float blend = MathX.Clamp( beliefLookConfidence * 1.35f, 0f, 0.48f );
direction = Vector3.Lerp( direction.Normal, toBelief.Normal, blend ).Normal;
}
}
float sweep = MathF.Sin( ( Time.Now + BotSlot * 0.31f ) * 3.2f ) * 34f;
Angles baseAngles = Rotation.LookAt( direction.Normal, Vector3.Up ).Angles();
Angles desired = new( 0f, baseAngles.yaw + sweep, 0f );
float follow = 1f - MathF.Exp( -7f * MathF.Max( Time.Delta, 0f ) );
_controller.EyeAngles = Rotation.Slerp(
_controller.EyeAngles.ToRotation(),
desired.ToRotation(),
MathX.Clamp( follow, 0f, 1f )
).Angles();
}
private bool TryHandleNoContactPatrol( OneMoreRoundWeapon weapon )
{
if ( _searchPoints.Count == 0 || _searchPointIndex >= _searchPoints.Count || Time.Now - _searchPlanCreatedAt > 4.5f )
{
Vector3 forward = WorldRotation.Forward.WithZ( 0f );
if ( _controller?.IsValid == true )
forward = _controller.EyeTransform.Rotation.Forward.WithZ( 0f );
if ( forward.IsNearlyZero( 0.01f ) ) forward = new Vector3( 1f, 0f, 0f );
forward = forward.Normal;
bool usedLearnedHotspot = false;
// Prefer the current belief peak over long-term historical habits. The peak
// fades/clears as ROOK disproves areas, so this is a search hypothesis rather
// than a permanent camp marker.
if (
TryGetBestOpponentBelief( out Vector3 beliefPeak, out float beliefPeakConfidence ) &&
beliefPeakConfidence >= MathF.Max( 0.035f, BeliefMinimumCellProbability * 2f )
)
{
Vector3 beliefDirection = ( beliefPeak - WorldPosition ).WithZ( 0f );
if ( !beliefDirection.IsNearlyZero( 0.01f ) )
{
usedLearnedHotspot = BuildSearchPlan( beliefPeak, beliefDirection.Normal, "BELIEF PEAK" );
if ( usedLearnedHotspot )
SearchStatus = $"BELIEF PEAK · {beliefPeakConfidence * 100f:0}%";
}
}
float hotspotUseChance = Difficulty switch
{
OMRBotDifficulty.Easy => 0.30f,
OMRBotDifficulty.Hard => 0.72f,
_ => 0.50f
};
if (
!usedLearnedHotspot &&
TryGetLearnedOpponentHotspot(
WorldPosition,
MathF.Max( 700f, SightRange ),
out Vector3 hotspot,
out float hotspotScore
) &&
Game.Random.Float( 0f, 1f ) <= hotspotUseChance
)
{
Vector3 hotspotDirection =
( hotspot - WorldPosition ).WithZ( 0f );
if ( !hotspotDirection.IsNearlyZero( 0.01f ) )
{
hotspotDirection = hotspotDirection.Normal;
usedLearnedHotspot =
BuildSearchPlan(
hotspot,
hotspotDirection,
"LEARNED HOTSPOT"
);
if ( usedLearnedHotspot )
{
MarkLearnedHotspotChecked( hotspot );
SearchStatus = $"LEARNED HOTSPOT · {hotspotScore:0.0}";
}
}
}
if ( !usedLearnedHotspot )
{
Vector3 lateral = new Vector3( -forward.y, forward.x, 0f );
Vector3 origin = WorldPosition + forward * 180f + lateral * _flankSide * 150f;
if ( !BuildSearchPlan( origin, forward, "AREA PATROL" ) )
{
StopMovementOnly();
SweepLook( IdleSweepSpeed * 0.55f );
SearchStatus = "FALLBACK SCAN";
return true;
}
}
}
return TryHandleIntelligentSearch( weapon );
}
private void ClearSearchPlan( string status )
{
_searchPoints.Clear();
_searchPointIndex = 0;
_searchPauseUntil = -1f;
_searchPlanCreatedAt = -999f;
_searchEvidence = string.Empty;
SearchStatus = status;
}
// =========================================================
// BOT 6 - MODIFIER AWARENESS / ONLINE ADAPTATION
// =========================================================
private void UpdateModifierAwareness()
{
if ( !UsesTacticalBehavior() )
{
_activeModifier = RoundModifier.Standard;
_activeRoundRule = null;
ModifierStatus = "OFF";
return;
}
if ( _roundManager?.IsValid != true )
{
_roundManager =
Scene?
.GetAllComponents<RoundManager>()
.FirstOrDefault();
}
if ( _roundRuleDirector?.IsValid != true )
{
_roundRuleDirector =
Scene?
.GetAllComponents<OMRRoundRuleDirector>()
.FirstOrDefault();
}
RoundModifier resolved =
_roundManager is not null &&
_roundManager.Mode == SessionMode.Sandbox
? _roundManager.SandboxBotTestModifier
: _roundManager?.ActiveModifier ?? RoundModifier.Standard;
if ( resolved != _activeModifier )
{
_activeModifier = resolved;
_highNoonFireAt = -1f;
_lastHighNoonStage = HighNoonStage.Inactive;
}
_activeRoundRule =
_roundRuleDirector?.
ResolveRule( _activeModifier );
string modifierName =
_activeRoundRule?.GetDisplayName() ??
_activeModifier.ToString().ToUpperInvariant();
string adaptation = _activeModifier switch
{
RoundModifier.HighNoon => "DUEL DISCIPLINE",
RoundModifier.LowGravity => "AIR / FLOAT TRACKING",
RoundModifier.HeadshotOnly => "HEAD PRIORITY",
RoundModifier.LargeCaliber => "AMMO DISCIPLINE",
RoundModifier.DoubleJump => "AIR MOBILITY",
RoundModifier.HipfireOnly => "HIPFIRE / CLOSE",
RoundModifier.Blitz => "FAST TEMPO",
_ => "BASELINE"
};
ModifierStatus = $"{modifierName} · {adaptation}";
}
private bool ActiveModifierIs( RoundModifier modifier ) =>
UsesTacticalBehavior() &&
_activeModifier == modifier;
private void UpdateDynamicTacticalVariation()
{
if ( !UsesTacticalBehavior() )
return;
if ( Time.Now < _nextDynamicVariationAt )
return;
float strength =
MathX.Clamp( DynamicVariationStrength, 0f, 0.35f );
// Hard still varies tactically, but its swings are a little tighter so
// unpredictability does not become deliberate stupidity.
float difficultyScale = Difficulty switch
{
OMRBotDifficulty.Easy => 1.15f,
OMRBotDifficulty.Hard => 0.82f,
_ => 1f
};
strength *= difficultyScale;
_dynamicPressureBias = Game.Random.Float( -strength, strength );
_dynamicCoverBias =
Game.Random.Float( -strength, strength ) -
_dynamicPressureBias * 0.35f;
_dynamicFlankBias = Game.Random.Float( -strength, strength );
_dynamicMovementBias = Game.Random.Float( -strength, strength );
_dynamicDecisionSerial++;
float minInterval =
MathF.Max( 0.8f, MathF.Min( DynamicDecisionIntervalMin, DynamicDecisionIntervalMax ) );
float maxInterval =
MathF.Max( minInterval + 0.1f, MathF.Max( DynamicDecisionIntervalMin, DynamicDecisionIntervalMax ) );
_nextDynamicVariationAt =
Time.Now + Game.Random.Float( minInterval, maxInterval );
}
private void UpdateOpponentLearning()
{
if ( !UsesTacticalBehavior() || !AdaptiveLearningEnabled )
{
AdaptationStatus = "DISABLED";
AdaptationConfidence = 0f;
return;
}
if (
!_hasConfirmedSight ||
_perceptionTarget?.IsAlive != true ||
Time.Now < _nextAdaptiveSampleAt
)
{
UpdateAdaptationDiagnostics();
return;
}
PlayerController targetController =
_perceptionTarget.Components.Get<PlayerController>();
if ( targetController?.IsValid != true )
{
UpdateAdaptationDiagnostics();
return;
}
_nextAdaptiveSampleAt = Time.Now + 0.18f;
Vector3 targetVelocity =
targetController.Velocity.WithZ( 0f );
float speed = targetVelocity.Length;
float distance =
( _observedTargetPosition - WorldPosition )
.WithZ( 0f )
.Length;
Vector3 towardBot =
( WorldPosition - _observedTargetPosition )
.WithZ( 0f );
if ( !towardBot.IsNearlyZero( 0.01f ) )
towardBot = towardBot.Normal;
float approachSpeed =
!towardBot.IsNearlyZero( 0.01f )
? targetVelocity.Dot( towardBot )
: 0f;
// Blend kinematic approach with actual distance trend. The second term
// catches aggressive pushes even when collision/strafe makes velocity noisy.
if ( _adaptivePreviousDistance >= 0f && _adaptivePreviousSampleAt > 0f )
{
float dt = MathF.Max( 0.05f, Time.Now - _adaptivePreviousSampleAt );
float distanceClosing =
( _adaptivePreviousDistance - distance ) / dt;
approachSpeed =
MathX.Lerp(
approachSpeed,
distanceClosing,
0.45f
);
}
_adaptivePreviousDistance = distance;
_adaptivePreviousSampleAt = Time.Now;
PlayerSlide targetSlide =
_perceptionTarget.Components.Get<PlayerSlide>();
PlayerCrouch targetCrouch =
_perceptionTarget.Components.Get<PlayerCrouch>();
PlayerSprint targetSprint =
_perceptionTarget.Components.Get<PlayerSprint>();
float mobilitySample =
MathX.Clamp( speed / 285f, 0f, 1f );
if ( targetSlide?.IsSliding == true )
mobilitySample = MathF.Max( mobilitySample, 0.95f );
else if ( targetSprint?.IsSprinting == true )
mobilitySample = MathF.Max( mobilitySample, 0.78f );
float airSample =
targetController.IsAirborne ? 1f : 0f;
float crouchSample =
( targetController.IsDucking || targetCrouch?.WantsCrouch == true )
? 1f
: 0f;
float aggressionSample =
MathX.Clamp(
0.50f + approachSpeed / 360f,
0f,
1f
);
if (
HasFreshHearing() &&
_lastHeardKind == "GUNSHOT" &&
Time.Now - _lastHeardAt <= 0.32f
)
{
aggressionSample =
MathF.Min( 1f, aggressionSample + 0.10f );
}
float distanceSample =
MathX.Clamp(
distance / MathF.Max( 500f, SightRange ),
0f,
1f
);
float rate =
MathX.Clamp( AdaptiveLearningRate, 0.01f, 0.35f );
_adaptiveOpponentAggression =
AdaptiveBlend( _adaptiveOpponentAggression, aggressionSample, rate );
_adaptiveOpponentMobility =
AdaptiveBlend( _adaptiveOpponentMobility, mobilitySample, rate );
_adaptiveOpponentAirUse =
AdaptiveBlend( _adaptiveOpponentAirUse, airSample, rate * 0.75f );
_adaptiveOpponentCrouchUse =
AdaptiveBlend( _adaptiveOpponentCrouchUse, crouchSample, rate * 0.70f );
_adaptivePreferredDistance =
AdaptiveBlend( _adaptivePreferredDistance, distanceSample, rate * 0.60f );
_adaptiveSampleCount++;
UpdateAdaptationDiagnostics();
}
private static float AdaptiveBlend( float current, float sample, float rate )
{
return MathX.Lerp(
current,
MathX.Clamp( sample, 0f, 1f ),
MathX.Clamp( rate, 0f, 1f )
);
}
private void UpdateAdaptationDiagnostics()
{
if ( !UsesTacticalBehavior() )
{
AdaptationStatus = "OFF";
AdaptationConfidence = 0f;
return;
}
if ( !AdaptiveLearningEnabled )
{
AdaptationStatus = "DISABLED";
AdaptationConfidence = 0f;
return;
}
int minimumSamples =
Math.Max( 1, AdaptiveMinimumSamples );
AdaptationConfidence =
MathX.Clamp(
_adaptiveSampleCount / (float)( minimumSamples * 2 ),
0f,
1f
);
if ( _adaptiveSampleCount < minimumSamples )
{
AdaptationStatus =
$"LEARNING {_adaptiveSampleCount}/{minimumSamples} · VAR {_dynamicDecisionSerial}";
return;
}
string style;
if ( _adaptiveOpponentAggression >= 0.66f && _adaptiveOpponentMobility >= 0.58f )
style = "RUSH / MOBILE";
else if ( _adaptiveOpponentAggression >= 0.66f )
style = "PRESSURE";
else if ( _adaptivePreferredDistance >= 0.62f )
style = "RANGE";
else if ( _adaptiveOpponentMobility >= 0.68f || _adaptiveOpponentAirUse >= 0.34f )
style = "MOBILE";
else if ( _adaptiveOpponentCrouchUse >= 0.34f )
style = "LOW PROFILE";
else if ( _adaptiveOpponentAggression <= 0.38f )
style = "CAUTIOUS";
else
style = "BALANCED";
AdaptationStatus =
$"{style} · AGG {_adaptiveOpponentAggression * 100f:0}% · MOB {_adaptiveOpponentMobility * 100f:0}%";
}
private float GetAdaptiveConfidenceWeight()
{
if ( !AdaptiveLearningEnabled )
return 0f;
return MathX.Clamp( AdaptationConfidence, 0f, 1f );
}
private float GetAdaptivePressureAdjustment()
{
float confidence = GetAdaptiveConfidenceWeight();
if ( confidence <= 0f )
return 0f;
// Counter-style instead of mirror-style: repeated hard pushes make a
// long-range bot value space, while a passive/ranged opponent invites angle
// changes and pressure. The effect is intentionally modest.
float opponentPressure =
( _adaptiveOpponentAggression - 0.50f ) * 0.18f;
return -opponentPressure * confidence;
}
private float GetAdaptiveHoldDistanceScale( OneMoreRoundWeapon weapon )
{
float confidence = GetAdaptiveConfidenceWeight();
if ( confidence <= 0f || weapon?.IsValid != true )
return 1f;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float scale = 1f;
if ( _adaptiveOpponentAggression >= 0.62f )
{
// Preserve space with rifles/precision weapons against a habitual pusher.
if ( doctrine.PreserveSpaceAgainstRush )
scale += 0.12f * confidence;
}
else if ( _adaptivePreferredDistance >= 0.62f )
{
// Close-range weapons should not politely accept the opponent's favorite
// long-range duel forever.
if ( doctrine.CloseDistanceAgainstRangedOpponent )
scale -= 0.14f * confidence;
}
return MathX.Clamp( scale, 0.82f, 1.18f );
}
private float GetEffectiveSearchPause()
{
float pause = MathF.Max( 0.16f, SearchPointPause );
pause *= Difficulty switch
{
OMRBotDifficulty.Easy => 1.12f,
OMRBotDifficulty.Hard => 0.72f,
_ => 0.88f
};
if ( ActiveModifierIs( RoundModifier.Blitz ) )
pause *= 0.58f;
float confidence = GetAdaptiveConfidenceWeight();
pause *= MathX.Lerp(
1f,
MathX.Lerp( 1.05f, 0.72f, _adaptiveOpponentMobility ),
confidence
);
return MathF.Max( 0.14f, pause );
}
private float GetModifierSearchPredictionScale()
{
float scale = 1f;
if ( ActiveModifierIs( RoundModifier.Blitz ) )
scale *= 1.08f;
if ( ActiveModifierIs( RoundModifier.LowGravity ) )
scale *= 1.10f;
float confidence = GetAdaptiveConfidenceWeight();
scale *= MathX.Lerp(
1f,
MathX.Lerp( 0.88f, 1.22f, _adaptiveOpponentMobility ),
confidence
);
return MathX.Clamp( scale, 0.78f, 1.30f );
}
private bool TryHandleHighNoonBehavior( OneMoreRoundWeapon weapon )
{
if (
!ActiveModifierIs( RoundModifier.HighNoon ) ||
_roundManager?.Mode != SessionMode.Match ||
!_roundManager.ActiveUsesHighNoonFlow
)
{
_highNoonFireAt = -1f;
_lastHighNoonStage = HighNoonStage.Inactive;
return false;
}
if ( _hasCoverTarget )
ClearCoverPlan( "HIGH NOON" );
if ( _flankActive )
CancelFlank( "HIGH NOON" );
if ( _searchPoints.Count > 0 )
ClearSearchPlan( "HIGH NOON" );
_suppressionUntil = -1f;
_suppressionShotsRemaining = 0;
SuppressionStatus = "OFF / HIGH NOON";
StopMovementOnly();
NavigationStatus = "HIGH NOON / LOCKED";
NavigationTargetName = _perceptionTarget?.DisplayName ?? "OPPONENT";
NavigationTargetDistance =
_perceptionTarget?.IsAlive == true
? MathF.Round( ( _perceptionTarget.WorldPosition - WorldPosition ).WithZ( 0f ).Length )
: 0f;
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
UpdateCombatAim( ResolveCombatAimPoint( _perceptionTarget ) );
weapon?.SetBotAimIntent( false );
CombatWantsAim = false;
HighNoonStage stage = _roundManager.HighNoonState;
if ( stage != _lastHighNoonStage )
{
_lastHighNoonStage = stage;
_highNoonFireAt = -1f;
}
if (
_roundManager.Phase != RoundPhase.Fighting ||
stage == HighNoonStage.Inactive
)
{
TacticalStatus = "HIGH NOON / READY";
CombatStatus = "HIGH NOON / LOCKED";
CurrentIntent = "HIGH NOON / HOLD";
return true;
}
if ( stage == HighNoonStage.WaitingForDraw )
{
TacticalStatus = "HIGH NOON / WAIT DRAW";
CombatStatus = "WAITING FOR DRAW";
CurrentIntent = "HIGH NOON / DISCIPLINE";
return true;
}
if ( stage == HighNoonStage.DrawOpen )
{
if ( _highNoonFireAt < 0f )
_highNoonFireAt = Time.Now + GetHighNoonReactionDelay();
float remaining = MathF.Max( 0f, _highNoonFireAt - Time.Now );
TacticalStatus = "HIGH NOON / DRAW";
CurrentIntent = "HIGH NOON / DRAW";
if ( Time.Now < _highNoonFireAt )
{
CombatStatus = $"REACTION {remaining:0.00}s";
return true;
}
if (
_hasConfirmedSight &&
_perceptionTarget?.IsAlive == true &&
weapon?.IsValid == true &&
weapon.Clip1 > 0 &&
!weapon.IsReloading &&
!weapon.IsCyclePendingOrActive
)
{
float tolerance = GetFireToleranceDegrees() * 1.35f;
if ( CombatAimError <= tolerance || Time.Now - _highNoonFireAt >= 0.16f )
{
if ( weapon.BotTryPrimaryFire() )
{
CombatStatus = "DRAW FIRED";
_highNoonFireAt = Time.Now + MathF.Max( 0.22f, GetBotTriggerInterval( weapon ) );
return true;
}
}
}
CombatStatus = "DRAW / ALIGNING";
return true;
}
return true;
}
private float GetHighNoonReactionDelay()
{
float min;
float max;
switch ( Difficulty )
{
case OMRBotDifficulty.Easy:
min = 0.34f;
max = 0.52f;
break;
case OMRBotDifficulty.Hard:
min = 0.16f;
max = 0.27f;
break;
default:
min = 0.23f;
max = 0.38f;
break;
}
return Game.Random.Float( min, max );
}
private void MaintainReactionMomentum()
{
if ( _movementInput?.IsValid != true || _controller?.IsValid != true )
{
StopMovementOnly();
return;
}
Vector3 velocity = _controller.Velocity.WithZ( 0f );
Vector3 localInput;
if ( velocity.Length >= 36f )
{
Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
localInput =
( basis.Inverse * velocity.Normal )
.WithZ( 0f )
.ClampLength( 0.72f );
}
else
{
float side = _strafeDirection >= 0f ? 1f : -1f;
float strength =
MathX.Clamp(
0.14f + MathF.Abs( _dynamicMovementBias ) * 0.30f,
0.12f,
0.28f
);
localInput = new Vector3( 0f, side * strength, 0f );
}
_movementInput.SetBotInput(
localInput,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
SetMovementDiagnostics( "REACTION / MOMENTUM" );
}
private void UpdateSearchClearMovement()
{
if ( _movementInput?.IsValid != true || _controller?.IsValid != true )
return;
float strength =
MathX.Clamp( SearchClearMoveStrength, 0f, 0.65f );
if ( strength <= 0.01f )
{
StopMovementOnly();
return;
}
float side = _flankSide >= 0 ? 1f : -1f;
Vector3 local = new Vector3( 0.05f, side * strength, 0f ).ClampLength( 1f );
Rotation basis = Rotation.FromYaw( _controller.EyeAngles.yaw );
Vector3 worldDirection = ( basis * local ).WithZ( 0f );
if ( !worldDirection.IsNearlyZero( 0.01f ) && !IsTraversalDirectionClear( worldDirection ) )
{
side *= -1f;
_flankSide *= -1;
local = new Vector3( 0.03f, side * strength, 0f ).ClampLength( 1f );
}
_movementInput.SetBotInput(
local,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
SetMovementDiagnostics( side > 0f ? "SEARCH CLEAR / DRIFT RIGHT" : "SEARCH CLEAR / DRIFT LEFT" );
}
private void UpdateSuppressiveMovement()
{
if ( _movementInput?.IsValid != true || _controller?.IsValid != true )
return;
float strength =
MathX.Clamp( SuppressionMoveStrength, 0f, 0.65f );
if ( strength <= 0.01f )
{
StopMovementOnly();
return;
}
float side = _strafeDirection >= 0f ? 1f : -1f;
Vector3 local = new Vector3( -0.04f, side * strength, 0f ).ClampLength( 1f );
_movementInput.SetBotInput(
local,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
SetMovementDiagnostics( side > 0f ? "SUPPRESS / STRAFE RIGHT" : "SUPPRESS / STRAFE LEFT" );
}
// =========================================================
// BOT 6.1 - SPATIAL / EPISODIC MEMORY
// =========================================================
private (int X, int Y) GetSpatialMemoryKey( Vector3 position )
{
float size = MathF.Max( 48f, SpatialMemoryCellSize );
return (
(int)MathF.Floor( position.x / size ),
(int)MathF.Floor( position.y / size )
);
}
private Vector3 GetSpatialMemoryCellCenter( (int X, int Y) key, float z = 0f )
{
float size = MathF.Max( 48f, SpatialMemoryCellSize );
return new Vector3(
( key.X + 0.5f ) * size,
( key.Y + 0.5f ) * size,
z
);
}
private SpatialMemoryCell GetSpatialMemoryCell( (int X, int Y) key, bool create )
{
if ( !_spatialMemory.TryGetValue( key, out SpatialMemoryCell cell ) )
{
if ( !create )
return null;
cell = new SpatialMemoryCell
{
LastUpdatedAt = Time.Now
};
_spatialMemory[key] = cell;
return cell;
}
DecaySpatialMemoryCell( cell );
return cell;
}
private void DecaySpatialMemoryCell( SpatialMemoryCell cell )
{
if ( cell is null )
return;
float dt = MathF.Max( 0f, Time.Now - cell.LastUpdatedAt );
if ( dt <= 0.001f )
return;
float tacticalHalfLife = MathF.Max( 8f, SpatialMemoryHalfLife );
float routeHalfLife = MathF.Max( 6f, RouteMemoryHalfLife );
float tacticalDecay = MathF.Pow( 0.5f, dt / tacticalHalfLife );
float routeDecay = MathF.Pow( 0.5f, dt / routeHalfLife );
cell.OpponentPresence *= tacticalDecay;
cell.OpponentStationaryPresence *= tacticalDecay;
cell.Danger *= tacticalDecay;
cell.DeathDanger *= tacticalDecay;
cell.RouteUse *= routeDecay;
cell.LastUpdatedAt = Time.Now;
}
private void UpdateSpatialMemoryLayoutScope()
{
if ( _memoryArena?.IsValid != true )
{
_memoryArena =
Scene?
.GetAllComponents<ArenaController>()
.FirstOrDefault();
}
if ( _memoryArena?.IsValid != true )
return;
ArenaLayoutState state = _memoryArena.CurrentLayoutState;
if ( !state.IsValid )
return;
if ( !_memoryLayoutInitialized )
{
_memoryLayoutInitialized = true;
_memoryLayoutState = state;
return;
}
if ( state == _memoryLayoutState )
return;
// Spatial coordinates mean something different after OMR swaps modular
// layout pieces. Keep the opponent-style model, but clear map-specific
// danger/hotspot memory so old geometry never turns into fake knowledge.
_memoryLayoutState = state;
ResetPerformanceCaches();
ClearCoverPlan( "LAYOUT CHANGED" );
_pathPoints.Clear();
_pathPointIndex = 0;
_nextRepathAt = 0f;
_spatialMemory.Clear();
_recentRouteSamples.Clear();
ResetOpponentBelief( "LAYOUT RESET" );
ResetPlayerPrediction( clearStyle: false );
_spatialDeathCount = 0;
_learnedHotspotChecks = 0;
ClearSearchPlan( "LAYOUT CHANGED" );
CancelFlank( "LAYOUT CHANGED" );
SpatialMemoryStatus = "LAYOUT RESET";
}
private void UpdateSpatialMemoryLifecycle()
{
if ( !UsesTacticalBehavior() )
{
SpatialMemoryStatus = "OFF";
AwarenessStatus = "LEGACY";
return;
}
UpdateSpatialMemoryLayoutScope();
bool alive =
_playerState?.IsAlive == true &&
( _health?.IsValid != true || !_health.IsDead );
if ( !_memoryLifeInitialized )
{
_memoryLifeInitialized = true;
_memoryWasAlive = alive;
if ( alive )
BeginNewBotLife();
}
else
{
if ( _memoryWasAlive && !alive )
RecordBotDeathMemory();
else if ( !_memoryWasAlive && alive )
BeginNewBotLife();
_memoryWasAlive = alive;
}
if ( alive && Time.Now >= _nextRouteMemorySampleAt )
{
_nextRouteMemorySampleAt =
Time.Now + MathF.Max( 0.08f, RouteSampleInterval );
RecordRouteMemorySample();
}
if ( Time.Now >= _nextSpatialMemoryDiagnosticAt )
{
_nextSpatialMemoryDiagnosticAt = Time.Now + 0.35f;
UpdateSpatialMemoryDiagnostics();
}
}
private void RecordRouteMemorySample()
{
(int X, int Y) key = GetSpatialMemoryKey( WorldPosition );
SpatialMemoryCell cell = GetSpatialMemoryCell( key, true );
cell.RouteUse = MathF.Min( 8f, cell.RouteUse + 0.10f );
_recentRouteSamples.Add(
new RecentRouteSample( WorldPosition, Time.Now )
);
float cutoff = Time.Now - 7.5f;
while ( _recentRouteSamples.Count > 0 && _recentRouteSamples[0].Time < cutoff )
_recentRouteSamples.RemoveAt( 0 );
if ( _recentRouteSamples.Count > 40 )
_recentRouteSamples.RemoveRange( 0, _recentRouteSamples.Count - 40 );
}
private void UpdateSpatialOpponentMemory()
{
if (
!UsesTacticalBehavior() ||
!_hasConfirmedSight ||
_perceptionTarget?.IsAlive != true
)
return;
PlayerController targetController =
_perceptionTarget.Components.Get<PlayerController>();
float speed =
targetController?.IsValid == true
? targetController.Velocity.WithZ( 0f ).Length
: 0f;
RecordOpponentSpatialObservation(
_observedTargetPosition,
speed,
MathF.Max( 0.01f, Time.Delta )
);
}
private void RecordOpponentSpatialObservation(
Vector3 position,
float speed,
float sampleSeconds
)
{
if ( !UsesTacticalBehavior() || !_hasConfirmedSight )
return;
SpatialMemoryCell cell =
GetSpatialMemoryCell( GetSpatialMemoryKey( position ), true );
float seconds = MathX.Clamp( sampleSeconds, 0.01f, 0.20f );
cell.OpponentPresence =
MathF.Min( 10f, cell.OpponentPresence + seconds * 0.22f );
if ( speed <= 55f )
{
cell.OpponentStationaryPresence =
MathF.Min(
10f,
cell.OpponentStationaryPresence + seconds * 0.45f
);
}
cell.LastOpponentSeenAt = Time.Now;
}
private void RecordBotDeathMemory()
{
_spatialDeathCount++;
SpatialMemoryCell deathCell =
GetSpatialMemoryCell( GetSpatialMemoryKey( WorldPosition ), true );
deathCell.DeathDanger = MathF.Min( 12f, deathCell.DeathDanger + 3.0f );
deathCell.Danger = MathF.Min( 12f, deathCell.Danger + 1.25f );
// "Scent of death": mark the recent approach, not only the exact death
// coordinate. Repeatedly dying on the same route therefore makes later
// search/flank/cover queries prefer another avenue instead of marching back
// into the same ambush.
Dictionary<(int X, int Y), float> routeWeights = new();
for ( int i = _recentRouteSamples.Count - 1; i >= 0; --i )
{
RecentRouteSample sample = _recentRouteSamples[i];
float age = MathF.Max( 0f, Time.Now - sample.Time );
if ( age > 7.5f )
break;
float weight = 1f - MathX.Clamp( age / 7.5f, 0f, 1f );
(int X, int Y) key = GetSpatialMemoryKey( sample.Position );
if ( !routeWeights.TryGetValue( key, out float previous ) || weight > previous )
routeWeights[key] = weight;
}
foreach ( KeyValuePair<(int X, int Y), float> entry in routeWeights )
{
SpatialMemoryCell cell = GetSpatialMemoryCell( entry.Key, true );
cell.Danger =
MathF.Min(
12f,
cell.Danger +
MathF.Max( 0.25f, DeathRoutePenalty ) *
entry.Value
);
}
_recentRouteSamples.Clear();
ResetOpponentBelief( "DEATH RESET" );
ClearSearchPlan( "DEATH / REPLAN" );
CancelFlank( "DEATH / REPLAN" );
}
private float GetSpatialMemoryRiskAt( Vector3 position )
{
SpatialMemoryCell cell =
GetSpatialMemoryCell( GetSpatialMemoryKey( position ), false );
if ( cell is null )
return 0f;
return
cell.Danger * 0.70f +
cell.DeathDanger * 1.20f +
cell.RouteUse * 0.10f;
}
private float EvaluatePathMemoryRisk( NavMeshPath path )
{
if ( path.Points is null )
return 0f;
HashSet<(int X, int Y)> visited = new();
float total = 0f;
foreach ( NavMeshPathPoint pathPoint in path.Points )
{
Vector3 point = pathPoint.Position;
(int X, int Y) key = GetSpatialMemoryKey( point );
if ( !visited.Add( key ) )
continue;
total += GetSpatialMemoryRiskAt( point );
}
return visited.Count > 0 ? total / visited.Count : 0f;
}
private bool TryGetLearnedOpponentHotspot(
Vector3 reference,
float radius,
out Vector3 hotspot,
out float hotspotScore
)
{
hotspot = Vector3.Zero;
hotspotScore = 0f;
float maxRadius = MathF.Max( 180f, radius );
foreach ( KeyValuePair<(int X, int Y), SpatialMemoryCell> entry in _spatialMemory )
{
SpatialMemoryCell cell = entry.Value;
DecaySpatialMemoryCell( cell );
float learned =
cell.OpponentPresence +
cell.OpponentStationaryPresence * 1.35f;
if ( learned <= 0.10f )
continue;
Vector3 center = GetSpatialMemoryCellCenter( entry.Key, reference.z );
float distance = ( center - reference ).WithZ( 0f ).Length;
if ( distance > maxRadius )
continue;
float recentCheckPenalty =
Time.Now - cell.LastCheckedAt <= 5f ? 1.10f : 0f;
float score =
learned -
( distance / maxRadius ) * 0.75f -
recentCheckPenalty;
if ( score <= hotspotScore )
continue;
hotspotScore = score;
hotspot = center;
}
return hotspotScore >= MathF.Max( 0.25f, LearnedHotspotMinimumScore );
}
private void MarkLearnedHotspotChecked( Vector3 position )
{
SpatialMemoryCell cell =
GetSpatialMemoryCell( GetSpatialMemoryKey( position ), false );
if ( cell is null )
return;
cell.LastCheckedAt = Time.Now;
_learnedHotspotChecks++;
}
private void UpdateSpatialMemoryDiagnostics()
{
float strongestDanger = 0f;
float strongestHotspot = 0f;
foreach ( SpatialMemoryCell cell in _spatialMemory.Values )
{
DecaySpatialMemoryCell( cell );
strongestDanger =
MathF.Max(
strongestDanger,
cell.Danger + cell.DeathDanger * 1.25f
);
strongestHotspot =
MathF.Max(
strongestHotspot,
cell.OpponentPresence + cell.OpponentStationaryPresence * 1.35f
);
}
SpatialMemoryStatus =
$"CELLS {_spatialMemory.Count} · DEATHS {_spatialDeathCount} · HOT {strongestHotspot:0.0} · RISK {strongestDanger:0.0}";
AwarenessStatus =
HasFreshHearing()
? HasActionableHearing()
? $"AUDIO ACTION · {HearingBearing}"
: $"AUDIO NOTICED / NO SNAP · {HearingBearing}"
: "VISUAL FOV / LOS · QUIET";
}
private void UpdateTacticalContext()
{
if ( !UsesTacticalBehavior() )
{
TacticalAggression = 0.50f;
TacticalContextStatus = "OFF";
return;
}
float ownHealthValue =
_health?.IsValid == true ? _health.Health : 0f;
float ownMaxHealth =
_health?.IsValid == true ? MathF.Max( _health.MaxHealth, 1f ) : 1f;
float ownHealthFraction =
_health?.IsValid == true
? MathX.Clamp( ownHealthValue / ownMaxHealth, 0f, 1f )
: 1f;
PlayerState human = ResolveHumanTarget( _perceptionTarget );
PlayerHealth opponentHealth =
human?.GameObject?.Components.Get<PlayerHealth>();
float opponentHealthFraction = ownHealthFraction;
bool knowsOpponentHealth =
opponentHealth?.IsValid == true && opponentHealth.MaxHealth > 0f;
if ( knowsOpponentHealth )
{
opponentHealthFraction =
MathX.Clamp(
opponentHealth.Health / opponentHealth.MaxHealth,
0f,
1f
);
}
float healthAdvantage =
ownHealthFraction - opponentHealthFraction;
// Outside a timed Match (Sandbox, for example), health advantage gently
// encourages pressure. This information is fair because OMR already exposes
// both health bars to human players in the HUD.
float aggression =
0.50f + healthAdvantage * 0.30f;
// BOT 6 adds bounded tactical variation plus a learned counter-style. These
// are intentionally small nudges, never stat cheats or hidden information.
aggression += _dynamicPressureBias * 0.55f;
aggression += GetAdaptivePressureAdjustment();
if ( ActiveModifierIs( RoundModifier.Blitz ) )
aggression += 0.14f;
else if ( ActiveModifierIs( RoundModifier.HipfireOnly ) )
aggression += 0.07f;
else if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
aggression -= 0.04f;
else if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
aggression -= 0.025f;
if ( opponentHealthFraction <= 0.30f )
aggression += 0.14f;
if ( ownHealthFraction <= 0.30f )
aggression -= 0.10f;
float timeRemaining = -1f;
RoundManager roundManager =
Scene?
.GetAllComponents<RoundManager>()
.FirstOrDefault();
if (
roundManager?.Mode == SessionMode.Match &&
roundManager.Phase == RoundPhase.Fighting &&
roundManager.FightEndsAt > 0f
)
{
timeRemaining =
MathF.Max( 0f, roundManager.FightEndsAt - Time.Now );
if ( timeRemaining <= 12f )
{
float urgency =
1f - MathX.Clamp( timeRemaining / 12f, 0f, 1f );
if ( healthAdvantage > 0.035f )
{
// OMR awards timeout by remaining health. When ahead late, a human
// has a strong reason to protect the lead rather than donate a duel.
aggression -= 0.42f * urgency;
}
else if ( healthAdvantage < -0.035f )
{
// When behind and the clock is dying, caution is already a losing
// strategy. Force a fight instead of politely timing out.
aggression += 0.48f * urgency;
}
}
}
TacticalAggression = MathX.Clamp( aggression, 0.08f, 0.95f );
string posture;
if ( timeRemaining >= 0f && timeRemaining <= 12f && healthAdvantage > 0.035f )
posture = "PROTECT LEAD";
else if ( timeRemaining >= 0f && timeRemaining <= 12f && healthAdvantage < -0.035f )
posture = "FORCE FIGHT";
else if ( opponentHealthFraction <= 0.30f && ownHealthFraction > opponentHealthFraction )
posture = "FINISH PRESSURE";
else if ( healthAdvantage >= 0.16f )
posture = "PRESS ADVANTAGE";
else if ( healthAdvantage <= -0.16f )
posture = "RECOVER / TRADE CAREFULLY";
else
posture = "BALANCED";
string timeText =
timeRemaining >= 0f
? $" · {timeRemaining:0.0}s"
: string.Empty;
if ( knowsOpponentHealth )
{
TacticalContextStatus =
$"{posture} · HP {ownHealthValue:0}/{ownMaxHealth:0} vs {opponentHealth.Health:0}/{opponentHealth.MaxHealth:0}{timeText}";
}
else
{
TacticalContextStatus = $"{posture}{timeText}";
}
}
private void UpdateThreatMemory()
{
if ( !UsesTacticalBehavior() )
{
ThreatStatus = "OFF";
ThreatBearing = "NONE";
ThreatAgeSeconds = -1f;
return;
}
if ( _health?.IsValid != true )
_health = Components.Get<PlayerHealth>();
if ( _health?.IsValid != true )
{
ThreatStatus = "NO HEALTH";
ThreatBearing = "NONE";
ThreatAgeSeconds = -1f;
return;
}
int sequence = _health.DamageFeedbackSequence;
if ( _lastProcessedDamageSequence < 0 )
_lastProcessedDamageSequence = sequence;
if ( sequence != _lastProcessedDamageSequence )
{
_lastProcessedDamageSequence = sequence;
if (
_health.DamageFeedbackAmount > 0f &&
_health.DamageFeedbackHasDirectionalSource
)
{
RegisterBlindThreatHit();
SpatialMemoryCell hitCell =
GetSpatialMemoryCell( GetSpatialMemoryKey( WorldPosition ), true );
hitCell.Danger =
MathF.Min(
12f,
hitCell.Danger +
MathX.Clamp( _health.DamageFeedbackAmount / 90f, 0.15f, 1.15f )
);
Vector3 rawDirection =
( _health.DamageFeedbackSourcePosition - WorldPosition )
.WithZ( 0f );
if ( !rawDirection.IsNearlyZero( 0.01f ) )
{
float uncertainty = GetThreatDirectionUncertainty();
float phase =
sequence * 1.731f +
BotSlot * 0.917f;
float noisyYaw =
Rotation.LookAt( rawDirection.Normal, Vector3.Up ).Angles().yaw +
MathF.Sin( phase ) * uncertainty;
Vector3 previousThreatDirection = _lastThreatDirection;
_lastThreatDirection =
Rotation.FromYaw( noisyYaw )
.Forward
.WithZ( 0f )
.Normal;
_lastThreatAt = Time.Now;
_threatOrientUntil = Time.Now + GetThreatOrientDuration();
ThreatStatus = "RECENT DAMAGE";
ThreatBearing = DescribeThreatBearing();
ThreatAgeSeconds = 0f;
// Re-evaluate an existing cover route only when the new hit suggests a
// materially different attack sector. Recomputing on every automatic-
// weapon hit would make the bot jitter between candidate walls.
if (
_hasCoverTarget &&
!previousThreatDirection.IsNearlyZero( 0.01f ) &&
previousThreatDirection.Dot( _lastThreatDirection ) < 0.5f
)
{
_hasCoverTarget = false;
_coverHoldUntil = -1f;
CoverStatus = "REASSESSING";
_nextCoverDecisionAt = Time.Now + 0.10f;
}
}
}
}
if ( HasRecentThreat() )
{
ThreatStatus = "RECENT DAMAGE";
ThreatAgeSeconds =
MathF.Round( MathF.Max( 0f, Time.Now - _lastThreatAt ) * 10f ) / 10f;
ThreatBearing = DescribeThreatBearing();
}
else
{
ThreatStatus = "NONE";
ThreatBearing = "NONE";
ThreatAgeSeconds = -1f;
}
}
private bool HasRecentThreat() =>
!_lastThreatDirection.IsNearlyZero( 0.01f ) &&
Time.Now - _lastThreatAt >= 0f &&
Time.Now - _lastThreatAt <= MathF.Max( 0.5f, ThreatMemoryDuration );
private float GetThreatDirectionUncertainty() =>
Difficulty switch
{
OMRBotDifficulty.Easy => 30f,
OMRBotDifficulty.Hard => 14f,
_ => 22f
};
private float GetThreatOrientDuration() =>
Difficulty switch
{
OMRBotDifficulty.Easy => 0.60f,
OMRBotDifficulty.Hard => 0.38f,
_ => 0.48f
};
private void OrientTowardThreat()
{
if ( _controller?.IsValid != true || _lastThreatDirection.IsNearlyZero( 0.01f ) )
return;
Angles desired =
Rotation.LookAt( _lastThreatDirection, Vector3.Up ).Angles();
float orientSpeed = Difficulty switch
{
OMRBotDifficulty.Easy => 4.8f,
OMRBotDifficulty.Hard => 7.0f,
_ => 5.8f
};
float follow =
1f - MathF.Exp( -orientSpeed * MathF.Max( Time.Delta, 0f ) );
Rotation smoothed = Rotation.Slerp(
_controller.EyeAngles.ToRotation(),
new Angles( 0f, desired.yaw, 0f ).ToRotation(),
MathX.Clamp( follow, 0f, 1f )
);
Angles result = smoothed.Angles();
_controller.EyeAngles = new Angles( 0f, result.yaw, 0f );
}
private string DescribeThreatBearing()
{
if ( _controller?.IsValid != true || _lastThreatDirection.IsNearlyZero( 0.01f ) )
return "UNKNOWN";
float threatYaw =
Rotation.LookAt( _lastThreatDirection, Vector3.Up ).Angles().yaw;
float delta =
MathX.DeltaDegrees( _controller.EyeAngles.yaw, threatYaw );
float absolute = MathF.Abs( delta );
if ( absolute <= 22.5f )
return "FRONT";
if ( absolute >= 157.5f )
return "REAR";
if ( absolute <= 67.5f )
return delta > 0f ? "FRONT-RIGHT" : "FRONT-LEFT";
if ( absolute <= 112.5f )
return delta > 0f ? "RIGHT" : "LEFT";
return delta > 0f ? "REAR-RIGHT" : "REAR-LEFT";
}
private float GetOwnHealthFraction()
{
if ( _health?.IsValid != true || _health.MaxHealth <= 0f )
return 1f;
return MathX.Clamp( _health.Health / _health.MaxHealth, 0f, 1f );
}
private bool TryGetOpponentHealthFraction( out float fraction )
{
fraction = 1f;
PlayerState human = ResolveHumanTarget( _perceptionTarget );
PlayerHealth health = human?.GameObject?.Components.Get<PlayerHealth>();
if ( health?.IsValid != true || health.MaxHealth <= 0f )
return false;
fraction = MathX.Clamp( health.Health / health.MaxHealth, 0f, 1f );
return true;
}
private void EvaluateTacticalActionScores(
OneMoreRoundWeapon weapon,
bool hasVisual,
bool recentThreat,
bool lowHealth,
bool needsReload,
out float engageScore,
out float coverScore
)
{
float ownHealth = GetOwnHealthFraction();
float opponentHealth = 1f;
TryGetOpponentHealthFraction( out opponentHealth );
engageScore = hasVisual ? 58f : 0f;
coverScore = 18f;
engageScore += MathX.Clamp( TacticalAggression, 0f, 1f ) * 58f;
coverScore += ( 1f - MathX.Clamp( TacticalAggression, 0f, 1f ) ) * 48f;
engageScore += _dynamicPressureBias * 30f;
coverScore += _dynamicCoverBias * 28f;
float adaptiveConfidence = GetAdaptiveConfidenceWeight();
if ( adaptiveConfidence > 0f )
{
// A habitual rusher makes cover/space slightly more valuable; a passive
// opponent makes proactive engagement and angle changes more attractive.
coverScore +=
MathF.Max( 0f, _adaptiveOpponentAggression - 0.55f ) *
28f *
adaptiveConfidence;
engageScore +=
MathF.Max( 0f, 0.48f - _adaptiveOpponentAggression ) *
24f *
adaptiveConfidence;
}
if ( ActiveModifierIs( RoundModifier.Blitz ) )
{
engageScore += 16f;
coverScore -= 8f;
}
else if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
{
coverScore += 7f;
}
else if ( ActiveModifierIs( RoundModifier.HeadshotOnly ) )
{
coverScore += 4f;
}
if ( weapon?.IsValid == true )
{
if ( weapon.Clip1 > 0 && !weapon.IsReloading )
engageScore += 24f;
else
engageScore -= 35f;
if (
ActiveModifierIs( RoundModifier.LargeCaliber ) &&
weapon.UsesPrimaryClip &&
weapon.PrimaryClipSize > 0 &&
weapon.Clip1 <= Math.Max( 2, (int)MathF.Ceiling( weapon.PrimaryClipSize * 0.34f ) )
)
{
coverScore += 18f;
engageScore -= 8f;
}
if ( hasVisual )
{
float distance =
( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
float maxRange = GetMaximumFireDistance( weapon );
float holdRange = MathF.Max( 80f, GetCombatHoldDistance( weapon ) );
if ( distance <= maxRange )
engageScore += 18f;
else
engageScore -= 18f;
float rangeError = MathF.Abs( distance - holdRange ) / holdRange;
engageScore += MathF.Max( 0f, 18f - rangeError * 18f );
}
}
if ( opponentHealth <= 0.35f )
engageScore += 28f;
if ( opponentHealth <= 0.18f )
engageScore += 14f;
if ( ownHealth <= 0.38f )
coverScore += 34f;
if ( ownHealth <= 0.22f )
coverScore += 22f;
if ( lowHealth )
coverScore += 30f;
if ( recentThreat )
{
coverScore += hasVisual ? 18f : 42f;
engageScore -= hasVisual ? 4f : 38f;
}
if ( needsReload )
{
coverScore += 105f;
engageScore -= 85f;
}
if ( hasVisual )
coverScore -= 16f;
else
coverScore += 12f;
if ( opponentHealth <= 0.30f && hasVisual )
coverScore -= 24f;
}
private bool ShouldInterruptCoverForEngage( OneMoreRoundWeapon weapon )
{
if (
!_hasCoverTarget ||
_coverReason == "RELOAD" ||
_coverPeekActive ||
!_hasConfirmedSight ||
_perceptionTarget?.IsAlive != true ||
weapon?.IsValid != true ||
weapon.IsReloading ||
weapon.Clip1 <= 0
)
return false;
float coverDistance = ( _coverTarget - WorldPosition ).WithZ( 0f ).Length;
if ( coverDistance <= MathF.Max( 36f, CoverArrivalDistance * 1.5f ) )
return false;
float ownHealth = GetOwnHealthFraction();
bool lowHealth = ownHealth <= MathX.Clamp( LowHealthCoverFraction, 0.15f, 0.75f );
EvaluateTacticalActionScores(
weapon,
hasVisual: true,
recentThreat: HasRecentThreat(),
lowHealth: lowHealth,
needsReload: false,
out float engageScore,
out float coverScore
);
return engageScore >= coverScore + MathF.Max( 0f, CoverEngageInterruptMargin );
}
private void UpdateUnderFireReaction( OneMoreRoundWeapon weapon )
{
ReleaseBotWeaponIntent( weapon );
OrientTowardThreat();
float side = _strafeDirection >= 0f ? 1f : -1f;
float lateral = MathX.Clamp( UnderFireEvadeStrength, 0.2f, 1f ) * side;
Vector3 moveInput = new Vector3( -0.10f, lateral, 0f ).ClampLength( 1f );
_movementInput?.SetBotInput(
moveInput,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
SetMovementDiagnostics( side > 0f ? "UNDER FIRE / EVADE RIGHT" : "UNDER FIRE / EVADE LEFT" );
TacticalStatus = "UNDER FIRE / LOCATE + EVADE";
CoverStatus = "ASSESSING";
CombatStatus = "LOCATING ATTACK SECTOR";
CurrentIntent = "TACTICAL / REACT TO FIRE";
}
private bool TryHandleLegacyTacticalPriority( OneMoreRoundWeapon weapon )
{
if ( !UsesTacticalBehavior() )
return false;
bool recentThreat = HasRecentThreat();
bool hasVisual = _hasConfirmedSight && _perceptionTarget?.IsAlive == true;
bool hasMemory = HasFreshMemory();
bool needsReload =
weapon?.IsValid == true &&
(
weapon.IsReloading ||
weapon.Clip1 == 0 ||
(
ActiveModifierIs( RoundModifier.LargeCaliber ) &&
weapon.UsesPrimaryClip &&
weapon.PrimaryClipSize > 0 &&
weapon.Ammo1 > 0 &&
weapon.Clip1 <= Math.Max( 1, (int)MathF.Floor( weapon.PrimaryClipSize * 0.24f ) )
)
);
if ( hasVisual && _searchPoints.Count > 0 )
ClearSearchPlan( "CONTACT" );
if (
recentThreat &&
!hasVisual &&
Time.Now < _threatOrientUntil
)
{
UpdateUnderFireReaction( weapon );
return true;
}
if ( _hasCoverTarget )
{
if ( ShouldInterruptCoverForEngage( weapon ) )
{
ClearCoverPlan( "INTERRUPTED / ENGAGE" );
TacticalStatus = "ENGAGE / COVER INTERRUPTED";
return false;
}
if ( UpdateActiveCoverPlan( weapon ) )
return true;
}
if ( _flankActive && TryHandleFlankPlan( weapon ) )
return true;
// A short burst at the *last actually observed* position gives automatic
// weapons a believable suppressive behavior without granting wall vision.
// Physical ballistics still decide whether those rounds hit cover or an
// opponent who happens to re-peek.
if ( !hasVisual && TryHandleSuppressiveFire( weapon ) )
return true;
if ( hasVisual && TryStartFlank( weapon ) )
return true;
float healthFraction = GetOwnHealthFraction();
bool lowHealth =
healthFraction <= MathX.Clamp( LowHealthCoverFraction, 0.15f, 0.75f ) &&
Time.Now >= _nextLowHealthCoverAt;
EvaluateTacticalActionScores(
weapon,
hasVisual,
recentThreat,
lowHealth,
needsReload,
out float engageScore,
out float coverScore
);
string reason = null;
if ( needsReload && ( hasVisual || hasMemory || recentThreat || HasFreshHearing() ) )
{
reason = "RELOAD";
}
else if ( recentThreat && !hasVisual )
{
// Getting hit while blind is evidence, not an unconditional retreat
// order. Healthy/aggressive bots first turn toward and investigate the
// threat sector; damaged/cautious bots still prefer hard cover.
bool defensiveReaction =
healthFraction <= 0.58f ||
TacticalAggression <= 0.43f ||
( healthFraction <= 0.78f && TacticalAggression < 0.60f );
if ( defensiveReaction )
reason = "UNDER FIRE";
else
{
TacticalStatus = "INVESTIGATE FIRE DIRECTION";
CoverStatus = "NONE";
if ( TryHandleIntelligentSearch( weapon ) )
return true;
}
}
else if (
( recentThreat || ( lowHealth && hasVisual ) ) &&
coverScore > engageScore + MathF.Max( 0f, CoverEngageInterruptMargin )
)
{
reason = recentThreat ? "UNDER FIRE" : "LOW HEALTH";
}
if ( reason is not null && Time.Now >= _nextCoverDecisionAt )
{
if ( !TryResolveThreatReference( out Vector3 threatReference, out Vector3 threatDirection ) )
{
TacticalStatus = $"{reason} / NO THREAT VECTOR";
CoverStatus = "NO REFERENCE";
_nextCoverDecisionAt = Time.Now + 0.5f;
}
else if ( !TryChooseCoverPosition( weapon, threatReference, threatDirection, out OMRCoverChoice cover ) )
{
TacticalStatus = $"{reason} / NO COVER";
CoverStatus = "NO CANDIDATE";
_nextCoverDecisionAt = Time.Now + MathF.Max( 0.5f, CoverDecisionCooldown );
}
else
{
_hasCoverTarget = true;
_coverTarget = cover.Anchor;
_coverReason = reason;
_coverHoldUntil = -1f;
_coverPosture = cover.Posture;
_coverLeftPeek = cover.LeftPeek;
_coverRightPeek = cover.RightPeek;
_coverHasLeftPeek = cover.HasLeftPeek;
_coverHasRightPeek = cover.HasRightPeek;
_coverPeekActive = false;
_coverPeekReturning = false;
_coverPeekUntil = -1f;
_nextCoverPeekAt = Time.Now + 0.12f;
_nextCoverDecisionAt = Time.Now + MathF.Max( 0.5f, CoverDecisionCooldown );
if ( reason == "LOW HEALTH" )
_nextLowHealthCoverAt = Time.Now + MathF.Max( 2f, LowHealthDecisionCooldown );
TacticalStatus = $"SEEK COVER / {reason}";
CoverStatus = $"SEEKING {reason} · {_coverPosture.ToString().ToUpperInvariant()}";
ClearSearchPlan( "COVER" );
CancelFlank( "COVER PRIORITY" );
return UpdateActiveCoverPlan( weapon );
}
}
if ( !hasVisual )
{
CoverStatus = "NONE";
if ( TryHandleIntelligentSearch( weapon ) )
return true;
}
TacticalStatus = hasVisual
? $"ENGAGE · E{engageScore:0} C{coverScore:0}"
: hasMemory || HasFreshHearing()
? "SEARCH"
: "PATROL";
if ( !_hasCoverTarget )
CoverStatus = "NONE";
return false;
}
private bool UpdateActiveCoverPlan( OneMoreRoundWeapon weapon )
{
if ( !_hasCoverTarget )
return false;
if ( _coverPeekActive )
return UpdateActiveCoverPeek( weapon );
Vector3 delta = ( _coverTarget - WorldPosition ).WithZ( 0f );
float distance = delta.Length;
bool arrived = distance <= MathF.Max( 28f, CoverArrivalDistance );
if ( !arrived )
return UpdateCoverTravel( weapon, distance );
_coverTravelStartedAt = -1f;
_pathPoints.Clear();
_pathPointIndex = 0;
NavigationStatus = "IN COVER";
NavigationTargetName = "TACTICAL COVER";
NavigationTargetDistance = MathF.Round( distance );
CurrentIntent = $"TACTICAL COVER / {_coverReason}";
TacticalStatus = $"IN COVER / {_coverReason}";
if ( _coverReason == "RELOAD" )
{
EnsureBotCrouchState( _coverPosture != OMRCoverPosture.Firing );
StopMovementOnly();
ReleaseBotWeaponIntent( weapon );
CombatWantsAim = false;
CoverStatus = $"HOLDING RELOAD · {_coverPosture.ToString().ToUpperInvariant()}";
if ( weapon?.IsValid == true && !weapon.IsReloading )
{
bool shouldReload =
weapon.Clip1 == 0 ||
(
ActiveModifierIs( RoundModifier.LargeCaliber ) &&
weapon.UsesPrimaryClip &&
weapon.PrimaryClipSize > 0 &&
weapon.Ammo1 > 0 &&
weapon.Clip1 <= Math.Max( 1, (int)MathF.Floor( weapon.PrimaryClipSize * 0.28f ) )
);
if ( shouldReload )
{
weapon.BotTryReload();
UpdateCombatAmmoDiagnostics( weapon );
}
}
if ( weapon?.IsValid == true && weapon.IsReloading )
{
CombatStatus = "RELOADING IN COVER";
return true;
}
}
bool hasVisual = _hasConfirmedSight && _perceptionTarget?.IsAlive == true;
bool hasMemory = HasFreshMemory();
if ( TryHoldPrecisionResetInCover( weapon ) )
return true;
if ( _coverReason != "RELOAD" && ( hasVisual || hasMemory ) )
{
if ( _coverPosture == OMRCoverPosture.Firing && hasVisual )
{
EnsureBotCrouchState( false );
StopMovementOnly();
CoverStatus = "FIRING COVER / OVER TOP";
TacticalStatus = "ENGAGE FROM COVER";
UpdateCoverFire( weapon );
_coverHoldUntil = Time.Now + MathF.Max( 0.35f, CoverHoldDuration );
return true;
}
if ( _coverPosture == OMRCoverPosture.Crouch )
{
if ( Time.Now >= _nextCoverPeekAt )
{
EnsureBotCrouchState( false );
StopMovementOnly();
CoverStatus = "CROUCH COVER / STAND PEEK";
TacticalStatus = "PEEK FROM LOW COVER";
_coverPeekUntil = Time.Now + MathF.Max( 0.25f, CoverPeekDuration );
_nextCoverPeekAt = _coverPeekUntil + MathF.Max( 0.25f, CoverPeekCooldown );
}
if ( Time.Now < _coverPeekUntil )
{
if ( hasVisual )
UpdateCoverFire( weapon );
else
CombatStatus = "LOW COVER / ACQUIRING";
return true;
}
EnsureBotCrouchState( true );
StopMovementOnly();
CoverStatus = "CROUCH COVER / HIDDEN";
}
else if (
_coverPosture == OMRCoverPosture.Full &&
Time.Now >= _nextCoverPeekAt &&
( _coverHasLeftPeek || _coverHasRightPeek )
)
{
StartCoverSidePeek();
return UpdateActiveCoverPeek( weapon );
}
else
{
EnsureBotCrouchState( false );
StopMovementOnly();
CoverStatus = $"HOLDING {_coverPosture.ToString().ToUpperInvariant()}";
}
}
else
{
EnsureBotCrouchState( _coverPosture == OMRCoverPosture.Crouch );
StopMovementOnly();
CoverStatus = $"HOLDING {_coverPosture.ToString().ToUpperInvariant()}";
}
if ( _coverHoldUntil < 0f )
{
float tacticalHold =
MathX.Lerp(
CoverHoldDuration * 1.20f,
CoverHoldDuration * 0.55f,
MathX.Clamp( TacticalAggression, 0f, 1f )
);
tacticalHold *=
MathX.Clamp( 1f + _dynamicCoverBias * 0.75f, 0.72f, 1.30f );
if ( ActiveModifierIs( RoundModifier.Blitz ) )
tacticalHold *= 0.62f;
else if ( ActiveModifierIs( RoundModifier.LargeCaliber ) )
tacticalHold *= 1.10f;
_coverHoldUntil = Time.Now + MathF.Max( 0.18f, tacticalHold );
}
if ( Time.Now < _coverHoldUntil )
{
CombatStatus = _coverReason == "LOW HEALTH"
? "RECOVERING POSITION"
: "HOLDING COVER";
return true;
}
ClearCoverPlan( "COMPLETE" );
return false;
}
private void EnsureBotCrouchState( bool wantsCrouch )
{
if ( _crouch?.IsValid != true || _slide?.IsSliding == true )
return;
if ( _crouch.WantsCrouch == wantsCrouch )
return;
if ( Time.Now < _crouchButtonHeldUntil )
return;
PulseCrouchButton();
}
private void StartCoverSidePeek()
{
bool chooseLeft;
if ( _coverHasLeftPeek && _coverHasRightPeek )
{
chooseLeft = _coverPeekSide < 0;
_coverPeekSide *= -1;
}
else
{
chooseLeft = _coverHasLeftPeek;
}
_coverPeekTarget = chooseLeft ? _coverLeftPeek : _coverRightPeek;
_coverPeekActive = true;
_coverPeekReturning = false;
_coverPeekUntil = -1f;
EnsureBotCrouchState( false );
}
private bool UpdateActiveCoverPeek( OneMoreRoundWeapon weapon )
{
if ( _hasConfirmedSight ) UpdateCombatAim( _observedTargetAimPoint );
else if ( HasFreshMemory() ) LookTowardWorldPoint( _observedTargetAimPoint, 7f );
Vector3 target = _coverPeekReturning ? _coverTarget : _coverPeekTarget;
float distance = ( target - WorldPosition ).WithZ( 0f ).Length;
float arrival = MathF.Max( 18f, CoverPeekArrivalDistance );
if ( distance > arrival )
{
NavigateToWorldPosition(
target,
_coverPeekReturning ? "COVER ANCHOR" : "COVER PEEK",
arrival,
_coverPeekReturning ? "RETURN TO COVER" : "MOVE TO COVER PEEK",
allowSprint: false,
faceMovement: false,
advancedMovement: true,
movementWeapon: weapon,
visibleCombatTarget: _hasConfirmedSight
);
CoverStatus = _coverPeekReturning ? "RETURNING TO ANCHOR" : "MOVING TO PEEK";
TacticalStatus = _coverPeekReturning ? "COVER RESET" : "SIDE PEEK";
return true;
}
StopMovementOnly();
if ( _coverPeekReturning )
{
_coverPeekActive = false;
_coverPeekReturning = false;
_coverPeekUntil = -1f;
_nextCoverPeekAt = Time.Now + MathF.Max( 0.25f, CoverPeekCooldown );
CoverStatus = "BACK AT ANCHOR";
return true;
}
if ( _coverPeekUntil < 0f )
_coverPeekUntil = Time.Now + MathF.Max( 0.25f, CoverPeekDuration );
CoverStatus = "SIDE PEEK / FIRING POSITION";
TacticalStatus = "ENGAGE FROM SIDE PEEK";
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
UpdateCoverFire( weapon );
else
CombatStatus = "PEEK / ACQUIRING";
if ( Time.Now >= _coverPeekUntil || ( HasRecentThreat() && ThreatAgeSeconds <= 0.25f ) )
{
_coverPeekReturning = true;
_coverPeekUntil = -1f;
}
return true;
}
private void UpdateCoverFire( OneMoreRoundWeapon weapon )
{
if (
weapon?.IsValid != true ||
!_hasConfirmedSight ||
_perceptionTarget?.IsAlive != true
)
return;
float distance =
( _observedTargetPosition - WorldPosition ).WithZ( 0f ).Length;
UpdateCombatAim( _observedTargetAimPoint );
bool wantsAds = ShouldUseAds( weapon, distance );
CombatWantsAim = wantsAds;
weapon.SetBotAimIntent( wantsAds );
UpdateVisibleWeaponAttack( weapon, distance, wantsAds );
}
private bool TryResolveThreatReference(
out Vector3 threatReference,
out Vector3 threatDirection
)
{
if ( _hasConfirmedSight && _perceptionTarget?.IsAlive == true )
{
threatReference = _observedTargetPosition + Vector3.Up * 34f;
threatDirection =
( _observedTargetPosition - WorldPosition )
.WithZ( 0f );
if ( !threatDirection.IsNearlyZero( 0.01f ) )
threatDirection = threatDirection.Normal;
return !threatDirection.IsNearlyZero( 0.01f );
}
if ( HasRecentThreat() )
{
threatDirection = _lastThreatDirection;
threatReference =
WorldPosition +
threatDirection * MathF.Max( 500f, ThreatProbeDistance ) +
Vector3.Up * 46f;
return true;
}
if ( HasFreshMemory() )
{
threatReference = LastKnownPosition + Vector3.Up * 34f;
threatDirection =
( LastKnownPosition - WorldPosition )
.WithZ( 0f );
if ( !threatDirection.IsNearlyZero( 0.01f ) )
threatDirection = threatDirection.Normal;
return !threatDirection.IsNearlyZero( 0.01f );
}
if ( HasActionableHearing() )
{
threatReference = _lastHeardPosition + Vector3.Up * 34f;
threatDirection = ( _lastHeardPosition - WorldPosition ).WithZ( 0f );
if ( !threatDirection.IsNearlyZero( 0.01f ) )
threatDirection = threatDirection.Normal;
return !threatDirection.IsNearlyZero( 0.01f );
}
if ( TryGetBestOpponentBelief( out Vector3 beliefThreat, out float beliefConfidence ) && beliefConfidence >= 0.06f )
{
threatReference = beliefThreat + Vector3.Up * 34f;
threatDirection = ( beliefThreat - WorldPosition ).WithZ( 0f );
if ( !threatDirection.IsNearlyZero( 0.01f ) )
threatDirection = threatDirection.Normal;
return !threatDirection.IsNearlyZero( 0.01f );
}
threatReference = Vector3.Zero;
threatDirection = Vector3.Zero;
return false;
}
private bool TryChooseCoverPosition(
OneMoreRoundWeapon weapon,
Vector3 threatReference,
Vector3 threatDirection,
out OMRCoverChoice bestChoice
)
{
bestChoice = default;
if ( Scene?.NavMesh is null || !Scene.NavMesh.IsEnabled ||
Scene.NavMesh.IsGenerating || threatDirection.IsNearlyZero( 0.01f ) ) return false;
// First shortlist protected nearby endpoints; expensive complete-path and
// exposure tests only run for useful cover, rather than empty arena space.
Vector3 away = ( -threatDirection ).WithZ( 0f ).Normal;
float minDistance = MathF.Max( 80f, CoverCandidateMinDistance );
float maxDistance = MathF.Max( minDistance + 40f, CoverCandidateMaxDistance );
if ( Time.Now >= _nextCoverShortlistAt ||
( WorldPosition - _coverShortlistOrigin ).Length > 24f ||
( threatReference - _coverShortlistThreat ).Length > 24f )
{
_nextCoverShortlistAt = Time.Now + 0.25f;
_coverShortlistOrigin = WorldPosition;
_coverShortlistThreat = threatReference;
_coverShortlist.Clear();
for ( int ring = 0; ring < 4; ring++ )
{
float distance = ring switch
{
0 => MathF.Min( 96f, minDistance ),
1 => minDistance,
2 => ( minDistance + maxDistance ) * 0.5f,
_ => maxDistance
};
foreach ( float angle in CoverSearchAngles )
{
Vector3 direction = ( Rotation.FromYaw( angle ) * away ).WithZ( 0f ).Normal;
Vector3 raw = WorldPosition + direction * distance;
Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 72f );
if ( projected is null ) continue;
Vector3 candidate = projected.Value;
float travel = ( candidate - WorldPosition ).WithZ( 0f ).Length;
if ( travel < 48f || ( candidate - raw ).WithZ( 0f ).Length > 60f ) continue;
bool duplicate = false;
foreach ( var item in _coverShortlist )
if ( ( item.Point - candidate ).Length < 36f ) { duplicate = true; break; }
if ( duplicate ) continue;
int protection = CountBlockedThreatSamples( candidate, threatReference );
if ( protection == 0 ) continue;
float risk = GetSpatialMemoryRiskAt( candidate );
// Adjacent side-steps into protection win over long perimeter retreats.
float priority = protection * 55f - travel * 0.45f - risk * 10f;
_coverShortlist.Add( ( candidate, priority ) );
}
}
_coverShortlist.Sort( ( a, b ) => b.Priority.CompareTo( a.Priority ) );
}
float bestScore = float.MinValue;
OMRBotTacticalPositionEvaluation bestEvaluation = default;
OMRBotWeaponDoctrine doctrine = GetWeaponDoctrine( weapon );
float desiredRange = MathF.Max( 90f, doctrine.LegacyHoldDistance );
foreach ( var item in _coverShortlist )
{
if ( _tacticalPathQueriesRemaining <= 0 ) break;
if ( !TryEvaluateTacticalPosition( item.Point, OMRBotTacticalQueryPurpose.Cover,
doctrine, threatReference, true, threatReference, desiredRange,
out OMRBotTacticalPositionEvaluation evaluation ) ) continue;
if ( evaluation.Route.PathLength > maxDistance * 2f || !IsCoverApproachSafe( evaluation.Route ) ) continue;
Vector3 candidate = evaluation.Position;
OMRCoverPosture posture = ClassifyCoverPosture( candidate, threatReference );
if ( posture == OMRCoverPosture.None ) continue;
EvaluateCoverOptions( candidate, threatReference, threatDirection, out int mobilityOptions,
out Vector3 leftPeek, out Vector3 rightPeek, out bool hasLeftPeek, out bool hasRightPeek );
int peekOptions = ( hasLeftPeek ? 1 : 0 ) + ( hasRightPeek ? 1 : 0 );
// A blind alcove is a poor place to disengage when still trading fire.
if ( _hasConfirmedSight && mobilityOptions == 0 ) continue;
float postureBonus = posture switch
{
OMRCoverPosture.Firing => 280f,
OMRCoverPosture.Crouch => 210f,
_ => 155f
};
float earlyProtection = evaluation.Route.FirstProtectionDistance >= 0f
? MathF.Max( 0f, 160f - evaluation.Route.FirstProtectionDistance ) * 1.1f : -120f;
float passivePenalty = posture == OMRCoverPosture.Full && peekOptions == 0 ? 150f : 0f;
float score = evaluation.Score + postureBonus + earlyProtection +
mobilityOptions * 48f + peekOptions * 80f - passivePenalty -
evaluation.Route.PathLength * 0.36f - evaluation.Route.ExposureFraction * 180f;
if ( score <= bestScore ) continue;
bestScore = score;
bestEvaluation = evaluation;
bestChoice = new OMRCoverChoice( candidate, posture, leftPeek, rightPeek, hasLeftPeek, hasRightPeek, score );
}
if ( bestScore == float.MinValue ) return false;
SetTacticalQueryDiagnostic( OMRBotTacticalQueryPurpose.Cover, bestEvaluation );
return true;
}
private OMRCoverPosture ClassifyCoverPosture(
Vector3 candidate,
Vector3 threatReference
)
{
bool standingEyeBlocked =
IsThreatRayBlocked( candidate + Vector3.Up * 64f, threatReference );
bool standingTorsoBlocked =
IsThreatRayBlocked( candidate + Vector3.Up * 40f, threatReference );
bool crouchEyeBlocked =
IsThreatRayBlocked( candidate + Vector3.Up * 42f, threatReference );
bool crouchTorsoBlocked =
IsThreatRayBlocked( candidate + Vector3.Up * 25f, threatReference );
// Ideal low/medium cover: the weapon/head can see over it while the torso
// remains protected. This is the highest-value fighting posture.
if ( !standingEyeBlocked && standingTorsoBlocked )
return OMRCoverPosture.Firing;
// Tall geometry blocks both standing samples. It is safe, but needs a side
// peek before it becomes an offensive position.
if ( standingEyeBlocked && standingTorsoBlocked )
return OMRCoverPosture.Full;
// Low geometry can still be useful when ducking even if standing would
// expose most of the body.
if ( crouchEyeBlocked && crouchTorsoBlocked )
return OMRCoverPosture.Crouch;
return OMRCoverPosture.None;
}
private void EvaluatePathExposure(
NavMeshPath path,
Vector3 threatReference,
out int exposedSamples,
out int partialSamples,
out int protectedSamples,
out float firstProtectionDistance
)
{
exposedSamples = 0;
partialSamples = 0;
protectedSamples = 0;
firstProtectionDistance = -1f;
Vector3 previous = WorldPosition;
float travelled = 0f;
float totalPathLength = CalculatePathLength( path );
float spacing = MathF.Max( MathF.Max( 36f, CoverRouteSampleSpacing ), totalPathLength / 10f );
float nextSampleDistance = spacing;
foreach ( NavMeshPathPoint point in path.Points )
{
Vector3 delta = point.Position - previous;
float segmentLength = delta.WithZ( 0f ).Length;
if ( segmentLength <= 0.01f )
{
previous = point.Position;
continue;
}
while ( nextSampleDistance <= travelled + segmentLength ||
( travelled + segmentLength >= totalPathLength - 0.1f && nextSampleDistance - spacing < travelled + segmentLength - 0.1f ) )
{
float t = MathX.Clamp( ( nextSampleDistance - travelled ) / segmentLength, 0f, 1f );
nextSampleDistance += spacing;
Vector3 sample = Vector3.Lerp( previous, point.Position, t );
int cover = CountBlockedThreatSamples( sample, threatReference );
float sampleDistance = travelled + segmentLength * t;
if ( cover >= 2 )
{
protectedSamples++;
if ( firstProtectionDistance < 0f )
firstProtectionDistance = sampleDistance;
}
else if ( cover == 1 )
{
partialSamples++;
if ( firstProtectionDistance < 0f )
firstProtectionDistance = sampleDistance;
}
else
{
exposedSamples++;
}
}
travelled += segmentLength;
previous = point.Position;
}
}
private void EvaluateCoverOptions(
Vector3 candidate,
Vector3 threatReference,
Vector3 threatDirection,
out int mobilityOptions,
out Vector3 leftPeek,
out Vector3 rightPeek,
out bool hasLeftPeek,
out bool hasRightPeek
)
{
mobilityOptions = 0;
leftPeek = Vector3.Zero;
rightPeek = Vector3.Zero;
hasLeftPeek = false;
hasRightPeek = false;
if ( Scene?.NavMesh is null )
return;
Vector3 lateral =
new Vector3( -threatDirection.y, threatDirection.x, 0f )
.Normal;
float sideDistance = MathF.Max( 45f, CoverSideProbeDistance );
Vector3 away = ( -threatDirection ).WithZ( 0f ).Normal;
Vector3[] optionDirections =
{
-lateral,
lateral,
away
};
for ( int i = 0; i < optionDirections.Length; i++ )
{
Vector3 optionDirection = optionDirections[i];
Vector3 raw = candidate + optionDirection * sideDistance;
Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 54f );
if ( projected is null )
continue;
if ( ( projected.Value - candidate ).WithZ( 0f ).Length < sideDistance * 0.45f )
continue;
mobilityOptions++;
if ( i >= 2 )
continue;
bool canSeeThreatFromEye =
!IsThreatRayBlocked( projected.Value + Vector3.Up * 64f, threatReference );
if ( !canSeeThreatFromEye )
continue;
if ( i == 0 )
{
leftPeek = projected.Value;
hasLeftPeek = true;
}
else
{
rightPeek = projected.Value;
hasRightPeek = true;
}
}
}
private float CalculatePathLength( NavMeshPath path )
{
Vector3 previous = WorldPosition;
float length = 0f;
foreach ( NavMeshPathPoint point in path.Points )
{
length += ( point.Position - previous ).WithZ( 0f ).Length;
previous = point.Position;
}
return length;
}
private int CountBlockedThreatSamples(
Vector3 candidate,
Vector3 threatReference
)
{
int blocked = 0;
if ( IsThreatRayBlocked( candidate + Vector3.Up * 58f, threatReference ) )
blocked++;
if ( IsThreatRayBlocked( candidate + Vector3.Up * 34f, threatReference ) )
blocked++;
return blocked;
}
private bool IsThreatRayBlocked(
Vector3 from,
Vector3 to
)
{
if ( Scene is null )
return false;
SceneTrace traceBuilder =
Scene
.Trace
.Ray( from, to )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
{
traceBuilder =
traceBuilder.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
}
SceneTraceResult trace = traceBuilder.Run();
return trace.Hit;
}
private void ClearCoverPlan( string finalStatus )
{
if ( _coverReason == "UNDER FIRE" )
{
// Reaching valid cover consumes that particular damage reaction. A new
// hit will immediately create a fresh threat event and force a reassess.
_lastThreatAt = -999f;
}
EnsureBotCrouchState( false );
_coverTravelStartedAt = -1f;
_hasCoverTarget = false;
_coverTarget = Vector3.Zero;
_coverReason = string.Empty;
_coverHoldUntil = -1f;
_coverPosture = OMRCoverPosture.None;
_coverLeftPeek = Vector3.Zero;
_coverRightPeek = Vector3.Zero;
_coverHasLeftPeek = false;
_coverHasRightPeek = false;
_coverPeekActive = false;
_coverPeekReturning = false;
_coverPeekTarget = Vector3.Zero;
_coverPeekUntil = -1f;
_nextCoverPeekAt = Time.Now + 0.25f;
CoverStatus = finalStatus;
_nextCoverDecisionAt =
MathF.Max( _nextCoverDecisionAt, Time.Now + MathF.Max( 0.5f, CoverDecisionCooldown ) );
}
private void ResetTacticalState()
{
_lastProcessedDamageSequence =
_health?.IsValid == true
? _health.DamageFeedbackSequence
: -1;
_lastThreatAt = -999f;
_lastThreatDirection = Vector3.Zero;
_threatOrientUntil = -1f;
_nextCoverDecisionAt = Time.Now + 0.4f;
_nextLowHealthCoverAt = Time.Now + 1.5f;
_hasCoverTarget = false;
_coverTarget = Vector3.Zero;
_coverReason = string.Empty;
_coverHoldUntil = -1f;
_coverPosture = OMRCoverPosture.None;
_coverLeftPeek = Vector3.Zero;
_coverRightPeek = Vector3.Zero;
_coverHasLeftPeek = false;
_coverHasRightPeek = false;
_coverPeekActive = false;
_coverPeekReturning = false;
_coverPeekTarget = Vector3.Zero;
_coverPeekUntil = -1f;
_nextCoverPeekAt = Time.Now + 0.25f;
_nextTraversalJumpAt = Time.Now + 0.35f;
_traversalBypassUntil = -1f;
_traversalBypassDirection = Vector3.Zero;
_traversalFrameStatus = string.Empty;
_traversalProgressStartedAt = -1f;
_traversalBestDistance = float.MaxValue;
_traversalProgressTarget = Vector3.Zero;
_traversalPreferredBypassSign = 1;
ResetTraversalOrbitWindow();
_traversalRecoveryUntil = -1f;
_traversalRecoveryDirection = Vector3.Zero;
_lastSeenVelocity = Vector3.Zero;
_lastSeenDirection = Vector3.Zero;
_previousObservedPosition = Vector3.Zero;
_previousObservedAt = -1f;
_confirmedSightStartedAt = -1f;
_sightWasConfirmedLastFrame = false;
_lostSightAt = -999f;
_heardOpponentWeapon = null;
_heardOpponentWeaponEventCount = -1;
_nextMovementHearingAt = 0f;
_lastHeardPosition = Vector3.Zero;
_lastHeardAt = -999f;
_lastHeardConfidence = 0f;
_lastHeardKind = string.Empty;
_searchPoints.Clear();
_searchPointIndex = 0;
_searchPlanCreatedAt = -999f;
_searchPauseUntil = -1f;
_searchFacingDirection = Vector3.Zero;
_searchEvidence = string.Empty;
_flankActive = false;
_flankTarget = Vector3.Zero;
_nextFlankDecisionAt = Time.Now + 1.8f;
_flankSide = BotSlot % 2 == 0 ? 1 : -1;
_suppressionUntil = -1f;
_nextSuppressionAt = Time.Now + 1.2f;
_suppressionShotsRemaining = 0;
_suppressionAimPoint = Vector3.Zero;
HearingStatus = UsesTacticalBehavior() ? "NONE" : "OFF";
HearingBearing = "NONE";
HearingAgeSeconds = -1f;
SearchStatus = UsesTacticalBehavior() ? "READY" : "OFF";
FlankStatus = UsesTacticalBehavior() ? "READY" : "OFF";
SuppressionStatus = UsesTacticalBehavior() ? "NONE" : "OFF";
TacticalStatus = UsesTacticalBehavior() ? "READY" : "OFF";
ThreatStatus = UsesTacticalBehavior() ? "NONE" : "OFF";
ThreatBearing = "NONE";
ThreatAgeSeconds = -1f;
CoverStatus = "NONE";
TacticalAggression = 0.50f;
TacticalContextStatus = UsesTacticalBehavior() ? "BALANCED" : "OFF";
ModifierStatus = UsesTacticalBehavior() ? "STANDARD · BASELINE" : "OFF";
_nextDynamicVariationAt = 0f;
_highNoonFireAt = -1f;
_lastHighNoonStage = HighNoonStage.Inactive;
UpdateAdaptationDiagnostics();
}
// =========================================================
// SHARED NAVIGATION CONSUMER
// =========================================================
private bool CanUseNavigation()
{
if (
Scene is null ||
Scene.NavMesh is null ||
!Scene.NavMesh.IsEnabled
)
{
StopNavigation(
"WAITING FOR NAVMESH",
"DISABLED"
);
return false;
}
if ( Scene.NavMesh.IsGenerating ||
( _navigationManager?.IsValid == true && !_navigationManager.NavigationReady ) )
{
StopNavigation(
"WAITING FOR NAVMESH",
"GENERATING"
);
return false;
}
if (
_playerState?.IsValid != true ||
_playerState.IsAlive != true ||
_movementInput?.IsValid != true ||
_controller?.IsValid != true
)
{
StopNavigation(
"WAITING FOR PLAYER STATE",
"NOT READY"
);
return false;
}
return true;
}
private void NavigateToWorldPosition(
Vector3 targetPosition,
string targetName,
float stopDistance,
string movingIntent,
bool allowSprint = true,
bool faceMovement = true,
bool advancedMovement = false,
OneMoreRoundWeapon movementWeapon = null,
bool visibleCombatTarget = false
)
{
Vector3 toTarget =
( targetPosition - WorldPosition )
.WithZ( 0f );
float targetDistance =
toTarget.Length;
SetLocomotionMode( ResolveLocomotionMode( movingIntent, visibleCombatTarget ) );
UpdateNavigationDiagnostics(
targetDistance,
targetName
);
if ( targetDistance <= stopDistance )
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
CurrentIntent = movingIntent.Contains( "VISIBLE" )
? "HOLDING VISIBLE TARGET"
: "HOLDING NEAR TARGET";
NavigationStatus = "ARRIVED";
return;
}
if ( !TryProjectNavigationEndpoints( targetPosition ) )
{
_pathPoints.Clear();
_pathPointIndex = 0;
StopMovementOnly();
CurrentIntent = "ENDPOINT OFF NAVMESH";
NavigationStatus = "NO START POINT";
return;
}
Vector3 previousSteeringPoint =
_pathPointIndex >= 0 && _pathPointIndex < _pathPoints.Count
? _pathPoints[_pathPointIndex]
: _previousPathSteeringPoint;
if ( Time.Now >= _nextRepathAt && Time.Now >= _nextMovementPathAt )
{
_nextRepathAt =
Time.Now + MathF.Max( 0.05f, RepathInterval );
_nextMovementPathAt = Time.Now + 0.15f;
NavMeshPath calculatedPath = CalculateCountedBotPath( _navigationStartOnMesh, _navigationTargetOnMesh );
List<Vector3> refreshedPoints = _refreshedPathPoints;
refreshedPoints.Clear();
if (
calculatedPath.IsValid &&
calculatedPath.Status == NavMeshPathStatus.Complete &&
calculatedPath.Points is not null
)
{
foreach ( NavMeshPathPoint point in calculatedPath.Points )
refreshedPoints.Add( point.Position );
}
if ( refreshedPoints.Count > 0 )
{
int continuityIndex = FluidMovementEnabled
? ChoosePathContinuityIndex( refreshedPoints, previousSteeringPoint )
: 0;
_pathPoints.Clear();
_pathPoints.AddRange( refreshedPoints );
_pathPointIndex = Math.Clamp( continuityIndex, 0, _pathPoints.Count - 1 );
_pathStatus =
calculatedPath.Status
.ToString()
.ToUpperInvariant();
_lastSuccessfulPathAt = Time.Now;
}
else
{
// BOT 6 continuity: a single transient failed repath must not zero the
// movement command and make ROOK look as if it stopped to think. Keep the
// last usable route for a short grace period while requesting another.
bool canUseGracePath =
_pathPoints.Count > 0 &&
Time.Now - _lastSuccessfulPathAt <= MathF.Max( 0.15f, RepathFailureGrace );
if ( canUseGracePath )
{
_pathStatus = "REPATH GRACE";
_nextRepathAt = MathF.Min( _nextRepathAt, Time.Now + 0.10f );
}
else
{
_pathPoints.Clear();
_pathPointIndex = 0;
_pathStatus = calculatedPath.IsValid
? $"NO PATH / {calculatedPath.Status.ToString().ToUpperInvariant()}"
: "NO PATH";
_nextRepathAt = Time.Now + MathF.Max( 0.25f, FailedPathRetryDelay );
}
}
}
NavigationStatus = _pathStatus;
if ( _pathPoints.Count == 0 )
{
StopMovementOnly();
CurrentIntent = "NO NAVIGATION PATH";
return;
}
AdvancePastReachedPathPoints();
if ( _pathPointIndex >= _pathPoints.Count )
{
StopMovementOnly();
CurrentIntent = "PATH COMPLETE / HOLD";
NavigationStatus = "ARRIVED";
return;
}
Vector3 steeringPoint =
_pathPoints[_pathPointIndex];
_previousPathSteeringPoint = steeringPoint;
Vector3 moveDirection = FluidMovementEnabled
? GetFluidNavigationDirection( steeringPoint )
: ( steeringPoint - WorldPosition ).WithZ( 0f );
if ( moveDirection.IsNearlyZero( 0.01f ) )
{
_pathPointIndex++;
if ( _pathPointIndex < _pathPoints.Count )
{
steeringPoint = _pathPoints[_pathPointIndex];
_previousPathSteeringPoint = steeringPoint;
moveDirection = FluidMovementEnabled
? GetFluidNavigationDirection( steeringPoint )
: ( steeringPoint - WorldPosition ).WithZ( 0f );
}
}
if ( moveDirection.IsNearlyZero( 0.01f ) )
{
StopMovementOnly();
CurrentIntent = "PATH COMPLETE / HOLD";
NavigationStatus = "ARRIVED";
return;
}
if (
advancedMovement &&
UsesTacticalBehavior() &&
DetectTraversalNoProgress( steeringPoint )
)
{
_pathPoints.Clear();
_pathPointIndex = 0;
_nextRepathAt = 0f;
StopMovementOnly();
NavigationStatus = "STUCK / REPATH";
CurrentIntent = $"{movingIntent} / STUCK REPATH";
return;
}
bool sprint =
allowSprint &&
targetDistance >= SprintDistance;
if ( sprint && ShouldSlowForUpcomingCorner() )
sprint = false;
bool crouchDown = false;
bool jumpPressed = false;
_traversalFrameStatus = string.Empty;
if ( advancedMovement && UsesTacticalBehavior() )
{
moveDirection =
ApplyTraversalAwareness(
moveDirection,
ref sprint,
ref jumpPressed
);
moveDirection =
ApplyTraversalOrbitGuard(
steeringPoint,
moveDirection,
ref sprint
);
if ( moveDirection.IsNearlyZero( 0.01f ) )
{
StopMovementOnly();
CurrentIntent = $"{movingIntent} / LOCAL BLOCKED";
NavigationStatus = "LOCAL BLOCKED";
return;
}
}
if ( faceMovement ) FaceMovementDirection( moveDirection );
Rotation movementBasis = Rotation.FromYaw( _controller.EyeAngles.yaw );
Vector3 moveInput = ( movementBasis.Inverse * moveDirection.Normal ).WithZ( 0f ).ClampLength( 1f );
if ( visibleCombatTarget || HasCombatFacingEvidence() ) sprint = false;
if ( advancedMovement )
{
ApplyAdvancedTraversalActions(
movementWeapon,
targetDistance,
visibleCombatTarget,
ref sprint,
ref crouchDown,
ref jumpPressed
);
}
if ( visibleCombatTarget || HasCombatFacingEvidence() ) sprint = false;
_movementInput.SetBotInput(
moveInput,
sprintDown: sprint,
crouchDown: crouchDown,
jumpPressed: jumpPressed
);
if ( advancedMovement )
{
if ( !string.IsNullOrWhiteSpace( _traversalFrameStatus ) )
{
SetMovementDiagnostics( _traversalFrameStatus );
CurrentIntent = $"{movingIntent} / {_traversalFrameStatus}";
}
else if ( _slide?.IsSliding == true )
{
SetMovementDiagnostics( "SLIDE" );
CurrentIntent = $"{movingIntent} / SLIDE";
}
else if ( jumpPressed )
{
SetMovementDiagnostics( _controller.IsAirborne ? "AIR JUMP REQUEST" : "JUMP REQUEST" );
CurrentIntent = $"{movingIntent} / JUMP";
}
else if ( sprint )
{
SetMovementDiagnostics( "SPRINT" );
CurrentIntent = $"{movingIntent} / SPRINT";
}
else
{
SetMovementDiagnostics( "MOVE" );
CurrentIntent = $"{movingIntent} / MOVE";
}
}
else
{
CurrentIntent = sprint
? $"{movingIntent} / SPRINT"
: $"{movingIntent} / MOVE";
}
}
private Vector3 ApplyTraversalAwareness(
Vector3 desiredWorldDirection,
ref bool sprint,
ref bool jumpPressed
)
{
if (
_controller?.IsValid != true ||
Scene is null ||
desiredWorldDirection.IsNearlyZero( 0.01f )
)
return desiredWorldDirection;
Vector3 forward =
desiredWorldDirection.WithZ( 0f ).Normal;
if (
Time.Now < _traversalRecoveryUntil &&
!_traversalRecoveryDirection.IsNearlyZero( 0.01f )
)
{
sprint = false;
_traversalFrameStatus = "ORBIT ESCAPE";
return _traversalRecoveryDirection * MathF.Max( desiredWorldDirection.Length, 1f );
}
if (
Time.Now < _traversalBypassUntil &&
!_traversalBypassDirection.IsNearlyZero( 0.01f )
)
{
sprint = false;
_traversalFrameStatus = "LOCAL BYPASS";
return _traversalBypassDirection * MathF.Max( desiredWorldDirection.Length, 1f );
}
if ( !_controller.IsOnGround )
return desiredWorldDirection;
bool lowBlocked =
IsTraversalRayBlocked( forward, TraversalLowProbeHeight );
bool highBlocked =
IsTraversalRayBlocked( forward, TraversalHighProbeHeight );
if ( !lowBlocked )
return desiredWorldDirection;
// A low obstacle with headroom is a legitimate jump request. The real
// PlayerLocomotion/PlayerController pipeline still decides whether the jump
// is physically legal, so this never grants bot-only traversal powers.
if (
!highBlocked &&
Time.Now >= _nextTraversalJumpAt
)
{
jumpPressed = true;
_nextTraversalJumpAt =
Time.Now + MathF.Max( 0.65f, TraversalJumpCooldown );
_traversalFrameStatus = "TRAVERSAL JUMP";
return desiredWorldDirection;
}
// If both low and high probes hit, this is a wall/ramp side rather than a
// jumpable lip. Try a short local bypass and force a fresh nav query instead
// of repeatedly jumping into the same geometry.
Vector3 bypass =
ChooseTraversalEscapeDirection(
forward,
_pathPointIndex < _pathPoints.Count
? _pathPoints[_pathPointIndex]
: WorldPosition + forward * 96f,
out string side
);
sprint = false;
if ( bypass.IsNearlyZero( 0.01f ) )
{
_nextRepathAt = 0f;
_traversalFrameStatus = "TRAVERSAL BLOCKED";
return Vector3.Zero;
}
_traversalPreferredBypassSign = side == "RIGHT" ? 1 : -1;
_traversalBypassDirection =
( forward * 0.18f + bypass * 0.99f )
.WithZ( 0f )
.Normal;
_traversalBypassUntil =
Time.Now + MathF.Max( 0.35f, TraversalBypassDuration );
// Do not let the regular repath timer immediately fight the committed local
// bypass. This was the source of the ramp-side orbit seen in 5A.1.
_nextRepathAt = MathF.Max( _nextRepathAt, _traversalBypassUntil );
_traversalFrameStatus = $"BYPASS {side}";
return _traversalBypassDirection * MathF.Max( desiredWorldDirection.Length, 1f );
}
private Vector3 ApplyTraversalOrbitGuard(
Vector3 steeringPoint,
Vector3 desiredWorldDirection,
ref bool sprint
)
{
if ( desiredWorldDirection.IsNearlyZero( 0.01f ) )
return desiredWorldDirection;
if (
Time.Now < _traversalRecoveryUntil &&
!_traversalRecoveryDirection.IsNearlyZero( 0.01f )
)
{
sprint = false;
_traversalFrameStatus = "ORBIT ESCAPE";
return _traversalRecoveryDirection * MathF.Max( desiredWorldDirection.Length, 1f );
}
Vector3 flatDirection = desiredWorldDirection.WithZ( 0f ).Normal;
float distance = ( steeringPoint - WorldPosition ).WithZ( 0f ).Length;
float targetShift = ( steeringPoint - _traversalOrbitTarget ).WithZ( 0f ).Length;
if (
_traversalOrbitWindowStartedAt < 0f ||
targetShift > 48f ||
_traversalOrbitLastPosition.IsNearlyZero( 0.01f )
)
{
ResetTraversalOrbitWindow( steeringPoint, flatDirection, distance );
return desiredWorldDirection;
}
float frameTravel =
( WorldPosition - _traversalOrbitLastPosition )
.WithZ( 0f )
.Length;
if ( frameTravel > 96f )
{
ResetTraversalOrbitWindow( steeringPoint, flatDirection, distance );
return desiredWorldDirection;
}
_traversalOrbitTravelDistance += frameTravel;
_traversalOrbitLastPosition = WorldPosition;
if ( !_traversalOrbitPreviousDirection.IsNearlyZero( 0.01f ) )
{
float dot = Math.Clamp(
Vector3.Dot( _traversalOrbitPreviousDirection, flatDirection ),
-1f,
1f
);
float crossZ =
_traversalOrbitPreviousDirection.x * flatDirection.y -
_traversalOrbitPreviousDirection.y * flatDirection.x;
float turn =
MathF.Abs( MathF.Atan2( crossZ, dot ) * ( 180f / MathF.PI ) );
_traversalOrbitTurnDegrees += turn;
}
_traversalOrbitPreviousDirection = flatDirection;
float elapsed = Time.Now - _traversalOrbitWindowStartedAt;
float improvement = _traversalOrbitStartDistance - distance;
float netDisplacement =
( WorldPosition - _traversalOrbitStartPosition )
.WithZ( 0f )
.Length;
if (
elapsed >= MathF.Max( 0.40f, TraversalOrbitWindow ) &&
_traversalOrbitTravelDistance >= MathF.Max( 48f, TraversalOrbitMinimumTravel ) &&
improvement < MathF.Max( 18f, TraversalMinimumProgress * 1.35f ) &&
(
_traversalOrbitTurnDegrees >= MathF.Max( 120f, TraversalOrbitTurnThreshold ) ||
( _traversalOrbitTravelDistance >= 125f && netDisplacement <= 58f )
)
)
{
Vector3 escape =
ChooseTraversalEscapeDirection(
flatDirection,
steeringPoint,
out string side
);
if ( escape.IsNearlyZero( 0.01f ) )
{
escape = -flatDirection;
side = "BACK";
}
_traversalPreferredBypassSign *= -1;
_traversalBypassUntil = -1f;
_traversalBypassDirection = Vector3.Zero;
_traversalRecoveryDirection = escape.WithZ( 0f ).Normal;
_traversalRecoveryUntil =
Time.Now + MathF.Max( 0.35f, TraversalRecoveryDuration );
_nextRepathAt = MathF.Max( _nextRepathAt, _traversalRecoveryUntil );
sprint = false;
_traversalFrameStatus = $"ORBIT ESCAPE {side}";
ResetTraversalOrbitWindow( steeringPoint, _traversalRecoveryDirection, distance );
return _traversalRecoveryDirection * MathF.Max( desiredWorldDirection.Length, 1f );
}
if ( improvement >= MathF.Max( 24f, TraversalMinimumProgress * 1.65f ) )
{
ResetTraversalOrbitWindow( steeringPoint, flatDirection, distance );
}
else if ( elapsed >= MathF.Max( 0.80f, TraversalOrbitWindow * 1.5f ) )
{
ResetTraversalOrbitWindow( steeringPoint, flatDirection, distance );
}
return desiredWorldDirection;
}
private Vector3 ChooseTraversalEscapeDirection(
Vector3 desiredDirection,
Vector3 steeringPoint,
out string sideLabel
)
{
sideLabel = string.Empty;
if ( desiredDirection.IsNearlyZero( 0.01f ) )
return Vector3.Zero;
Vector3 forward = desiredDirection.WithZ( 0f ).Normal;
float[] angles = _traversalPreferredBypassSign >= 0
? new[] { 48f, 74f, 102f, 132f, -48f, -74f, -102f, -132f, 180f }
: new[] { -48f, -74f, -102f, -132f, 48f, 74f, 102f, 132f, 180f };
float currentDistance = ( steeringPoint - WorldPosition ).WithZ( 0f ).Length;
float bestScore = float.MinValue;
Vector3 bestDirection = Vector3.Zero;
string bestLabel = string.Empty;
foreach ( float angle in angles )
{
Vector3 candidate =
( Rotation.FromYaw( angle ) * forward )
.WithZ( 0f )
.Normal;
if ( !IsTraversalDirectionClear( candidate ) )
continue;
Vector3 probePosition =
WorldPosition + candidate * MathF.Max( 56f, TraversalProbeDistance * 1.45f );
Vector3? navPoint = Scene.NavMesh.GetClosestPoint( probePosition, 72f );
if ( navPoint is null )
continue;
Vector3 actualDirection =
( navPoint.Value - WorldPosition )
.WithZ( 0f );
if ( actualDirection.IsNearlyZero( 0.01f ) )
continue;
actualDirection = actualDirection.Normal;
float candidateDistance =
( steeringPoint - navPoint.Value )
.WithZ( 0f )
.Length;
float progress = currentDistance - candidateDistance;
float alignment = Vector3.Dot( forward, actualDirection );
float lateral = 1f - MathF.Abs( alignment );
float score =
progress * 2.4f +
alignment * 28f +
lateral * 16f;
if ( MathF.Abs( angle ) >= 170f )
score -= 32f;
if ( score <= bestScore )
continue;
bestScore = score;
bestDirection = actualDirection;
bestLabel = angle > 8f ? "RIGHT" : angle < -8f ? "LEFT" : "BACK";
}
sideLabel = bestLabel;
return bestDirection;
}
private void ResetTraversalOrbitWindow()
{
_traversalOrbitWindowStartedAt = -1f;
_traversalOrbitStartPosition = Vector3.Zero;
_traversalOrbitTarget = Vector3.Zero;
_traversalOrbitLastPosition = Vector3.Zero;
_traversalOrbitPreviousDirection = Vector3.Zero;
_traversalOrbitStartDistance = float.MaxValue;
_traversalOrbitTravelDistance = 0f;
_traversalOrbitTurnDegrees = 0f;
}
private void ResetTraversalOrbitWindow(
Vector3 steeringPoint,
Vector3 direction,
float distance
)
{
_traversalOrbitWindowStartedAt = Time.Now;
_traversalOrbitStartPosition = WorldPosition;
_traversalOrbitTarget = steeringPoint;
_traversalOrbitLastPosition = WorldPosition;
_traversalOrbitPreviousDirection = direction.WithZ( 0f ).Normal;
_traversalOrbitStartDistance = distance;
_traversalOrbitTravelDistance = 0f;
_traversalOrbitTurnDegrees = 0f;
}
private bool DetectTraversalNoProgress( Vector3 steeringPoint )
{
float distance = ( steeringPoint - WorldPosition ).WithZ( 0f ).Length;
float targetShift = ( steeringPoint - _traversalProgressTarget ).WithZ( 0f ).Length;
if (
_traversalProgressStartedAt < 0f ||
targetShift > 48f ||
float.IsNaN( _traversalBestDistance ) || float.IsInfinity( _traversalBestDistance )
)
{
_traversalProgressStartedAt = Time.Now;
_traversalBestDistance = distance;
_traversalProgressTarget = steeringPoint;
return false;
}
float requiredProgress = MathF.Max( 4f, TraversalMinimumProgress );
if ( distance <= _traversalBestDistance - requiredProgress )
{
_traversalBestDistance = distance;
_traversalProgressStartedAt = Time.Now;
return false;
}
if ( Time.Now - _traversalProgressStartedAt < MathF.Max( 0.45f, TraversalNoProgressTimeout ) )
return false;
// The bot has been moving without getting meaningfully closer to the current
// path corner. Break the local orbit, alternate bypass side and request a new
// path on the next frame.
_traversalProgressStartedAt = Time.Now;
_traversalBestDistance = distance;
_traversalPreferredBypassSign *= -1;
_traversalBypassUntil = -1f;
_traversalBypassDirection = Vector3.Zero;
_traversalFrameStatus = "STUCK / REPATH";
return true;
}
private bool IsTraversalDirectionClear( Vector3 direction )
{
if ( direction.IsNearlyZero( 0.01f ) )
return false;
return
!IsTraversalRayBlocked( direction, TraversalLowProbeHeight ) &&
!IsTraversalRayBlocked( direction, TraversalHighProbeHeight );
}
private bool IsTraversalRayBlocked(
Vector3 direction,
float height
)
{
if ( Scene is null || direction.IsNearlyZero( 0.01f ) )
return false;
Vector3 planar = direction.WithZ( 0f ).Normal;
var probeKey = ( (int)MathF.Round( planar.x * 20f ), (int)MathF.Round( planar.y * 20f ), (int)MathF.Round( height ) );
if ( _traversalProbeCache.TryGetValue( probeKey, out TraversalProbeSample cachedProbe ) &&
Time.Now <= cachedProbe.ExpiresAt && ( WorldPosition - cachedProbe.Origin ).Length <= 12f &&
planar.Dot( cachedProbe.Direction ) >= 0.999f ) return cachedProbe.Blocked;
if ( _traversalProbeCache.Count > 64 ) _traversalProbeCache.Clear();
Vector3 start = WorldPosition + Vector3.Up * MathF.Max( 4f, height );
Vector3 end =
start +
direction.WithZ( 0f ).Normal * MathF.Max( 24f, TraversalProbeDistance );
SceneTrace traceBuilder =
Scene
.Trace
.Ray( start, end )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
{
traceBuilder =
traceBuilder.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
}
_traversalProbes++;
bool blocked = traceBuilder.Run().Hit;
_traversalProbeCache[probeKey] = new TraversalProbeSample( WorldPosition, planar, blocked );
return blocked;
}
private void AdvancePastReachedPathPoints()
{
if ( _pathPoints.Count == 0 )
return;
float reachDistance =
Math.Clamp( LookAheadDistance * 0.42f, 24f, 42f );
while ( _pathPointIndex < _pathPoints.Count )
{
Vector3 delta =
( _pathPoints[_pathPointIndex] - WorldPosition )
.WithZ( 0f );
if ( delta.Length > reachDistance )
break;
_pathPointIndex++;
}
}
private PlayerState ResolveHumanTarget(
PlayerState cached
)
{
if (
cached is not null &&
cached.IsValid &&
!cached.IsBot &&
cached.IsAlive
)
{
return cached;
}
return Scene?
.GetAllComponents<PlayerState>()
.FirstOrDefault(
x =>
x is not null &&
x.IsValid &&
!x.IsBot &&
x.IsAlive
);
}
private void FaceMovementDirection( Vector3 moveDirection )
{
if ( _controller?.IsValid != true || moveDirection.IsNearlyZero( 0.001f ) ) return;
LookTowardWorldPoint( _controller.EyePosition + moveDirection.Normal * 180f, 8f );
}
private void StopNavigation(
string intent,
string navigationStatus
)
{
_pathPoints.Clear();
_pathPointIndex = 0;
_pathStatus = navigationStatus;
_fluidSteeringDirection = Vector3.Zero;
_previousPathSteeringPoint = Vector3.Zero;
SetLocomotionMode( OMRBotLocomotionMode.Idle );
StopMovementOnly();
CurrentIntent = intent;
NavigationStatus = navigationStatus;
NavigationTargetName = "NONE";
NavigationTargetDistance = 0f;
}
private void StopMovementOnly()
{
if ( UsesAdvancedMovement() )
UpdateScheduledCrouchRelease();
_movementInput?.SetBotInput(
Vector3.Zero,
sprintDown: false,
crouchDown: GetBotCrouchButtonDown(),
jumpPressed: false
);
if ( UsesAdvancedMovement() )
SetMovementDiagnostics( _controller?.IsDucking == true ? "CROUCH HOLD" : "HOLD" );
}
private void UpdateNavigationDiagnostics(
float distance,
string targetName
)
{
if ( Time.Now < _nextDiagnosticAt )
return;
_nextDiagnosticAt =
Time.Now + 0.10f;
NavigationTargetDistance =
MathF.Round( distance );
NavigationTargetName =
string.IsNullOrWhiteSpace( targetName )
? "PLAYER"
: targetName;
}
private void ResetRuntimeBehaviorState()
{
_nextRepathAt = 0f;
_pathPoints.Clear();
_pathPointIndex = 0;
_pathStatus =
BehaviorMode == OMRBotBehaviorMode.Stationary
? "OFF"
: "NO PATH";
ResetFluidMovementState();
ResetCombatHumanization();
ResetCombatContinuity();
ResetPerformanceCaches();
_navigationTarget = null;
_perceptionTarget = null;
_nextPerceptionScanAt = 0f;
_rawVisibleSince = -1f;
_lastConfirmedSeenAt = -999f;
_hasConfirmedSight = false;
_observedTargetPosition = Vector3.Zero;
_observedTargetAimPoint = Vector3.Zero;
HasLastKnownPosition = false;
LastKnownPosition = Vector3.Zero;
_nextSearchTurnAt = 0f;
_searchTurnDirection = 1f;
_nextBotTriggerAt = 0f;
_nextStrafeSwitchAt = Time.Now + 0.65f;
_strafeDirection = BotSlot % 2 == 0 ? 1f : -1f;
_nextSlideAt = Time.Now + 1.35f;
_nextJumpAt = Time.Now + 2.1f;
_airJumpAttemptPending = false;
_airJumpAttemptAt = 0f;
_nextCrouchDodgeAt = Time.Now + 1.6f;
_crouchDodgeEndAt = -1f;
_crouchButtonHeldUntil = -1f;
_wasSliding = false;
MovementStatus =
UsesAdvancedMovement()
? "READY"
: "OFF";
MovementSpeed = 0f;
SafeReleaseLastCombatWeapon();
ResetCombatDiagnostics(
BehaviorMode == OMRBotBehaviorMode.Combat ||
UsesAdvancedMovement()
? "NO CONTACT"
: "OFF"
);
ResetPerceptionDiagnostics(
BehaviorMode == OMRBotBehaviorMode.PerceptionSearch ||
BehaviorMode == OMRBotBehaviorMode.Combat ||
UsesAdvancedMovement()
? "NO CONTACT"
: "OFF"
);
ResetTacticalState();
ResetBrainArchitecture();
ResetTacticalMapState();
ResetUtilityBrainState();
ResetOpponentBelief();
ResetPlayerPrediction( clearStyle: true );
}
private void ResetPerceptionDiagnostics(
string status
)
{
PerceptionStatus = status;
PerceptionTargetName = "NONE";
PerceptionTargetDistance = 0f;
PerceptionLastSeenSeconds = -1f;
PerceptionReactionRemaining = 0f;
}
private void SuppressHumanControl()
{
if ( _controller?.IsValid == true )
{
_controller.UseInputControls = false;
_controller.UseLookControls = false;
_controller.UseCameraControls = false;
}
if ( _movementInput?.IsValid == true )
{
_movementInput.SetBotDriven( true );
}
}
}