OMRBotController.TacticalMap.cs
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Linq;
public enum OMRBotTacticalQueryPurpose
{
Cover,
Flank,
Search,
Press,
OpenDistance,
Reposition,
Retreat
}
/// <summary>
/// Shared route appraisal used by BOT 7C. Route length is only one input: the
/// evaluation also records exposure to the currently *known* threat reference,
/// learned danger/death-route memory and how indirect the route is.
/// </summary>
public readonly struct OMRBotRouteEvaluation
{
public Vector3 Destination { get; }
public float PathLength { get; }
public float DirectDistance { get; }
public float DetourRatio { get; }
public float MemoryRisk { get; }
public int ExposedSamples { get; }
public int PartialSamples { get; }
public int ProtectedSamples { get; }
public float ExposureFraction { get; }
public float ProtectionFraction { get; }
public float FirstProtectionDistance { get; }
public OMRBotRouteEvaluation(
Vector3 destination,
float pathLength,
float directDistance,
float detourRatio,
float memoryRisk,
int exposedSamples,
int partialSamples,
int protectedSamples,
float exposureFraction,
float protectionFraction,
float firstProtectionDistance
)
{
Destination = destination;
PathLength = pathLength;
DirectDistance = directDistance;
DetourRatio = detourRatio;
MemoryRisk = memoryRisk;
ExposedSamples = exposedSamples;
PartialSamples = partialSamples;
ProtectedSamples = protectedSamples;
ExposureFraction = exposureFraction;
ProtectionFraction = protectionFraction;
FirstProtectionDistance = firstProtectionDistance;
}
}
/// <summary>
/// A destination scored in tactical rather than purely geometric terms.
/// Higher Score is better.
/// </summary>
public readonly struct OMRBotTacticalPositionEvaluation
{
public Vector3 Position { get; }
public float Score { get; }
public OMRBotRouteEvaluation Route { get; }
public float RangeToThreat { get; }
public float RangeFit { get; }
public float AngularGainDegrees { get; }
public float LocalOpenness { get; }
public int EscapeOptions { get; }
public int CoverSamples { get; }
public bool HasSightToThreat { get; }
public float MetadataScore { get; }
public float EvidenceRelevance { get; }
public OMRBotTacticalPositionEvaluation(
Vector3 position,
float score,
OMRBotRouteEvaluation route,
float rangeToThreat,
float rangeFit,
float angularGainDegrees,
float localOpenness,
int escapeOptions,
int coverSamples,
bool hasSightToThreat,
float metadataScore,
float evidenceRelevance
)
{
Position = position;
Score = score;
Route = route;
RangeToThreat = rangeToThreat;
RangeFit = rangeFit;
AngularGainDegrees = angularGainDegrees;
LocalOpenness = localOpenness;
EscapeOptions = escapeOptions;
CoverSamples = coverSamples;
HasSightToThreat = hasSightToThreat;
MetadataScore = metadataScore;
EvidenceRelevance = evidenceRelevance;
}
}
/// <summary>
/// BOT 7C tactical-space layer.
///
/// NavMesh answers "can I get there?". This layer answers "is getting there a
/// good idea for what I am trying to do?". It is deliberately shared by cover,
/// flank and search so those behaviors stop maintaining subtly different ideas
/// of what constitutes a safe/useful route.
/// </summary>
internal readonly struct OMRBotCachedRoute
{
public bool Success { get; }
public OMRBotRouteEvaluation Evaluation { get; }
public float ExpiresAt { get; }
public OMRBotCachedRoute( bool success, OMRBotRouteEvaluation evaluation, float expiresAt )
{
Success = success;
Evaluation = evaluation;
ExpiresAt = expiresAt;
}
}
public sealed partial class OMRBotController
{
[Property, Group( "Tactics / Tactical Map" )]
public bool TacticalPositionQueriesEnabled { get; set; } = true;
[Property, Group( "Tactics / Tactical Map" )]
public float TacticalSpaceProbeDistance { get; set; } = 150f;
[Property, Group( "Tactics / Tactical Map" )]
public float TacticalEscapeProbeDistance { get; set; } = 92f;
[Property, Group( "Tactics / Tactical Map" )]
public float TacticalMetadataRefreshInterval { get; set; } = 1.5f;
[Property, Group( "Tactics / Tactical Map" ), Range( 2, 16 )]
public int TacticalPathQueryBudgetPerUpdate { get; set; } = 6;
[Property, Group( "Tactics / Tactical Map" ), Range( 0.05f, 1f )]
public float TacticalRouteCacheDuration { get; set; } = 0.30f;
[Property, Sync( SyncFlags.FromHost )]
public string TacticalMapStatus { get; set; } = "AUTO";
[Property, Sync( SyncFlags.FromHost )]
public string TacticalQueryStatus { get; set; } = "NONE";
private readonly List<OMRTacticalRegion> _tacticalRegions = new();
private readonly List<OMRTacticalPoint> _tacticalPoints = new();
private float _nextTacticalMetadataRefreshAt;
private ArenaLayoutState _tacticalMetadataLayoutState = ArenaLayoutState.Invalid;
private int _tacticalPathQueriesRemaining;
private readonly Dictionary<(Vector3 Start, Vector3 Destination, Vector3 Threat, bool HasThreat), OMRBotCachedRoute> _tacticalRouteCache = new();
private void UpdateTacticalMapContext( OneMoreRoundWeapon weapon )
{
if ( !UsesTacticalBehavior() || !TacticalPositionQueriesEnabled )
{
TacticalMapStatus = "OFF";
TacticalQueryStatus = "LEGACY";
return;
}
RefreshTacticalMetadataIfNeeded();
RefreshTacticalQueryBudget();
if ( _tacticalRouteCache.Count > 160 )
_tacticalRouteCache.Clear();
string weaponName = GetWeaponDoctrine( weapon ).DisplayName;
if ( string.IsNullOrWhiteSpace( weaponName ) )
weaponName = "UNARMED";
TacticalMapStatus =
_tacticalRegions.Count == 0 && _tacticalPoints.Count == 0
? $"AUTO · {weaponName.ToUpperInvariant()}"
: $"AUTO + R{_tacticalRegions.Count}/P{_tacticalPoints.Count} · {weaponName.ToUpperInvariant()}";
}
private void RefreshTacticalMetadataIfNeeded( bool force = false )
{
if ( Scene is null )
return;
ArenaLayoutState layout =
_memoryArena?.IsValid == true
? _memoryArena.CurrentLayoutState
: ArenaLayoutState.Invalid;
bool layoutChanged =
layout.IsValid &&
( !_tacticalMetadataLayoutState.IsValid || layout != _tacticalMetadataLayoutState );
if ( layoutChanged )
_tacticalRouteCache.Clear();
if ( !force && !layoutChanged && Time.Now < _nextTacticalMetadataRefreshAt )
return;
_tacticalRegions.Clear();
_tacticalPoints.Clear();
foreach ( OMRTacticalRegion region in Scene.GetAllComponents<OMRTacticalRegion>() )
{
if ( region?.IsValid == true )
_tacticalRegions.Add( region );
}
foreach ( OMRTacticalPoint point in Scene.GetAllComponents<OMRTacticalPoint>() )
{
if ( point?.IsValid == true )
_tacticalPoints.Add( point );
}
if ( layout.IsValid )
_tacticalMetadataLayoutState = layout;
_nextTacticalMetadataRefreshAt =
Time.Now + MathF.Max( 0.35f, TacticalMetadataRefreshInterval );
}
private void ResetTacticalMapState()
{
_tacticalRegions.Clear();
_tacticalPoints.Clear();
_nextTacticalMetadataRefreshAt = 0f;
_tacticalMetadataLayoutState = ArenaLayoutState.Invalid;
_tacticalRouteCache.Clear();
_tacticalPathQueriesRemaining = 0;
TacticalMapStatus = TacticalPositionQueriesEnabled ? "AUTO" : "OFF";
TacticalQueryStatus = "NONE";
}
private bool TryEvaluateTacticalRoute(
Vector3 rawDestination,
Vector3 threatReference,
bool hasThreatReference,
out OMRBotRouteEvaluation evaluation
)
{
evaluation = default;
if (
Scene?.NavMesh is null ||
!Scene.NavMesh.IsEnabled ||
Scene.NavMesh.IsGenerating
)
return false;
Vector3? start = Scene.NavMesh.GetClosestPoint( WorldPosition, 128f );
Vector3? destination = Scene.NavMesh.GetClosestPoint( rawDestination, 120f );
if ( start is null || destination is null )
return false;
var cacheKey = ( start.Value, destination.Value, hasThreatReference ? threatReference : Vector3.Zero, hasThreatReference );
if (
_tacticalRouteCache.TryGetValue( cacheKey, out OMRBotCachedRoute cached ) &&
Time.Now <= cached.ExpiresAt
)
{
evaluation = cached.Evaluation;
return cached.Success;
}
if ( _tacticalPathQueriesRemaining <= 0 )
return false;
_tacticalPathQueriesRemaining--;
NavMeshPath path = CalculateCountedBotPath( start.Value, destination.Value );
if (
!path.IsValid ||
path.Status != NavMeshPathStatus.Complete ||
path.Points is null ||
path.Points.Count == 0
)
{
_tacticalRouteCache[cacheKey] = new OMRBotCachedRoute(
false,
default,
Time.Now + MathF.Min( 0.15f, MathF.Max( 0.05f, TacticalRouteCacheDuration ) )
);
return false;
}
float pathLength = CalculatePathLength( path );
float directDistance =
( destination.Value - WorldPosition ).WithZ( 0f ).Length;
float detourRatio =
directDistance > 24f
? MathF.Max( 1f, pathLength / directDistance )
: 1f;
float memoryRisk = EvaluatePathMemoryRisk( path );
int exposed = 0;
int partial = 0;
int protectedCount = 0;
float firstProtectionDistance = -1f;
if ( hasThreatReference )
{
EvaluatePathExposure(
path,
threatReference,
out exposed,
out partial,
out protectedCount,
out firstProtectionDistance
);
}
int samples = exposed + partial + protectedCount;
float exposureFraction =
samples > 0
? ( exposed + partial * 0.45f ) / samples
: 0f;
float protectionFraction =
samples > 0
? ( protectedCount + partial * 0.45f ) / samples
: 0f;
evaluation = new OMRBotRouteEvaluation(
destination.Value,
pathLength,
directDistance,
detourRatio,
memoryRisk,
exposed,
partial,
protectedCount,
exposureFraction,
protectionFraction,
firstProtectionDistance
);
_tacticalRouteCache[cacheKey] = new OMRBotCachedRoute(
true,
evaluation,
Time.Now + MathF.Max( 0.05f, TacticalRouteCacheDuration )
);
return true;
}
private bool TryEvaluateTacticalPosition(
Vector3 rawCandidate,
OMRBotTacticalQueryPurpose purpose,
OMRBotWeaponDoctrine doctrine,
Vector3 threatReference,
bool hasThreatReference,
Vector3 evidenceOrigin,
float desiredRange,
out OMRBotTacticalPositionEvaluation evaluation
)
{
evaluation = default;
if ( !TacticalPositionQueriesEnabled )
return false;
if (
!TryEvaluateTacticalRoute(
rawCandidate,
threatReference,
hasThreatReference,
out OMRBotRouteEvaluation route
)
)
return false;
Vector3 candidate = route.Destination;
// Cover already performs richer peek/mobility probes in EvaluateCoverOptions.
// Avoid duplicating those traces across ~30 cover candidates; flank/search
// still use the generic local-space probes because they lack that specialist
// evaluation.
bool probeLocalSpace = purpose != OMRBotTacticalQueryPurpose.Cover;
float openness = probeLocalSpace ? MeasureTacticalOpenness( candidate ) : 0.5f;
int escapeOptions = probeLocalSpace ? CountTacticalEscapeOptions( candidate ) : 0;
int coverSamples =
hasThreatReference
? CountBlockedThreatSamples( candidate, threatReference )
: 0;
if ( purpose == OMRBotTacticalQueryPurpose.Retreat && hasThreatReference &&
( coverSamples == 0 || !IsCoverApproachSafe( route ) ) ) return false;
bool hasSightToThreat =
hasThreatReference &&
!IsThreatRayBlocked( candidate + Vector3.Up * 60f, threatReference );
float rangeToThreat = 0f;
float rangeFit = 0.5f;
float angularGain = 0f;
if ( hasThreatReference )
{
rangeToThreat =
( candidate - threatReference ).WithZ( 0f ).Length;
float targetRange = MathF.Max( 80f, desiredRange );
rangeFit =
1f - MathX.Clamp(
MathF.Abs( rangeToThreat - targetRange ) / targetRange,
0f,
1f
);
Vector3 currentFromThreat =
( WorldPosition - threatReference ).WithZ( 0f );
Vector3 candidateFromThreat =
( candidate - threatReference ).WithZ( 0f );
if (
!currentFromThreat.IsNearlyZero( 0.01f ) &&
!candidateFromThreat.IsNearlyZero( 0.01f )
)
{
float dot = MathX.Clamp(
currentFromThreat.Normal.Dot( candidateFromThreat.Normal ),
-1f,
1f
);
angularGain = MathF.Acos( dot ) * 180f / MathF.PI;
}
}
float evidenceRelevance = 0.5f;
if ( !evidenceOrigin.IsNearlyZero( 0.01f ) )
{
float evidenceDistance =
( candidate - evidenceOrigin ).WithZ( 0f ).Length;
float scale = MathF.Max( 180f, SearchForwardDistance * 1.8f );
evidenceRelevance =
1f - MathX.Clamp( evidenceDistance / scale, 0f, 1f );
}
float metadataScore = GetTacticalMetadataScore( candidate, purpose );
float score = ScoreTacticalPosition(
purpose,
doctrine ?? OMRBotWeaponDoctrine.Empty,
route,
rangeFit,
angularGain,
openness,
escapeOptions,
coverSamples,
hasSightToThreat,
metadataScore,
evidenceRelevance
);
evaluation = new OMRBotTacticalPositionEvaluation(
candidate,
score,
route,
rangeToThreat,
rangeFit,
angularGain,
openness,
escapeOptions,
coverSamples,
hasSightToThreat,
metadataScore,
evidenceRelevance
);
return true;
}
private float ScoreTacticalPosition(
OMRBotTacticalQueryPurpose purpose,
OMRBotWeaponDoctrine doctrine,
OMRBotRouteEvaluation route,
float rangeFit,
float angularGain,
float openness,
int escapeOptions,
int coverSamples,
bool hasSightToThreat,
float metadataScore,
float evidenceRelevance
)
{
float detourPenalty = MathF.Max( 0f, route.DetourRatio - 1f );
float tightness = 1f - MathX.Clamp( openness, 0f, 1f );
float precision = MathX.Clamp( doctrine.PrecisionBias, 0f, 1f );
float closePressure = MathX.Clamp( doctrine.ClosePressureBias, 0f, 1f );
float health = GetOwnHealthFraction();
float cautious = 1f - MathX.Clamp( TacticalAggression, 0f, 1f );
float routeSafety =
route.ProtectionFraction * MathX.Lerp( 75f, 190f, cautious ) -
route.ExposureFraction * MathX.Lerp( 85f, 235f, cautious ) -
route.MemoryRisk * MathX.Lerp( 56f, 92f, cautious ) -
detourPenalty * 38f;
switch ( purpose )
{
case OMRBotTacticalQueryPurpose.Cover:
return
145f +
routeSafety +
coverSamples * 82f +
escapeOptions * 25f +
rangeFit * 30f +
tightness * 24f -
route.PathLength * 0.13f +
metadataScore +
( 1f - health ) * 95f;
case OMRBotTacticalQueryPurpose.Flank:
return
angularGain * 1.55f +
routeSafety +
rangeFit * 118f +
( hasSightToThreat ? 118f : -32f ) +
escapeOptions * 20f +
closePressure * tightness * 72f +
precision * openness * 44f -
route.PathLength * 0.10f +
metadataScore;
case OMRBotTacticalQueryPurpose.Search:
return
evidenceRelevance * 230f +
openness * 34f -
( openness < 0.18f ? 42f : 0f ) +
route.ProtectionFraction * 62f -
route.ExposureFraction * MathX.Lerp( 28f, 88f, cautious ) -
route.MemoryRisk * 58f -
route.PathLength * 0.065f +
escapeOptions * 13f +
metadataScore;
case OMRBotTacticalQueryPurpose.Press:
return
routeSafety * 0.55f +
rangeFit * 145f +
closePressure * 110f -
route.PathLength * 0.08f +
escapeOptions * 12f +
metadataScore;
case OMRBotTacticalQueryPurpose.OpenDistance:
return
routeSafety +
rangeFit * 150f +
precision * openness * 95f +
coverSamples * 36f +
escapeOptions * 18f -
route.PathLength * 0.09f +
metadataScore;
case OMRBotTacticalQueryPurpose.Retreat:
return
routeSafety * 1.25f +
coverSamples * 58f +
escapeOptions * 24f -
route.PathLength * 0.08f +
metadataScore;
case OMRBotTacticalQueryPurpose.Reposition:
default:
return
routeSafety +
openness * 48f -
( openness < 0.18f ? 58f : 0f ) +
rangeFit * 75f +
escapeOptions * 22f -
route.PathLength * 0.08f +
metadataScore;
}
}
private float MeasureTacticalOpenness( Vector3 position )
{
if ( Scene is null )
return 0.5f;
float distance = MathF.Max( 72f, TacticalSpaceProbeDistance );
Vector3 start = position + Vector3.Up * 42f;
int clear = 0;
const int samples = 6;
for ( int i = 0; i < samples; i++ )
{
float yaw = i * ( 360f / samples );
Vector3 direction = Rotation.FromYaw( yaw ).Forward.WithZ( 0f ).Normal;
Vector3 end = start + direction * distance;
SceneTrace trace =
Scene.Trace.Ray( start, end )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
trace = trace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
if ( !trace.Run().Hit )
clear++;
}
return clear / (float)samples;
}
private int CountTacticalEscapeOptions( Vector3 position )
{
if ( Scene?.NavMesh is null )
return 0;
float distance = MathF.Max( 54f, TacticalEscapeProbeDistance );
int options = 0;
const int samples = 4;
for ( int i = 0; i < samples; i++ )
{
float yaw = i * ( 360f / samples );
Vector3 direction = Rotation.FromYaw( yaw ).Forward.WithZ( 0f ).Normal;
Vector3 raw = position + direction * distance;
Vector3? projected = Scene.NavMesh.GetClosestPoint( raw, 44f );
if ( projected is null )
continue;
if ( ( projected.Value - position ).WithZ( 0f ).Length < distance * 0.55f )
continue;
if ( IsTacticalSegmentClear( position, projected.Value ) )
options++;
}
return options;
}
private bool IsTacticalSegmentClear( Vector3 from, Vector3 to )
{
if ( Scene is null )
return false;
Vector3 lowStart = from + Vector3.Up * 24f;
Vector3 lowEnd = to + Vector3.Up * 24f;
Vector3 highStart = from + Vector3.Up * 54f;
Vector3 highEnd = to + Vector3.Up * 54f;
SceneTrace lowTrace =
Scene.Trace.Ray( lowStart, lowEnd )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
SceneTrace highTrace =
Scene.Trace.Ray( highStart, highEnd )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "trigger" )
.UseHitboxes( false );
if ( _perceptionTarget?.GameObject is not null )
{
lowTrace = lowTrace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
highTrace = highTrace.IgnoreGameObjectHierarchy( _perceptionTarget.GameObject );
}
if ( lowTrace.Run().Hit )
return false;
return !highTrace.Run().Hit;
}
private float GetTacticalMetadataScore(
Vector3 position,
OMRBotTacticalQueryPurpose purpose
)
{
float score = 0f;
foreach ( OMRTacticalRegion region in _tacticalRegions )
{
if ( region?.IsValid != true || !region.Contains( position ) )
continue;
float weight = MathX.Clamp( region.Weight, 0f, 2f );
OMRTacticalRegionType type = region.Type;
if ( type.HasFlag( OMRTacticalRegionType.HighRisk ) )
score -= 95f * weight;
if ( type.HasFlag( OMRTacticalRegionType.Crossing ) )
score += purpose == OMRBotTacticalQueryPurpose.Press ? 18f * weight : -28f * weight;
if ( type.HasFlag( OMRTacticalRegionType.LongLane ) )
score += purpose == OMRBotTacticalQueryPurpose.OpenDistance ? 75f * weight : 0f;
if ( type.HasFlag( OMRTacticalRegionType.CloseLane ) )
score += purpose == OMRBotTacticalQueryPurpose.Press ? 65f * weight : 0f;
if ( type.HasFlag( OMRTacticalRegionType.Tight ) )
score += purpose == OMRBotTacticalQueryPurpose.Flank ? 42f * weight : 0f;
if ( type.HasFlag( OMRTacticalRegionType.Open ) )
score += purpose == OMRBotTacticalQueryPurpose.OpenDistance ? 48f * weight : 0f;
}
foreach ( OMRTacticalPoint point in _tacticalPoints )
{
if ( point?.IsValid != true )
continue;
float influence = MathF.Max( 48f, point.InfluenceRadius );
float distance = ( point.WorldPosition - position ).WithZ( 0f ).Length;
if ( distance > influence )
continue;
float falloff = 1f - MathX.Clamp( distance / influence, 0f, 1f );
float weight = MathX.Clamp( point.Weight, 0f, 2f ) * falloff;
OMRTacticalPointType type = point.Type;
if ( purpose == OMRBotTacticalQueryPurpose.Cover && type.HasFlag( OMRTacticalPointType.Cover ) )
score += 85f * weight;
if ( purpose == OMRBotTacticalQueryPurpose.Flank && type.HasFlag( OMRTacticalPointType.FlankEntry ) )
score += 95f * weight;
if ( purpose == OMRBotTacticalQueryPurpose.Search && type.HasFlag( OMRTacticalPointType.SearchAnchor ) )
score += 80f * weight;
if ( purpose == OMRBotTacticalQueryPurpose.OpenDistance && type.HasFlag( OMRTacticalPointType.Vantage ) )
score += 90f * weight;
if ( ( purpose == OMRBotTacticalQueryPurpose.Press || purpose == OMRBotTacticalQueryPurpose.Flank ) && type.HasFlag( OMRTacticalPointType.Ambush ) )
score += 62f * weight;
if ( purpose == OMRBotTacticalQueryPurpose.Search && type.HasFlag( OMRTacticalPointType.Choke ) )
score += 44f * weight;
}
return score;
}
private void AddAuthoredTacticalCandidates(
List<Vector3> destinations,
OMRBotTacticalQueryPurpose purpose,
Vector3 reference,
float maximumDistance
)
{
if ( destinations is null || _tacticalPoints.Count == 0 )
return;
foreach ( OMRTacticalPoint point in _tacticalPoints )
{
if ( point?.IsValid != true || !PointSupportsPurpose( point.Type, purpose ) )
continue;
if ( maximumDistance > 0f )
{
float distance = ( point.WorldPosition - reference ).WithZ( 0f ).Length;
if ( distance > maximumDistance )
continue;
}
bool duplicate = destinations.Any(
existing =>
( existing - point.WorldPosition ).WithZ( 0f ).Length < 70f
);
if ( !duplicate )
destinations.Add( point.WorldPosition );
}
}
private static bool PointSupportsPurpose(
OMRTacticalPointType type,
OMRBotTacticalQueryPurpose purpose
)
{
return purpose switch
{
OMRBotTacticalQueryPurpose.Cover => type.HasFlag( OMRTacticalPointType.Cover ),
OMRBotTacticalQueryPurpose.Flank =>
type.HasFlag( OMRTacticalPointType.FlankEntry ) ||
type.HasFlag( OMRTacticalPointType.Ambush ),
OMRBotTacticalQueryPurpose.Search =>
type.HasFlag( OMRTacticalPointType.SearchAnchor ) ||
type.HasFlag( OMRTacticalPointType.Choke ) ||
type.HasFlag( OMRTacticalPointType.Ambush ),
OMRBotTacticalQueryPurpose.OpenDistance => type.HasFlag( OMRTacticalPointType.Vantage ),
OMRBotTacticalQueryPurpose.Press =>
type.HasFlag( OMRTacticalPointType.Ambush ) ||
type.HasFlag( OMRTacticalPointType.FlankEntry ),
_ => false
};
}
private void SetTacticalQueryDiagnostic(
OMRBotTacticalQueryPurpose purpose,
OMRBotTacticalPositionEvaluation evaluation
)
{
TacticalQueryStatus =
$"{purpose.ToString().ToUpperInvariant()} {evaluation.Score:0} · " +
$"EXP {evaluation.Route.ExposureFraction * 100f:0}% · " +
$"RISK {evaluation.Route.MemoryRisk:0.0} · " +
$"ESC {evaluation.EscapeOptions}";
}
}