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