OMRBotMatchupContext.cs
using Sandbox;
using System;

/// <summary>
/// BOT 7F - compositional combat interpretation of the current weapon matchup
/// and round rule. This contains preferences only; it never grants movement,
/// accuracy, health or information unavailable to a human player.
/// </summary>
public sealed class OMRBotMatchupContext
{
	public static OMRBotMatchupContext Neutral { get; } = new();

	public OMRBotWeaponDoctrine OwnWeapon { get; private set; } = OMRBotWeaponDoctrine.Empty;
	public OMRBotWeaponDoctrine OpponentWeapon { get; private set; } = OMRBotWeaponDoctrine.Empty;
	public bool KnowsOpponentWeapon { get; private set; }
	public string KnowledgeSource { get; private set; } = "UNKNOWN";

	public float DesiredRangeScale { get; private set; } = 1f;
	public float EngageAdjustment { get; private set; }
	public float PressureAdjustment { get; private set; }
	public float OpenDistanceAdjustment { get; private set; }
	public float CoverAdjustment { get; private set; }
	public float RepositionAdjustment { get; private set; }
	public float FlankAdjustment { get; private set; }
	public float AmbushAdjustment { get; private set; }
	public float MovementAdjustment { get; private set; }
	public float AirActionAdjustment { get; private set; }
	public float ReloadSafetyAdjustment { get; private set; }
	public string Summary { get; private set; } = "NEUTRAL";

	public void Refresh(
		OMRBotWeaponDoctrine own,
		OMRBotWeaponDoctrine opponent,
		bool knowsOpponentWeapon,
		string knowledgeSource,
		OMRBotRuleContext rule
	)
	{
		OwnWeapon = own ?? OMRBotWeaponDoctrine.Empty;
		OpponentWeapon = opponent ?? OMRBotWeaponDoctrine.Empty;
		KnowsOpponentWeapon = knowsOpponentWeapon && OpponentWeapon != OMRBotWeaponDoctrine.Empty;
		KnowledgeSource = string.IsNullOrWhiteSpace( knowledgeSource ) ? "UNKNOWN" : knowledgeSource;

		DesiredRangeScale = 1f;
		EngageAdjustment = 0f;
		PressureAdjustment = 0f;
		OpenDistanceAdjustment = 0f;
		CoverAdjustment = 0f;
		RepositionAdjustment = 0f;
		FlankAdjustment = 0f;
		AmbushAdjustment = 0f;
		MovementAdjustment = 0f;
		AirActionAdjustment = 0f;
		ReloadSafetyAdjustment = 0f;

		ApplyWeaponMatchup();
		ApplyRuleComposition( rule ?? OMRBotRuleContext.Standard );
		Clamp();
		UpdateSummary( rule ?? OMRBotRuleContext.Standard );
	}

	private void ApplyWeaponMatchup()
	{
		if ( OwnWeapon == OMRBotWeaponDoctrine.Empty )
			return;

		if ( !KnowsOpponentWeapon )
		{
			// Even without opponent weapon knowledge, the bot can reason from its own
			// weapon role without inventing anything about the other player.
			PressureAdjustment += OwnWeapon.ClosePressureBias * 0.05f;
			CoverAdjustment += OwnWeapon.CoverCycleBias * 0.04f;
			MovementAdjustment += ( OwnWeapon.MovementFireTolerance - 0.5f ) * 0.08f;
			return;
		}

		float ownRange = MathF.Max( 80f, OwnWeapon.LegacyHoldDistance );
		float opponentRange = MathF.Max( 80f, OpponentWeapon.LegacyHoldDistance );
		float rangeRatio = opponentRange / ownRange;

		// If the opponent's weapon wants a longer fight than ours, closing space is
		// valuable. If ours wants more distance, preserving/opening space is valuable.
		if ( rangeRatio >= 1.18f )
		{
			DesiredRangeScale *= 0.90f;
			PressureAdjustment += 0.10f;
			FlankAdjustment += 0.09f;
			MovementAdjustment += 0.04f;
		}
		else if ( rangeRatio <= 0.84f )
		{
			DesiredRangeScale *= 1.12f;
			OpenDistanceAdjustment += 0.13f;
			CoverAdjustment += 0.07f;
			RepositionAdjustment += 0.07f;
		}

		if ( OwnWeapon.ClosePressureBias >= 0.72f && OpponentWeapon.PrecisionBias >= 0.55f )
		{
			PressureAdjustment += 0.08f;
			FlankAdjustment += 0.08f;
			DesiredRangeScale *= 0.94f;
		}

		if ( OwnWeapon.PrecisionBias >= 0.55f && OpponentWeapon.ClosePressureBias >= 0.68f )
		{
			OpenDistanceAdjustment += 0.10f;
			CoverAdjustment += 0.08f;
			AmbushAdjustment += 0.05f;
			DesiredRangeScale *= 1.06f;
		}

		// Fast mobile weapons can exploit a slow precision opponent with angle changes.
		float movementEdge = OwnWeapon.MovementFireTolerance - OpponentWeapon.MovementFireTolerance;
		MovementAdjustment += movementEdge * 0.10f;
		RepositionAdjustment += MathF.Max( 0f, movementEdge ) * 0.07f;

		// Long-cycle weapons become more attractive to pressure after a miss, while
		// they themselves value cover/peek cadence more strongly.
		float opponentCycle = MathX.Clamp( OpponentWeapon.FireInterval / 0.85f, 0f, 1f );
		PressureAdjustment += opponentCycle * OwnWeapon.ClosePressureBias * 0.07f;
		CoverAdjustment += OwnWeapon.CoverCycleBias * 0.05f;
	}

