Scoring/SquishEfficiency.cs
using System;
using SquishyTerry.Sim;

namespace SquishyTerry.Scoring;

public enum SquishKind
{
	/// <summary>The grabbed point was pulled away from the head.</summary>
	Stretch,
	/// <summary>The pull slid across the face and compressed it.</summary>
	Squish,
}

/// <summary>
/// Judges how well each pull was executed, like a rhythm game's "PERFECT".
///
/// Every pull is classified as a <see cref="SquishKind.Stretch"/> (pulled away from the head) or a
/// <see cref="SquishKind.Squish"/> (slid across the face, squashing it), then scored 0-100% on:
/// <list type="bullet">
/// <item><b>Target (45%)</b>: did it land in the sweet spot? A big stretch that isn't absurd, or a
/// deep squish that isn't a collapse.</item>
/// <item><b>Release (30%)</b>: let go at full tension, held steady. Easing off first, or letting go
/// mid-yank, loses points.</item>
/// <item><b>Snap (25%)</b>: the original efficiency, wobble out vs effort in (a clean snap-back).</item>
/// </list>
/// 95%+ is a PERFECT STRETCH / PERFECT SQUISH.
///
/// The sweet spots and the snap par were measured in the test harness (see the "judge" mode).
/// Engine-agnostic: call these from your grab/physics code and pass in your mesh data each step.
/// </summary>
public class SquishEfficiency
{
	// --- Tuning ---
	public float SpringK = 1f;               // match your mesh stiffness
	public float MinInputEnergy = 0.02f;     // ignore tiny taps (anti-exploit)
	public float SettleThreshold = 0.01f;    // avg vertex speed that counts as "still"
	public float SnapPar = 1.1f;             // wobble-out / effort-in of a clean big snap-back = full marks
	public float MaxEfficiencyPercent = 100f;

	// Sweet spots (head units / fraction of volume), measured in the harness.
	public float StretchIdealMin = 1.5f, StretchIdealMax = 2.5f;
	public float SquishIdealMin = 0.20f, SquishIdealMax = 0.35f;

	// Classification: a stretch moves the point mostly outward and doesn't compress the head.
	const float OutwardFraction = 0.7f;
	const float CompressionForSquish = 0.03f;

	// Release: steady for this many steps near the peak, and still at 95% of it on letting go.
	const int SteadySteps = 6;
	const float SteadySpeed = 0.01f;

	// --- State ---
	Vec3 _restPos, _centre;
	float _faceSize;
	float _restRadius;
	float _peakStretch;
	float _outwardAtPeak;
	float _minVolume;
	float _lastStretch;
	float _releaseStretch;
	int _steady;
	int _bestSteady;
	float _inputEnergy;
	float _outputEnergy;
	float _peakSquish;
	bool _pulling;
	bool _settling;

	public bool IsSettling => _settling;
	public bool IsPulling => _pulling;

	/// <summary>Peak distance of the grabbed point from rest this pull, in face sizes.</summary>
	public float PeakStretch => _peakStretch;

	/// <summary>What the last pull was (valid from <see cref="Release"/>).</summary>
	public SquishKind Kind { get; private set; }

	/// Call when the player grabs a vertex. centre: the head's centre at rest.
	public void BeginPull( Vec3 grabbedVertexRestPos, Vec3 centre, float faceSize )
	{
		_restPos = grabbedVertexRestPos;
		_centre = centre;
		_faceSize = MathF.Max( faceSize, 0.0001f );
		_restRadius = (_restPos - _centre).Length;
		_peakStretch = 0f;
		_outwardAtPeak = 1f;
		_minVolume = 1f;
		_lastStretch = 0f;
		_steady = _bestSteady = 0;
		_outputEnergy = 0f;
		_peakSquish = 0f;
		_pulling = true;
		_settling = false;
	}

	/// Call every step while held. volumeRatio: the head's volume / its rest volume.
	public void UpdatePull( Vec3 grabPos, float volumeRatio )
	{
		if ( !_pulling ) return;

		float stretch = (grabPos - _restPos).Length / _faceSize;
		if ( stretch > _peakStretch )
		{
			_peakStretch = stretch;
			float growth = ((grabPos - _centre).Length - _restRadius) / _faceSize;
			_outwardAtPeak = growth / MathF.Max( stretch, 1e-4f );
		}
		_minVolume = MathF.Min( _minVolume, volumeRatio );

		// Held steady near full tension?
		bool steady = MathF.Abs( stretch - _lastStretch ) < SteadySpeed && stretch >= 0.9f * _peakStretch;
		_steady = steady ? _steady + 1 : 0;
		_bestSteady = Math.Max( _bestSteady, _steady );
		_lastStretch = stretch;
	}

