OMRBotController.MatchStrategy.cs
using Sandbox;
using System;

/// <summary>
/// BOT 7H match-level strategy. Combat mechanics remain identical to humans;
/// this layer only lets a host-owned bot make the same BANK/ONE MORE ROUND and
/// modifier choices a human is asked to make.
/// </summary>
public sealed partial class OMRBotController
{
	[Property, Group( "BOT 7H / Match Strategy" )]
	public bool MatchStrategyEnabled { get; set; } = true;

	[Property, Sync( SyncFlags.FromHost )]
	public string MatchDecisionStatus { get; set; } = "IDLE";

	public bool DecideBankForMatch(
		int bankedScore,
		int opponentScore,
		int currentPot,
		int nextPot,
		int targetScore,
		RoundModifier optionA,
		OMRRoundRuleProfile ruleA,
		GameObject weaponA,
		RoundModifier optionB,
		OMRRoundRuleProfile ruleB,
		GameObject weaponB
	)
	{
		if ( !MatchStrategyEnabled )
			return true;

		targetScore = Math.Max( 1, targetScore );
		currentPot = Math.Max( 0, currentPot );
		nextPot = Math.Max( currentPot + 1, nextPot );

		if ( bankedScore + currentPot >= targetScore )
		{
			MatchDecisionStatus = "BANK · MATCH WON";
			return true;
		}

		float fitA = EvaluateModifierFit( optionA, ruleA, weaponA );
		float fitB = EvaluateModifierFit( optionB, ruleB, weaponB );
		// The opponent chooses the actual modifier after the bot accepts the risk,
		// so value the wager against the less favorable offered future.
		float worstOfferFit = MathF.Min( fitA, fitB );

		float baseWinConfidence = Difficulty switch
		{
			OMRBotDifficulty.Easy => 0.43f,
			OMRBotDifficulty.Hard => 0.57f,
			_ => 0.50f
		};
		float modelConfidence = MathX.Clamp( _brainBlackboard.OpponentModel.Confidence, 0f, 1f );
		float opponentPressure = MathX.Clamp(
			( _brainBlackboard.OpponentModel.Aggression - 0.5f ) * 0.07f +
			( _brainBlackboard.OpponentModel.Mobility - 0.5f ) * 0.04f,
			-0.06f,
			0.06f
		) * modelConfidence;
		float subjectiveWin = MathX.Clamp(
			baseWinConfidence + ( worstOfferFit - 0.5f ) * 0.34f - opponentPressure,
			0.26f,
			0.76f
		);

		float bankValue = currentPot;
		float riskValue = subjectiveWin * nextPot;
		float scoreDeficit = opponentScore - bankedScore;
		float riskBias = ( TacticalAggression - 0.5f ) * 0.30f;
		if ( scoreDeficit >= 2 ) riskBias += 0.16f;
		if ( bankedScore > opponentScore ) riskBias -= 0.07f;
		if ( bankedScore + nextPot >= targetScore ) riskBias += 0.14f;
		if ( currentPot >= 5 ) riskBias -= 0.12f;

		float executionJitter = Difficulty switch
		{
			OMRBotDifficulty.Easy => Game.Random.Float( -0.10f, 0.10f ),
			OMRBotDifficulty.Hard => Game.Random.Float( -0.025f, 0.025f ),
			_ => Game.Random.Float( -0.055f, 0.055f )
		};
		bool risk = riskValue + riskBias + executionJitter > bankValue;
		MatchDecisionStatus = risk
			? $"OMR · EV {riskValue:0.00}/{bankValue:0.00} · FIT {worstOfferFit:0.00}"
			: $"BANK · EV {riskValue:0.00}/{bankValue:0.00} · FIT {worstOfferFit:0.00}";
		return !risk;
	}

