manager/Manager.Stats.cs

Part of a Manager class handling player run statistics, leaderboard and achievement submission. It collects run data (perks, player stats, time, boss damage), computes a score for leaderboards, logs data, and calls sandboxed Stats APIs to increment or set stat values. Also contains small data types RunInfo and PlayerInfo and helper methods for stat names and unlocking achievements.

NetworkingHttp Calls
using Sandbox;
using Sandbox.Diagnostics;
using Sandbox.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Xml.Linq;

public enum StatType { LeaderboardRun, NumRuns, NumVictory, NumDefeat, NumKills, NumMinibossKills, PerkChosen, PerkIgnored, SurvivalTime }
public enum DateRangeMode { All, Day, Week, Month }
public enum PlayerStatDifficultyMode { All, Normal, Expert, Cursed }
public enum LeaderboardMode { FastestWin, MostWins, MostRuns, MostEnemyKills, MostMinibossKills, LongestSurvival }

public class RunInfo
{
	public string displayName;
	public long steamId;
	public float score;
	public string dateString;
	public Dictionary<string, object> data;

	public RunInfo( string displayName, long steamId, float score, string dateString, Dictionary<string, object> data )
	{
		this.displayName = displayName;
		this.steamId = steamId;
		this.score = score;
		this.data = data;
		this.dateString = dateString;
	}
}

public class PlayerInfo
{
	public string displayName;
	public long steamId;
	public int level;

	public PlayerInfo( string displayName, long steamId, int level = 0)
	{
		this.displayName = displayName;
		this.steamId = steamId;
		this.level = level;
	}	
}

public partial class Manager
{
	public const float LEADERBOARD_VERSION = 8;

	public RunInfo RunEntryToShow { get; set; }

	public PlayerInfo PlayerProfileToShow { get; set; }

	public int NumPlayersLeaderboardToShow { get; set; }

	public DateRangeMode DateRangeToShow { get; set; }

	public LeaderboardMode LeaderboardModeToShow { get; set; }

	public bool LeaderboardShowFriends { get; set; }
	 
	public bool ShowQuestPanel { get; set; }

	public bool ShowShopPanel { get; set; }

	public bool ShowLoadoutPanel { get; set; }

	public bool ShowPerkUnlockPanel { get; set; }

	public void RestartStats()
	{

	}

	public void SubmitGameOverStats()
	{
		if( !(LocalPlayer.IsValid() && !LocalPlayer.IsProxy) )
			return;

		if ( !LocalPlayer.HasSubmittedRunPlayedStat )
		{
			Sandbox.Services.Stats.Increment( GetStatString(StatType.NumRuns, NumPlayersThisRun, Difficulty), 1 );
			LocalPlayer.HasSubmittedRunPlayedStat = true;
		}
	}

	public void SubmitScore( bool victory )
	{
		if ( !(LocalPlayer.IsValid() && !LocalPlayer.IsProxy) )
			return;

		var elapsedTime = ElapsedTime.Relative;
		var data = new Dictionary<string, object>();

		data.Add( "run_time", elapsedTime );

		Dictionary<int, int> importantPerks = LocalPlayer.GetImportantPerks();
		var importantPerksJson = JsonSerializer.Serialize( importantPerks );
		data.Add( "important_perks", importantPerksJson );

		Dictionary<int, int> allPerks = new();

		// add all perks to data
		foreach ( var pair in LocalPlayer.Perks )
		{
			int perkIdent = pair.Key;
			int perkLevel = pair.Value.Level;

			//Log.Info( $"perk: {perkIdent} level: {perkLevel}" );
			allPerks.Add( perkIdent, perkLevel );
		}

		var allPerksJson = JsonSerializer.Serialize( allPerks );
		data.Add( "perks", allPerksJson );

		data.Add( "player_level", LocalPlayer.Level);
		data.Add( "num_kills", LocalPlayer.Stats[PlayerStat.NumEnemiesKilled] );
		data.Add( "hp_healed", MathX.CeilToInt( LocalPlayer.Stats[PlayerStat.TotalHealed] ) );
		data.Add( "num_deaths", LocalPlayer.Stats[PlayerStat.NumTimesDied] );

		data.Add( "num_players", NumPlayersThisRun );

		if ( NumPlayersThisRun > 1 )
		{
			int num = 0;
			foreach( var player in Manager.Instance.Players )
			{
				if( player == LocalPlayer )
					continue;

				//data.Add( $"other_player_id_{num}", JsonSerializer.Serialize( player.Network.Owner.SteamId ) );
				data.Add( $"other_player_id_{num}", (long)player.Network.Owner.SteamId );
				Log.Info( $"submitting - other_player_id_{num}: {(long)player.Network.Owner.SteamId}" );

				data.Add( $"other_player_name_{num}", player.Network.Owner.DisplayName );
				Log.Info( $"submitting - other_player_name_{num}: {player.Network.Owner.DisplayName}" );

				data.Add( $"other_player_level_{num}", player.Level );
				Log.Info( $"submitting - other_player_level_{num}: {player.Level}" );

				num++;
			}
		}

		float bossLifeRemovedPct = -1f;
		if ( !victory && HasSpawnedBoss && Boss.IsValid() )
		{
			bossLifeRemovedPct = (1f - Boss.HpPercent) * 100f;
			data.Add( "boss_life_removed_pct", bossLifeRemovedPct );
		}

		if( !Game.IsEditor )
		{
			Sandbox.Services.Stats.Increment( GetStatString( victory ? StatType.NumVictory : StatType.NumDefeat, NumPlayersThisRun, Difficulty ), 1 );

			var score = GetScore( elapsedTime, victory, bossLifeRemovedPct );
			Log.Info( $"submitting run for {GetStatString( StatType.LeaderboardRun, NumPlayersThisRun, Difficulty )} - victory: {victory} score: {score} time: {elapsedTime} bossLifeRemovedPct: {bossLifeRemovedPct} data: {data}" );
			//Sandbox.Services.Stats.SetValue( GetStatString( StatType.LeaderboardRun, NumPlayersThisRun, Difficulty ), score, data );
			Sandbox.Services.Stats.SetValue( GetStatString( StatType.LeaderboardRun, NumPlayersThisRun, Difficulty ), score );

			Sandbox.Services.Stats.SetValue( GetStatString( StatType.SurvivalTime, NumPlayersThisRun, Difficulty ), elapsedTime );
		}

		if( victory )
		{
			switch( Difficulty )
			{
				case 0: UnlockAchievement( "victory_normal" ); break;
				case 1: UnlockAchievement( "victory_expert" ); break;
				case 2: UnlockAchievement( "victory_cursed" ); break;
			}

			var timesDashed = LocalPlayer.ResultStats.ContainsKey( ResultStat.TimesDashed ) ? LocalPlayer.ResultStats[ResultStat.TimesDashed] : 0;
			if( timesDashed == 0 )
				UnlockAchievement( "no_dashing" );

			bool isJackOfAllTradesValid = true;
			// check if any perk is above level 1 - if so, not valid for achievement
			foreach ( var pair in LocalPlayer.Perks )
			{
				if ( pair.Value.Level > 1 )
				{
					isJackOfAllTradesValid = false;
					break;
				}
			}

			if ( isJackOfAllTradesValid )
				UnlockAchievement( "jack_of_all_trades" );

			if( (int)LocalPlayer.Stats[PlayerStat.NumBulletsHitGround] == 0 )
				UnlockAchievement( "no_bullets_hit_ground" );

			if( !LocalPlayer.HasLostHp )
				UnlockAchievement( "no_hp_lost" );

			if( LocalPlayer.NumItemsCollected == 0 )
				UnlockAchievement( "no_items_collected" );

			if ( LocalPlayer.Perks.Count <= 12 )
				UnlockAchievement( "limited_different_perks" );
		}
	}

