things/player/Player.Perks.cs

Player partial class handling the perk system. Manages available perks, synchronized network state for UI, choosing/banishing/rerolling perks, applying/ removing perk instances, categories, and related UI messages and stats.

NetworkingFile Access
using Sandbox;
using Sandbox.Diagnostics;
using System;
using System.Numerics;
using System.Runtime.CompilerServices;

public partial class Player
{
	public Dictionary<int, Perk> Perks = new();
	[Sync] public NetDictionary<int, int> SyncPerks { get; set; } = new();

	public List<int> ValidPerks = new();
	public Dictionary<PerkCategory, List<Type>> PerkCategories = new();
	private Dictionary<PerkCategory, List<PlayerStat>> _perkCategoryDefaultStats = new();
	private Dictionary<PerkCategory, int> _perkCategoryIncludeCounts = new();
	public List<TypeDescription> CurrentPerkChoices = new(); // Client-only
	public List<bool> CurrentPerkChoicesUnknown = new();
	public List<CurseWrongPerkInfo.WrongPerkInfo?> CurrentPerkChoicesWrongInfo = new();
	[Sync] public NetList<int> SyncCurrentPerkChoiceIdentities { get; set; } = new();
	[Sync] public NetList<int> SyncCurrentPerkChoicesUnknown { get; set; } = new();
	[Sync] public NetList<int> SyncCurrentPerkChoiceWrongTypeIdentities { get; set; } = new();
	[Sync] public NetList<int> SyncCurrentPerkChoiceWrongAspects { get; set; } = new();
	[Sync] public NetList<string> SyncCurrentPerkChoiceDisplayNames { get; set; } = new();
	[Sync] public NetList<string> SyncCurrentPerkChoiceDisplayDescriptions { get; set; } = new();
	[Sync] public NetList<string> SyncCurrentPerkChoiceDisplayIconPaths { get; set; } = new();
	[Sync] public NetList<int> BanishedPerkIdentities { get; set; } = new();
	public int NumBanishedPerks => BanishedPerkIdentities.Count;

	private List<int> _perkIncreaseOrder = new();

	//private bool _addedArmorPerks;

	[Sync] public RealTimeSince RealTimeSinceBanishChoice { get; set; }
	[Sync] public int BanishChoiceIndex { get; set; }

	[Sync] public int PerkHash { get; set; }
	[Sync] public int PerkChoiceHash { get; set; }
	[Sync] public float AutoRerollTimerProgress { get; set; }

	private bool _shouldCheckForAdditionalPerkPoints;
	private int _checkForAdditionalPerkPointsFrameDelay;

	public int NumPendingUniqueChoices { get; set; }

	public List<TypeDescription> FavoritePerks { get; set; } = new(); // only for PerkRandomFavorite

	[Sync] public int CurrLevelsUntilCurseChoice { get; set; }
	[Sync] public int NumLevelsBetweenCurseChoices { get; set; }
	public int NumCursesToChoose { get; set; }
	[Sync] public bool IsBeingShownCurseChoices { get; set; }
	public bool ShouldShowCurseMessage { get; set; }
	public int NumCursesMessagesShown { get; set; }
	[Sync] public int AvailableCurseCount { get; set; }

	//public const int MAX_PERK_CHOICES = 999;

	public int PerkRandomRotationSeed { get; set; }

	public bool HasDisplayedPerk( TypeDescription type )
	{
		if ( type == null )
			return false;

		return IsProxy
			? SyncPerks.ContainsKey( type.Identity )
			: HasPerk( type );
	}

	public int GetDisplayedPerkLevel( TypeDescription type )
	{
		if ( type == null )
			return 0;

		if ( IsProxy )
			return SyncPerks.TryGetValue( type.Identity, out var level ) ? level : 0;

		return GetPerkLevel( type );
	}

	public int GetDisplayedPerkChoiceCount()
	{
		return IsProxy ? SyncCurrentPerkChoiceIdentities.Count : CurrentPerkChoices.Count;
	}

	public TypeDescription GetDisplayedPerkChoice( int index )
	{
		if ( index < 0 || index >= GetDisplayedPerkChoiceCount() )
			return null;

		return IsProxy
			? PerkManager.IdentityToType( SyncCurrentPerkChoiceIdentities[index] )
			: CurrentPerkChoices[index];
	}

	public bool GetDisplayedPerkChoiceUnknown( int index )
	{
		if ( index < 0 )
			return false;

		return IsProxy
			? index < SyncCurrentPerkChoicesUnknown.Count && SyncCurrentPerkChoicesUnknown[index] != 0
			: index < CurrentPerkChoicesUnknown.Count && CurrentPerkChoicesUnknown[index];
	}

	public CurseWrongPerkInfo.WrongPerkInfo? GetDisplayedPerkChoiceWrongInfo( int index )
	{
		if ( index < 0 )
			return null;

		if ( !IsProxy )
			return index < CurrentPerkChoicesWrongInfo.Count ? CurrentPerkChoicesWrongInfo[index] : null;

		if ( index >= SyncCurrentPerkChoiceWrongTypeIdentities.Count || index >= SyncCurrentPerkChoiceWrongAspects.Count )
			return null;

		var wrongTypeIdentity = SyncCurrentPerkChoiceWrongTypeIdentities[index];
		if ( wrongTypeIdentity < 0 )
			return null;

		return new CurseWrongPerkInfo.WrongPerkInfo
		{
			WrongType = PerkManager.IdentityToType( wrongTypeIdentity ),
			Aspect = (CurseWrongPerkInfo.WrongAspect)SyncCurrentPerkChoiceWrongAspects[index]
		};
	}

	public float GetDisplayedAutoRerollTimerProgress()
	{
		if ( IsProxy )
			return AutoRerollTimerProgress;

		var autoRerollCurseType = TypeLibrary.GetType( typeof( CurseRerollAuto ) );
		if ( !HasPerk( autoRerollCurseType ) )
			return 0f;

		return GetPerk( autoRerollCurseType ) is CurseRerollAuto autoRerollCurse
			? autoRerollCurse.TimerProgress
			: 0f;
	}

	public string GetDisplayedPerkChoiceName( int index, TypeDescription fallbackType, bool isUnknown )
	{
		if ( isUnknown )
			return "Unknown";

		if ( IsProxy && index >= 0 && index < SyncCurrentPerkChoiceDisplayNames.Count )
			return SyncCurrentPerkChoiceDisplayNames[index] ?? "";

		return Perk.GetName( fallbackType?.TargetType ) ?? "";
	}

	public string GetDisplayedPerkChoiceDescription( int index, TypeDescription fallbackType, int level, bool isChoice, bool isUnknown, Rarity rarity )
	{
		if ( IsProxy && index >= 0 && index < SyncCurrentPerkChoiceDisplayDescriptions.Count )
			return SyncCurrentPerkChoiceDisplayDescriptions[index] ?? "";

		if ( !isChoice )
			return ResolvePerkDescription( fallbackType?.TargetType, level );

		if ( level > PerkManager.GetMaxLevelForRarity( rarity ) )
			return "ALREADY MAX!";

		if ( isUnknown )
			return "Hidden by a curse";

		return ResolvePerkDescription( fallbackType?.TargetType, level );
	}

