Scoring/HumanInput.cs
using System;
using System.Collections.Generic;

namespace SquishyTerry.Scoring;

/// <summary>
/// Anti-macro / inhuman-click checks on the mouse presses that drive squishing.
/// <list type="bullet">
/// <item>Inhuman speed: presses closer together than a person can click are ignored, and a burst
/// of too many presses in one second is a strike.</item>
/// <item>Robotic timing: a long run of press intervals that are nearly identical (auto-clickers
/// fire on a fixed timer; people drift) is a strike.</item>
/// <item>Robotic movement: a run of grab-and-release gestures that start and end on exactly the
/// same pixels (scripted drags) is a strike.</item>
/// </list>
/// A strike makes the player <see cref="Suspicious"/> for a while (squishes don't count); enough
/// strikes in a session mark it <see cref="Flagged"/> (nothing is submitted for the rest of it).
/// The thresholds are deliberately loose so fast, skilled play never trips them.
/// </summary>
sealed class HumanInput
{
	// Fastest humans manage roughly 12-16 clicks per second in short bursts.
	const float MinPressInterval = 0.055f;
	const int MaxPressesPerSecond = 16;

	// Timing regularity: this many intervals, all under 0.4 s, with a spread below 4 ms.
	const int TimingWindow = 16;
	const float MaxRhythmicInterval = 0.4f;
	const float MinHumanJitter = 0.004f;

	// Movement: this many gestures whose press and release points match the first to within a pixel.
	const int GestureWindow = 10;
	const float SamePixel = 1f;

	const float SuspicionTime = 30f;
	const int StrikesToFlag = 3;

	readonly List<float> _presses = new();
	readonly List<(float X, float Y, float UpX, float UpY)> _gestures = new();
	float _pressX, _pressY;
	float _suspiciousUntil = -1f;

	public int Strikes { get; private set; }
	public bool Flagged => Strikes >= StrikesToFlag;

	public bool Suspicious( float now ) => Flagged || now < _suspiciousUntil;

	/// <summary>Records a press; returns false if this press itself was inhumanly fast.</summary>
	public bool Press( float now, float x, float y )
	{
		bool human = _presses.Count == 0 || now - _presses[^1] >= MinPressInterval;

		_presses.Add( now );
		if ( _presses.Count > 64 ) _presses.RemoveAt( 0 );
		_pressX = x;
		_pressY = y;

		int lastSecond = 0;
		for ( int i = _presses.Count - 1; i >= 0 && now - _presses[i] <= 1f; i-- ) lastSecond++;
		if ( lastSecond > MaxPressesPerSecond ) Strike( now, clearPresses: true );
		else if ( RoboticTiming() ) Strike( now, clearPresses: true );

		return human;
	}

	public void Release( float now, float x, float y )
	{
		_gestures.Add( (_pressX, _pressY, x, y) );
		if ( _gestures.Count > GestureWindow ) _gestures.RemoveAt( 0 );

		if ( _gestures.Count == GestureWindow && RoboticMovement() )
		{
			Strike( now, clearPresses: false );
			_gestures.Clear();
		}
	}

	bool RoboticTiming()
	{
		if ( _presses.Count < TimingWindow + 1 ) return false;

		int start = _presses.Count - TimingWindow;
		float sum = 0f, sumSq = 0f;
		for ( int i = start; i < _presses.Count; i++ )
		{
			float d = _presses[i] - _presses[i - 1];
			if ( d > MaxRhythmicInterval ) return false;
			sum += d;
			sumSq += d * d;
		}
		float mean = sum / TimingWindow;
		float spread = MathF.Sqrt( MathF.Max( 0f, sumSq / TimingWindow - mean * mean ) );
		return spread < MinHumanJitter;
	}

	bool RoboticMovement()
	{
		var first = _gestures[0];
		foreach ( var g in _gestures )
		{
			if ( MathF.Abs( g.X - first.X ) > SamePixel || MathF.Abs( g.Y - first.Y ) > SamePixel ) return false;
			if ( MathF.Abs( g.UpX - first.UpX ) > SamePixel || MathF.Abs( g.UpY - first.UpY ) > SamePixel ) return false;
		}
		// Pressing and releasing on the very same spot (a plain tap) is normal; only identical drags count.
		return MathF.Abs( first.UpX - first.X ) > SamePixel || MathF.Abs( first.UpY - first.Y ) > SamePixel;
	}

	void Strike( float now, bool clearPresses )
	{
		Strikes++;
		_suspiciousUntil = now + SuspicionTime;
		if ( clearPresses ) _presses.Clear();
	}
}