feat(progression): Profile aggregate (slice 4)
best times only improve; achievement unlock announces on the transition only, so popup/Steam subscribers cannot double-fire; statistics are increase-only (IncrementStat took any int). RelockAchievement is console-only and documented as not reaching Steam - a Steam achievement cannot be un-earned, so the disagreement is permanent and deliberate. refactor: extract EventLog, composed by both aggregates now that the duplication actually exists. 34 tests.
This commit is contained in:
75
progression/Profile.cs
Normal file
75
progression/Profile.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user