	public string GetDisplayedPerkChoiceIconPath( int index, TypeDescription fallbackType, bool isUnknown )
	{
		if ( isUnknown )
			return "textures/icons/vector/unknown.png";

		if ( IsProxy && index >= 0 && index < SyncCurrentPerkChoiceDisplayIconPaths.Count )
			return SyncCurrentPerkChoiceDisplayIconPaths[index] ?? "textures/icons/vector/unknown.png";

		return Perk.GetImagePath( fallbackType?.TargetType ) ?? "textures/icons/vector/unknown.png";
	}

	private static string ResolvePerkDescription( Type type, int level )
	{
		if ( type == null )
			return "";

		if ( level <= 1 )
			return Perk.GetDescription( type, level ) ?? "";

		return Perk.GetUpgradeDescription( type, level )
			?? Perk.GetDescription( type, level )
			?? "";
	}

	private void ClearSyncedPerkChoiceState()
	{
		SyncCurrentPerkChoiceIdentities.Clear();
		SyncCurrentPerkChoicesUnknown.Clear();
		SyncCurrentPerkChoiceWrongTypeIdentities.Clear();
		SyncCurrentPerkChoiceWrongAspects.Clear();
		SyncCurrentPerkChoiceDisplayNames.Clear();
		SyncCurrentPerkChoiceDisplayDescriptions.Clear();
		SyncCurrentPerkChoiceDisplayIconPaths.Clear();
	}

	private void SyncPerkChoiceState()
	{
		ClearSyncedPerkChoiceState();
		AutoRerollTimerProgress = GetDisplayedAutoRerollTimerProgress();

		if ( IsChoosingLevelUpReward )
		{
			for ( int i = 0; i < CurrentPerkChoices.Count; i++ )
			{
				var perkType = CurrentPerkChoices[i];
				var isUnknown = i < CurrentPerkChoicesUnknown.Count && CurrentPerkChoicesUnknown[i];
				SyncCurrentPerkChoiceIdentities.Add( perkType?.Identity ?? -1 );
				SyncCurrentPerkChoicesUnknown.Add( isUnknown ? 1 : 0 );

				var playerPerkLevel = GetPerkLevel( perkType );
				var perkLevel = playerPerkLevel + 1;
				var displayNameType = perkType?.TargetType;
				var displayDescType = perkType?.TargetType;
				var displayIconType = perkType?.TargetType;

				if ( i < CurrentPerkChoicesWrongInfo.Count && CurrentPerkChoicesWrongInfo[i].HasValue )
				{
					var wrongInfo = CurrentPerkChoicesWrongInfo[i].Value;
					SyncCurrentPerkChoiceWrongTypeIdentities.Add( wrongInfo.WrongType?.Identity ?? -1 );
					SyncCurrentPerkChoiceWrongAspects.Add( (int)wrongInfo.Aspect );

					if ( wrongInfo.Aspect == CurseWrongPerkInfo.WrongAspect.Name )
						displayNameType = wrongInfo.WrongType?.TargetType ?? displayNameType;
					else if ( wrongInfo.Aspect == CurseWrongPerkInfo.WrongAspect.Description )
						displayDescType = wrongInfo.WrongType?.TargetType ?? displayDescType;
					else if ( wrongInfo.Aspect == CurseWrongPerkInfo.WrongAspect.Icon )
						displayIconType = wrongInfo.WrongType?.TargetType ?? displayIconType;
				}
				else
				{
					SyncCurrentPerkChoiceWrongTypeIdentities.Add( -1 );
					SyncCurrentPerkChoiceWrongAspects.Add( -1 );
				}

				var displayName = isUnknown ? "Unknown" : (Perk.GetName( displayNameType ) ?? "");
				string displayDescription;
				if ( perkLevel > PerkManager.GetMaxLevelForRarity( perkType.GetAttribute<PerkAttribute>()?.Rarity ?? Rarity.Common ) )
					displayDescription = "ALREADY MAX!";
				else if ( isUnknown )
					displayDescription = "Hidden by a curse";
				else
					displayDescription = ResolvePerkDescription( displayDescType, perkLevel );

				var displayIconPath = isUnknown
					? "textures/icons/vector/unknown.png"
					: (Perk.GetImagePath( displayIconType ) ?? "textures/icons/vector/unknown.png");

				SyncCurrentPerkChoiceDisplayNames.Add( displayName );
				SyncCurrentPerkChoiceDisplayDescriptions.Add( displayDescription );
				SyncCurrentPerkChoiceDisplayIconPaths.Add( displayIconPath );
			}
		}

		PerkChoiceHash++;
	}

	private void UpdatePerkChoiceRuntimeSyncState()
	{
		var autoRerollTimerProgress = GetDisplayedAutoRerollTimerProgress();
		if ( MathF.Abs( AutoRerollTimerProgress - autoRerollTimerProgress ) > 0.0001f )
			AutoRerollTimerProgress = autoRerollTimerProgress;
	}

	void PerksStart()
	{
		if ( PerkCategories.Count == 0 )
			CreatePerkCategories();
	}

	public void InitPerks()
	{
		if ( PerkCategories.Count == 0 )
			CreatePerkCategories();

		ClearAllPerks();

		Perks.Clear();
		SyncPerks.Clear();
		ClearSyncedPerkChoiceState();
		BanishedPerkIdentities.Clear();
		_perkIncreaseOrder.Clear();

		ValidPerks.Clear();
		_perkCategoryIncludeCounts.Clear();
		foreach ( var type in TypeLibrary.GetTypes<Perk>() )
		{
			var attrib = type.GetAttribute<PerkAttribute>();
			if ( attrib == null )
				continue;

			if ( attrib.Disabled )
				continue;

			if ( attrib.IncludedAtStart || attrib.AlwaysOfferDebug )
			//if ( attrib.IncludedAtStart )
				ValidPerks.Add( PerkManager.TypeToIdentity( type ) );
		}

		//_addedArmorPerks = false;
		_shouldCheckForAdditionalPerkPoints = false;

		PerkHash = 0;
		PerkChoiceHash = 0;
		AutoRerollTimerProgress = 0f;

		NumPendingUniqueChoices = 0;

		DetermineFavoritePerks();

		CurrLevelsUntilCurseChoice = NumLevelsBetweenCurseChoices = 6;
		NumCursesToChoose = 0;
		IsBeingShownCurseChoices = false;
		ShouldShowCurseMessage = false;
		NumCursesMessagesShown = 0;

		RefreshAvailableCurseCount();
	}

	public void RefreshAvailableCurseCount()
	{
		AvailableCurseCount = PerkManager.GetAvailableCurseCount( this );
	}

