flat snapshot record per aggregate - separate files mean saving a run doesn't rewrite the Profile, deleting a slot is deleting a file, and a corrupt slot doesn't cost achievements or best times. snapshots hold primitives so Restore re-runs the value-object factories; a serialiser building LevelId directly would bypass every guard. LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the bug where a version bump made ContinueGame overwrite the save. Profile.Restore writes collections directly - replaying UnlockAchievement would re-fire popups and Steam pushes on every load. 44 tests.
110 lines
4.3 KiB
C#
110 lines
4.3 KiB
C#
namespace Mr.BrickAdventures.Progression;
|
|
|
|
/// <summary>
|
|
/// Everything shared across all Playthroughs, one per installation. Never reset by starting a new
|
|
/// Playthrough — see docs/adr/0001-three-progression-scopes.md.
|
|
/// </summary>
|
|
public sealed class Profile
|
|
{
|
|
private readonly Dictionary<LevelId, LevelTime> _bestTimes = new();
|
|
private readonly HashSet<AchievementId> _achievements = new();
|
|
private readonly EventLog _events = new();
|
|
|
|
private Profile() { }
|
|
|
|
public static Profile Empty() => new();
|
|
|
|
public bool HasAchievement(AchievementId achievement) => _achievements.Contains(achievement);
|
|
|
|
/// <summary>
|
|
/// Unlocks an Achievement. Announces it only on the transition, so repeated calls are harmless.
|
|
/// </summary>
|
|
public void UnlockAchievement(AchievementId achievement)
|
|
{
|
|
if (!_achievements.Add(achievement)) return;
|
|
|
|
_events.Raise(new AchievementUnlocked(achievement));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes an unlocked Achievement. Exists for console commands only.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately asymmetric with <see cref="UnlockAchievement"/>: 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.
|
|
/// </remarks>
|
|
public void RelockAchievement(AchievementId achievement) => _achievements.Remove(achievement);
|
|
|
|
private readonly Dictionary<StatName, int> _statistics = new();
|
|
|
|
public int ReadStat(StatName stat) => _statistics.GetValueOrDefault(stat);
|
|
|
|
/// <summary>
|
|
/// Adds to a lifetime statistic. The amount must be positive — these only ever go up.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the events raised since the last drain, clearing them.
|
|
/// </summary>
|
|
public IReadOnlyList<ProgressionEvent> 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));
|
|
|
|
/// <summary>
|
|
/// Rebuilds a Profile from a snapshot, re-running every value object's factory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Writes the collections directly rather than replaying <see cref="UnlockAchievement"/>. Going
|
|
/// through the operation would raise <see cref="AchievementUnlocked"/> for every achievement on
|
|
/// every load, so a subscriber pushing to Steam or showing a popup would fire on startup.
|
|
/// </remarks>
|
|
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<StatName>(stat, out var known))
|
|
profile._statistics[known] = value;
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
public LevelTime? BestTimeFor(LevelId level) =>
|
|
_bestTimes.TryGetValue(level, out var time) ? time : null;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool RecordTime(LevelId level, LevelTime time)
|
|
{
|
|
if (BestTimeFor(level) is { } best && !time.IsFasterThan(best))
|
|
return false;
|
|
|
|
_bestTimes[level] = time;
|
|
return true;
|
|
}
|
|
}
|