	public const float VICTORY_OFFSET = 2000000f;

	/// <summary>
	/// Defeat score offset for runs where the boss was reached. Must be higher than max possible
	/// pre-boss survival time (~780s). Boss defeat scores range from BOSS_DEFEAT_OFFSET to BOSS_DEFEAT_OFFSET+10000.
	/// </summary>
	public const float BOSS_DEFEAT_OFFSET = 1000f;

	/// <summary>
	/// If victory, lower time is better. If not victory and boss was reached, higher boss damage % is better.
	/// If not victory and boss was not reached, higher survival time is better.
	/// </summary>
	public static float GetScore( float elapsedTime, bool victory, float bossLifeRemovedPct = -1f )
	{
		if ( victory )
			return VICTORY_OFFSET - elapsedTime;

		if ( bossLifeRemovedPct >= 0f )
			return BOSS_DEFEAT_OFFSET + bossLifeRemovedPct * 100f;

		return elapsedTime;
	}

	private void LogData( Dictionary<string, object> data, float value )
	{
		var sb = new System.Text.StringBuilder();
		sb.AppendLine( $"**** run - time: {value} data:" );

		foreach ( var pair in data )
		{
			if ( pair.Value is Dictionary<int, int> intDict )
			{
				sb.AppendLine( $"{pair.Key}:" );
				foreach ( var subPair in intDict )
				{
					sb.AppendLine( $"  {subPair.Key}: {subPair.Value}" );
				}
			}
			else if ( pair.Value is Dictionary<int, object> objDict )
			{
				sb.AppendLine( $"{pair.Key}:" );
				foreach ( var subPair in objDict )
				{
					sb.AppendLine( $"  {subPair.Key}: {subPair.Value}" );
				}
			}
			else
			{
				sb.AppendLine( $"{pair.Key}: {pair.Value}" );
			}
		}

		Log.Info( sb.ToString() );
	}

	public static string GetStatString( StatType statType, int numPlayers, int difficulty )
	{
		if( statType == StatType.PerkChosen || statType == StatType.PerkIgnored )
			throw new Exception( "GetStatString - use GetPerkStatString for perk stats" );

		// run time scores include the number of players, other stats don't

		if( statType == StatType.LeaderboardRun || statType == StatType.SurvivalTime )
			return $"{GetStatPrefix( statType )}_p{numPlayers}_diff{difficulty}_v{LEADERBOARD_VERSION}";

		return $"{GetStatPrefix( statType )}_diff{difficulty}_v{LEADERBOARD_VERSION}";
	}

	public static string GetPerkStatString( StatType statType, TypeDescription perkType )
	{
		return $"{GetStatPrefix( statType )}_{perkType.ToString()}_v{LEADERBOARD_VERSION}";
	}

	static string GetStatPrefix( StatType statType )
	{
		switch( statType )
		{
			case StatType.LeaderboardRun: return "run";
			case StatType.NumRuns: return "num_runs";
			case StatType.NumVictory: return "num_victory";
			case StatType.NumDefeat: return "num_defeat";
			case StatType.NumKills: return "num_kills";
			case StatType.NumMinibossKills: return "num_miniboss_kills";
			case StatType.SurvivalTime: return "survival_time";
			case StatType.PerkChosen: return "chosen";
			case StatType.PerkIgnored: return "ignored";
			default: throw new Exception( $"GetStatPrefix - unhandled statType {statType}" );
		}
	}

	public void UnlockAchievement( string achievementIdent )
	{
		Log.Info( $"unlocking achievement {achievementIdent}" );

		//Sandbox.Services.Achievements.Unlock( achievementIdent );
	}
}