Game/PersistentData/PlayerSave.cs

PlayerSave class that stores and persists player progression and shop state to FileSystem.Data as JSON. It tracks fragments, lifetime stats, owned/active modifiers and bombs, consumables, pet XP/level, tutorials, and provides methods to load/save, record runs, buy/toggle items, and update daily fragment counters.

File Access
// PlayerSave.cs
using Sandbox;
using System.Text.Json;
using System.Collections.Generic;

/// <summary>
/// Handles persistent player data across runs.
/// Saved to FileSystem.Data as JSON.
/// </summary>
public class PlayerSave
{
    // ── Currency ─────────────────────────────────────────────
    public int TotalFragments { get; set; } = 0;
    // ── Lifetime stats ────────────────────────────────────────
    public int BestScore         { get; set; } = 0;
    public int BestWave          { get; set; } = 0;
    public int TotalRuns         { get; set; } = 0;
    public int TotalChestsCleared { get; set; } = 0;
    public int TotalChallengesCompleted { get; set; } = 0;
    public float TotalMinutesPlayed { get; set; } = 0f;
    public bool PassiveIdleGridActivated {get; set; } = false;

    // ── Save / Load ───────────────────────────────────────────
    const string SavePath = "player_save.json";

    // Today Fragments (from pet farm) ──────────────────────────
    public int TodayFragments { get; set; } = 0;
    public int TodayIdleFragments { get; set; } = 0;  // idle farm only
    public string LastPlayDate { get; set; } = "";

    // Shop stuff ───────────────────────────────────────────────
    public List<string> OwnedModifiers {get; set;} = new();
    public List<string> ActiveModifiers {get; set;} = new();
    public bool OwnsModifier(ModifierId id) => OwnedModifiers.Contains(id.ToString());
    public static readonly int MaxActiveModifiers = 3;

    // Heart consumable
    public int MaxHeartConsumables =>
    PetLevel >= 2 ? 1 : 2; // pet lv2 already uses one life slot (used for shop heart consumable limit)

    // Trial items ───────────────────────────────────────────────
    public int TrialBombsRunsLeft { get; set; } = 3; // counts down each run
    public bool IsInTrial => TrialBombsRunsLeft > 0;
    
    // Welcome Gifts ─────────────────────────────────────────────
    public bool WelcomeShown { get; set; } = false;
    public int WelcomeChestsOpened { get; set; } = 0;

    // Player Save ───────────────────────────────────────────────
    public bool TutorialCompleted { get; set; } = false;
    public bool TutorialChainStepDone { get; set; } = false;

    // Game Over / Leaderboard stats ────────────────────────────
    public int BestChainLength { get; set; } = 0;

    // Pet System ───────────────────────────────────────────────
    public string PetCustomName    { get; set; } = "";
    public bool   PetUnlockShown   { get; set; } = false;
    public bool   PendingEvolution { get; set; } = false;
    public int PetLevel { get; set; } = 1;

    // ── Set PendingEvolution = true in PlayerSave wherever you level up the pet ──
    // That way the flag persists across scene loads — even if the menu
    // reloads between run end and menu return, the cinematic still triggers.

    // ────────── 100 TOP CONTpublic bool TutorialChainStepDone { get; set; } = false;tutorRIBUTOR GLOBAL XP BAR  ────────────
    public long ForceBaseline  { get; set; } = 0L;
    public int  ForceFillCount { get; set; } = 0;
    public int  ForceMedalTier { get; set; } = 0;

    // Main Menu Tutorial ────────────────────────────────────────
    public bool MenuTutorialCompleted { get; set; } = false;
    public bool TutorialHeartFragsGranted { get; set; } = false;
    public int MenuTutorialStep { get; set; } = 0;

    public bool SurvivalTutorialCompleted { get; set; } = false;

    public bool ModifierActive(ModifierId id)
    {
        // Bomb slots auto-apply when owned, no need to be in ActiveModifiers
        if(id == ModifierId.BombSlot2 || id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4)
            return OwnsModifier(id);
        
        return ActiveModifiers.Contains(id.ToString());
    }