	public RoundModifier ChooseModifierForMatch(
		RoundModifier optionA,
		OMRRoundRuleProfile ruleA,
		GameObject weaponA,
		RoundModifier optionB,
		OMRRoundRuleProfile ruleB,
		GameObject weaponB
	)
	{
		if ( !MatchStrategyEnabled )
			return optionA;

		float fitA = EvaluateModifierFit( optionA, ruleA, weaponA );
		float fitB = EvaluateModifierFit( optionB, ruleB, weaponB );
		float jitter = Difficulty switch
		{
			OMRBotDifficulty.Easy => 0.10f,
			OMRBotDifficulty.Hard => 0.025f,
			_ => 0.055f
		};
		fitA += Game.Random.Float( -jitter, jitter );
		fitB += Game.Random.Float( -jitter, jitter );
		RoundModifier choice = fitB > fitA ? optionB : optionA;
		MatchDecisionStatus = $"MOD {choice.ToString().ToUpperInvariant()} · A {fitA:0.00} / B {fitB:0.00}";
		return choice;
	}

	private float EvaluateModifierFit(
		RoundModifier modifier,
		OMRRoundRuleProfile rule,
		GameObject previewWeapon
	)
	{
		OMRBotWeaponDoctrine doctrine = OMRBotWeaponDoctrine.FromPrefab( previewWeapon, SightRange );
		if ( doctrine == OMRBotWeaponDoctrine.Empty )
			doctrine = BrainWeaponDoctrine;

		float skill = Difficulty switch
		{
			OMRBotDifficulty.Easy => 0.36f,
			OMRBotDifficulty.Hard => 0.66f,
			_ => 0.51f
		};
		float fit = 0.50f;
		float precision = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.PrecisionBias;
		float close = doctrine == OMRBotWeaponDoctrine.Empty ? 0.45f : doctrine.ClosePressureBias;
		float movement = doctrine == OMRBotWeaponDoctrine.Empty ? 0.5f : doctrine.MovementFireTolerance;
		float air = doctrine == OMRBotWeaponDoctrine.Empty ? 0.4f : doctrine.AirFireTolerance;
		float modelConfidence = MathX.Clamp( _brainBlackboard.OpponentModel.Confidence, 0f, 1f );

		bool headshots = rule?.HeadshotOnly == true || modifier == RoundModifier.HeadshotOnly;
		bool aimAllowed = rule?.AllowAim ?? modifier != RoundModifier.HipfireOnly;
		float gravity = rule?.GravityScale ?? ( modifier == RoundModifier.LowGravity ? 0.45f : 1f );
		int extraJumps = rule?.AdditionalAirJumps ?? ( modifier == RoundModifier.DoubleJump ? 1 : 0 );
		float durationOverride = rule?.FightDurationOverride ?? ( modifier == RoundModifier.Blitz ? 0f : 0f );

		if ( headshots )
			fit += ( skill - 0.5f ) * 0.32f + ( precision - 0.5f ) * 0.15f;
		if ( !aimAllowed )
			fit += ( close - 0.5f ) * 0.18f + ( movement - 0.5f ) * 0.10f - precision * 0.06f;
		if ( gravity < 0.85f )
		{
			fit += ( air - 0.4f ) * 0.13f + ( skill - 0.5f ) * 0.08f;
			fit -= _brainBlackboard.OpponentModel.AirUse * modelConfidence * 0.07f;
		}
		if ( extraJumps > 0 )
		{
			fit += movement * 0.07f + air * 0.07f + ( skill - 0.5f ) * 0.08f;
			fit -= _brainBlackboard.OpponentModel.AirUse * modelConfidence * 0.08f;
		}

		switch ( modifier )
		{
			case RoundModifier.HighNoon:
				fit += ( skill - 0.5f ) * 0.34f + precision * 0.12f;
				break;
			case RoundModifier.LargeCaliber:
				fit += ( skill - 0.5f ) * 0.14f + precision * 0.08f - TacticalAggression * 0.04f;
				break;
			case RoundModifier.Blitz:
				fit += TacticalAggression * 0.12f + close * 0.10f - precision * 0.05f;
				fit -= _brainBlackboard.OpponentModel.PushTendency * modelConfidence * 0.05f;
				break;
		}

		if ( durationOverride > 0f && durationOverride < 40f )
			fit += TacticalAggression * 0.05f;

		return MathX.Clamp( fit, 0.15f, 0.85f );
	}
}