ProgressManager.cs

Progress and progression manager for the game. Defines shop item, quest and achievement data structures and provides persistent player progress handling: stats, quests, achievements, coins, shop ownership, gem upgrades, equip/loadout and save/load logic.

File AccessReflectionHttp Calls
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

public enum ShopItemCategory
{
	Gun,
	Charm,
	Gem,
}

public struct ShopItemDef
{
	public string Id;
	public ShopItemCategory Category;
	public string Name;
	public int Price;
	public string IconPath;
	public readonly string PrefabPath => Category switch
	{
		ShopItemCategory.Gun   => $"prefabs/guns/{Id}.prefab",
		ShopItemCategory.Charm => $"prefabs/charms/{Id}.prefab",
		ShopItemCategory.Gem   => $"prefabs/gems/{Id}.prefab",
		_ => null
	};
	public int[] UpgradePrices; // gems only: null/empty = not upgradeable; each entry is the cost per upgrade level
	public int RequiredPurchases; // 0 = no gate; N = must own N items in this category first
	public int GemSocketCount; // guns only: number of gem sockets (0/3 = three sockets)
	public int CharmSlotCount; // guns only: number of charm slots (0/1 = one slot)
	public Func<string>? ItemDescription; // guns and charms: rich description (no level)
	public Func<int, string>? GemDescription; // gems only: description at the given 1-indexed level (shown in Loadout)
	public Func<int, string>? GemUpgradeDescription; // gems only: upgrade progression description at the given 1-indexed level (shown in Shop)
}

public enum ProgressStat
{
	EnemyKills,
	MinibossKills,
	BossKills,
	DamageDealt,
	TotalRuns,
	RunsWon,
	Deaths,
	PerksCollected,
	ChestsOpened,
	EvilChestsOpened,
	ItemsCollected,
	FireDamageDealt,
	BarrelsDestroyed,
	EnemiesFrozen,
	PoisonDamageDealt,
	BuzzsawDamageDealt,
	Rerolls,
	PerksBanished,
	DamageTaken,
	HpHealed,
	SelfDamageTaken,
	ArmorDamageBlocked,
	BulletBounces,
	BulletPierces,
	SecondsStandingStill,
	EnemiesExecuted,
	UniquePerksCollected,
	CursesCollected,
	MagnetsCollected,
	ExplosionKills,
	BoomerangKills,
	TimesDodged,
	XpCoinsCollected,
	PerkMaxedOut,
	FlyingSkullsDashDestroyed,
	ShieldsLost,
	EnemiesScared,
	TimesDashed,
	RadiationDamageDealt,
	SecondsAfterBoss,
	ZombieEliteKills,
	ChargerEliteKills,
	SpikerEliteKills,
	SpitterEliteKills,
	RunnerEliteKills,
	ExploderEliteKills,
	ExploderMiniKills,
	ProjectileHits,
	CriticalHits,
	ShockKills,
	HealthPacksCollected,
	SecondsAtOneHp,
	Revives,
	HpHealedOthers,
	MushroomsDisturbed,
}

