Scoring/PlayerProfile.cs
using System;
using System.Globalization;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;
namespace SquishyTerry.Scoring;
/// <summary>
/// A player's lifelong save: there's no "new game", every session continues it.
/// <para>
/// Stored in the game's data folder (one file per Steam account) and signed, so an edited or
/// copied file is thrown away. On start it is also merged with the account's stats on the s&box
/// services (the higher value wins), so progress follows the player to another PC and survives a
/// deleted file.
/// </para>
/// The profile is only ever shown to the player; leaderboard submissions come from live play and
/// never from this file, so editing it can't change a leaderboard.
/// </summary>
public sealed class PlayerProfile
{
public int Version { get; set; } = CurrentVersion;
const int CurrentVersion = 4;
public long SteamId { get; set; }
public double BestStretch { get; set; }
public int BestCombo { get; set; }
public long TotalSquishes { get; set; }
public double BestEfficiency { get; set; }
public double EfficiencySum { get; set; }
public long EfficiencyCount { get; set; }
public long TotalStretches { get; set; }
public int MostWrinkles { get; set; }
public long TotalFatalities { get; set; }
public int MostBlood { get; set; }
public long Signature { get; set; }
public double AverageEfficiency() => EfficiencyCount > 0 ? EfficiencySum / EfficiencyCount : 0;
static string PathFor( long steamId ) => $"profile_{steamId}.json";
/// <summary>Loads the saved profile for this account, or starts a fresh one if there's none (or it was tampered with).</summary>
public static PlayerProfile Load( long steamId )
{
PlayerProfile profile = null;
try
{
if ( FileSystem.Data.FileExists( PathFor( steamId ) ) )
profile = FileSystem.Data.ReadJsonOrDefault<PlayerProfile>( PathFor( steamId ), null );
}
catch ( Exception e )
{
Log.Warning( $"Squish profile could not be read: {e.Message}" );
}
if ( profile is not null && (profile.SteamId != steamId || profile.Signature != profile.Sign( profile.Version )) )
{
Log.Warning( "Squish profile failed its integrity check; ignoring it (stats reload from the server)." );
profile = null;
}
profile ??= new PlayerProfile { SteamId = steamId };
if ( profile.Version < 2 || profile.BestEfficiency > 100 )
{
// v1 measured efficiency on a different scale (wobble per effort, up to 300%); v2 judges
// stretches and squishes 0-100%. Start those fresh; everything else carries over.
profile.BestEfficiency = 0;
profile.EfficiencySum = 0;
profile.EfficiencyCount = 0;
}
profile.Version = CurrentVersion;
return profile;
}
public void Save()
{
try
{
Version = CurrentVersion;
Signature = Sign( Version );
FileSystem.Data.WriteJson( PathFor( SteamId ), this );
}
catch ( Exception e )
{
Log.Warning( $"Squish profile could not be saved: {e.Message}" );
}
}
/// <summary>Pulls the account's server-side stats and keeps the higher of each value.</summary>
public async Task MergeFromServer()
{
try
{
await Stats.LocalPlayer.Refresh();
var stats = Stats.LocalPlayer;
if ( stats.TryGet( ScoreKeeper.StretchStat, out var stretch ) ) BestStretch = Math.Max( BestStretch, stretch.Max );
if ( stats.TryGet( ScoreKeeper.ComboStat, out var combo ) ) BestCombo = Math.Max( BestCombo, (int)combo.Max );
if ( stats.TryGet( ScoreKeeper.SquishStat, out var squishes ) ) TotalSquishes = Math.Max( TotalSquishes, (long)squishes.Sum );
if ( stats.TryGet( ScoreKeeper.StretchCountStat, out var stretches ) ) TotalStretches = Math.Max( TotalStretches, (long)stretches.Sum );
if ( stats.TryGet( ScoreKeeper.WrinkleStat, out var wrinkles ) ) MostWrinkles = Math.Max( MostWrinkles, (int)wrinkles.Max );
if ( stats.TryGet( ScoreKeeper.FatalityStat, out var fatalities ) ) TotalFatalities = Math.Max( TotalFatalities, (long)fatalities.Sum );
if ( stats.TryGet( ScoreKeeper.BloodStat, out var blood ) ) MostBlood = Math.Max( MostBlood, (int)blood.Max );
if ( stats.TryGet( ScoreKeeper.EfficiencyStat, out var efficiency ) )
{
BestEfficiency = Math.Max( BestEfficiency, efficiency.Max );
long serverCount = efficiency.Avg > 0 ? (long)Math.Round( efficiency.Sum / efficiency.Avg ) : 0;
if ( serverCount > EfficiencyCount )
{
EfficiencyCount = serverCount;
EfficiencySum = efficiency.Sum;
}
}
Save();
}
catch ( Exception e )
{
// Offline, or the game isn't published yet: the local save is all we have.
Log.Info( $"Squish profile: server stats unavailable ({e.Message})." );
}
}
/// <summary>
/// Integrity signature (FNV-1a over the values, the account and a salt). It stops casual edits
/// and copying another player's file; it isn't a secret from someone reading the game's code,
/// which is why the profile never feeds the leaderboards.
/// </summary>
long Sign( int version )
{
var c = CultureInfo.InvariantCulture;
string data = string.Join( "|", version, SteamId, BestStretch.ToString( "R", c ), BestCombo, TotalSquishes,
BestEfficiency.ToString( "R", c ), EfficiencySum.ToString( "R", c ), EfficiencyCount, "squishy-terry-v1" );
if ( version >= 2 ) data += $"|{TotalStretches}|{MostWrinkles}";
if ( version >= 3 ) data += $"|{TotalFatalities}";
if ( version >= 4 ) data += $"|{MostBlood}";
unchecked
{
ulong hash = 14695981039346656037ul;
foreach ( char ch in data )
{
hash ^= ch;
hash *= 1099511628211ul;
}
return (long)hash;
}
}
}