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();
}
}