public enum QuestId
{
	// Combat (Tab 0)
	KillEnemies,
	KillMinibosses,
	KillBosses,
	DealDamage,
	DealFireDamage,
	// Exploration (Tab 1)
	CompleteRuns,
	WinRuns,
	OpenChests,
	OpenEvilChests,
	DestroyBarrels,
	// Collection (Tab 2)
	CollectPerks,
	CollectItems,
	RerollPerks,
	BanishPerks,
	// Combat (continued)
	FreezeEnemies,
	DealPoisonDamage,
	DealBuzzsawDamage,
	TakeDamage,
	TakeSelfDamage,
	BlockDamageWithArmor,
	BounceWithBullets,
	PierceWithBullets,
	// Survival (continued)
	HealHp,
	StandStill,
	// Collection (continued)
	GetUniquePerks,
	GetCurses,
	GetMagnets,
	// Combat (continued)
	ExecuteEnemies,
	KillWithExplosions,
	// Survival (Tab 3)
	DieDeaths,
	// Combat (continued)
	KillWithBoomerangs,
	DestroyFlyingSkullsDashing,
	// Survival (continued)
	DodgeTimes,
	LoseShield,
	// Collection (continued)
	CollectXpCoins,
	MaxOutPerks,
	CollectHealthPacks,
	// Combat (continued)
	ScareEnemies,
	DealRadiationDamage,
	LandCriticalHits,
	KillWithShock,
	KillEliteZombies,
	KillEliteChargers,
	KillEliteSpikers,
	KillEliteSpitters,
	KillEliteWerewolves,
	KillEliteExploders,
	KillExploderMinis,
	// Survival (continued)
	DashTimes,
	SurviveAfterBoss,
	SurviveAtOneHp,
	ReviveTimes,
	HealOtherPlayers,
	DisturbMushrooms,
	// Combat (continued)
	GetHitByProjectiles,
}

public struct QuestDef
{
	public QuestId Id;
	public string Name;          // Use {0} as placeholder for target value
	public string Description;   // Short description shown below the title
	public ProgressStat Stat;
	public int[] LevelTargets;   // per-level targets; length = max level
	public int[] RewardAmounts;  // reward per level, same length as LevelTargets
	public string RewardIcon;    // path to icon image, shared across all levels
	public Color Color;
	public int Tab;              // 0=Combat, 1=Exploration, 2=Collection, 3=Survival
}

public struct AchievementDef
{
	public string Name;         // unique key used in UnlockAchievement calls
	public string DisplayName;
	public string Description;
	public int RewardAmount;
	public string RewardIcon;
	public Color Color;
}

public class ProgressData
{
	public Dictionary<ProgressStat, float> Stats { get; set; } = new();
	public Dictionary<QuestId, int> QuestLevels { get; set; } = new();
	public HashSet<string> UnlockedAchievements { get; set; } = new();
	public HashSet<string> ClaimedAchievements { get; set; } = new();
	public int Coins { get; set; }
	public HashSet<string> OwnedShopItems { get; set; } = new();
	public HashSet<string> UnlockedLockedPerks { get; set; } = new();
	public string SelectedGunId { get; set; }
	public string SelectedCharmId { get; set; }
	public List<string> SelectedCharmIds { get; set; } = new();
	public Dictionary<string, int> GemUpgradeLevels { get; set; } = new(); // 0 = base level (just bought)
	public List<string> EquippedGems { get; set; } = new();               // max 3
	public float PerkUnlockXp { get; set; }      // XP accumulated toward next unlock
}

public static partial class ProgressManager
{
	private static ProgressData _data;
	private static string FilePath => "progress.json";
	private static bool _isDirty = false;
	private static RealTimeSince _timeSinceLastSave = 0f;
	private const float SaveInterval = 5f;

	public static int StateVersion { get; private set; } = 0;

	// Perk unlock XP progress
	public static float PerkUnlockXp { get { if ( _data == null ) Load(); return _data.PerkUnlockXp; } }
	public static int UnlockedLockedPerksCount { get { if ( _data == null ) Load(); return _data.UnlockedLockedPerks.Count; } }

	private static float _perkUnlockXpBeforeRun = 0f;
	private static float _runXpEarned = 0f;
	private static int _runPerkUnlocksEarned = 0;
	public static float PerkUnlockXpBeforeRun => _perkUnlockXpBeforeRun;
	public static float RunXpEarned => _runXpEarned;
	public static int RunPerkUnlocksEarned => _runPerkUnlocksEarned;

	public static float GetXpRequiredForNextPerkUnlock( int extraUnlocksSoFar = 0 )
	{
		if ( _data == null ) Load();
		int total = _data.UnlockedLockedPerks.Count + extraUnlocksSoFar;
		const float Base = 50f;
		const float Step = 5f;
		const float Max = 1000f;
		return Math.Min( Base + Step * total, Max );
	}

