namespace Mr.BrickAdventures.Progression; /// /// Everything shared across all Playthroughs, one per installation. Never reset by starting a new /// Playthrough — see docs/adr/0001-three-progression-scopes.md. /// public sealed class Profile { private readonly Dictionary _bestTimes = new(); private readonly HashSet _achievements = new(); private readonly EventLog _events = new(); private Profile() { } public static Profile Empty() => new(); public bool HasAchievement(AchievementId achievement) => _achievements.Contains(achievement); /// /// Unlocks an Achievement. Announces it only on the transition, so repeated calls are harmless. /// public void UnlockAchievement(AchievementId achievement) { if (!_achievements.Add(achievement)) return; _events.Raise(new AchievementUnlocked(achievement)); } /// /// Removes an unlocked Achievement. Exists for console commands only. /// /// /// Deliberately asymmetric with : unlocking is mirrored to Steam /// by a subscriber, relocking is not and cannot be, because a Steam achievement cannot be /// un-earned. After relocking, this Profile and Steam disagree permanently. That is a property /// of Steam, not a bug here, but it is why this is not offered to gameplay code. /// public void RelockAchievement(AchievementId achievement) => _achievements.Remove(achievement); private readonly Dictionary _statistics = new(); public int ReadStat(StatName stat) => _statistics.GetValueOrDefault(stat); /// /// Adds to a lifetime statistic. The amount must be positive — these only ever go up. /// public void Record(StatName stat, int amount) { if (amount < 1) throw new ArgumentOutOfRangeException(nameof(amount), amount, "A statistic can only be increased."); _statistics[stat] = ReadStat(stat) + amount; } /// /// Takes the events raised since the last drain, clearing them. /// public IReadOnlyList DrainEvents() => _events.Drain(); public ProfileSnapshot Export() => new( _bestTimes.ToDictionary(e => e.Key.Value, e => e.Value.Seconds), _achievements.Select(a => a.Value).ToList(), _statistics.ToDictionary(e => e.Key.ToString(), e => e.Value)); /// /// Rebuilds a Profile from a snapshot, re-running every value object's factory. /// /// /// Writes the collections directly rather than replaying . Going /// through the operation would raise for every achievement on /// every load, so a subscriber pushing to Steam or showing a popup would fire on startup. /// public static Profile Restore(ProfileSnapshot snapshot) { var profile = new Profile(); foreach (var (level, seconds) in snapshot.BestTimes) profile._bestTimes[LevelId.Of(level)] = LevelTime.Of(seconds); foreach (var achievement in snapshot.Achievements) profile._achievements.Add(AchievementId.Of(achievement)); foreach (var (stat, value) in snapshot.Statistics) { // A statistic this build no longer tracks is dropped. Losing one counter beats refusing // to load the Profile and with it every achievement and best time. if (Enum.TryParse(stat, out var known)) profile._statistics[known] = value; } return profile; } public LevelTime? BestTimeFor(LevelId level) => _bestTimes.TryGetValue(level, out var time) ? time : null; /// /// Records a completion time, keeping it only if it beats what is already there. Returns true /// when it became the Best Time. A slower run is not an error — it just isn't a record. /// public bool RecordTime(LevelId level, LevelTime time) { if (BestTimeFor(level) is { } best && !time.IsFasterThan(best)) return false; _bestTimes[level] = time; return true; } }