OneMoreRoundWeapon.Accuracy.cs
using Sandbox;
using System;

[Flags]
public enum WeaponAccuracyFlags
{
	None = 0,
	Moving = 1 << 0,
	Crouched = 1 << 1,
	Airborne = 1 << 2,
	Sliding = 1 << 3
}

/// <summary>
/// Accuracy state and spread resolution for OneMoreRoundWeapon.
///
/// The default LegacyThreshold model intentionally preserves the behavior used
/// by the existing OMR arsenal. ContinuousSpeed is available as the scalable
/// replacement, but does nothing until a weapon definition explicitly selects
/// it. This lets architecture move forward without sneaking a balance pass into
/// the refactor.
/// </summary>
public partial class OneMoreRoundWeapon
{
	// =========================================================
	// AUTHORED ACCURACY
	// =========================================================

	[Property, Group( "Accuracy" )]
	public OMRMovementAccuracyModel MovementAccuracyModel { get; set; } =
		OMRMovementAccuracyModel.LegacyThreshold;

	/// <summary>
	/// Legacy movement threshold in world-units per second.
	/// Used only by LegacyThreshold.
	/// </summary>
	[Property, Group( "Accuracy" )]
	public float MovingSpeedThreshold { get; set; } = 25f;

	/// <summary>
	/// ContinuousSpeed begins adding the moving penalty at this normalized
	/// fraction of the locomotion provider's reference move speed.
	/// </summary>
	[Property, Group( "Accuracy" )]
	[Range( 0f, 1f )]
	public float MovementAccuracyStartFraction { get; set; } = 0.10f;

	/// <summary>
	/// ContinuousSpeed reaches the full MovingSpreadMultiplier at this normalized
	/// fraction of reference move speed.
	/// </summary>
	[Property, Group( "Accuracy" )]
	[Range( 0f, 1f )]
	public float MovementAccuracyFullFraction { get; set; } = 1.0f;

	[Property, Group( "Accuracy" )]
	[Range( 1f, 6f )]
	public float MovingSpreadMultiplier { get; set; } = 1.30f;

	[Property, Group( "Accuracy" )]
	[Range( 0.1f, 2f )]
	public float CrouchedSpreadMultiplier { get; set; } = 0.85f;

	[Property, Group( "Accuracy" )]
	[Range( 1f, 10f )]
	public float AirborneSpreadMultiplier { get; set; } = 2.20f;

	[Property, Group( "Accuracy" )]
	[Range( 1f, 10f )]
	public float SlidingSpreadMultiplier { get; set; } = 1.60f;

	[Property, Group( "Accuracy" )]
	[Range( 0f, 1f )]
	public float AimSpreadMultiplier { get; set; } = 0.30f;

	// =========================================================
	// RUNTIME ACCURACY STATE
	// =========================================================

	[Sync]
	public WeaponAccuracyFlags AccuracyFlags { get; set; }

	/// <summary>
	/// 0 = settled movement contribution, 1 = full moving penalty.
	/// In LegacyThreshold this is exactly 0 or 1. In ContinuousSpeed it blends.
	/// </summary>
	public float CurrentMovementAccuracyBlend { get; private set; }

	/// <summary>
	/// Effective multiplier currently applied on top of BaseCombatWeapon spread.
	/// Useful to HUD/debug code without reimplementing the rules.
	/// </summary>
	public float CurrentAccuracyMultiplier => ResolveAccuracyMultiplier();

	public override Vector2 CurrentSpread
	{
		get
		{
			if (
				TryGetTemporarySpreadOverride(
					out Vector2 spreadOverride
				)
			)
			{
				return spreadOverride;
			}

			return
				base.CurrentSpread *
				ResolveAccuracyMultiplier();
		}
	}

	// =========================================================
	// STATE COLLECTION
	// =========================================================

