OneMoreRoundWeapon.Cycling.cs
using Sandbox;
using System;

/// <summary>
/// Generic post-shot manual cycling capability. Bolt-action is the first use,
/// but the mechanics intentionally understand only "cycle" so pump/lever
/// weapons can reuse the same action gate later.
/// </summary>
public partial class OneMoreRoundWeapon
{
	[Property, Group( "Weapon Cycling" ), Description( "Optional reusable cycling profile. When a Weapon Definition is assigned, its profile becomes canonical during initialization." )]
	public OMRWeaponCycleProfile CycleProfile { get; set; }

	private bool _cyclePending;
	private bool _cyclePresentationStarted;
	private float _cyclePresentationStartAt = -1f;
	private float _cycleCompleteAt = -1f;
	private bool _cycleStartedWhileAiming;

	public bool HasCycleProfile =>
		CycleProfile is not null &&
		CycleProfile.IsValid;

	public bool IsChambered { get; private set; } = true;

	public bool IsCyclePendingOrActive =>
		HasCycleProfile &&
		_cyclePending;

	public bool CycleStartedWhileAiming => _cycleStartedWhileAiming;

	public float CycleProgress
	{
		get
		{
			if ( !IsCyclePendingOrActive )
				return IsChambered ? 1f : 0f;

			float duration = MathF.Max( CycleProfile.Duration, 0f );
			if ( duration <= 0f )
				return RealTime.Now >= _cyclePresentationStartAt ? 1f : 0f;

			return MathX.Clamp(
				(RealTime.Now - _cyclePresentationStartAt) / duration,
				0f,
				1f
			);
		}
	}

	internal bool IsPrimaryBlockedByCycle() =>
		HasCycleProfile &&
		(_cyclePending || !IsChambered);

	internal bool CanAimDuringCurrentCycle()
	{
		if ( !IsCyclePendingOrActive )
			return true;

		// StartDelay belongs to the just-fired shot, not to the visible bolt/pump
		// action. Keep the shot's ADS state valid until the authored cycle begins.
		if ( !_cyclePresentationStarted )
			return true;

		return IsAimAllowedDuringActiveCycle();
	}

	private bool IsAimAllowedDuringActiveCycle()
	{
		if ( !HasCycleProfile )
			return true;

		return CycleProfile.AimPolicy switch
		{
			OMRCycleAimPolicy.ForceExit => false,
			OMRCycleAimPolicy.RetainIfAlreadyAiming => _cycleStartedWhileAiming,
			OMRCycleAimPolicy.Allow => true,
			_ => CycleProfile.AllowAimDuringCycle
		};
	}

	private void BeginPostShotCycle()
	{
		if (
			!HasCycleProfile ||
			!CycleProfile.AutoCycleAfterPrimaryShot
		)
		{
			return;
		}

		float now = RealTime.Now;
		float startDelay = MathF.Max( CycleProfile.StartDelay, 0f );
		float duration = MathF.Max( CycleProfile.Duration, 0f );

		IsChambered = false;
		_cycleStartedWhileAiming = IsAiming;
		_cyclePending = true;
		_cyclePresentationStarted = false;
		_cyclePresentationStartAt = now + startDelay;
		_cycleCompleteAt = _cyclePresentationStartAt + duration;

		// Keep the shot's ADS pose alive through StartDelay. The visible/mechanical
		// cycle has not started yet, so forcing ADS off here can also restore the
		// full-strength fire animation while b_attack is still in flight. ADS is
		// released at StartCyclePresentation instead.

		// Zero-delay profiles should expose their presentation event immediately.
		if ( startDelay <= 0f )
		{
			StartCyclePresentation();
		}
	}

	private void UpdateCycleRuntimeState()
	{
		if ( !_cyclePending )
			return;

		if ( !HasCycleProfile )
		{
			ResetCycleState( chambered: true );
			return;
		}

		float now = RealTime.Now;

		if (
			!_cyclePresentationStarted &&
			now >= _cyclePresentationStartAt
		)
		{
			StartCyclePresentation();
		}

		if ( now >= _cycleCompleteAt )
		{
			CompleteCycle();
		}
	}

	private void StartCyclePresentation()
	{
		if ( _cyclePresentationStarted )
			return;

		_cyclePresentationStarted = true;

		if ( !IsAimAllowedDuringActiveCycle() && IsAiming )
		{
			SetAiming( false );
		}

		SetMechanicalActionState( OMRWeaponActionState.Cycling );
	}

	private void CompleteCycle()
	{
		if ( !_cyclePending )
			return;

		_cyclePending = false;
		IsChambered = true;
		_cycleStartedWhileAiming = false;
		_cyclePresentationStartAt = -1f;
		_cycleCompleteAt = -1f;

		if ( _cyclePresentationStarted )
		{
			_cyclePresentationStarted = false;
			ClearMechanicalActionState( OMRWeaponActionState.Cycling );
		}
	}

	private void ResetCycleState( bool chambered )
	{
		_cyclePending = false;
		_cyclePresentationStarted = false;
		_cycleStartedWhileAiming = false;
		_cyclePresentationStartAt = -1f;
		_cycleCompleteAt = -1f;
		IsChambered = chambered;

		// A capability can disappear during hotload/authoring. Never leave the
		// generic mechanical gate stranded in Cycling when that happens.
		if ( _mechanicalActionState == OMRWeaponActionState.Cycling )
		{
			_mechanicalActionState = OMRWeaponActionState.Ready;
		}
	}

	public override bool CanReload()
	{
		if ( !AllowReload )
			return false;

		if (
			HasCycleProfile &&
			CycleProfile.BlockReloadDuringCycle &&
			IsCyclePendingOrActive
		)
		{
			return false;
		}

		return base.CanReload();
	}

	internal float GetCycleMinimumFireInterval()
	{
		if (
			!HasCycleProfile ||
			!CycleProfile.AutoCycleAfterPrimaryShot
		)
		{
			return 0f;
		}

		return CycleProfile.TotalLockoutTime;
	}
}