434 lines
15 KiB
C#
434 lines
15 KiB
C#
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<UserProfileMissionTotal> missionTotals = new();
|
|
public List<UserProfileAchievementState> achievements = new();
|
|
public List<string> 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; }
|
|
}
|
|
|
|
/// <summary>Local versioned profile persistence and Steam INT stat mapping only.</summary>
|
|
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>();
|
|
}
|
|
|
|
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<UserProfileAchievementState>();
|
|
}
|
|
|
|
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<SteamAchievementDefinition> definitions)
|
|
{
|
|
if (definitions == null || Profile?.achievements == null)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
List<string> 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<UserProfile>(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<UserProfileMissionTotal>();
|
|
profile.achievements ??= new List<UserProfileAchievementState>();
|
|
profile.unlockedSkinIds ??= new List<string>();
|
|
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;
|
|
}
|
|
}
|
|
}
|