Scoring/BloodTally.cs
using System;

namespace SquishyTerry.Scoring;

/// <summary>
/// The blood score for one knife run, with no engine dependencies (so the test harness can run it).
/// Blood is counted in millilitres from the first stab until the run ends (the fatality, or the knife
/// going away without one):
/// <list type="bullet">
/// <item>Open wounds bleed every second; a deeper knife bleeds more.</item>
/// <item>Stretching and squishing him while he bleeds pumps it out faster.</item>
/// <item>Each stab adds a gush; each squish that lands adds a spurt.</item>
/// </list>
/// Anti-cheat: everything lives in <see cref="Guarded"/> storage; the rate is capped at
/// <see cref="MaxRate"/>; stabs can't come faster than <see cref="MinStabGap"/>; and when the run ends
/// the total is checked against the most that was physically possible in that time (a total over
/// that bound, or any tampering, scores nothing).
/// </summary>
public sealed class BloodTally
{
	public const float MaxRate = 400f;        // ml / s, however hard he's bleeding
	public const float WoundRate = 20f;       // ml / s per bleeding wound
	public const float DepthRate = 60f;       // extra ml / s with the knife all the way in
	public const float MotionBoost = 3f;      // squishing him hard bleeds up to 1 + this times faster
	public const float StabBurst = 120f;      // ml per stab
	public const float SquishBonus = 40f;     // ml per squish that lands while he bleeds
	public const float MinStabGap = 0.35f;    // seconds; faster "stabs" aren't counted
	public const float MaxRunTime = 600f;     // seconds; a run stops counting after this
	public const int MaxWounds = 16;
	public const float MaxSquishesPerSecond = 8f;

	readonly Guarded _live = new();
	readonly Guarded _total = new();
	readonly Guarded _start = new();
	readonly Guarded _stabs = new();
	readonly Guarded _squishes = new();
	readonly Guarded _lastStab = new( -999f );

	public bool Live => _live.Value > 0.5f;

	/// <summary>Blood so far this run (ml).</summary>
	public int Millilitres => (int)_total.Value;

	public bool Tampered => _live.Tampered || _total.Tampered || _start.Tampered || _stabs.Tampered || _squishes.Tampered || _lastStab.Tampered;

	/// <summary>Starts a run (the first stab of a knife that was genuinely earned).</summary>
	public void Start( float now )
	{
		_live.Value = 1f;
		_total.Value = 0f;
		_start.Value = now;
		_stabs.Value = 0f;
		_squishes.Value = 0f;
		_lastStab.Value = -999f;
	}

	/// <summary>The knife went in (again). <paramref name="suspicious"/>: the anti-macro verdict right now.</summary>
	public void Stab( float now, bool suspicious )
	{
		if ( !Live || suspicious ) return;
		if ( now - _lastStab.Value < MinStabGap ) return;
		_lastStab.Value = now;
		_stabs.Value += 1f;
		_total.Value += StabBurst;
	}

	/// <summary>A validated squish landed while he bleeds.</summary>
	public void Squish()
	{
		if ( !Live ) return;
		_squishes.Value += 1f;
		_total.Value += SquishBonus;
	}

	/// <summary>
	/// Bleeding over <paramref name="dt"/> seconds: <paramref name="wounds"/> bleeding wounds, the knife
	/// <paramref name="depth01"/> of the way in (0 when it's out), the face moving <paramref name="motion01"/>
	/// (0 still, 1 being squished hard).
	/// </summary>
	public void Tick( float dt, float now, int wounds, float depth01, float motion01 )
	{
		if ( !Live || now - _start.Value > MaxRunTime ) return;
		dt = Math.Clamp( dt, 0f, 0.1f );
		wounds = Math.Clamp( wounds, 0, MaxWounds );
		depth01 = Math.Clamp( depth01, 0f, 1f );
		motion01 = Math.Clamp( motion01, 0f, 1f );
		if ( wounds == 0 ) return;

		float rate = (wounds * WoundRate + depth01 * DepthRate) * (1f + MotionBoost * motion01);
		_total.Value += MathF.Min( rate, MaxRate ) * dt;
	}

	/// <summary>Ends the run; returns the millilitres it scored (0 if it doesn't hold up).</summary>
	public int End( float now )
	{
		// Read everything first: a Guarded value only notices an edit when it's read.
		bool live = Live;
		float total = _total.Value, start = _start.Value, stabs = _stabs.Value, squishes = _squishes.Value;
		_live.Value = 0f;
		if ( !live || Tampered ) return 0;

		float duration = Math.Clamp( now - start, 0f, MaxRunTime );
		if ( squishes > MaxSquishesPerSecond * duration + 1f ) return 0;
		if ( stabs > duration / MinStabGap + 1f ) return 0;
		float possible = MaxRate * duration + StabBurst * stabs + SquishBonus * squishes;
		if ( total > possible + 1f ) return 0;
		return (int)total;
	}
}