OneMoreRoundWeapon.ShotValidation.cs
using Sandbox;
using System;

/// <summary>
/// Host-side validation for owner-predicted BaseCombatWeapon shot claims.
///
/// S&box explicitly documents ShotClaim as client-reported data and exposes
/// OnValidateShotClaim as the hardening hook. This layer validates the pieces
/// OMR can know authoritatively without reimplementing engine ballistics.
/// </summary>
public partial class OneMoreRoundWeapon
{
	[Property, Group( "Networking / Validation" )]
	public bool EnableShotClaimValueValidation { get; set; } = true;

	/// <summary>
	/// Cadence validation is implemented now but left disabled by default during
	/// this migration. Arrival-time rate checks need host/client testing under
	/// real latency before becoming a production gate.
	/// </summary>
	[Property, Group( "Networking / Validation" )]
	public bool EnableShotClaimCadenceValidation { get; set; } = false;

	private bool _claimCadenceInitialized;
	private float _claimCadenceLastAt;
	private float _claimCadenceCredits;
	private int _acceptedShotClaims;
	private int _rejectedShotClaims;

	public int AcceptedShotClaims => _acceptedShotClaims;
	public int RejectedShotClaims => _rejectedShotClaims;

	private void ResetShotValidationState()
	{
		_claimCadenceInitialized = false;
		_claimCadenceLastAt = 0f;
		_claimCadenceCredits = 0f;
		_acceptedShotClaims = 0;
		_rejectedShotClaims = 0;
	}

	protected override bool OnValidateShotClaim(
		in BaseCombatWeapon.ShotClaim claim
	)
	{
		if ( !base.OnValidateShotClaim( in claim ) )
		{
			RejectShotClaim( "BaseCombatWeapon rejected claim." );
			return false;
		}

		if (
			EnableShotClaimValueValidation &&
			!ValidateClaimValues( in claim, out string valueReason )
		)
		{
			RejectShotClaim( valueReason );
			return false;
		}

		if (
			EnableShotClaimCadenceValidation &&
			!ValidateClaimCadence( out string cadenceReason )
		)
		{
			RejectShotClaim( cadenceReason );
			return false;
		}

		_acceptedShotClaims++;
		return true;
	}

	private bool ValidateClaimValues(
		in BaseCombatWeapon.ShotClaim claim,
		out string reason
	)
	{
		reason = string.Empty;

		if ( !IsFinite( claim.Damage ) || !IsFinite( claim.Force ) )
		{
			reason = "Non-finite damage/force.";
			return false;
		}

		float expectedDamage = MathF.Max( Ballistics.Damage, 0f );
		float expectedForce = MathF.Max( Ballistics.Force, 0f );

		if ( !ApproximatelyClaimValue( claim.Damage, expectedDamage ) )
		{
			reason =
				$"Damage mismatch. Claim:{claim.Damage:0.###} Expected:{expectedDamage:0.###}";
			return false;
		}

		if ( !ApproximatelyClaimValue( claim.Force, expectedForce ) )
		{
			reason =
				$"Force mismatch. Claim:{claim.Force:0.###} Expected:{expectedForce:0.###}";
			return false;
		}

		int claimedHitPellets = claim.Pellets?.Length ?? 0;
		int maximumPellets = Math.Max( Ballistics.Pellets, 1 );

		// ShotClaim contains pellets that actually hit something, so fewer than
		// Ballistics.Pellets is normal. More than authored is impossible.
		if ( claimedHitPellets > maximumPellets )
		{
			reason =
				$"Too many hit pellets. Claim:{claimedHitPellets} Maximum:{maximumPellets}";
			return false;
		}

		return true;
	}

	private bool ValidateClaimCadence(
		out string reason
	)
	{
		reason = string.Empty;

		float interval = MathF.Max(
			MathF.Max( PrimaryDelay, GetCycleMinimumFireInterval() ),
			0.001f
		);
		float now = RealTime.Now;
		float maximumCredits = ResolveClaimCadenceBurstAllowance( interval );

		if ( !_claimCadenceInitialized )
		{
			_claimCadenceInitialized = true;
			_claimCadenceLastAt = now;
			_claimCadenceCredits = maximumCredits - 1f;
			return true;
		}

		float elapsed = MathF.Max( now - _claimCadenceLastAt, 0f );
		_claimCadenceLastAt = now;

		_claimCadenceCredits = MathF.Min(
			maximumCredits,
			_claimCadenceCredits + elapsed / interval
		);

		if ( _claimCadenceCredits + 0.001f < 1f )
		{
			reason =
				$"Fire cadence exceeded. Interval:{interval:0.###}s Credits:{_claimCadenceCredits:0.##}";
			return false;
		}

		_claimCadenceCredits -= 1f;
		return true;
	}

	/// <summary>
	/// A small token bucket tolerates ordinary network batching without allowing
	/// a client to sustain a fire rate above the authored weapon cadence. Fast
	/// automatics get a little more burst slack than slow precision weapons.
	/// </summary>
	private static float ResolveClaimCadenceBurstAllowance(
		float interval
	)
	{
		if ( interval >= 0.50f )
			return 2f;

		if ( interval >= 0.15f )
			return 3f;

		return 5f;
	}

	private static bool ApproximatelyClaimValue(
		float actual,
		float expected
	)
	{
		float tolerance = MathF.Max(
			0.01f,
			MathF.Abs( expected ) * 0.005f
		);

		return MathF.Abs( actual - expected ) <= tolerance;
	}

	private static bool IsFinite( float value )
	{
		return !float.IsNaN( value ) &&
			!float.IsInfinity( value );
	}

	private void RejectShotClaim( string reason )
	{
		_rejectedShotClaims++;

		if ( !EnableDebugLogging )
			return;

		Log.Warning(
			$"[OMR SHOT CLAIM] Rejected | Weapon:{WeaponId} | Reason:{reason}"
		);
	}
}