	public static void ProcessRunXp( float xp )
	{
		if ( _data == null ) Load();
		_perkUnlockXpBeforeRun = _data.PerkUnlockXp;
		_runXpEarned = xp;
		_runPerkUnlocksEarned = 0;

		if ( AreAllLockedPerksUnlocked() ) return;

		_data.PerkUnlockXp += xp;
		float required = GetXpRequiredForNextPerkUnlock();
		if ( _data.PerkUnlockXp >= required )
		{
			_runPerkUnlocksEarned = 1;
			_data.PerkUnlockXp = 0f;
		}
		_isDirty = true;
		StateVersion++;
	}

	public static bool AreAllLockedPerksUnlocked()
	{
		if ( _data == null ) Load();
		var allLocked = TypeLibrary.GetTypes<Perk>()
			.Where( t => t.GetAttribute<PerkAttribute>() is { Locked: true, Disabled: false } )
			.ToList();
		return allLocked.All( t => _data.UnlockedLockedPerks.Contains( t.FullName ) );
	}

	public static void ClearPerkUnlockXp()
	{
		if ( _data == null ) Load();
		_data.PerkUnlockXp = 0f;
		_runPerkUnlocksEarned = 0;
		_isDirty = true;
		StateVersion++;
		Save();
	}

	// Quest definitions — defined in code, never serialized.
	// CONSTRAINT: Each QuestId must map to a unique ProgressStat.
	// CollectQuestReward resets that stat to the remainder; two quests sharing a stat would corrupt each other.
	private static QuestDef[] _quests;
	private static string[] _questTabNames;
	public static QuestDef[] Quests => _quests ??= BuildQuests();
	public static string[] QuestTabNames => _questTabNames ??= GetQuestGroups().OrderBy( g => g.Tab ).Select( g => g.Name ).ToArray();

	private record struct QuestGroup( int Tab, string Name, QuestDef[] Quests );

	private static QuestDef[] BuildQuests()
	{
		var result = new List<QuestDef>();
		foreach ( var group in GetQuestGroups() )
			foreach ( var quest in group.Quests )
			{
				var q = quest;
				q.Tab = group.Tab;
				result.Add( q );
			}
		return result.ToArray();
	}

	public static void Load()
	{
		_data = FileSystem.Data.ReadJsonSafe<ProgressData>( FilePath, new ProgressData() );
		// Migrate single SelectedCharmId to SelectedCharmIds list
		if ( (_data.SelectedCharmIds == null || _data.SelectedCharmIds.Count == 0) && !string.IsNullOrEmpty( _data.SelectedCharmId ) )
		{
			_data.SelectedCharmIds = [_data.SelectedCharmId];
		}
		_data.SelectedCharmIds ??= [];
		_quests = null;
		_achievements = null;
	}

	public static void Save()
	{
		FileSystem.Data.WriteJson( FilePath, _data );
		_isDirty = false;
		_timeSinceLastSave = 0f;
	}

	public static void Tick()
	{
		if ( _isDirty && _timeSinceLastSave > SaveInterval )
			Save();
	}

	public static void IncrementStat( ProgressStat stat, float amount )
	{
		if ( _data == null ) Load();
		if ( !_data.Stats.ContainsKey( stat ) ) _data.Stats[stat] = 0f;
		_data.Stats[stat] += amount;
		_isDirty = true;
		StateVersion++;
	}

	public static float GetStat( ProgressStat stat )
	{
		if ( _data == null ) Load();
		return _data.Stats.TryGetValue( stat, out float v ) ? v : 0f;
	}

	public static int GetQuestLevel( QuestId id )
	{
		if ( _data == null ) Load();
		return _data.QuestLevels.TryGetValue( id, out int v ) ? v : 0;
	}

	public static bool IsQuestCompleted( QuestId id )
	{
		var def = GetDef( id );
		return GetQuestLevel( id ) >= def.LevelTargets.Length;
	}