	/// Call when the player lets go.
	public void Release()
	{
		if ( !_pulling ) return;
		_pulling = false;
		_releaseStretch = _lastStretch;
		_inputEnergy = 0.5f * SpringK * _peakStretch * _peakStretch;
		Kind = _minVolume < 1f - CompressionForSquish || _outwardAtPeak < OutwardFraction ? SquishKind.Squish : SquishKind.Stretch;
		_settling = true;
	}

	/// Call every step after release until it returns a result.
	/// velocities: per-vertex velocity (per second). current/rest: per-vertex positions.
	/// Returns null while still wobbling.
	public SquishResult? UpdateSettle( Vec3[] velocities, Vec3[] current, Vec3[] rest, float dt )
	{
		if ( !_settling ) return null;

		int n = velocities.Length;
		float kinetic = 0f;
		float speedSum = 0f;
		float squish = 0f;

		for ( int i = 0; i < n; i++ )
		{
			float speed = velocities[i].Length / _faceSize;
			kinetic += 0.5f * speed * speed;
			speedSum += speed;

			// Squish = how far each vertex is displaced from rest right now
			squish += (current[i] - rest[i]).Length / _faceSize;
		}

		// Average per vertex so mesh resolution doesn't change scores
		kinetic /= n;
		squish /= n;

		_outputEnergy += kinetic * dt;          // dt keeps it frame-rate independent
		_peakSquish = MathF.Max( _peakSquish, squish );

		if ( speedSum / n > SettleThreshold ) return null;

		_settling = false;
		return BuildResult();
	}

	/// Ends the settle early (e.g. the player grabbed again before the face went still) and scores
	/// what was collected so far. Most of the wobble energy arrives in the first second, so this
	/// is close to the full result. Returns null if nothing was settling.
	public SquishResult? FinishNow()
	{
		if ( !_settling ) return null;
		_settling = false;
		return BuildResult();
	}

	/// <summary>1 inside [min, max]; ramps up from 0 at <paramref name="zero"/>, eases down to <paramref name="floor"/> at <paramref name="far"/> past max.</summary>
	static float Band( float x, float zero, float min, float max, float far, float floor )
	{
		if ( x <= zero ) return 0f;
		if ( x < min ) return (x - zero) / (min - zero);
		if ( x <= max ) return 1f;
		float t = MathF.Min( (x - max) / (far - max), 1f );
		return 1f - t * (1f - floor);
	}

	SquishResult BuildResult()
	{
		// Too small a pull: no score, stops tap-spam from farming.
		if ( _inputEnergy < MinInputEnergy )
			return new SquishResult( Kind, 0f, 0f, 0f, 0f, 0f, _inputEnergy, _outputEnergy, _peakSquish, "Too gentle" );

		float target = Kind == SquishKind.Stretch
			? Band( _peakStretch, 0.3f, StretchIdealMin, StretchIdealMax, 4f, 0.6f )
			: Band( 1f - _minVolume, 0f, SquishIdealMin, SquishIdealMax, 0.55f, 0.6f );

		float held = Math.Clamp( (_releaseStretch / MathF.Max( _peakStretch, 1e-4f ) - 0.6f) / 0.35f, 0f, 1f );
		float release = held * (_bestSteady >= SteadySteps ? 1f : 0.85f);

		float ratio = _outputEnergy / _inputEnergy;
		float snap = Math.Clamp( ratio / SnapPar, 0f, 1f );

		float percent = MathF.Min( 100f * (0.45f * target + 0.30f * release + 0.25f * snap), MaxEfficiencyPercent );
		return new SquishResult( Kind, ratio, percent, target, release, snap, _inputEnergy, _outputEnergy, _peakSquish, Rate( percent, Kind ) );
	}

	static string Rate( float percent, SquishKind kind )
	{
		string what = kind == SquishKind.Stretch ? "stretch" : "squish";
		return percent switch
		{
			>= 95f => $"PERFECT {what.ToUpperInvariant()}!",
			>= 80f => $"Great {what}",
			>= 60f => $"Good {what}",
			>= 40f => $"Okay {what}",
			_ => $"Sloppy {what}"
		};
	}
}

public readonly record struct SquishResult(
	SquishKind Kind,
	float Ratio,          // raw wobble-out / effort-in
	float Percent,        // 0-100, 95+ = perfect; use for leaderboards
	float Target,         // 0-1: how close to the sweet spot
	float Release,        // 0-1: let go at full, steady tension
	float Snap,           // 0-1: clean snap-back (ratio vs par)
	float InputEnergy,
	float OutputEnergy,
	float PeakSquish,     // max average mesh deformation
	string Rating );