	private void ApplyRuleComposition( OMRBotRuleContext rule )
	{
		float tempo = MathX.Clamp( rule.TempoPressure, 0f, 1f );
		float air = MathX.Clamp( rule.AirMobility, 0f, 1f );
		float precision = MathX.Clamp( rule.PrecisionPriority, 0f, 1f );
		float ammo = MathX.Clamp( rule.AmmoDiscipline, 0f, 1f );

		PressureAdjustment += tempo * 0.09f;
		RepositionAdjustment += tempo * 0.05f;
		CoverAdjustment -= tempo * 0.04f;

		// Headshot-only rewards stable, deliberate exposures rather than generic body
		// pressure. Precision-oriented weapons gain more from that rule.
		float precisionFit = MathX.Clamp( OwnWeapon.PrecisionBias * 0.7f + OwnWeapon.CoverCycleBias * 0.3f, 0f, 1f );
		EngageAdjustment += precision * precisionFit * 0.07f;
		CoverAdjustment += precision * precisionFit * 0.08f;
		AmbushAdjustment += precision * precisionFit * 0.08f;
		MovementAdjustment -= precision * ( 1f - OwnWeapon.MovementFireTolerance ) * 0.08f;

		ReloadSafetyAdjustment += ammo * 0.14f;
		CoverAdjustment += ammo * 0.06f;
		PressureAdjustment -= ammo * 0.05f;

		// Extra air mobility is only tactically useful for shooting if the weapon can
		// tolerate airborne spread. Otherwise it remains traversal/reposition value.
		float airFireFit = MathX.Clamp( OwnWeapon.AirFireTolerance, 0f, 1f );
		AirActionAdjustment += air * ( airFireFit - 0.35f ) * 0.18f;
		MovementAdjustment += air * 0.06f;
		RepositionAdjustment += air * 0.05f;

		if ( rule.IsHipfireOnly )
		{
			// Hipfire-only naturally favors mobile/close doctrines and devalues long
			// precision holds without changing the actual weapon stats.
			PressureAdjustment += OwnWeapon.ClosePressureBias * 0.08f;
			MovementAdjustment += OwnWeapon.MovementFireTolerance * 0.05f;
			AmbushAdjustment -= OwnWeapon.PrecisionBias * 0.04f;
		}

		if ( rule.IsLowGravity )
			RepositionAdjustment += 0.03f + airFireFit * 0.03f;
	}

	private void Clamp()
	{
		DesiredRangeScale = MathX.Clamp( DesiredRangeScale, 0.76f, 1.28f );
		EngageAdjustment = MathX.Clamp( EngageAdjustment, -0.25f, 0.25f );
		PressureAdjustment = MathX.Clamp( PressureAdjustment, -0.30f, 0.30f );
		OpenDistanceAdjustment = MathX.Clamp( OpenDistanceAdjustment, -0.30f, 0.30f );
		CoverAdjustment = MathX.Clamp( CoverAdjustment, -0.30f, 0.30f );
		RepositionAdjustment = MathX.Clamp( RepositionAdjustment, -0.30f, 0.30f );
		FlankAdjustment = MathX.Clamp( FlankAdjustment, -0.30f, 0.30f );
		AmbushAdjustment = MathX.Clamp( AmbushAdjustment, -0.30f, 0.30f );
		MovementAdjustment = MathX.Clamp( MovementAdjustment, -0.25f, 0.25f );
		AirActionAdjustment = MathX.Clamp( AirActionAdjustment, -0.25f, 0.25f );
		ReloadSafetyAdjustment = MathX.Clamp( ReloadSafetyAdjustment, 0f, 0.30f );
	}

	private void UpdateSummary( OMRBotRuleContext rule )
	{
		string opponent = KnowsOpponentWeapon ? OpponentWeapon.DisplayName : "?";
		Summary = $"{OwnWeapon.DisplayName} vs {opponent} · RANGE x{DesiredRangeScale:0.00} · P {PressureAdjustment:+0.00;-0.00;0.00} · C {CoverAdjustment:+0.00;-0.00;0.00} · {rule.DisplayName}";
	}
}