	public static bool IsQuestReadyToCollect( QuestId id )
	{
		var def = GetDef( id );
		int level = GetQuestLevel( id );
		if ( level >= def.LevelTargets.Length ) return false;
		return GetStat( def.Stat ) >= def.LevelTargets[level];
	}

	/// <summary>
	/// Collects the current level reward for a quest. Advances the quest level and resets the
	/// tracking stat to zero — overflow is always discarded. Returns false if the quest is not
	/// ready to collect.
	/// </summary>
	public static bool CollectQuestReward( QuestId id )
	{
		if ( !IsQuestReadyToCollect( id ) ) return false;

		var def = GetDef( id );
		int level = GetQuestLevel( id );
		int reward = def.RewardAmounts[level];
		_data.Stats[def.Stat] = 0f;
		_data.QuestLevels[id] = level + 1;
		_data.Coins += reward;
		StateVersion++;
		Save(); // immediate save on reward collection
		return true;
	}

	/// <summary>
	/// Returns (current, target) for the active level of the quest. If completed, returns (lastTarget, lastTarget).
	/// </summary>
	public static (float current, int target) GetQuestProgress( QuestId id )
	{
		var def = GetDef( id );
		int level = GetQuestLevel( id );
		if ( level >= def.LevelTargets.Length )
		{
			int last = def.LevelTargets[^1];
			return (last, last);
		}
		return (GetStat( def.Stat ), def.LevelTargets[level]);
	}

	public static int GetCurrentRewardAmount( QuestId id )
	{
		var def = GetDef( id );
		int level = GetQuestLevel( id );
		if ( level >= def.RewardAmounts.Length ) return def.RewardAmounts[^1];
		return def.RewardAmounts[level];
	}

	// Achievement definitions — defined in code, never serialized.
	// Run snapshot — captures stat values at run start so we can diff at run end.
	private static Dictionary<ProgressStat, float> _runSnapshot = new();
	public static HashSet<string> AchievementsUnlockedThisRun { get; private set; } = new();

	public static void TakeRunSnapshot()
	{
		_runSnapshot.Clear();
		AchievementsUnlockedThisRun.Clear();
		if ( _data == null ) Load();
		foreach ( var kvp in _data.Stats )
			_runSnapshot[kvp.Key] = kvp.Value;
	}

	public static float GetStatGainedThisRun( ProgressStat stat )
	{
		var current = GetStat( stat );
		var snapshot = _runSnapshot.TryGetValue( stat, out float v ) ? v : 0f;
		return Math.Max( 0f, current - snapshot );
	}

	private static AchievementDef[] _achievements;
	public static AchievementDef[] Achievements => _achievements ??= BuildAchievements();

	// BuildAchievements() is defined in AchievementDefs.cs

	/// <summary>
	/// Marks an achievement as unlocked and ready to collect. Does nothing if already claimed.
	/// Call this from gameplay code when the achievement condition is met.
	/// </summary>
	public static void UnlockAchievement( string name )
	{
		if ( _data == null ) Load();
		if ( _data.ClaimedAchievements.Contains( name ) ) return;
		if ( _data.UnlockedAchievements.Add( name ) )
		{
			AchievementsUnlockedThisRun.Add( name );
			StateVersion++;
			Save();
		}
	}

	public static bool IsAchievementUnlocked( string name )
	{
		if ( _data == null ) Load();
		return _data.UnlockedAchievements.Contains( name );
	}

	public static bool IsAchievementClaimed( string name )
	{
		if ( _data == null ) Load();
		return _data.ClaimedAchievements.Contains( name );
	}

	/// <summary>
	/// Collects the reward for an unlocked achievement. Returns false if not yet unlocked or already claimed.
	/// </summary>
	public static bool ClaimAchievementReward( string name )
	{
		if ( _data == null ) Load();
		if ( !_data.UnlockedAchievements.Contains( name ) ) return false;
		if ( _data.ClaimedAchievements.Contains( name ) ) return false;
		var achDef = Achievements.FirstOrDefault( a => a.Name == name );
		int reward = achDef.Name != null ? achDef.RewardAmount : 0;
		_data.UnlockedAchievements.Remove( name );
		_data.ClaimedAchievements.Add( name );
		_data.Coins += reward;
		StateVersion++;
		Save();
		return true;
	}

