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}";
	}
}