Scoring/ComboTracker.cs
using System;

namespace SquishyTerry.Scoring;

/// <summary>
/// The combo rules, with no engine dependencies (so the test harness can run them):
/// <list type="bullet">
/// <item>Every validated squish adds one to the combo.</item>
/// <item>If nothing lands within <see cref="Window"/> seconds (paused while the player holds a pull), the
/// combo starts counting down (<see cref="ComboDrain"/>).</item>
/// <item>A squish during the countdown rescues it: the combo continues from the value it had counted
/// down to, plus one.</item>
/// <item>When the countdown finishes, the chain ends and <see cref="Ended"/> reports its best value
/// (the highest it reached, even if a rescue continued from lower).</item>
/// </list>
/// Anti-tamper: the combo, the chain best and the countdown's starting value live in
/// <see cref="Guarded"/> storage (edits are detected), and a rescue can never restore more than the
/// combo actually had.
/// </summary>
public sealed class ComboTracker
{
	/// <summary>Seconds allowed between squishes before the countdown starts.</summary>
	public float Window = 2.5f;

	readonly Guarded _combo = new();
	readonly Guarded _chainBest = new();
	readonly Guarded _peak = new();
	float _chainStart;

	public int Combo => (int)_combo.Value;
	public float SinceLast { get; private set; } = 999f;
	public bool IsDraining { get; private set; }
	public float DrainElapsed { get; private set; }

	/// <summary>How long the current chain has been going (seconds).</summary>
	public float ChainSeconds( float now ) => MathF.Max( now - _chainStart, 0.001f );

	/// <summary>Set once any of the protected values was changed from outside.</summary>
	public bool Tampered => _combo.Tampered || _chainBest.Tampered || _peak.Tampered;

	/// <summary>A chain ended: its best combo and how long it lasted (seconds).</summary>
	public event Action<int, float> Ended;

	/// <summary>A draining combo was rescued; the argument is the combo it continues at.</summary>
	public event Action<int> Rescued;

	/// <summary>Advance time. <paramref name="holding"/>: the player is mid-pull (the window pauses).</summary>
	public void Tick( float dt, bool holding, float now )
	{
		if ( !holding ) SinceLast += dt;

		if ( Combo > 0 && !IsDraining && SinceLast > Window )
		{
			IsDraining = true;
			DrainElapsed = 0f;
			_peak.Value = Combo;
		}

		if ( IsDraining )
		{
			DrainElapsed += dt;
			if ( DrainElapsed >= ComboDrain.Duration( (int)_peak.Value ) ) End( now );
		}
	}

	/// <summary>A validated squish landed.</summary>
	public void Hit( float now )
	{
		bool rescued = false;
		if ( IsDraining )
		{
			// Landed during the countdown: keep what it had counted down to (never more than it had).
			int saved = Math.Min( ComboDrain.ValueAt( (int)_peak.Value, DrainElapsed ), Combo );
			IsDraining = false;
			if ( saved > 0 ) { _combo.Value = saved; rescued = true; }
			else { End( now ); _chainStart = now; }
		}
		else if ( Combo == 0 || SinceLast > Window )
		{
			End( now );
			_chainStart = now;
		}

		_combo.Value = Combo + 1;
		if ( Combo > (int)_chainBest.Value ) _chainBest.Value = Combo;
		SinceLast = 0f;
		if ( rescued ) Rescued?.Invoke( Combo );
	}

	/// <summary>Ends the chain now (time ran out, the session is closing, or input looked suspicious).</summary>
	public void End( float now )
	{
		int best = Math.Max( Combo, (int)_chainBest.Value );
		float duration = MathF.Max( now - _chainStart, 0.001f );
		_combo.Value = 0f;
		_chainBest.Value = 0f;
		IsDraining = false;
		if ( best > 0 ) Ended?.Invoke( best, duration );
	}
}