    public static PlayerSave Load()
    {
        try
        {
            if(FileSystem.Data.FileExists(SavePath))
            {
                string json = FileSystem.Data.ReadAllText(SavePath);
                var save = JsonSerializer.Deserialize<PlayerSave>(json) ?? new PlayerSave();
                   
                // Bomb slots auto-apply now, don't keep them in ActiveModifiers
                save.ActiveModifiers?.RemoveAll(m => m == "BombSlot3" || m == "BombSlot4");
                
                return save;
            }
        }
        catch(System.Exception e)
        {
            Log.Warning($"PlayerSave.Load failed: {e.Message}");
        }

        // ── First time player — 
        return new PlayerSave();
    }

    public void Save()
    {
        try
        {
            string json = JsonSerializer.Serialize( this, new JsonSerializerOptions { WriteIndented = true } );
            FileSystem.Data.WriteAllText( SavePath, json );
        }
        catch ( System.Exception e )
        {
            Log.Warning( $"PlayerSave.Save failed: {e.Message}" );
        }
    }

    /// <summary>
    /// Call at end of each run to update lifetime stats and save.
    /// </summary>
    public void RecordRun( int score, int wave, int chestsCleared, 
    int challengesCompleted, int fragmentsEarned, float minutesPlayed, int bestChainLength)
    {
        TotalRuns++;
        // at least need to reach wave 10 to get xp. Get more if you go beyond that.
        if (wave >= 10)
        {
            int xp = wave switch {
                < 15 => 1,   // wave 10-14 = 1 XP
                < 20 => 2,   // wave 15-19 = 2 XP
                < 30 => 3,   // wave 20-29 = 3 XP
                < 50 => 4,   // wave 30-49 = 4 XP
                _    => 5    // wave 50+   = 5 XP — cap here, even big bois grind
            };

            AddPetXp(xp);
            
        }
        TotalChestsCleared       += chestsCleared;
        TotalChallengesCompleted += challengesCompleted;
        TotalMinutesPlayed       += minutesPlayed;
        BestChainLength = bestChainLength;

        AddActiveFragments(fragmentsEarned);

        // Level 1 pet bonus — +5 frags per 20 waves survived
        if (PetLevel >= 1 && wave >= 20)
        {
            int petFragBonus = (wave / 20) * 5;
            AddActiveFragments(petFragBonus);
        }

        if ( score > BestScore ) BestScore = score;
        if ( wave  > BestWave  ) BestWave  = wave;

        // Trial (bombs)
        if(TrialBombsRunsLeft > 0)
            TrialBombsRunsLeft--;


        // persists immediately
        Save();
    }

   // Call this at the start of any fragment-adding operation
    void EnsureDayReset()
    {
        string today = System.DateTime.UtcNow.ToString("yyyy-MM-dd");
        if(LastPlayDate != today)
        {
            LastPlayDate       = today;
            TodayFragments     = 0;
            TodayIdleFragments = 0;
            // don't Save() here — the caller will save after updating amounts
        }
    }

    // Called by idle farm only
    public void AddIdleFragments(int amount)
    {
        EnsureDayReset();
        TodayIdleFragments += amount;
        TotalFragments     += amount;
        Save();
    }

    // Called by active play (RecordRun, boss kill, secret chest)
    public void AddActiveFragments(int amount)
    {
        EnsureDayReset();
        TodayFragments += amount;
        TotalFragments += amount;
        Save();
    }

    public int GetTodayIdleFragments()
    {
        EnsureDayReset();
        return TodayIdleFragments;
    }

    public int GetTodayActiveFragments()
    {
        EnsureDayReset();
        return TodayFragments;
    }

    // -------------------------------------------------------------------------------- //
    // --------------------------------- *Shop stuff* --------------------------------- // 
    // -------------------------------------------------------------------------------- //

