OMRBotManager.cs
using Sandbox;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
/// <summary>
/// Host-only lifecycle manager for OMR player bots.
///
/// BOT 0/0.5 establishes a host-owned player bot. BOT 1 manages the runtime
/// navigation mesh for the modular arena. BOT 2 reuses that same mesh while
/// perception decides whether the bot may navigate to a visible or remembered
/// target position. BOT 3 layers real weapon combat on top. BOT 4 adds
/// contextual sprint/strafe/slide/crouch/jump decisions through synthetic input.
/// BOT 5 adds tactical cover, under-fire reactions, hearing/search, flanking and
/// anti-loop traversal safeguards. BOT 6 adds modifier-aware decisions, bounded
/// tactical variation and an online opponent model while keeping physical movement
/// inside the normal OMR locomotion pipeline.
/// </summary>
public sealed class OMRBotManager : Component
{
[Property]
public RoundManager RoundManager { get; set; }
[Property, ResourceType( "prefab" )]
public string PlayerPrefabPath { get; set; } =
"prefabs/player.prefab";
[Property, Group( "Diagnostics" )]
public bool LogLifecycle { get; set; } = true;
private readonly List<OMRBotController> _managedBots = new();
private readonly List<PlayerState> _managedHumans = new();
private float _incrementalNavigationQueuedAt;
private float _navigationRetryAfter;
private int _navigationFailureCount;
// RequestTilesGeneration queues work for future frames. IsGenerating alone
// can briefly be false before that queued work begins.
public bool NavigationReady => _navigationHasInitialFullBuild &&
!_navigationRebuildPending && !_incrementalNavigationRefreshPending &&
_navigationGenerationTask is null && Scene?.NavMesh is not null &&
!Scene.NavMesh.IsGenerating &&
( RoundManager?.Arena?.CurrentLayoutState ?? ArenaLayoutState.Invalid ) == _lastNavigationLayoutState;
private float _nextSyncAt;
private ArenaLayoutState _lastNavigationLayoutState =
ArenaLayoutState.Invalid;
private bool _navigationMeshConfigured;
private bool _navigationRebuildPending;
private float _navigationRebuildAt;
private ArenaLayoutState _pendingNavigationLayoutState =
ArenaLayoutState.Invalid;
private Task<bool> _navigationGenerationTask;
private ArenaLayoutState _generatingNavigationLayoutState =
ArenaLayoutState.Invalid;
private bool _navigationHasInitialFullBuild;
private bool _incrementalNavigationRefreshPending;
private ArenaLayoutState _incrementalNavigationLayoutState =
ArenaLayoutState.Invalid;
protected override void OnStart()
{
RoundManager ??=
Components.Get<RoundManager>();
}
protected override void OnUpdate()
{
if ( !Networking.IsHost )
return;
if ( Time.Now < _nextSyncAt )
return;
_nextSyncAt =
Time.Now + 0.20f;
SyncNavigationMesh();
SyncManagedBot();
}
public void SyncNow()
{
if ( !Networking.IsHost )
return;
_nextSyncAt = 0f;
SyncNavigationMesh();
SyncManagedBot();
}
public void DestroyAllBots()
{
if ( !Networking.IsHost || Scene is null )
return;
foreach (
OMRBotController bot in
Scene
.GetAllComponents<OMRBotController>()
.ToArray()
)
{
if (
bot?.GameObject is null ||
bot.GameObject.IsDestroyed
)
continue;
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] Destroying '{bot.GameObject.Name}'"
);
}
bot.GameObject.Destroy();
}
}
private void SyncNavigationMesh()
{
if (
RoundManager is null ||
Scene?.NavMesh is null
)
return;
bool sandboxNavigationRequested =
RoundManager.Mode == SessionMode.Sandbox &&
RoundManager.SandboxBotEnabled &&
RoundManager.SandboxBotBehavior != OMRBotBehaviorMode.Stationary;
bool matchNavigationRequested =
RoundManager.Mode == SessionMode.Match &&
RoundManager.MatchBotEnabled;
bool navigationRequested = sandboxNavigationRequested || matchNavigationRequested;
Scene.NavMesh.DrawMesh =
RoundManager.Mode == SessionMode.Sandbox && RoundManager.SandboxBotDrawNavMesh;
if ( !navigationRequested )
{
_navigationRebuildPending = false;
_pendingNavigationLayoutState = ArenaLayoutState.Invalid;
return;
}
if ( !_navigationMeshConfigured )
{
Scene.NavMesh.IsEnabled = true;
Scene.NavMesh.IncludeStaticBodies = true;
Scene.NavMesh.IncludeKeyframedBodies = true;
Scene.NavMesh.DeferGeneration = true;
// Dynamic player capsules and triggers are never arena geometry.
Scene.NavMesh.ExcludedBodies.Add( "player" );
Scene.NavMesh.ExcludedBodies.Add( "playercontroller" );
Scene.NavMesh.ExcludedBodies.Add( "trigger" );
// Match the authored player capsule instead of the older generic
// scene defaults. This preserves realistic clearance around cover.
Scene.NavMesh.AgentHeight = 72f;
Scene.NavMesh.AgentRadius = 20f;
Scene.NavMesh.AgentStepSize = 18f;
Scene.NavMesh.AgentMaxSlope = 40f;
_navigationMeshConfigured = true;
}
ArenaLayoutState layoutState =
RoundManager.Arena?.CurrentLayoutState ??
ArenaLayoutState.Invalid;
// Finish/log an explicit generation request before considering another.
if ( _navigationGenerationTask is not null )
{
if ( !_navigationGenerationTask.IsCompleted )
return;
bool generated = false;
if ( _navigationGenerationTask.Status == TaskStatus.RanToCompletion )
generated = _navigationGenerationTask.Result;
if ( LogLifecycle )
{
Vector3? samplePoint =
generated ? Scene.NavMesh.GetRandomPoint() : null;
string sampleText =
samplePoint is null
? "NONE"
: samplePoint.Value.ToString();
Log.Info(
$"[OMR BOT] NavMesh generation finished | Layout:{_generatingNavigationLayoutState} | Success:{generated} | Dirty:{Scene.NavMesh.IsDirty} | Sample:{sampleText}"
);
}
if ( generated )
{
_navigationHasInitialFullBuild = true;
_navigationFailureCount = 0;
_navigationRetryAfter = 0f;
}
else
{
_lastNavigationLayoutState = ArenaLayoutState.Invalid;
_navigationHasInitialFullBuild = false;
_navigationFailureCount = System.Math.Min( 5, _navigationFailureCount + 1 );
_navigationRetryAfter = Time.Now + System.MathF.Min( 8f, _navigationFailureCount * 1.5f );
if ( _navigationGenerationTask.IsFaulted )
Log.Warning( $"[OMR BOT] NavMesh build failed: {_navigationGenerationTask.Exception?.GetBaseException().Message}" );
}
_navigationGenerationTask = null;
_generatingNavigationLayoutState = ArenaLayoutState.Invalid;
}
if ( _incrementalNavigationRefreshPending && Time.Now >= _incrementalNavigationQueuedAt + 0.25f && !Scene.NavMesh.IsGenerating )
{
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] NavMesh incremental refresh settled | Layout:{_incrementalNavigationLayoutState}"
);
}
_incrementalNavigationRefreshPending = false;
_incrementalNavigationLayoutState = ArenaLayoutState.Invalid;
}
if ( layoutState.IsValid && layoutState != _lastNavigationLayoutState )
{
_lastNavigationLayoutState = layoutState;
// Arena pieces are cloned at runtime. Give their physics shapes a few
// frames to enter the PhysicsWorld before generating Recast data.
_pendingNavigationLayoutState = layoutState;
_navigationRebuildPending = true;
_navigationRebuildAt = System.MathF.Max( Time.Now + 0.40f, _navigationRetryAfter );
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] NavMesh refresh scheduled | Layout:{layoutState} | Mode:{(_navigationHasInitialFullBuild ? "TILES" : "FULL")}"
);
}
}
if (
_navigationRebuildPending &&
Time.Now >= _navigationRebuildAt &&
_navigationGenerationTask is null &&
!_incrementalNavigationRefreshPending &&
!Scene.NavMesh.IsGenerating
)
{
_navigationRebuildPending = false;
_generatingNavigationLayoutState = _pendingNavigationLayoutState;
_pendingNavigationLayoutState = ArenaLayoutState.Invalid;
if ( !_navigationHasInitialFullBuild )
{
// The first bot-active layout needs a complete build. Subsequent modular
// swaps keep the arena bounds and refresh the overlapping tiles instead
// of repeatedly rebuilding all Recast data between rounds.
_navigationGenerationTask =
Scene.NavMesh.Generate( Scene.PhysicsWorld );
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] NavMesh full generation started | Layout:{_generatingNavigationLayoutState}"
);
}
}
else
{
Scene.NavMesh.RequestTilesGeneration( Scene.NavMesh.Bounds );
_incrementalNavigationQueuedAt = Time.Now;
_incrementalNavigationRefreshPending = true;
_incrementalNavigationLayoutState = _generatingNavigationLayoutState;
_generatingNavigationLayoutState = ArenaLayoutState.Invalid;
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] NavMesh incremental tile refresh queued | Layout:{_incrementalNavigationLayoutState}"
);
}
}
}
}
private void SyncManagedBot()
{
RoundManager ??= Components.Get<RoundManager>();
if ( RoundManager is null || Scene is null )
return;
List<OMRBotController> bots = _managedBots;
List<PlayerState> humans = _managedHumans;
bots.Clear(); humans.Clear();
foreach ( OMRBotController bot in Scene.GetAllComponents<OMRBotController>() )
if ( bot?.IsValid == true && bot.GameObject is not null && !bot.GameObject.IsDestroyed ) bots.Add( bot );
foreach ( PlayerState human in Scene.GetAllComponents<PlayerState>() )
if ( human?.IsValid == true && !human.IsBot ) humans.Add( human );
bool sandboxRequested =
RoundManager.Mode == SessionMode.Sandbox &&
RoundManager.SandboxBotEnabled &&
humans.Count < 2;
bool matchRequested =
RoundManager.Mode == SessionMode.Match &&
RoundManager.MatchBotEnabled &&
humans.Count == 1;
bool shouldHaveBot = sandboxRequested || matchRequested;
if ( !shouldHaveBot )
{
if ( bots.Count > 0 ) DestroyAllBots();
return;
}
for ( int index = bots.Count - 1; index >= 1; index-- )
{
bots[index].GameObject.Destroy();
bots.RemoveAt( index );
}
OMRBotDifficulty difficulty = matchRequested ? RoundManager.MatchBotDifficulty : RoundManager.SandboxBotDifficulty;
OMRBotBehaviorMode behavior = matchRequested ? OMRBotBehaviorMode.TacticalCombat : RoundManager.SandboxBotBehavior;
string name = matchRequested ? RoundManager.MatchBotName : RoundManager.SandboxBotName;
if ( bots.Count == 0 )
{
SpawnManagedBot( name, difficulty, behavior, matchRequested );
return;
}
OMRBotController existing = bots[0];
existing.Configure( 0, difficulty, behavior );
PlayerState existingState = existing.Components.Get<PlayerState>( true );
if ( existingState is not null )
{
existingState.BotDisplayName = name;
int desiredTeam = ResolveSandboxBotTeam();
if ( desiredTeam >= 0 ) existingState.TeamId = desiredTeam;
}
}
private int ResolveSandboxBotTeam()
{
PlayerState human = _managedHumans.FirstOrDefault( x => x.IsValid && !x.IsBot );
if ( human?.TeamId == 1 )
return 0;
// Unassigned/Team A humans default to the conventional Team B bot slot.
return 1;
}
private void SpawnManagedBot( string botName, OMRBotDifficulty difficulty, OMRBotBehaviorMode behavior, bool competitiveMatch )
{
if (
!Networking.IsHost ||
RoundManager is null ||
string.IsNullOrWhiteSpace( PlayerPrefabPath )
)
return;
int botTeam =
ResolveSandboxBotTeam();
GameObject spawn =
botTeam == 0
? RoundManager.SpawnA
: RoundManager.SpawnB;
Transform spawnTransform =
spawn is not null
? spawn.WorldTransform.WithScale( 1f )
: WorldTransform.WithScale( 1f );
GameObject bot =
GameObject.Clone(
PlayerPrefabPath,
new CloneConfig
{
Name = $"Bot - {botName}",
StartEnabled = false,
Transform = spawnTransform
}
);
if ( bot is null || !bot.IsValid )
{
Log.Error(
$"[OMR BOT] Could not clone player prefab '{PlayerPrefabPath}'."
);
return;
}
// The clone is intentionally created with StartEnabled = false so none of
// the human-player components start running before bot ownership/input is
// configured. ComponentList.Get() ignores inactive components by default,
// so every pre-NetworkSpawn lookup here must include disabled components.
PlayerState state =
bot.Components.Get<PlayerState>( true );
if ( state is null )
{
Log.Error(
"[OMR BOT] Player prefab has no PlayerState."
);
bot.Destroy();
return;
}
state.IsBot = true;
state.BotDisplayName = botName;
state.TeamId = botTeam;
state.PreMatchState =
PreMatchPlayerState.Lobby;
PlayerController playerController =
bot.Components.Get<PlayerController>( true );
if ( playerController is not null )
{
playerController.UseInputControls = false;
playerController.UseLookControls = false;
playerController.UseCameraControls = false;
// A bot is host-owned, but visually it is always another third-person
// competitor. Never let ownership-based first-person hiding make its
// Citizen body disappear for the host tester.
playerController.HideBodyInFirstPerson = false;
}
// OMR keeps CalculatePath points as input for the shared PlayerLocomotion
// pipeline. The historical NavMeshAgent transform bug (#11811) was fixed
// by S&box on 2026-09-15; retaining input-driven motion is an architectural
// choice, not a workaround for a current engine defect.
// These components exist for the human owner experience only. Keeping
// them disabled on a host-controlled bot prevents a second local camera,
// settings path, hitmarker path or inventory-input reader from attaching
// itself to the host.
PlayerInventoryInput inventoryInput =
bot.Components.Get<PlayerInventoryInput>( true );
if ( inventoryInput is not null )
inventoryInput.Enabled = false;
PlayerCameraFeel cameraFeel =
bot.Components.Get<PlayerCameraFeel>( true );
if ( cameraFeel is not null )
cameraFeel.Enabled = false;
PlayerCombatFeedback combatFeedback =
bot.Components.Get<PlayerCombatFeedback>( true );
if ( combatFeedback is not null )
combatFeedback.Enabled = false;
Dresser dresser =
bot.Components.Get<Dresser>( true );
if ( dresser is not null )
{
dresser.Source =
Dresser.ClothingSource.Manual;
dresser.RemoveUnownedItems = false;
dresser.Randomize();
}
OMRBotController botController =
bot.Components.Get<OMRBotController>( true );
botController ??=
bot.AddComponent<OMRBotController>();
botController.Configure(
0,
difficulty,
behavior
);
// No connection argument: the host owns/simulates this networked player.
// This is the same player-bot pattern used by released S&box games such
// as Dropout, while avoiding fake/debug Connection objects.
if ( !bot.NetworkSpawn() )
{
Log.Error(
"[OMR BOT] NetworkSpawn failed."
);
bot.Destroy();
return;
}
if ( LogLifecycle )
{
Log.Info(
$"[OMR BOT] Spawned {state.BotDisplayName} | Team:{botTeam} | Difficulty:{difficulty} | Behavior:{behavior} | Match:{competitiveMatch}"
);
}
}
}