manager/Manager.Stats.cs

Manager.Stats.cs, part of the Manager class. Handles collecting and submitting player run statistics, leaderboard score computation, achievement unlocking stubs, and formatting stat keys for the game services.

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