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