Scoring/ScoreKeeper.cs
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Services;
using SquishyTerry.Sim;
namespace SquishyTerry.Scoring;
/// <summary>
/// The only thing that records scores. It watches the simulation itself (not the UI) and submits
/// the leaderboard stats:
/// <list type="bullet">
/// <item><c>stretch</c>: how far a grabbed point was pulled from rest (head units), best per pull.</item>
/// <item><c>combo</c>: squishes chained within <see cref="ComboWindow"/> of each other.</item>
/// <item><c>squishes</c> / <c>stretches</c>: every pull that snaps back, counted by kind, summed.</item>
/// <item><c>efficiency</c>: how well each pull was executed (<see cref="SquishEfficiency"/>, 0-100%).</item>
/// <item><c>wrinkles</c>: the most wrinkles a single pull caused (<see cref="WrinkleMeter"/>).</item>
/// </list>
/// Tamper resistance, in layers:
/// <list type="bullet">
/// <item>Scores are derived from the simulation's own state each step; nothing public can set them.</item>
/// <item>A squish only counts if this component saw the grab, the pull and the release itself.</item>
/// <item>Physical plausibility limits on every value (reach of the pull, squishes per second, efficiency cap).</item>
/// <item>Anti-macro checks on the mouse input (<see cref="HumanInput"/>).</item>
/// <item>Running totals are obfuscated in memory (<see cref="Guarded"/>); any edit flags the session.</item>
/// <item>A flagged session stops submitting; submissions carry a small evidence payload for auditing.</item>
/// </list>
/// Stats are sent by the s&box services for the signed-in Steam account, so nobody can post for
/// someone else. What a client-side game can't do is stop a fully modified client, so pair this with
/// the leaderboard limits on sbox.game (see the summary in the project notes).
/// </summary>
public sealed class ScoreKeeper : Component
{
public const string StretchStat = "stretch";
public const string ComboStat = "combo";
public const string SquishStat = "squishes";
public const string EfficiencyStat = "efficiency";
public const string StretchCountStat = "stretches";
public const string WrinkleStat = "wrinkles";
public const string FatalityStat = "fatalities";
public const string BloodStat = "blood";
/// <summary>Combo that earns a fatality (the knife drops).</summary>
public const int FatalityCombo = FatalityGate.Combo;
/// <summary>Seconds you have to land the next squish before the combo drops (paused while pulling).</summary>
[Property, Range( 0.5f, 6f )] public float ComboWindow { get; set; } = 2.5f;
// Plausibility limits. The pull target lies on the plane through the face inside the view, so
// even at the widest zoom a point can't get much more than ~5 head units from rest.
const float MaxPlausibleStretch = 6f;
const float MaxSquishesPerSecond = 8f;
// Every edge fully compressed can't exceed a few thousand; real pulls land around 150-400.
const float MaxPlausibleWrinkles = 3000f;
const int MinPullSteps = 2;
const float Evidence = 1f; // payload version
/// <summary>Current combo (0 when none). The rules live in <see cref="ComboTracker"/>.</summary>
public int Combo => _combo.Combo;
/// <summary>Seconds since the last counted squish.</summary>
public float SinceLastSquish => _combo.SinceLast;
/// <summary>The most recent efficiency result, and how long ago it arrived.</summary>
public SquishResult? LastEfficiency { get; private set; }
public float EfficiencyAge { get; private set; } = 999f;
/// <summary>Wrinkles the most recent pull caused (at its worst moment).</summary>
public int LastWrinkles { get; private set; }
/// <summary>True once the session is flagged (tampering or repeated macro strikes): nothing more is submitted.</summary>
public bool Flagged => _tampered || _input.Flagged || _combo.Tampered || _sessionSquishes.Tampered || _validReleases.Tampered || _fatality.Tampered || _bloodTally.Tampered;
/// <summary>
/// The player's lifelong save (bests and totals). Loaded when the game starts and merged with
/// their server-side stats, so returning players pick up where they left off.
/// </summary>
public PlayerProfile Profile { get; private set; }
/// <summary>A counted squish landed (for UI punch effects).</summary>
public event Action SquishLanded;
/// <summary>
/// A combo ran out of time and is counting down (<see cref="ComboDrain"/>). A squish landed during
/// the countdown rescues it: the combo continues from the value it had counted down to.
/// </summary>
public bool IsDraining => _combo.IsDraining;
public float DrainElapsed => _combo.DrainElapsed;
/// <summary>A draining combo was rescued; the argument is the combo it continues at.</summary>
public event Action<int> Rescued;
/// <summary>Something was submitted to the stats service (leaderboards may want to refresh).</summary>
public event Action Submitted;
/// <summary>Blood so far in the current knife run (ml), and whether one is running.</summary>
public int BloodMl => _bloodTally.Millilitres;
public bool Bleeding => _bloodTally.Live;
/// <summary>A knife run's bleeding ended: the millilitres it scored, and whether that's a new best.</summary>
public event Action<int, bool> BloodScored;
SquishyFace _face;
readonly SquishEfficiency _efficiency = new();
readonly HumanInput _input = new();
readonly ComboTracker _combo = new();
readonly Guarded _sessionSquishes = new();
readonly Guarded _validReleases = new();
readonly FatalityGate _fatality = new();
readonly BloodTally _bloodTally = new();
bool _tampered;
// Gesture state.
bool _mouseWasDown;
bool _pressHuman = true;
bool _wasDragging;
int _dragTriangle = -1;
int _pullSteps;
bool _pullValid;
bool _awaitingSnap;
Vec3[] _velocityScratch = Array.Empty<Vec3>();
// Built on the first step from the settled start shape.
WrinkleMeter _wrinkles;
Vec3 _centre;
float _restVolume;
float _peakWrinkles;
SquishKind _kind;
float _saveTimer;
bool _profileDirty;
const float SaveInterval = 5f;
protected override void OnEnabled()
{
long steamId = Sandbox.Utility.Steam.SteamId;
Profile = PlayerProfile.Load( steamId );
_ = Profile.MergeFromServer();
_combo.Ended += OnComboEnded;
_combo.Rescued += OnComboRescued;
_face = Components.Get<SquishyFace>();
if ( _face is null ) return;
_face.Stepped += OnStepped;
_face.Slapped += OnSnapBack;
}
protected override void OnDisabled()
{
_combo.End( RealTime.Now );
_combo.Ended -= OnComboEnded;
_combo.Rescued -= OnComboRescued;
Profile?.Save();
if ( _face is null ) return;
_face.Stepped -= OnStepped;
_face.Slapped -= OnSnapBack;
_face = null;
}
protected override void OnUpdate()
{
float now = RealTime.Now;
// Mouse presses feed the anti-macro checks (independent of whether they hit the face).
bool down = Input.Down( "Attack1" );
if ( down && !_mouseWasDown ) _pressHuman = _input.Press( now, Mouse.Position.x, Mouse.Position.y );
else if ( !down && _mouseWasDown ) _input.Release( now, Mouse.Position.x, Mouse.Position.y );
_mouseWasDown = down;
// Combo timer runs in real time between squishes (holding a stretch pauses it); when it runs
// out the combo counts down, and only ends (and is submitted) if nothing rescues it.
_combo.Window = ComboWindow;
// The combo clock runs at slow-motion speed, so a slow-mo moment never eats into the window.
SlowMo.Update( now, RealTime.Delta );
_combo.Tick( RealTime.Delta * SlowMo.Scale, _face is not null && _face.IsDragging, now );
EfficiencyAge += RealTime.Delta;
// Save the profile a few seconds after it changes (and on exit), not every squish.
if ( _profileDirty && (_saveTimer += RealTime.Delta) >= SaveInterval )
{
Profile.Save();
_profileDirty = false;
_saveTimer = 0f;
}
}
/// <summary>One simulation step: follow the pull and the settle.</summary>
void OnStepped()
{
var sim = _face?.Sim;
var rest = _face?.InitialPositions;
if ( sim is null || rest is null ) return;
if ( _wrinkles is null )
{
_wrinkles = new WrinkleMeter( sim.Indices, rest, sim.Wrinkle );
_centre = Vec3.Zero;
foreach ( var p in rest ) _centre += p;
_centre /= rest.Length;
_restVolume = MathF.Max( Metrics.Volume( rest, sim.Indices ), 1e-6f );
}
bool dragging = _face.IsDragging;
if ( dragging && !_wasDragging )
{
// A new grab: finish scoring the previous pull if the face is still wobbling.
ReportEfficiency( _efficiency.FinishNow() );
_dragTriangle = _face.DraggingTriangle;
_pullSteps = 0;
_pullValid = _pressHuman && !_input.Suspicious( RealTime.Now );
_awaitingSnap = false;
_efficiency.BeginPull( rest[sim.Indices[_dragTriangle * 3]], _centre, 1f );
_peakWrinkles = 0f;
}
if ( dragging )
{
_pullSteps++;
_efficiency.UpdatePull( sim.Pos[sim.Indices[_dragTriangle * 3]], sim.ComputeVolume() / _restVolume );
_peakWrinkles = MathF.Max( _peakWrinkles, _wrinkles.Measure( sim.Pos ) );
}
else if ( _wasDragging )
{
_efficiency.Release();
_kind = _efficiency.Kind;
float stretch = _efficiency.PeakStretch;
bool plausible = _pullSteps >= MinPullSteps && stretch <= MaxPlausibleStretch && float.IsFinite( stretch );
_pullValid &= plausible;
_awaitingSnap = _pullValid;
if ( _pullValid )
{
_validReleases.Value += 1f;
float value = MathF.Round( stretch, 3 );
if ( value > (Profile?.BestStretch ?? 0) && Submit( StretchStat, value, "steps", _pullSteps ) )
UpdateProfile( p => p.BestStretch = value );
}
}
else if ( _efficiency.IsSettling )
{
// The algorithm wants per-second velocities; the sim's are per step (one step = 1/60 s).
var vel = sim.Vel;
if ( _velocityScratch.Length != vel.Length ) _velocityScratch = new Vec3[vel.Length];
for ( int i = 0; i < vel.Length; i++ ) _velocityScratch[i] = vel[i] * 60f;
// The snap-back is where most wrinkles appear, so keep measuring while it wobbles.
_peakWrinkles = MathF.Max( _peakWrinkles, _wrinkles.Measure( sim.Pos ) );
ReportEfficiency( _efficiency.UpdateSettle( _velocityScratch, sim.Pos, rest, 1f / 60f ) );
}
_wasDragging = dragging;
}
void ReportEfficiency( SquishResult? result )
{
if ( result is not { } r ) return;
bool counted = _pullValid && r.Percent > 0f && r.Percent <= _efficiency.MaxEfficiencyPercent && float.IsFinite( r.Percent );
LastEfficiency = r;
EfficiencyAge = 0f;
LastWrinkles = (int)MathF.Round( _peakWrinkles );
int wrinkles = LastWrinkles;
bool wrinklesPlausible = _pullValid && wrinkles > 0 && wrinkles <= MaxPlausibleWrinkles;
if ( wrinklesPlausible && wrinkles > (Profile?.MostWrinkles ?? 0) && Submit( WrinkleStat, wrinkles, "kind", r.Kind.ToString() ) )
UpdateProfile( p => p.MostWrinkles = wrinkles );
if ( counted && Submit( EfficiencyStat, MathF.Round( r.Percent, 1 ), "in", r.InputEnergy ) )
{
UpdateProfile( p =>
{
p.BestEfficiency = Math.Max( p.BestEfficiency, MathF.Round( r.Percent, 1 ) );
p.EfficiencySum += MathF.Round( r.Percent, 1 );
p.EfficiencyCount++;
} );
}
}
/// <summary>The face's snap-back: counts as a squish only for a pull this component validated.</summary>
void OnSnapBack()
{
if ( !_awaitingSnap ) return;
_awaitingSnap = false;
if ( _input.Suspicious( RealTime.Now ) ) { _combo.End( RealTime.Now ); return; }
// Never more squishes than validated releases.
if ( _sessionSquishes.Value + 1f > _validReleases.Value ) { _tampered = true; return; }
_sessionSquishes.Value += 1f;
_combo.Window = ComboWindow;
_combo.Hit( RealTime.Now );
_fatality.OnSquish( _combo.Combo, _combo.ChainSeconds( RealTime.Now ), RealTime.Now );
if ( _bloodTally.Live ) _bloodTally.Squish();
if ( !Flagged )
{
if ( _kind == SquishKind.Stretch )
{
Stats.Increment( StretchCountStat, 1 );
UpdateProfile( p => p.TotalStretches++ );
}
else
{
Stats.Increment( SquishStat, 1 );
UpdateProfile( p => p.TotalSquishes++ );
}
}
SquishLanded?.Invoke();
}
void OnComboRescued( int combo ) => Rescued?.Invoke( combo );
/// <summary>
/// The knife went all the way in: ends the combo and counts a fatality if <see cref="FatalityGate"/>
/// agrees it was earned (a genuine, humanly paced chain to <see cref="FatalityCombo"/>, killed in a
/// possible time, with enough squishes behind it, by human input). Returns whether it counted.
/// </summary>
public bool RecordFatality()
{
float now = RealTime.Now;
EndBloodRun();
_combo.End( now );
if ( !_fatality.TryRecord( now, (int)_sessionSquishes.Value, _input.Suspicious( now ) ) ) return false;
if ( Flagged ) return false;
Stats.Increment( FatalityStat, 1 );
UpdateProfile( p => p.TotalFatalities++ );
Submitted?.Invoke();
return true;
}
/// <summary>A combo chain ended: submit its best if it's worth recording and physically possible.</summary>
void OnComboEnded( int combo, float duration )
{
if ( combo < 2 ) return;
// A chain can never be longer than the squishes actually counted, nor faster than a person can squish.
bool plausible = combo <= _sessionSquishes.Value && (combo - 1) / duration <= MaxSquishesPerSecond;
if ( plausible && combo > (Profile?.BestCombo ?? 0) && Submit( ComboStat, combo, "secs", MathF.Round( duration, 2 ) ) )
UpdateProfile( p => p.BestCombo = combo );
}
/// <summary>
/// The knife went into him. Starts a blood run on the first stab of a knife that was genuinely
/// earned (an armed fatality), and counts every stab after that.
/// </summary>
public void BloodStab()
{
float now = RealTime.Now;
if ( !_bloodTally.Live )
{
if ( !_fatality.Armed ) return;
_bloodTally.Start( now );
}
_bloodTally.Stab( now, _input.Suspicious( now ) );
}
/// <summary>He's bleeding: see <see cref="BloodTally.Tick"/>.</summary>
public void BloodTick( float dt, int wounds, float depth01, float motion01 )
=> _bloodTally.Tick( dt, RealTime.Now, wounds, depth01, motion01 );
/// <summary>The knife run is over: score the blood (if it holds up) and submit a personal best.</summary>
public void EndBloodRun()
{
if ( !_bloodTally.Live ) return;
float now = RealTime.Now;
int ml = _bloodTally.End( now );
bool best = ml > 0 && ml > (Profile?.MostBlood ?? 0) && Submit( BloodStat, ml, "ml", ml );
if ( best ) UpdateProfile( p => p.MostBlood = ml );
BloodScored?.Invoke( ml, best );
}
void UpdateProfile( Action<PlayerProfile> change )
{
if ( Profile is null ) return;
change( Profile );
_profileDirty = true;
}
/// <summary>Sends one stat value; returns false if the session is flagged and nothing was sent.</summary>
bool Submit( string stat, double value, string key, object evidence )
{
if ( Flagged ) return false;
Stats.SetValue( stat, value, new Dictionary<string, object>
{
["v"] = Evidence,
[key] = evidence,
["n"] = (int)_sessionSquishes.Value,
} );
Submitted?.Invoke();
return true;
}
}