	public static void DebugClearOwnedByCategory( ShopItemCategory category )
	{
		if ( _data == null ) Load();
		var ids = ShopItems.Where( d => d.Category == category ).Select( d => d.Id ).ToHashSet();
		_data.OwnedShopItems.ExceptWith( ids );
		if ( category == ShopItemCategory.Gem )
		{
			_data.GemUpgradeLevels.Clear();
			_data.EquippedGems.RemoveAll( id => ids.Contains( id ) );
		}
		StateVersion++;
		Save();
	}

	public static void DebugResetQuestLevels()
	{
		if ( _data == null ) Load();
		_data.QuestLevels.Clear();
		_data.UnlockedAchievements.Clear();
		_data.ClaimedAchievements.Clear();
		StateVersion++;
		Save();
	}

	public static void DebugResetStats()
	{
		if ( _data == null ) Load();
		_data.Stats.Clear();
		StateVersion++;
		Save();
	}

	public static IEnumerable<QuestDef> GetQuestsForTab( int tab )
		=> Quests.Where( d => d.Tab == tab );

	private static QuestDef GetDef( QuestId id )
		=> Quests.First( d => d.Id == id );

	// ── Coins ──────────────────────────────────────────────────────────────

	public static int GetCoins()
	{
		if ( _data == null ) Load();
		return _data.Coins;
	}

	public static void AddCoins( int amount )
	{
		if ( _data == null ) Load();
		_data.Coins += amount;
		_isDirty = true;
		StateVersion++;
	}

	// ── Shop ────────────────────────────────────────────────────────────────

	public static string GetItemRichTextToken( string id ) => $"[item:{id}]";

	private static ShopItemDef[] _shopItems;
	public static ShopItemDef[] ShopItems => _shopItems ??= BuildShopItems();

	// BuildGunItems(), BuildCharmItems(), BuildGemItems() are defined in GunDefs.cs, CharmDefs.cs, GemDefs.cs
	private static ShopItemDef[] BuildShopItems()
	{
		var items = BuildGunItems().Concat( BuildCharmItems() ).Concat( BuildGemItems() ).ToArray();
		var duplicates = items.GroupBy( d => d.Id ).Where( g => g.Count() > 1 ).Select( g => g.Key ).ToList();
		foreach ( var id in duplicates )
			Log.Error( $"ShopItemDef duplicate ID detected: \"{id}\"" );
		return items;
	}

	[ConCmd( "reload_shop_item_defs" )]
	public static void ReloadShopItemDefs()
	{
		_shopItems = null;
		StateVersion++;
		Log.Info( $"Shop item defs reloaded ({ShopItems.Length} items)" );
	}

	public static bool IsItemOwned( string id )
	{
		if ( _data == null ) Load();
		return _data.OwnedShopItems.Contains( id );
	}

	/// <summary>
	/// Attempts to purchase a shop item. Deducts coins and marks as owned.
	/// Returns false if already owned or can't afford it.
	/// </summary>
	public static bool BuyItem( string id )
	{
		if ( _data == null ) Load();
		if ( _data.OwnedShopItems.Contains( id ) ) return false;

		var def = ShopItems.FirstOrDefault( d => d.Id == id );
		if ( def.Id == null ) return false;
		if ( _data.Coins < def.Price ) return false;

		_data.Coins -= def.Price;
		_data.OwnedShopItems.Add( id );
		StateVersion++;
		Save();
		return true;
	}

	/// <summary>Returns the number of shop items the player owns in the given category.</summary>
	public static int GetOwnedCountByCategory( ShopItemCategory category )
	{
		if ( _data == null ) Load();
		return ShopItems.Count( d => d.Category == category && _data.OwnedShopItems.Contains( d.Id ) );
	}

