using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using UnityEngine; namespace BumpCombat.Progression { public static class MissionIds { public const string FirstSweep = "mission.first_sweep"; public const string GrowthFooting = "mission.growth_footing"; public const string BodyBreakthrough = "mission.body_breakthrough"; public const string ArtifactUse = "mission.artifact_use"; public const string RearAttack = "mission.rear_attack"; public const string CrowdControl = "mission.crowd_control"; public const string CancelAttack = "mission.cancel_attack"; public const string GuardDefense = "mission.guard_defense"; public const string MatchShield = "mission.match_shield"; public const string ChargedMoment = "mission.charged_moment"; public const string Rampage = "mission.rampage"; } [Serializable] public sealed class UserProfile { public int schemaVersion = UserProfileStore.CurrentSchemaVersion; public List missionTotals = new(); public List achievements = new(); public List unlockedSkinIds = new(); public string selectedSkinId = string.Empty; } [Serializable] public sealed class UserProfileMissionTotal { public string missionId; public int completions; } [Serializable] public sealed class UserProfileAchievementState { public string achievementId; public bool unlocked; } public readonly struct SteamIntegerStatSnapshot { public SteamIntegerStatSnapshot(string apiName, int value) { ApiName = apiName; Value = value; } public string ApiName { get; } public int Value { get; } } public readonly struct SteamAchievementDefinition { public SteamAchievementDefinition(string achievementId, string steamApiName) { AchievementId = achievementId; SteamApiName = steamApiName; } public string AchievementId { get; } public string SteamApiName { get; } } /// Local versioned profile persistence and Steam INT stat mapping only. public sealed class UserProfileStore { public const int CurrentSchemaVersion = 1; public const int MaximumSteamIntegerStat = int.MaxValue; public const string DefaultFileName = "user-profile.json"; private static readonly (string MissionId, string ApiName)[] SteamIntegerMappings = { (MissionIds.FirstSweep, "bc_mission_first_sweep_completions"), (MissionIds.GrowthFooting, "bc_mission_growth_footing_completions"), (MissionIds.BodyBreakthrough, "bc_mission_body_breakthrough_completions"), (MissionIds.ArtifactUse, "bc_mission_artifact_use_completions"), (MissionIds.RearAttack, "bc_mission_rear_attack_completions"), (MissionIds.CrowdControl, "bc_mission_crowd_control_completions"), (MissionIds.CancelAttack, "bc_mission_cancel_attack_completions"), (MissionIds.GuardDefense, "bc_mission_guard_defense_completions"), (MissionIds.MatchShield, "bc_mission_match_shield_completions"), (MissionIds.ChargedMoment, "bc_mission_charged_moment_completions"), (MissionIds.Rampage, "bc_mission_rampage_completions"), }; private static readonly Regex SchemaVersionPattern = new( "\\\"schemaVersion\\\"\\s*:\\s*(-?\\d+)", RegexOptions.Compiled); private readonly string filePath; public UserProfileStore(string filePath = null) { this.filePath = string.IsNullOrWhiteSpace(filePath) ? Path.Combine(Application.persistentDataPath, DefaultFileName) : filePath; Load(); } public UserProfile Profile { get; private set; } public string FilePath => filePath; public bool CanSave { get; private set; } public string LoadIssue { get; private set; } public int GetMissionCompletions(string missionId) { if (Profile?.missionTotals == null || string.IsNullOrEmpty(missionId)) { return 0; } for (int i = 0; i < Profile.missionTotals.Count; i++) { UserProfileMissionTotal total = Profile.missionTotals[i]; if (total != null && total.missionId == missionId) { return Mathf.Max(0, total.completions); } } return 0; } public int GetTotalMissionCompletions() { if (Profile?.missionTotals == null) { return 0; } long total = 0; for (int i = 0; i < Profile.missionTotals.Count; i++) { UserProfileMissionTotal entry = Profile.missionTotals[i]; if (entry != null) { total += Mathf.Max(0, entry.completions); } } return total > int.MaxValue ? int.MaxValue : (int)total; } public bool IncrementMissionCompletion(string missionId) { if (!CanSave || string.IsNullOrWhiteSpace(missionId)) { return false; } if (Profile.missionTotals == null) { Profile.missionTotals = new List(); } UserProfileMissionTotal existing = null; for (int i = 0; i < Profile.missionTotals.Count; i++) { UserProfileMissionTotal entry = Profile.missionTotals[i]; if (entry != null && entry.missionId == missionId) { existing = entry; break; } } if (existing == null) { existing = new UserProfileMissionTotal { missionId = missionId }; Profile.missionTotals.Add(existing); } existing.completions = SaturatingAdd(existing.completions, 1); return Save(); } public bool UnlockAchievement(string achievementId) { if (!CanSave || string.IsNullOrWhiteSpace(achievementId)) { return false; } if (Profile.achievements == null) { Profile.achievements = new List(); } for (int i = 0; i < Profile.achievements.Count; i++) { UserProfileAchievementState state = Profile.achievements[i]; if (state != null && state.achievementId == achievementId) { if (state.unlocked) { return LoadIssue == "save-failed" ? Save() : true; } state.unlocked = true; return Save(); } } Profile.achievements.Add(new UserProfileAchievementState { achievementId = achievementId, unlocked = true, }); return Save(); } public SteamIntegerStatSnapshot[] CreateSteamIntegerStatSnapshot() { SteamIntegerStatSnapshot[] result = new SteamIntegerStatSnapshot[SteamIntegerMappings.Length]; for (int i = 0; i < SteamIntegerMappings.Length; i++) { (string missionId, string apiName) = SteamIntegerMappings[i]; result[i] = new SteamIntegerStatSnapshot( apiName, Mathf.Clamp(GetMissionCompletions(missionId), 0, MaximumSteamIntegerStat)); } return result; } public string[] CreateSteamUnlockedAchievementApiNameSnapshot( IReadOnlyList definitions) { if (definitions == null || Profile?.achievements == null) { return Array.Empty(); } List result = new(); for (int i = 0; i < definitions.Count; i++) { SteamAchievementDefinition definition = definitions[i]; if (string.IsNullOrWhiteSpace(definition.AchievementId) || string.IsNullOrWhiteSpace(definition.SteamApiName) || !IsAchievementUnlocked(definition.AchievementId)) { continue; } result.Add(definition.SteamApiName); } return result.ToArray(); } private void Load() { if (!File.Exists(filePath)) { Profile = new UserProfile(); Normalize(Profile); CanSave = true; return; } try { string json = File.ReadAllText(filePath); Match schemaMatch = SchemaVersionPattern.Match(json ?? string.Empty); if (!schemaMatch.Success || !int.TryParse(schemaMatch.Groups[1].Value, out int schemaVersion)) { Profile = new UserProfile(); Normalize(Profile); CanSave = false; LoadIssue = "malformed"; return; } UserProfile loaded = JsonUtility.FromJson(json); if (loaded == null || loaded.schemaVersion != schemaVersion || schemaVersion != CurrentSchemaVersion) { Profile = new UserProfile(); Normalize(Profile); CanSave = false; LoadIssue = loaded == null ? "malformed" : loaded.schemaVersion > CurrentSchemaVersion ? "newer-schema" : "unsupported-schema"; return; } Profile = loaded; Normalize(Profile); CanSave = true; } catch (Exception exception) when ( exception is IOException || exception is UnauthorizedAccessException || exception is ArgumentException || exception is FormatException) { Profile = new UserProfile(); Normalize(Profile); CanSave = false; LoadIssue = "malformed"; } } private bool Save() { if (!CanSave || Profile == null) { return false; } string temporaryPath = filePath + ".tmp"; try { string directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } File.WriteAllText(temporaryPath, JsonUtility.ToJson(Profile, true)); if (File.Exists(filePath)) { File.Replace(temporaryPath, filePath, null); } else { File.Move(temporaryPath, filePath); } LoadIssue = null; return true; } catch (Exception exception) when ( exception is IOException || exception is UnauthorizedAccessException || exception is ArgumentException) { TryDeleteTemporaryFile(temporaryPath); LoadIssue = "save-failed"; return false; } } private static void Normalize(UserProfile profile) { profile.missionTotals ??= new List(); profile.achievements ??= new List(); profile.unlockedSkinIds ??= new List(); profile.selectedSkinId ??= string.Empty; for (int i = profile.missionTotals.Count - 1; i >= 0; i--) { UserProfileMissionTotal entry = profile.missionTotals[i]; if (entry == null || string.IsNullOrWhiteSpace(entry.missionId)) { profile.missionTotals.RemoveAt(i); continue; } entry.completions = Mathf.Max(0, entry.completions); } for (int i = profile.achievements.Count - 1; i >= 0; i--) { if (profile.achievements[i] == null || string.IsNullOrWhiteSpace(profile.achievements[i].achievementId)) { profile.achievements.RemoveAt(i); } } for (int i = profile.unlockedSkinIds.Count - 1; i >= 0; i--) { if (string.IsNullOrWhiteSpace(profile.unlockedSkinIds[i])) { profile.unlockedSkinIds.RemoveAt(i); } } } private static int SaturatingAdd(int value, int amount) { long result = (long)Mathf.Max(0, value) + Mathf.Max(0, amount); return result >= int.MaxValue ? int.MaxValue : (int)result; } private static void TryDeleteTemporaryFile(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // A failed temporary cleanup does not affect the original profile. } catch (UnauthorizedAccessException) { // A failed temporary cleanup does not affect the original profile. } } private bool IsAchievementUnlocked(string achievementId) { for (int i = 0; i < Profile.achievements.Count; i++) { UserProfileAchievementState state = Profile.achievements[i]; if (state != null && state.achievementId == achievementId && state.unlocked) { return true; } } return false; } } }