	private void UpdateAccuracyState()
	{
		if (
			!TryGetLocomotionSnapshot(
				out WeaponLocomotionSnapshot locomotion
			)
		)
		{
			CurrentMovementAccuracyBlend = 0f;
			SetAccuracyFlags( WeaponAccuracyFlags.None );
			return;
		}

		float movementBlend = ResolveMovementAccuracyBlend( locomotion );
		CurrentMovementAccuracyBlend = movementBlend;

		WeaponAccuracyFlags nextFlags =
			WeaponAccuracyFlags.None;

		if ( movementBlend > 0f )
		{
			nextFlags |= WeaponAccuracyFlags.Moving;
		}

		if ( locomotion.IsCrouched )
		{
			nextFlags |= WeaponAccuracyFlags.Crouched;
		}

		if ( locomotion.IsAirborne )
		{
			nextFlags |= WeaponAccuracyFlags.Airborne;
		}

		if ( locomotion.IsSliding )
		{
			nextFlags |= WeaponAccuracyFlags.Sliding;
		}

		SetAccuracyFlags( nextFlags );
	}

	private float ResolveMovementAccuracyBlend(
		WeaponLocomotionSnapshot locomotion
	)
	{
		if (
			MovementAccuracyModel ==
			OMRMovementAccuracyModel.LegacyThreshold
		)
		{
			return locomotion.HorizontalSpeed >= MovingSpeedThreshold
				? 1f
				: 0f;
		}

		float start = MathX.Clamp(
			MovementAccuracyStartFraction,
			0f,
			1f
		);

		float full = MathX.Clamp(
			MovementAccuracyFullFraction,
			start,
			1f
		);

		float speed = MathX.Clamp(
			locomotion.NormalizedMoveSpeed,
			0f,
			1f
		);

		if ( speed <= start )
			return 0f;

		if ( full <= start + 0.0001f )
			return 1f;

		return MathX.Clamp(
			(speed - start) /
			(full - start),
			0f,
			1f
		);
	}

	private void SetAccuracyFlags(
		WeaponAccuracyFlags flags
	)
	{
		if ( AccuracyFlags == flags )
			return;

		AccuracyFlags = flags;
	}

	private void ResetAccuracyState()
	{
		AccuracyFlags = WeaponAccuracyFlags.None;
		CurrentMovementAccuracyBlend = 0f;
		ResetLocomotionProviderCache();
	}

	// =========================================================
	// SPREAD RESOLUTION
	// =========================================================

	private float ResolveAccuracyMultiplier()
	{
		float multiplier = 1f;

		bool sliding =
			AccuracyFlags.HasFlag( WeaponAccuracyFlags.Sliding );

		bool airborne =
			AccuracyFlags.HasFlag( WeaponAccuracyFlags.Airborne );

		bool moving =
			AccuracyFlags.HasFlag( WeaponAccuracyFlags.Moving );

		bool crouched =
			AccuracyFlags.HasFlag( WeaponAccuracyFlags.Crouched );

		if ( sliding )
		{
			multiplier *= SlidingSpreadMultiplier;
		}
		else if ( airborne )
		{
			multiplier *= AirborneSpreadMultiplier;
		}
		else
		{
			if ( moving )
			{
				if (
					MovementAccuracyModel ==
					OMRMovementAccuracyModel.ContinuousSpeed
				)
				{
					multiplier *= MathX.Lerp(
						1f,
						MovingSpreadMultiplier,
						CurrentMovementAccuracyBlend
					);
				}
				else
				{
					multiplier *= MovingSpreadMultiplier;
				}
			}

			if ( crouched )
			{
				multiplier *= CrouchedSpreadMultiplier;
			}
		}

		float aimBlend = AimAccuracyBlend;
		if ( aimBlend > 0f )
		{
			multiplier *= MathX.Lerp(
				1f,
				AimSpreadMultiplier,
				aimBlend
			);
		}

		// Zero is intentionally valid for precision weapons at full ADS. Existing
		// weapons without an optic profile keep their old immediate ADS multiplier.
		// definitions use non-zero values, so this changes no current gun feel.
		return MathF.Max( multiplier, 0f );
	}
}