	/// <summary>
	/// Returns true when the player has bought enough items in the item's category
	/// to satisfy its RequiredPurchases gate. Always true when RequiredPurchases == 0.
	/// </summary>
	public static bool IsItemUnlocked( string id )
	{
		var def = ShopItems.FirstOrDefault( d => d.Id == id );
		if ( def.Id == null || def.RequiredPurchases == 0 ) return true;
		return GetOwnedCountByCategory( def.Category ) >= def.RequiredPurchases;
	}

	/// <summary>
	/// Returns how many more purchases in the same category are needed before this
	/// item unlocks. Returns 0 if already unlocked.
	/// </summary>
	public static int GetPurchasesNeeded( string id )
	{
		var def = ShopItems.FirstOrDefault( d => d.Id == id );
		if ( def.Id == null || def.RequiredPurchases == 0 ) return 0;
		return Math.Max( 0, def.RequiredPurchases - GetOwnedCountByCategory( def.Category ) );
	}

	// ── Gems ────────────────────────────────────────────────────────────────

	/// <summary>Returns 1 = base level (Lv1), 2+ = upgraded. Returns 0 if not owned.</summary>
	public static int GetGemDisplayLevel( string id )
	{
		int raw = GetGemLevel( id );
		return raw < 0 ? 0 : raw + 1;
	}

	/// <summary>Returns 0 = base level, 1+ = upgraded. Returns -1 if not owned.</summary>
	public static int GetGemLevel( string id )
	{
		if ( _data == null ) Load();
		if ( !_data.OwnedShopItems.Contains( id ) ) return -1;
		return _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0;
	}

	/// <summary>Returns the upgrade cost at the gem's current level, or null if not upgradeable or already maxed.</summary>
	public static int? GetGemUpgradeCost( string id )
	{
		var def = ShopItems.FirstOrDefault( d => d.Id == id );
		if ( def.Id == null || def.UpgradePrices == null || def.UpgradePrices.Length == 0 ) return null;
		int level = GetGemLevel( id );
		if ( level < 0 || level >= def.UpgradePrices.Length ) return null;
		return def.UpgradePrices[level];
	}

	public static bool IsGemMaxed( string id )
	{
		if ( !IsItemOwned( id ) ) return false;
		return GetGemUpgradeCost( id ) == null;
	}

	/// <summary>Upgrades a gem by one level. Deducts coins. Returns false if not upgradeable or can't afford.</summary>
	public static bool UpgradeGem( string id )
	{
		if ( _data == null ) Load();
		int? cost = GetGemUpgradeCost( id );
		if ( cost == null || _data.Coins < cost.Value ) return false;
		_data.Coins -= cost.Value;
		_data.GemUpgradeLevels[id] = ( _data.GemUpgradeLevels.TryGetValue( id, out int v ) ? v : 0 ) + 1;
		StateVersion++;
		Save();
		return true;
	}

	public static List<string> GetEquippedGems()
	{
		if ( _data == null ) Load();
		return _data.EquippedGems ?? ( _data.EquippedGems = new List<string>() );
	}

	public static List<string> GetEffectiveEquippedGems()
	{
		return Manager.HideProgressionSystem ? [] : GetEquippedGems();
	}

