Scoring/FatalityGate.cs
using System;

namespace SquishyTerry.Scoring;

/// <summary>
/// Decides whether a fatality counts, with no engine dependencies (so the test harness can run it).
/// <list type="bullet">
/// <item>A fatality is <b>armed</b> only by a validated squish that takes the combo to
/// <see cref="Combo"/>, and only if the chain got there at a humanly possible pace.</item>
/// <item>An armed fatality must be <b>spent</b> within <see cref="ArmWindow"/> seconds, and no sooner
/// than <see cref="MinKillTime"/> (the knife has to drop, be yanked off its string and driven in).</item>
/// <item>Each one needs its own <see cref="Combo"/> squishes: a session can never have more
/// fatalities than squishes / <see cref="Combo"/>.</item>
/// <item>Arming again while armed just restarts the window: arms never stack.</item>
/// </list>
/// All state lives in <see cref="Guarded"/> storage, so memory edits are detected.
/// </summary>
public sealed class FatalityGate
{
	/// <summary>Combo that earns a fatality.</summary>
	public const int Combo = 25;

	/// <summary>Fastest chain a person can squish (squishes per second).</summary>
	public const float MaxSquishesPerSecond = 8f;

	/// <summary>Seconds after arming that the kill must happen within.</summary>
	public const float ArmWindow = 600f; // long enough to let him bleed first (the blood score)

	/// <summary>Seconds after arming before a kill is physically possible.</summary>
	public const float MinKillTime = 0.75f;

	readonly Guarded _armed = new();
	readonly Guarded _armedAt = new();
	readonly Guarded _count = new();

	public bool Armed => _armed.Value > 0.5f;

	/// <summary>Fatalities counted this session.</summary>
	public int Count => (int)_count.Value;

	public bool Tampered => _armed.Tampered || _armedAt.Tampered || _count.Tampered;

	/// <summary>
	/// A validated squish just landed. Arms if it took the combo to <see cref="Combo"/> at a possible pace.
	/// </summary>
	public void OnSquish( int combo, float chainSeconds, float now )
	{
		if ( combo != Combo ) return;
		if ( (combo - 1) / MathF.Max( chainSeconds, 0.001f ) > MaxSquishesPerSecond ) return;
		_armed.Value = 1f;
		_armedAt.Value = now;
	}

	/// <summary>
	/// The knife went all the way in. Spends the arm and returns true if this fatality counts.
	/// <paramref name="sessionSquishes"/> is the validated squishes this session; <paramref name="suspicious"/>
	/// is the anti-macro verdict on the input right now.
	/// </summary>
	public bool TryRecord( float now, int sessionSquishes, bool suspicious )
	{
		// Read everything first: a Guarded value only notices an edit when it's read.
		bool armed = Armed;
		float since = now - _armedAt.Value;
		int count = Count;
		if ( !armed ) return false;
		_armed.Value = 0f; // spent (or thrown away) either way: one knife, one try

		if ( suspicious || Tampered ) return false;
		if ( since < MinKillTime || since > ArmWindow ) return false;
		if ( sessionSquishes < Combo * (count + 1) ) return false;

		_count.Value = count + 1;
		return true;
	}
}