	void HandlePerks()
	{
		//string debug = "";

		var dt = Time.Delta;
		for ( int i = Perks.Count - 1; i >= 0; i-- )
		{
			Perk perk = Perks.Values.ElementAt( i );
			perk.UpdateIcon( RealTime.Delta );
			if ( perk.ShouldUpdate && (!IsDead || perk.UpdateWhenDead) )
				perk.Update( dt );

			//debug += $"{perk.Title} ({perk.Level})\n";

			if ( perk.IsCurse )
				continue;

			perk.Importance += dt * perk.Level * GetImportanceWeightForRarity( perk.Rarity ) * perk.ImportanceMultiplier;
		}

		// hack to prevent choice UI from getting messed up (repro: get PerkNumChoicesCantMove, then when seeing OnlyShowIcon choices, level up again, then make a choice - choices will remain small but with cramped descriptions etc)
		// later on added the SkipShowingChoicesFrames hack for a similar reason, maybe _shouldCheckForAdditionalPerkPoints isn't necessary anymore
		if ( _shouldCheckForAdditionalPerkPoints )
		{
			if ( _checkForAdditionalPerkPointsFrameDelay > 0 )
			{
				_checkForAdditionalPerkPointsFrameDelay--;
			}
			else if ( !IsChoosingLevelUpReward && NumPerkPointsAvailable > 0 )
			{
				IsChoosingLevelUpReward = true;
				RefreshPerkChoices();
				RealTimeSinceOfferedChoices = 0f;
			}
		}

		UpdatePerkChoiceRuntimeSyncState();

		//DebugOverlay.Text($"{Client.Name}", Position, 0f, float.MaxValue);
	}

