using System; using System.Collections.Generic; using System.IO; using KernelPanic.Data; using UnityEngine; namespace KernelPanic.Meta { /// /// Provides save and load contracts for persistent meta progression. /// public sealed class SaveService { private const string SaveFileName = "kernel-panic-save.json"; public string SavePath => Path.Combine(Application.persistentDataPath, SaveFileName); public void Save(SaveData data) { data ??= new SaveData(); data.EnsureLists(); string directory = Path.GetDirectoryName(SavePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } string json = JsonUtility.ToJson(data, true); File.WriteAllText(SavePath, json); } public SaveData Load() { if (!File.Exists(SavePath)) { return SaveData.CreateDefault(); } try { SaveData data = JsonUtility.FromJson(File.ReadAllText(SavePath)); data ??= SaveData.CreateDefault(); data.EnsureLists(); return data; } catch (Exception ex) { Debug.LogWarning($"Save file could not be loaded; starting fresh. {ex.Message}"); return SaveData.CreateDefault(); } } } /// /// Stores persistent progression data for serialization. /// [Serializable] public sealed class SaveData { public bool starterChosen; public int entropyBalance; public int rootCredits = GachaService.TestRootCreditsBalance; public int commitsBalance; public int bandwidthBalance; // Legacy conflated balance. Older builds used this for both Commits and Bandwidth. // Migration preserves it as Bandwidth and starts Commits at 0 because the source is ambiguous. public int standardPullCurrency; // Legacy pull-token field kept only so older saves deserialize cleanly. public int limitedPullCurrency; public int cacheBalance; // Legacy per-pull package-dupe pool. Package dupes now auto-scrap directly to Cache. public int packageMerges; // Legacy global balance kept only so older saves deserialize cleanly. // New duplicate-pull merges live on OwnedUnitSaveEntry.merges. public int merges; public List ownedUnits = new(); public List ownedPackages = new(); public List ownedPackageIds = new(); public List packageLoadouts = new(); public List ownedUnitIds = new(); public List bannerPoolIds = new(); public GachaBannerState beginnerBannerState = new(GachaService.BeginnerBannerId); public LastRunLoadoutSaveEntry lastRunLoadout = new(); public string lastRunDistroId; public List distroBestWaves = new(); // Populated by NormalizeOwnedPackages when a save contains more than one instance of the // same package id (packages are unique-per-type). Not serialized: the owner (bootstrap code // with access to PackageDatabase) consumes this once per load to grant Cache for the extras, // then clears it. See MainMenuController.LoadMetaState. [NonSerialized] public List collapsedPackageDuplicates = new(); public static SaveData CreateDefault() { return new SaveData(); } public void EnsureLists() { ownedUnits ??= new List(); ownedPackages ??= new List(); ownedPackageIds ??= new List(); packageLoadouts ??= new List(); ownedUnitIds ??= new List(); bannerPoolIds ??= new List(); distroBestWaves ??= new List(); beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId); beginnerBannerState.bannerId = GachaService.BeginnerBannerId; beginnerBannerState.EnsureLists(); lastRunLoadout ??= new LastRunLoadoutSaveEntry(); lastRunLoadout.EnsureLists(); if (string.IsNullOrWhiteSpace(lastRunDistroId) && !string.IsNullOrWhiteSpace(lastRunLoadout.distroId)) { lastRunDistroId = lastRunLoadout.distroId; } MigrateLegacyConflatedCurrency(); entropyBalance = Math.Max(0, entropyBalance); rootCredits = Math.Max(0, rootCredits); commitsBalance = Math.Max(0, commitsBalance); bandwidthBalance = Math.Max(0, bandwidthBalance); merges = Math.Max(0, merges); packageMerges = Math.Max(0, packageMerges); cacheBalance = Math.Max(0, cacheBalance); MigrateLegacyOwnedUnitIds(); MigrateLegacyOwnedPackageIds(); NormalizeOwnedUnits(); NormalizeOwnedPackages(); NormalizePackageLoadouts(); NormalizeDistroBestWaves(); MigrateLegacyGlobalMerges(); } private void MigrateLegacyConflatedCurrency() { if (standardPullCurrency <= 0) { return; } if (bandwidthBalance <= 0) { bandwidthBalance = Math.Max(0, standardPullCurrency); Debug.Log("Migrated legacy standardPullCurrency to Bandwidth. Commits defaulted to 0 because the old field conflated pull tokens and upgrade currency."); } standardPullCurrency = 0; limitedPullCurrency = 0; } public OwnedUnitSaveEntry FindOwnedUnit(string id) { if (string.IsNullOrWhiteSpace(id)) { return null; } EnsureLists(); for (int i = 0; i < ownedUnits.Count; i++) { OwnedUnitSaveEntry entry = ownedUnits[i]; if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase)) { return entry; } } return null; } public bool IsUnitOwned(string id) { return FindOwnedUnit(id) != null; } public OwnedUnitSaveEntry AddOwnedUnit(string id, int version) { if (string.IsNullOrWhiteSpace(id)) { return null; } EnsureLists(); OwnedUnitSaveEntry existing = FindOwnedUnit(id); if (existing != null) { existing.version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, existing.version)); return existing; } OwnedUnitSaveEntry entry = new() { id = id, version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, version)) }; ownedUnits.Add(entry); return entry; } private void MigrateLegacyOwnedUnitIds() { for (int i = 0; i < ownedUnitIds.Count; i++) { string id = ownedUnitIds[i]; if (string.IsNullOrWhiteSpace(id) || HasOwnedUnitEntry(id)) { continue; } ownedUnits.Add(new OwnedUnitSaveEntry { id = id, version = 1 }); } } private void NormalizeOwnedUnits() { HashSet seen = new(StringComparer.OrdinalIgnoreCase); for (int i = ownedUnits.Count - 1; i >= 0; i--) { OwnedUnitSaveEntry entry = ownedUnits[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.id) || !seen.Add(entry.id)) { ownedUnits.RemoveAt(i); continue; } entry.version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, entry.version)); entry.merges = Math.Max(0, entry.merges); } } private void NormalizeOwnedPackages() { collapsedPackageDuplicates.Clear(); Dictionary highestById = new(StringComparer.OrdinalIgnoreCase); Dictionary extraCountById = new(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < ownedPackages.Count; i++) { OwnedPackageSaveEntry entry = ownedPackages[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.id)) { continue; } entry.upgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, entry.upgradeLevel)); if (highestById.TryGetValue(entry.id, out OwnedPackageSaveEntry existing)) { extraCountById[entry.id] = extraCountById.TryGetValue(entry.id, out int count) ? count + 1 : 1; if (entry.upgradeLevel > existing.upgradeLevel) { highestById[entry.id] = entry; } } else { highestById[entry.id] = entry; } } ownedPackages = new List(highestById.Values); foreach (KeyValuePair extra in extraCountById) { collapsedPackageDuplicates.Add(new CollapsedPackageDuplicate(extra.Key, extra.Value)); } ownedPackageIds.Clear(); for (int i = 0; i < ownedPackages.Count; i++) { ownedPackageIds.Add(ownedPackages[i].id); } } public int GetBestWave(string distroId) { if (string.IsNullOrWhiteSpace(distroId)) { return 0; } EnsureLists(); for (int i = 0; i < distroBestWaves.Count; i++) { DistroBestWaveSaveEntry entry = distroBestWaves[i]; if (entry != null && string.Equals(entry.distroId, distroId, StringComparison.OrdinalIgnoreCase)) { return Math.Max(0, entry.bestWave); } } return 0; } public void RecordRunStats(string distroId, int waveReached) { if (string.IsNullOrWhiteSpace(distroId)) { return; } EnsureLists(); lastRunDistroId = distroId; lastRunLoadout ??= new LastRunLoadoutSaveEntry(); lastRunLoadout.distroId = distroId; int safeWave = Math.Max(0, waveReached); for (int i = 0; i < distroBestWaves.Count; i++) { DistroBestWaveSaveEntry entry = distroBestWaves[i]; if (entry != null && string.Equals(entry.distroId, distroId, StringComparison.OrdinalIgnoreCase)) { entry.bestWave = Math.Max(entry.bestWave, safeWave); return; } } distroBestWaves.Add(new DistroBestWaveSaveEntry { distroId = distroId, bestWave = safeWave }); } private void NormalizeDistroBestWaves() { HashSet seen = new(StringComparer.OrdinalIgnoreCase); for (int i = distroBestWaves.Count - 1; i >= 0; i--) { DistroBestWaveSaveEntry entry = distroBestWaves[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.distroId) || !seen.Add(entry.distroId)) { distroBestWaves.RemoveAt(i); continue; } entry.bestWave = Math.Max(0, entry.bestWave); } } private void MigrateLegacyOwnedPackageIds() { for (int i = 0; i < ownedPackageIds.Count; i++) { string id = ownedPackageIds[i]; if (string.IsNullOrWhiteSpace(id) || HasOwnedPackageEntry(id)) { continue; } ownedPackages.Add(new OwnedPackageSaveEntry { id = id, upgradeLevel = 0 }); } } private bool HasOwnedPackageEntry(string id) { for (int i = 0; i < ownedPackages.Count; i++) { OwnedPackageSaveEntry entry = ownedPackages[i]; if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private void NormalizePackageLoadouts() { HashSet seenDistros = new(StringComparer.OrdinalIgnoreCase); for (int i = packageLoadouts.Count - 1; i >= 0; i--) { PackageLoadoutSaveEntry entry = packageLoadouts[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.distroId) || !seenDistros.Add(entry.distroId)) { packageLoadouts.RemoveAt(i); continue; } entry.EnsureLists(); } } private void MigrateLegacyGlobalMerges() { if (merges <= 0 || ownedUnits.Count == 0) { return; } bool hasSpecificMerges = false; for (int i = 0; i < ownedUnits.Count; i++) { if (ownedUnits[i] != null && ownedUnits[i].merges > 0) { hasSpecificMerges = true; break; } } if (!hasSpecificMerges) { ownedUnits[0].merges = Math.Max(0, ownedUnits[0].merges) + merges; merges = 0; } } private bool HasOwnedUnitEntry(string id) { for (int i = 0; i < ownedUnits.Count; i++) { OwnedUnitSaveEntry entry = ownedUnits[i]; if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } } [Serializable] public sealed class OwnedUnitSaveEntry { public string id; public int version = 1; public int merges; } [Serializable] public sealed class OwnedPackageSaveEntry { public string id; public int upgradeLevel; } public sealed class CollapsedPackageDuplicate { public CollapsedPackageDuplicate(string id, int extraCount) { Id = id; ExtraCount = extraCount; } public string Id { get; } public int ExtraCount { get; } } [Serializable] public sealed class LastRunLoadoutSaveEntry { public string distroId; public List cardIds = new(); public void EnsureLists() { cardIds ??= new List(); } } [Serializable] public sealed class DistroBestWaveSaveEntry { public string distroId; public int bestWave; } [Serializable] public sealed class PackageLoadoutSaveEntry { public string distroId; public List slots = new(); public void EnsureLists() { slots ??= new List(); HashSet seen = new(); for (int i = slots.Count - 1; i >= 0; i--) { PackageLoadoutSlotSaveEntry slot = slots[i]; if (slot == null || string.IsNullOrWhiteSpace(slot.packageId) || !seen.Add(slot.slot)) { slots.RemoveAt(i); } } } } [Serializable] public sealed class PackageLoadoutSlotSaveEntry { public PackageSlot slot; public string packageId; } }