  public bool TryBuyModifier(ModifierId id, int cost)
    {
        if(OwnsModifier(id)) return false;
        if(TotalFragments < cost) return false;
        TotalFragments -= cost;
        OwnedModifiers.Add(id.ToString());
        
        // Bomb slots auto-apply, don't add to ActiveModifiers (don't count toward cap)
        bool isBombSlot = id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4;

        if(!isBombSlot)
            ActiveModifiers.Add(id.ToString());
        
        Save();
        return true;
    }

    public void ToggleModifier(ModifierId id)
    {
        // Bomb slots can't be toggled — they're permanent once owned
        if(id == ModifierId.BombSlot2 || id == ModifierId.BombSlot3 || id == ModifierId.BombSlot4)
            return;
        
        string key = id.ToString();
        if(ActiveModifiers.Contains(key))
            ActiveModifiers.Remove(key);
        else
        {
            if(ActiveModifiers.Count >= MaxActiveModifiers) return;
            ActiveModifiers.Add(key);
        }
        Save();
    }


   public int MaxIdleFragsPerDay => PetLevel switch
    {
        1 => 80,
        2 => 120,
        3 => 180,
        4 => 250,
        5 => 350,
        6 => 500,
        _ => 500
    };

    // BOMB TYPES
    public List<string> OwnedBombs { get; set; } = new();

    public bool OwnsBomb(GridManager.BombType type) => OwnedBombs.Contains(type.ToString());

    public bool TryBuyBomb(GridManager.BombType type, int cost)
    {
        if(OwnsBomb(type)) return false;
        if(TotalFragments < cost) return false;
        TotalFragments -= cost;
        OwnedBombs.Add(type.ToString());
        Save();
        return true;
    }

    // FOR DEBUG PURPOSES 
    public static void DEBUG_WipeSave()
    {
        if(FileSystem.Data.FileExists(SavePath))
            FileSystem.Data.DeleteFile(SavePath);
    }

    // ── Consumables ───────────────────────────────────────────────
    // Stores count of each consumable owned — key is ConsumableId.ToString()
    public Dictionary<string, int> OwnedConsumables { get; set; } = new();

    public int GetConsumableCount(ConsumableId id) =>
        OwnedConsumables.TryGetValue(id.ToString(), out int count) ? count : 0;

    public bool TryBuyConsumable(ConsumableId id, int cost)
    {
        if (TotalFragments < cost) return false;
        TotalFragments -= cost;
        string key = id.ToString();
        if (!OwnedConsumables.ContainsKey(key))
            OwnedConsumables[key] = 0;
        OwnedConsumables[key]++;
        Save();
        return true;
    }

    public bool TryUseConsumable(ConsumableId id)
    {
        string key = id.ToString();
        if (!OwnedConsumables.ContainsKey(key) || OwnedConsumables[key] <= 0) return false;
        OwnedConsumables[key]--;
        Save();
        return true;
    }

    // Max 3 consumables carried into a run (any mix)
    public int TotalConsumablesOwned => OwnedConsumables.Values.Sum();


    // ── PET SYSTEM ───────────────────────────────────────────────

    // Pet XP — gained from playing runs
    public int PetXp { get; set; } = 0;

   public void AddPetXp(int runs)
    {
        PetXp += runs;
        while (PetLevel < 6 && PetXp >= GetXpNeeded(PetLevel))
        {
            PetXp -= GetXpNeeded(PetLevel);
            PetLevel++;
            PendingEvolution = true;
        }
        Save();
    }

   public static int GetXpNeeded(int level) => level switch
    {
        1 => 50,
        2 => 150,
        3 => 400,
        4 => 800,
        5 => 1000,
        _ => 2000   // level 6 = maxed, no further XP needed (or keep growing if you want)
    };


    public void ConsumeConsumable(ConsumableId id)
    {
        string key = id.ToString();
        if(OwnedConsumables.ContainsKey(key) && OwnedConsumables[key] > 0)
        {
            OwnedConsumables[key]--;
            Save();
        }
    }
    
}