	void GiveRandomPerkZeroChoices( CurseSelection curseSelection )
	{
		var type = GetRandomPerkType( curseSelection: curseSelection, isReward: false );

		Manager.Instance.Chat.AddLocalChatMessage( $"0 perk choices, so got a random {(curseSelection == CurseSelection.OnlyCurses ? "curse" : "perk")}: {Perk.GetRichTextToken( type )}", from: "" );

		GivePerkItem( type, dir: Utils.GetRandomVectorInCone( -FacingDir ) );

		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), "0 CHOICES!", new Color( 1f, 0.25f, 0.5f ), 1.3f, FloaterType.NegativeMessage );
	}

	public void ForEachPerk( Action<Perk> action )
	{
		if ( IsProxy )
			return;

		var snapshot = Perks.Values.ToList();
		for ( int i = snapshot.Count - 1; i >= 0; i-- )
			action( snapshot[i] );
	}

	public void ForEachLoadoutItem( Action<LoadoutItem> action )
	{
		if ( IsProxy || Manager.HideProgressionSystem )
			return;

		if ( CurrentGun != null )
			action( CurrentGun );

		foreach ( var charm in CurrentCharms )
			if ( charm != null ) action( charm );

		foreach ( var gem in CurrentGems )
			action( gem );
	}

	[Rpc.Owner]
	public void AddPerkRpc( int typeIdentity )
	{
		AddPerk( PerkManager.IdentityToType( typeIdentity ) );
	}

	public void AddPerk( TypeDescription type )
	{
		Assert.True( !IsProxy );

		Perk perk = null;
		var typeIdentity = type.Identity;

		ForEachPerk( perk => perk.OnAddPerkBefore( type ) );
		ForEachLoadoutItem( item => item.OnAddPerkBefore( type ) );

		// first upgrade
		if ( Perks.Count == 0 )
			IncludePerkCategory( PerkCategory.AfterFirstPerk );

		if ( Perks.ContainsKey( typeIdentity ) )
		{
			perk = Perks[typeIdentity];

			if ( perk.Level >= perk.MaxLevel )
			{
				Manager.Instance.PlaySfxUI( "error2", pitch: 0.55f, volume: 0.9f );
				Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 70f ), "ALREADY MAX!", new Color( 1f, 0.5f, 0.5f ), size: 1.75f, floaterType: FloaterType.NegativeMessage );
				Log.Info( $"{perk} already at max level of {perk.MaxLevel}!" );
				return;
			}

			perk.IncreaseLevel();

			AddResultsStat( ResultStat.PerkLeveledUp, 1 );

			if ( perk.Level >= perk.MaxLevel )
				ProgressManager.IncrementStat( ProgressStat.PerkMaxedOut, 1 );
		}
		else
		{
			AddResultsStat( ResultStat.NewPerk, 1 );
		}

		if ( perk == null )
		{
			perk = PerkManager.CreatePerk( type );
			Perks.Add( typeIdentity, perk );
			perk.Player = this;

			perk.IncreaseLevel();
		}

		if ( SyncPerks.ContainsKey( typeIdentity ) )
			SyncPerks[typeIdentity]++;
		else
			SyncPerks.Add( typeIdentity, 1 );

		if ( _perkIncreaseOrder.Contains( typeIdentity ) )
			_perkIncreaseOrder.Remove( typeIdentity );
		_perkIncreaseOrder.Add( typeIdentity );

		float progress = perk.Level / (float)perk.MaxLevel;
		var attrib = type.GetAttribute<PerkAttribute>();
		//var pos = WorldPosition.WithZ( 65f );
		var OFFSET = 10f;
		var pos = WorldPosition.WithZ( 65f ) + new Vector3( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * OFFSET;
		pos += (Manager.Instance.CameraContainer.WorldPosition - pos).Normal * Game.Random.Float( 55f, 65f );
		Manager.Instance.SpawnPerkFloaterRpc( pos, Perk.GetImagePath( perk.GetType() ), attrib.Rarity, attrib.Curse, progress );

		ForEachPerk( perk => perk.OnAddPerkAfter( type ) );
		ForEachLoadoutItem( item => item.OnAddPerkAfter( type ) );

		PerkHash++;

		if( perk.IsCurse )
		{
			AddResultsStat( ResultStat.CursesGot, 1 );
			ProgressManager.IncrementStat( ProgressStat.CursesCollected, 1 );
			RefreshAvailableCurseCount();

			Manager.Instance.PlaySfxUI( "cursed", pitch: Game.Random.Float(1f, 1.2f), volume: 0.95f );
		}

		if ( attrib?.Rarity == Rarity.Unique )
			ProgressManager.IncrementStat( ProgressStat.UniquePerksCollected, 1 );

		ProgressManager.IncrementStat( ProgressStat.PerksCollected, 1 );
	}

	private void ClearAllPerks()
	{
		if ( IsProxy )
			return;

		foreach ( KeyValuePair<int, Perk> pair in Perks )
		{
			Perk perk = pair.Value;
			perk.Remove( restart: true );
		}

		Perks.Clear();
	}

	public void AddValidPerk( Type type )
	{
		int typeIdent = TypeLibrary.GetType( type ).Identity;

		if ( !ValidPerks.Contains( typeIdent ) && !BanishedPerkIdentities.Contains( typeIdent ) )
			ValidPerks.Add( typeIdent );
	}

	public void RemoveValidPerk( Type type )
	{
		int typeIdent = TypeLibrary.GetType( type ).Identity;

		if ( ValidPerks.Contains( typeIdent ) )
			ValidPerks.Remove( typeIdent );
	}

	public void IncludePerkCategory( PerkCategory category )
	{
		if ( _perkCategoryIncludeCounts.ContainsKey( category ) )
		{
			_perkCategoryIncludeCounts[category]++;
			return;
		}

		AddIncludedPerkCategories( category );
		_perkCategoryIncludeCounts[category] = 1;

		if ( category == PerkCategory.Fire || category == PerkCategory.Freeze )
		{
			if ( _perkCategoryIncludeCounts.ContainsKey( PerkCategory.Fire ) && _perkCategoryIncludeCounts.ContainsKey( PerkCategory.Freeze ) )
				AddIncludedPerkCategories( PerkCategory.FireAndFreeze );
		}

		if ( category == PerkCategory.OneHpLeft || category == PerkCategory.Dodge )
		{
			if ( _perkCategoryIncludeCounts.ContainsKey( PerkCategory.OneHpLeft ) && _perkCategoryIncludeCounts.ContainsKey( PerkCategory.Dodge ) )
				AddIncludedPerkCategories( PerkCategory.OneHpLeftAndDodge );
		}
	}

	void AddIncludedPerkCategories( PerkCategory category )
	{
		if ( PerkCategories.ContainsKey( category ) )
		{
			foreach ( Type type in PerkCategories[category] )
				AddValidPerk( type );
		}

		if ( _perkCategoryDefaultStats.ContainsKey( category ) )
		{
			foreach ( PlayerStat statType in _perkCategoryDefaultStats[category] )
				AddDefaultValueStat( statType );
		}
	}

	public void ExcludePerkCategory( PerkCategory category )
	{
		if ( !_perkCategoryIncludeCounts.ContainsKey( category ) )
			return;

		if ( _perkCategoryIncludeCounts[category] > 1 )
		{
			_perkCategoryIncludeCounts[category]--;
		}
		else
		{
			_perkCategoryIncludeCounts.Remove( category );
			RemoveIncludedPerkCategories( category );
		}

		if ( category == PerkCategory.Fire || category == PerkCategory.Freeze )
		{
			if ( !_perkCategoryIncludeCounts.ContainsKey( PerkCategory.Fire ) || !_perkCategoryIncludeCounts.ContainsKey( PerkCategory.Freeze ) )
				RemoveIncludedPerkCategories( PerkCategory.FireAndFreeze );
		}

		if ( category == PerkCategory.OneHpLeft || category == PerkCategory.Dodge )
		{
			if ( !_perkCategoryIncludeCounts.ContainsKey( PerkCategory.OneHpLeft ) || !_perkCategoryIncludeCounts.ContainsKey( PerkCategory.Dodge ) )
				RemoveIncludedPerkCategories( PerkCategory.OneHpLeftAndDodge );
		}
	}

	void RemoveIncludedPerkCategories( PerkCategory category )
	{
		if ( PerkCategories.ContainsKey( category ) )
		{
			foreach ( Type type in PerkCategories[category] )
				RemoveValidPerk( type );
		}

		if ( _perkCategoryDefaultStats.ContainsKey( category ) )
		{
			foreach ( PlayerStat statType in _perkCategoryDefaultStats[category] )
				RemoveDefaultValueStat( statType );
		}
	}

	public bool HasPerk( TypeDescription type )
	{
		return Perks.ContainsKey( type.Identity );
	}

	public Perk GetPerk( TypeDescription type )
	{
		if ( Perks.ContainsKey( type.Identity ) )
			return Perks[type.Identity];

		return null;
	}

	public int GetPerkLevel( TypeDescription type )
	{
		if ( Perks.ContainsKey( type.Identity ) )
			return Perks[type.Identity].Level;

		return 0;
	}

	//public void RemovePerkReqs()
	//{
	//	foreach ( var type in TypeLibrary.GetTypes<Perk>() )
	//	{
	//		var attrib = type.GetAttribute<PerkAttribute>();
	//		if ( attrib == null )
	//			continue;

	//		if ( attrib.Disabled )
	//			continue;

	//		bool skip = false;
	//		foreach ( var banishedIdentity in BanishedPerkIdentities )
	//		{
	//			TypeDescription banishedType = PerkManager.IdentityToType( banishedIdentity );
	//			if ( banishedType == type )
	//			{
	//				skip = true;
	//				break;
	//			}
	//		}

	//		if ( skip )
	//			continue;

	//		if ( !attrib.IncludedAtStart )
	//		{
	//			//Log.Info($"adding valid: {type.TargetType}");
	//			ValidPerks.Add( PerkManager.TypeToIdentity( type ) );
	//		}
	//	}
	//}

	//[Rpc.Owner]
	//public void GiveRandomPerkItemRpc( Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false )
	//{
	//	GiveRandomPerkItem( rarity, curseSelection, forceToCollect );
	//}

	//public void GiveRandomPerkItem( Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false )
	//{
	//	List<TypeDescription> perkTypes = PerkManager.GetRandomPerks( this, 1, rarity, curseSelection, isChoice: false );
	//	if ( perkTypes.Count == 0 )
	//		return;

	//	var typeIdentity = PerkManager.TypeToIdentity( perkTypes.FirstOrDefault() );
	//	var playerToTarget = forceToCollect ? this : null;
	//	Manager.Instance.SpawnPerkItemRpc( typeIdentity, Position2D, Utils.GetRandomVectorInCone( -FacingDir ), playerToTarget );
	//}

	[Rpc.Owner]
	public void GiveRandomPerkItemRpc( Vector2 pos, Vector2 dir, Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false, bool isReward = false )
	{
		List<TypeDescription> perkTypes = PerkManager.GetRandomPerks( this, numPerks: 1, rarity, curseSelection, isChoice: false, isReward: isReward );
		if ( perkTypes.Count == 0 )
			return;

		var typeIdentity = PerkManager.TypeToIdentity( perkTypes.FirstOrDefault() );
		var playerToTarget = forceToCollect ? this : null;
		Manager.Instance.SpawnPerkItemRpc( typeIdentity, pos, dir, playerToTarget );
	}

	public void GiveRandomPerkItemWithMessage( TypeDescription sourcePerkType, Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false, bool isReward = false )
	{
		var message = $"Got a random {(curseSelection == CurseSelection.OnlyCurses ? "curse" : "perk")}:";
		GiveRandomPerkItemWithMessage( sourcePerkType, message, rarity, curseSelection, forceToCollect, isReward );
	}

	//[Rpc.Owner]
	//public void GiveRandomPerkItemWithMessageRpc( TypeDescription sourcePerkType, string message, Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false, bool isReward = false )
	//{
	//	GiveRandomPerkItemWithMessage( sourcePerkType, message, rarity, curseSelection, forceToCollect, isReward );
	//}

	public void GiveRandomPerkItemWithMessage( TypeDescription sourcePerkType, string message, Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool forceToCollect = false, bool isReward = false )
	{
		var type = GetRandomPerkType( rarity, curseSelection, isReward: isReward );

		if ( type == null )
			return;

		if ( !string.IsNullOrEmpty( message ) )
		{
			var sourcePerkToken = sourcePerkType != null ? $"{Perk.GetRichTextToken( sourcePerkType )} " : "";
			Manager.Instance.Chat.AddLocalChatMessage( $"{sourcePerkToken}{message} {Perk.GetRichTextToken( type )}", from: "" );
		}

		GivePerkItem( type, dir: Utils.GetRandomVectorInCone( -FacingDir ), forceToCollect );
	}

	public TypeDescription GetRandomPerkType( Rarity rarity = Rarity.None, CurseSelection curseSelection = CurseSelection.NoCurses, bool isChoice = false, List<TypeDescription> notAllowed = null, bool isReward = false )
	{
		List<TypeDescription> perkTypes = PerkManager.GetRandomPerks( this, 1, rarity, curseSelection, isChoice, notAllowed, isReward );
		if ( perkTypes.Count > 0 )
			return perkTypes.FirstOrDefault();

		return null;
	}

	public bool GiveRandomExistingPerk( Type ignoreType = null, bool showMessage = false, TypeDescription perkType = null, CurseSelection curseSelection = CurseSelection.NoCurses )
	{
		List<TypeDescription> validTypes = new();

		foreach ( var (_, perk) in Perks )
		{
			Type type = perk.GetType();
			if ( type != null && type == ignoreType )
				continue;

			if ( perk.Level >= perk.MaxLevel )
				continue;

			if ( perk is PerkBanishOtherChoices )
				continue;

			if( curseSelection == CurseSelection.NoCurses && perk.IsCurse )
				continue;

			if ( curseSelection == CurseSelection.OnlyCurses && !perk.IsCurse )
				continue;

			validTypes.Add( TypeLibrary.GetType( type ) );
		}

		if ( validTypes.Count > 0 )
		{
			var type = validTypes[Game.Random.Int( 0, validTypes.Count - 1 )];

			GivePerkItem( type, dir: Utils.GetRandomVectorInCone( -FacingDir ) );

			if ( showMessage )
			{
				var sourcePerkToken = perkType != null ? $"{Perk.GetRichTextToken( perkType )} " : "";
				Manager.Instance.Chat.AddLocalChatMessage( $"{sourcePerkToken}Got a random existing perk: {Perk.GetRichTextToken( type )}", from: "" );
			}

			return true;
		}

		return false;
	}

	public TypeDescription GetRandomExistingPerkType( Type ignoreType = null )
	{
		List<TypeDescription> validTypes = new();

		foreach ( var (_, perk) in Perks )
		{
			Type type = perk.GetType();
			if ( type != null && type == ignoreType )
				continue;

			validTypes.Add( TypeLibrary.GetType( type ) );
		}

		if ( validTypes.Count > 0 )
			return validTypes[Game.Random.Int( 0, validTypes.Count - 1 )];

		return null;
	}

	public void GivePerkItem( TypeDescription perkType, Vector2 dir, bool forceToCollect = false )
	{
		var playerToTarget = forceToCollect ? this : null;
		Manager.Instance.SpawnPerkItemRpc( PerkManager.TypeToIdentity( perkType ), Position2D, dir, playerToTarget );

		DodgeDuckRpc( dir, time: Game.Random.Float( 0.05f, 0.07f ) );
	}

	//public void GivePerkItemUIChoice( TypeDescription perkType, Vector2 dir )
	//{
	//	ForEachPerk( perk => perk.OnChoosePerk( perkType ) );

	//	GivePerkItem( perkType, dir );
	//	RefreshAfterChoosingPerk();
	//}

	public void RefreshPerkChoices()
	{
		CurrentPerkChoices.Clear();

		var numChoices = DetermineNumPerkChoicesToSee();

		if ( numChoices <= 0 )
		{
			GiveRandomPerkZeroChoices( curseSelection: IsBeingShownCurseChoices ? CurseSelection.OnlyCurses : CurseSelection.NoCurses );

			RefreshAfterChoosingPerk();

			return;
		}

		//numChoices = Math.Min( numChoices, MAX_PERK_CHOICES );

		CurrentPerkChoices = PerkManager.GetRandomPerks( this, numChoices, Rarity.None, curseSelection: IsBeingShownCurseChoices ? CurseSelection.OnlyCurses : CurseSelection.NoCurses, isChoice: true );

		// Satisfy pending Unique choices (PerkHurtUniqueChoice)
		int numUniqueConsumed = 0;
		if ( !IsBeingShownCurseChoices && NumPendingUniqueChoices > 0 )
		{
			// Naturally-occurring Unique perks in the choices satisfy pending counts
			foreach ( var choice in CurrentPerkChoices )
			{
				if ( NumPendingUniqueChoices <= 0 ) break;
				var attrib = choice.GetAttribute<PerkAttribute>();
				if ( attrib?.Rarity == Rarity.Unique )
				{
					NumPendingUniqueChoices--;
					numUniqueConsumed++;
				}
			}

			// Replace non-Unique slots for any remaining pending counts
			int replaceIndex = 0;
			while ( NumPendingUniqueChoices > 0 && replaceIndex < CurrentPerkChoices.Count )
			{
				var slotAttrib = CurrentPerkChoices[replaceIndex].GetAttribute<PerkAttribute>();
				if ( slotAttrib?.Rarity == Rarity.Unique )
				{
					replaceIndex++;
					continue;
				}

				var notAllowedUnique = new List<TypeDescription>( CurrentPerkChoices );
				notAllowedUnique.RemoveAt( replaceIndex );
				var uniqueResult = PerkManager.GetRandomPerks( this, 1, Rarity.Unique, CurseSelection.NoCurses, isChoice: false, notAllowedUnique );

				if ( uniqueResult.Count > 0 )
					CurrentPerkChoices[replaceIndex] = uniqueResult[0];

				NumPendingUniqueChoices--;
				numUniqueConsumed++;
				replaceIndex++;
			}
		}

		if ( numUniqueConsumed > 0 )
			ForEachPerk( perk => perk.OnPerkChoicesRefreshed( numUniqueConsumed ) );
			ForEachLoadoutItem( item => item.OnPerkChoicesRefreshed( numUniqueConsumed ) );

		CurrentPerkChoicesUnknown.Clear();
		for ( int i = 0; i < CurrentPerkChoices.Count; i++ )
			CurrentPerkChoicesUnknown.Add( Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.CurseUnknownPerkChance] );

		CurrentPerkChoicesWrongInfo.Clear();
		CurseWrongPerkInfo.PopulateWrongInfo( this, CurrentPerkChoicesWrongInfo );

		CanInteractWithChoices = true;

		SyncPerkChoiceState();

		//RealTimeSinceRefreshChoices = 0f;
		//RealTimeSinceOfferedChoices = 0f;

		Manager.Instance.SkipShowingChoicesFrames = 1;

		// todo: handle this better
		if ( CurrentPerkChoices.Count() == 0 )
			IsChoosingLevelUpReward = false;

		if ( !IsChoosingLevelUpReward )
			SyncPerkChoiceState();

		ForEachPerk( perk => perk.OnPerkChoicesAssigned() );
	}

	public void AddPerkUIChoice( TypeDescription type )
	{
		Sandbox.Services.Stats.Increment( Manager.GetPerkStatString( StatType.PerkChosen, type ), 1 );

		foreach ( var choicePerkType in CurrentPerkChoices )
		{
			if ( choicePerkType.FullName.Equals( type.FullName ) )
				continue;

			Sandbox.Services.Stats.Increment( Manager.GetPerkStatString( StatType.PerkIgnored, choicePerkType ), 1 );
		}

		ForEachPerk( perk => perk.OnChoosePerk( type ) );
		ForEachLoadoutItem( item => item.OnChoosePerk( type ) );

		AddPerk( type );

		RefreshAfterChoosingPerk();
	}

	public void SubmitPerkStatsOnReroll()
	{
		foreach ( var choicePerkType in CurrentPerkChoices )
		{
			Sandbox.Services.Stats.Increment( Manager.GetPerkStatString( StatType.PerkIgnored, choicePerkType ), 1 );
		}
	}

	public void RefreshAfterChoosingPerk()
	{
		ReimburseWaitingRerolls();

		HoveredPerkChoiceSlot = -1;
		IsChoosingLevelUpReward = false;
		RealTimeSinceChosePerk = 0f;

		// handle curses
		if ( Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses )
		{
			// curses don't count as one of your perk points
			if ( IsBeingShownCurseChoices )
				NumCursesToChoose--;
			else
				NumPerkPointsAvailable--;

			CurrLevelsUntilCurseChoice--;

			if ( CurrLevelsUntilCurseChoice <= 0 )
			{
				// only queue a curse choice if there are available curses
				if ( AvailableCurseCount > 0 )
					NumCursesToChoose++;
				CurrLevelsUntilCurseChoice = NumLevelsBetweenCurseChoices;
			}
		}
		else
		{
			NumPerkPointsAvailable--;
		}

		IsBeingShownCurseChoices = NumCursesToChoose > 0 && AvailableCurseCount > 0;
		ShouldShowCurseMessage = true;

		if ( NumPerkPointsAvailable > 0 )
		{
			_shouldCheckForAdditionalPerkPoints = true;
			_checkForAdditionalPerkPointsFrameDelay = 1;

			if ( IsBeingShownCurseChoices )
				ShowCurseMessage();
		}

		SyncPerkChoiceState();
	}

	public void ShowCurseMessage()
	{
		List<string> potentialMessages = new()
		{
			"It's time to choose a curse...",
			"A curse awaits you...",
			"Choose a curse...",
			"Prepare for a curse...",
		};

		var message = potentialMessages[Game.Random.Int( 0, potentialMessages.Count - 1 )];

		Manager.Instance.Chat.AddLocalChatMessage( message, from: "" );
		Manager.Instance.PlaySfxUI( "curse_show_choices", pitch: Utils.Map( Level, 1, 50, 1.2f, 1.5f ), volume: 0.95f );
		ShouldShowCurseMessage = false;
		NumCursesMessagesShown++;
	}

	public void BanishPerkUIChoice( TypeDescription type )
	{
		BanishPerk( type );

		NumBanishAvailable--;
		IsBanishMode = false;

		ReimburseWaitingRerolls();

		BanishExistingPerkChoice( type.Identity );

		//RealTimeSinceRefreshChoices = 0f;
		//RealTimeSinceOfferedChoices = 0f;
	}

	public void BanishPerk( TypeDescription type )
	{
		RemoveValidPerk( type.TargetType );

		var typeIdent = type.Identity;

		if ( !BanishedPerkIdentities.Contains( typeIdent ) )
			BanishedPerkIdentities.Add( typeIdent );

		ForEachPerk( perk => perk.OnBanish() );
		ForEachLoadoutItem( item => item.OnBanish() );

		AddResultsStat( ResultStat.NumTimesBanish, 1 );
		ProgressManager.IncrementStat( ProgressStat.PerksBanished, 1 );
	}

	public void BanishExistingPerkChoice( int typeIdentity )
	{
		if ( CurrentPerkChoices == null )
			return;

		TypeDescription banishedType = PerkManager.IdentityToType( typeIdentity );

		List<TypeDescription> notAllowed = new();
		foreach ( var perkType in CurrentPerkChoices )
			notAllowed.Add( perkType );

		for ( int i = CurrentPerkChoices.Count - 1; i >= 0; i-- )
		{
			var currPerkType = CurrentPerkChoices[i];

			if ( banishedType == currPerkType )
			{
				CurrentPerkChoices.RemoveAt( i );

				List<TypeDescription> perkTypes = PerkManager.GetRandomPerks( this, 1, Rarity.None, curseSelection: IsBeingShownCurseChoices ? CurseSelection.OnlyCurses : CurseSelection.NoCurses, isChoice: true, notAllowed );
				if ( perkTypes.Count > 0 )
				{
					var type = perkTypes.FirstOrDefault();

					RealTimeSinceBanishChoice = 0f;
					BanishChoiceIndex = i;

					CurrentPerkChoices.Insert( i, type );
				}

				ForEachPerk( perk => perk.OnPerkChoicesAssigned() );

				if ( i < CurrentPerkChoicesUnknown.Count )
					CurrentPerkChoicesUnknown[i] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.CurseUnknownPerkChance];

				if ( i < CurrentPerkChoicesWrongInfo.Count )
					CurrentPerkChoicesWrongInfo[i] = i < CurrentPerkChoices.Count
						? CurseWrongPerkInfo.GetWrongInfoForChoice( this, CurrentPerkChoices[i] )
						: null;

				break;
			}
		}

		CanInteractWithChoices = true;
		SyncPerkChoiceState();
	}

	public void BanishAllCurrentChoices()
	{
		foreach ( var perkType in CurrentPerkChoices )
		{
			if ( perkType == TypeLibrary.GetType( typeof( PerkBanishOtherChoices ) ) )
				continue;

			BanishPerk( perkType );
		}

		//RefreshPerkChoices();
	}

	public void RerollSinglePerkChoice( TypeDescription typeToReroll )
	{
		if ( CurrentPerkChoices == null )
			return;

		List<TypeDescription> notAllowed = new();
		foreach ( var perkType in CurrentPerkChoices )
			notAllowed.Add( perkType );

		for ( int i = CurrentPerkChoices.Count - 1; i >= 0; i-- )
		{
			var currPerkType = CurrentPerkChoices[i];

			if ( typeToReroll == currPerkType )
			{
				CurrentPerkChoices.RemoveAt( i );

				List<TypeDescription> perkTypes = PerkManager.GetRandomPerks( this, 1, Rarity.None, curseSelection: IsBeingShownCurseChoices ? CurseSelection.OnlyCurses : CurseSelection.NoCurses, isChoice: true, notAllowed );
				if ( perkTypes.Count > 0 )
				{
					var type = perkTypes.FirstOrDefault();

					RealTimeSinceBanishChoice = 0f;
					BanishChoiceIndex = i;

					CurrentPerkChoices.Insert( i, type );

					ForEachPerk( perk => perk.OnPerkChoicesAssigned() );

					PerkChoiceHash++;
				}

				if ( i < CurrentPerkChoicesUnknown.Count )
					CurrentPerkChoicesUnknown[i] = Game.Random.Float( 0f, 1f ) < Stats[PlayerStat.CurseUnknownPerkChance];

				if ( i < CurrentPerkChoicesWrongInfo.Count )
					CurrentPerkChoicesWrongInfo[i] = i < CurrentPerkChoices.Count
						? CurseWrongPerkInfo.GetWrongInfoForChoice( this, CurrentPerkChoices[i] )
						: null;

				break;
			}
		}

		SyncPerkChoiceState();
	}

	public TypeDescription GetLastPerkType()
	{
		if ( _perkIncreaseOrder.Count == 0 )
			return null;

		return TypeLibrary.GetTypeByIdent( _perkIncreaseOrder[_perkIncreaseOrder.Count - 1] );
	}

	public void RemovePerk( TypeDescription type )
	{
		var typeIdentity = type.Identity;
		if ( !Perks.ContainsKey( typeIdentity ) )
			return;

		Perk perk = Perks[typeIdentity];

		SpawnRemovePerkFloater( perk, type );

		Perks.Remove( typeIdentity );

		SyncPerks.Remove( typeIdentity );

		RemoveModifiers( perk );

		if ( _perkIncreaseOrder.Contains( typeIdentity ) )
			_perkIncreaseOrder.Remove( typeIdentity );

		var wasCurse = perk.IsCurse;

		perk.Remove();

		PerkHash++;

		if ( Perks.Count == 0 )
			ExcludePerkCategory( PerkCategory.AfterFirstPerk );

		ForEachPerk( perk => perk.OnRemovePerk( type ) );
		ForEachLoadoutItem( item => item.OnRemovePerk( type ) );

		if ( wasCurse )
			RefreshAvailableCurseCount();
	}

	void SpawnRemovePerkFloater( Perk perk, TypeDescription type, bool noProgress = false )
	{
		float progress = noProgress ? 0f : (perk.Level / (float)perk.MaxLevel);
		var attrib = type.GetAttribute<PerkAttribute>();
		var OFFSET = 10f;
		var pos = WorldPosition.WithZ( 25f ) + new Vector3( Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ) * OFFSET;
		pos += (Manager.Instance.CameraContainer.WorldPosition - pos).Normal * Game.Random.Float( 55f, 65f );
		Manager.Instance.SpawnPerkFloaterRpc( pos, Perk.GetImagePath( perk.GetType() ), attrib.Rarity, attrib.Curse, progress, wasRemoved: true );
	}

	public void LevelDownPerk( TypeDescription type )
	{
		var typeIdentity = type.Identity;
		if ( !Perks.ContainsKey( typeIdentity ) )
			return;

		Perk perk = Perks[typeIdentity];

		if ( perk.Level <= 1 )
		{
			RemovePerk( type );
			return;
		}

		SpawnRemovePerkFloater( perk, type, noProgress: true );

		perk.DecreaseLevel();

		SyncPerks[typeIdentity]--;

		PerkHash++;

		if ( perk.IsCurse )
			RefreshAvailableCurseCount();
	}

	public void LevelEarliestPerk()
	{
		foreach ( var (_, perk) in Perks )
		{
			if ( perk.Level >= perk.MaxLevel )
				continue;

			if ( perk is PerkIncreaseEarliestPerk || perk is PerkBanishOtherChoices )
				continue;

			var typeIdent = TypeLibrary.GetTypeIdent( perk.GetType() );
			if ( BanishedPerkIdentities.Contains( typeIdent ) )
				continue;

			GivePerkItem( TypeLibrary.GetType( perk.GetType() ), dir: Utils.GetRandomVector() );
			return;
		}

		Manager.Instance.PlaySfxUI( "error2", pitch: 0.5f, volume: 0.95f );
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), "FAILED!", new Color( 1f, 0.5f, 0.5f ), 1.3f, FloaterType.NegativeMessage );
	}

	public void LevelPreviousPerk()
	{
		for ( int i = _perkIncreaseOrder.Count - 1; i >= 0; i-- )
		{
			var typeIdentity = _perkIncreaseOrder[i];
			if ( !Perks.ContainsKey( typeIdentity ) )
				continue;

			Perk perk = Perks[typeIdentity];
			if ( perk.Level >= perk.MaxLevel )
				continue;

			if ( perk is PerkDoubleDown || perk is PerkBanishOtherChoices )
				continue;

			if ( perk.IsCurse )
				continue;

			GivePerkItem( TypeLibrary.GetTypeByIdent( typeIdentity ), dir: Utils.GetRandomVectorInCone( -FacingDir ) );
			return;
		}

		Manager.Instance.PlaySfxUI( "error2", pitch: 0.5f, volume: 0.95f );
		Manager.Instance.SpawnFloaterText( WorldPosition.WithZ( 65f ), "FAILED!", new Color( 1f, 0.5f, 0.5f ), 1.3f, FloaterType.NegativeMessage );
	}

	[Rpc.Owner]
	public void SetMinute( int minute )
	{
		ForEachPerk( perk => perk.OnSetMinute( minute ) );
		ForEachLoadoutItem( item => item.OnSetMinute( minute ) );
	}

	[Rpc.Owner( NetFlags.Unreliable )]
	public void HighlightPerkRpc( int typeIdentity )
	{
		HighlightPerk( PerkManager.IdentityToType( typeIdentity ) );
	}

	public void HighlightPerk( TypeDescription perkType )
	{
		Assert.True( !IsProxy );

		var perk = GetPerk( perkType );
		if ( perk != null )
		{
			perk.Highlight();
			perk.IconScale = Game.Random.Float( 1.2f, 1.3f );
			perk.IconAngleOffset = Game.Random.Float( 10f, 20f ) * (Game.Random.Int( 0, 1 ) == 0 ? -1f : 1f);
		}
	}

	public Dictionary<int, int> GetImportantPerks()
	{
		var importantPerks = new Dictionary<int, int>();

		// Get the 3 perks with highest Importance values
		var topPerks = Perks
			.OrderByDescending( pair => pair.Value.Importance )
			.Take( 3 );

		foreach ( var pair in topPerks )
		{
			importantPerks[pair.Key] = pair.Value.Level;
		}

		return importantPerks;
	}

	public static float GetImportanceWeightForRarity( Rarity rarity )
	{
		switch ( rarity )
		{
			case Rarity.Common: default: return 1f;
			case Rarity.Uncommon: return (7f / 6f);
			case Rarity.Rare: return (7f / 5f);
			case Rarity.Epic: return (7f / 4.1f);
			case Rarity.Mythic: return (7f / 3.2f);
			case Rarity.Legendary: return (7f / 2.3f);
			case Rarity.Unique: return (7f / 1.3f);
		}
	}

	void CreatePerkCategories()
	{
		PerkCategories.Clear();
		_perkCategoryDefaultStats.Clear();
		_perkCategoryIncludeCounts.Clear();

		PerkCategories.Add( PerkCategory.AfterFirstPerk, new List<Type>()
		{
			typeof( PerkDoubleDown ),
			typeof( PerkIncreaseEarliestPerk ),
			typeof( PerkRestartChoices ),
			typeof( PerkRandomExisting ),
			typeof( PerkCleanse ),
			typeof( PerkMaxAmmoDownside ),
			typeof( PerkHandoutsRemove ),
		} );

		PerkCategories.Add( PerkCategory.Fire, new List<Type>()
		{
			typeof( PerkFireDamage ),
			typeof( PerkFireLifetime ),
			typeof( PerkFireSpread ),
			typeof( PerkFireStack ),
		} );

		_perkCategoryDefaultStats.Add( PerkCategory.Fire, new List<PlayerStat>()
		{
			PlayerStat.FireDamage,
			PlayerStat.FireLifetime,
		} );

		PerkCategories.Add( PerkCategory.Freeze, new List<Type>()
		{
			typeof( PerkFreezeLifetime ),
			typeof( PerkFreezeShards ),
			typeof( PerkFreezeStrength ),
			typeof( PerkFreezeDoubleDamage ),
		} );

		_perkCategoryDefaultStats.Add( PerkCategory.Freeze, new List<PlayerStat>()
		{
			PlayerStat.FreezeStrengthDisplay,
			PlayerStat.FreezeLifetime,
		} );

		PerkCategories.Add( PerkCategory.FireAndFreeze, new List<Type>()
		{
			typeof( PerkFreezeBurn ),
		} );

		PerkCategories.Add( PerkCategory.Poison, new List<Type>()
		{
			typeof( PerkPoisonDamage ),
			typeof( PerkPoisonFinishDamage ),
			typeof( PerkPoisonSpread ),
			typeof( PerkPoisonFlammable ),
			typeof( PerkPoisonDoubleDmgLoseHp ),
			typeof( PerkPoisonIncreaseNearby ),
		} );

		_perkCategoryDefaultStats.Add( PerkCategory.Poison, new List<PlayerStat>()
		{
			PlayerStat.PoisonDamage,
		} );

		PerkCategories.Add( PerkCategory.Fear, new List<Type>()
		{
			typeof( PerkFearDamage ),
			typeof( PerkFearBomb ),
			typeof( PerkFearBomb ),
			typeof( PerkFearDrain ),
			typeof( PerkFearLifetime ), 
			//typeof(FearVoodooUpgrade)
			typeof( PerkFearSharedPain ),
			typeof( PerkFearReduceDamage ),
		} );

		_perkCategoryDefaultStats.Add( PerkCategory.Fear, new List<PlayerStat>()
		{
			PlayerStat.FearLifetime,
		} );

		PerkCategories.Add( PerkCategory.SelfDmg, new List<Type>()
		{
			typeof( PerkSelfDmgReduction ),
			typeof( PerkSelfDmgAoe ),
			typeof( PerkSelfDmgHealthPack ),
			typeof( PerkSelfDamageInvuln ),
			typeof( PerkSelfDmgDash ),
		} );

		PerkCategories.Add( PerkCategory.Dodge, new List<Type>()
		{
			typeof( PerkDodgeReload ),
			typeof( PerkDodgeAoe ),
			typeof( PerkDodgeHeal ),
			typeof( PerkDodgeClipBonus ),
			typeof( PerkDodgeHpDamage ),
		} );

		PerkCategories.Add( PerkCategory.Explosion, new List<Type>()
		{
			typeof( PerkExplosionDamage ),
			typeof( PerkExplosionSize ),
			typeof( PerkExplosionMagnet ),
			typeof( PerkExplosionFear ),
			typeof( PerkExplosionInward ),
		} );

		PerkCategories.Add( PerkCategory.BulletBounce, new List<Type>()
		{
			typeof( PerkBounceDamage ),
			typeof( PerkBounceResetLifetime ),
			typeof( PerkBounceTarget ),
			typeof( PerkBulletBounceCopy ),
		} );

		PerkCategories.Add( PerkCategory.BulletPierce, new List<Type>()
		{
			typeof( PerkBulletPierceCopy ),
		} );

		PerkCategories.Add( PerkCategory.Armor, new List<Type>()
		{
			typeof( PerkArmorRerollCost ),
			typeof( PerkArmorShoot ),
			typeof( PerkArmorSpeed ),
			typeof( PerkArmorDamageReduction ),
		} );

		PerkCategories.Add( PerkCategory.Shield, new List<Type>()
		{
			typeof( PerkShieldBreakHeal ),
			typeof( PerkShieldBreakAoe ),
			typeof( PerkShieldMinDmg ),
		} );

		PerkCategories.Add( PerkCategory.Radiation, new List<Type>()
		{
			typeof( PerkRadiationDelay ),
			typeof( PerkRadiationHeal ),
			typeof( PerkRadiationRepel ),
		} );

		PerkCategories.Add( PerkCategory.Landmine, new List<Type>()
		{
			typeof( PerkLandmineBullets ),
			typeof( PerkLandmineRepelPlayers ),
		} );

		PerkCategories.Add( PerkCategory.Aoe, new List<Type>()
		{
			typeof( PerkRadiusMultiplier ),
		} );

		PerkCategories.Add( PerkCategory.NumProjectiles, new List<Type>()
		{
			typeof( PerkReduceSpread ),
		} );

		PerkCategories.Add( PerkCategory.Bomb, new List<Type>()
		{
			typeof( PerkBombSticky ),
		} );

		PerkCategories.Add( PerkCategory.Blink, new List<Type>()
		{
			typeof( PerkBlinkCursor ),
		} );

		PerkCategories.Add( PerkCategory.Boomerang, new List<Type>()
		{
			typeof( PerkBoomerangHurtSelf ),
			typeof( PerkBoomerangBounceSelf ),
		} );

		PerkCategories.Add( PerkCategory.Punch, new List<Type>()
		{
			typeof( PerkPunchBullets ),
			typeof( PerkPunchReload ),
			typeof( PerkPunchForce ),
			typeof( PerkPunchLifesteal ),

			// todo: add punch dmg default stat
		} );

		PerkCategories.Add( PerkCategory.OneHpLeft, new List<Type>()
		{
			typeof( PerkOnly1HpAttackSpeed ),
			typeof( PerkOnly1HpMoveSpeed ),
			typeof( PerkOnly1HpXpGain ),
			typeof( PerkOnly1HpBulletHoming ),
		} );

		PerkCategories.Add( PerkCategory.OneHpLeftAndDodge, new List<Type>()
		{
			typeof( PerkOnly1HpDodge ),
		} );

		PerkCategories.Add( PerkCategory.Jump, new List<Type>()
		{
			typeof( PerkJumpLandDamage ),
			typeof( PerkJumpLandArcBullets ),
		} );

		PerkCategories.Add( PerkCategory.Artillery, new List<Type>()
		{
			typeof( PerkArtillerySupplies ),
		} );

		PerkCategories.Add( PerkCategory.OrbiterBlade, new List<Type>()
		{
			typeof( PerkOrbiterBladeStopTime ),
			//typeof( PerkOrbiterBladeHitInterval ),
			typeof( PerkOrbiterBladeSpeed ),
			typeof( PerkOrbiterBladeBullet ),
			typeof( PerkOrbiterBladeConsecutiveDmg ),
		} );

		PerkCategories.Add( PerkCategory.ArcBullets, new List<Type>()
		{
			typeof( PerkArcBulletsBounceGround ),
		} );
	}

	void DetermineFavoritePerks()
	{
		FavoritePerks.Clear();

		Dictionary<TypeDescription, float> favoritePerks = new();

		foreach ( var type in TypeLibrary.GetTypes<Perk>() )
		{
			var attrib = type.GetAttribute<PerkAttribute>();
			if ( attrib == null )
				continue;

			if ( attrib.Disabled )
				continue;

			if ( attrib.Curse )
				continue;

			var timesChosen = (int)Sandbox.Services.Stats.LocalPlayer.Get( Manager.GetPerkStatString( StatType.PerkChosen, type ) ).Sum;
			var timesIgnored = (int)Sandbox.Services.Stats.LocalPlayer.Get( Manager.GetPerkStatString( StatType.PerkIgnored, type ) ).Sum;

			if ( timesChosen >= 2 )
			{
				var percent = MathX.Clamp( timesChosen / (float)(timesChosen + timesIgnored), 0f, 1f );

				favoritePerks[type] = percent;
			}
		}

		FavoritePerks.AddRange( favoritePerks.OrderByDescending( x => x.Value ).Take( 10 ).Select( x => x.Key ) );
	}
}