	public static int GetSelectedGunSocketCount()
	{
		var gunId = GetSelectedGunId();
		if ( gunId == DefaultGun.Id ) return DefaultGun.GemSocketCount > 0 ? DefaultGun.GemSocketCount : 3;
		var count = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun ).GemSocketCount;
		return count > 0 ? count : 3;
	}

	public static bool EquipGem( string id, int maxCount = 3 )
	{
		if ( _data == null ) Load();
		if ( !IsItemOwned( id ) ) return false;
		var equipped = GetEquippedGems();
		if ( equipped.Contains( id ) ) return false;
		if ( equipped.Count >= maxCount ) return false;
		equipped.Add( id );
		StateVersion++;
		_isDirty = true;
		return true;
	}

	public static bool UnequipGem( string id )
	{
		if ( _data == null ) Load();
		var equipped = GetEquippedGems();
		if ( !equipped.Remove( id ) ) return false;
		StateVersion++;
		_isDirty = true;
		return true;
	}

	// ── Loadout ─────────────────────────────────────────────────────────────

	public static string GetSelectedGunId()
	{
		if ( _data == null ) Load();
		return string.IsNullOrEmpty( _data.SelectedGunId ) ? "gun_default" : _data.SelectedGunId;
	}

	public static string GetEffectiveSelectedGunId()
	{
		return Manager.HideProgressionSystem ? DefaultGun.Id : GetSelectedGunId();
	}

	public static void SetSelectedGunId( string id )
	{
		if ( _data == null ) Load();
		_data.SelectedGunId = id;
		StateVersion++;
		_isDirty = true;
	}

	public static string GetSelectedCharmId()
	{
		var ids = GetSelectedCharmIds();
		return ids.Count > 0 ? ids[0] : "";
	}

	public static void SetSelectedCharmId( string id )
	{
		SetSelectedCharmIds( string.IsNullOrEmpty( id ) ? [] : [id] );
	}

	public static List<string> GetSelectedCharmIds()
	{
		if ( _data == null ) Load();
		return _data.SelectedCharmIds ??= [];
	}

	public static List<string> GetEffectiveSelectedCharmIds()
	{
		return Manager.HideProgressionSystem ? [] : GetSelectedCharmIds();
	}

	public static void SetSelectedCharmIds( List<string> ids )
	{
		if ( _data == null ) Load();
		_data.SelectedCharmIds = ids ?? [];
		_data.SelectedCharmId = _data.SelectedCharmIds.Count > 0 ? _data.SelectedCharmIds[0] : "";
		StateVersion++;
		_isDirty = true;
	}

	public static int GetSelectedGunCharmSlotCount()
	{
		var gunId = GetSelectedGunId();
		ShopItemDef def;
		if ( gunId == DefaultGun.Id )
			def = DefaultGun;
		else
			def = ShopItems.FirstOrDefault( x => x.Id == gunId && x.Category == ShopItemCategory.Gun );
		return def.CharmSlotCount > 0 ? def.CharmSlotCount : 1;
	}

	public static bool IsLockedPerkUnlocked( TypeDescription perkType )
	{
		if ( _data == null ) Load();
		return _data.UnlockedLockedPerks.Contains( perkType.FullName );
	}

	public static void UnlockLockedPerk( TypeDescription perkType )
	{
		if ( _data == null ) Load();
		if ( _data.UnlockedLockedPerks.Add( perkType.FullName ) )
		{
			StateVersion++;
			Save();
		}
	}

	public static void RelockLockedPerk( TypeDescription perkType )
	{
		if ( _data == null ) Load();
		if ( _data.UnlockedLockedPerks.Remove( perkType.FullName ) )
		{
			StateVersion++;
			Save();
		}
	}

	public static void RelockAllLockedPerks()
	{
		if ( _data == null ) Load();
		if ( _data.UnlockedLockedPerks.Count == 0 ) return;
		_data.UnlockedLockedPerks.Clear();
		StateVersion++;
		Save();
	}

	/// <summary>
	/// Returns all items in a category that the player owns.
	/// </summary>
	public static List<ShopItemDef> GetOwnedItemsByCategory( ShopItemCategory category )
	{
		return ShopItems
			.Where( d => d.Category == category && IsItemOwned( d.Id ) )
			.ToList();
	}

	/// <summary>
	/// Looks up the prefab path for a shop item by its id. Returns null if not found.
	/// </summary>
	public static string GetPrefabPath( string id )
	{
		if ( string.IsNullOrEmpty( id ) ) return null;
		var def = ShopItems.FirstOrDefault( d => d.Id == id );
		return def.Id != null ? def.PrefabPath : null;
	}
}