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