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:
2026-07-28 18:56:58 +02:00
parent ffa2b986ae
commit 05149bb4a9
7 changed files with 206 additions and 9 deletions

View File

@@ -0,0 +1,21 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// Stable identity of an Achievement, matching the authored <c>AchievementResource</c> id that Steam
/// also keys on.
/// </summary>
public readonly struct AchievementId : IEquatable<AchievementId>
{
public string Value { get; }
private AchievementId(string value) => Value = value;
public static AchievementId Of(string value) => string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("An achievement id cannot be blank.", nameof(value))
: new AchievementId(value);
public bool Equals(AchievementId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is AchievementId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value;
}

19
progression/EventLog.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// Collects the events an aggregate raises until someone drains them. Composed rather than
/// inherited — aggregates own one, they are not a kind of it.
/// </summary>
internal sealed class EventLog
{
private readonly List<ProgressionEvent> _events = new();
public void Raise(ProgressionEvent @event) => _events.Add(@event);
public IReadOnlyList<ProgressionEvent> Drain()
{
var drained = _events.ToList();
_events.Clear();
return drained;
}
}

View File

@@ -4,7 +4,7 @@ namespace Mr.BrickAdventures.Progression;
/// A completion time for one Level. Strictly positive — a zero time means the clock was never
/// running, which is a defect rather than a fast run.
/// </summary>
public readonly struct LevelTime
public readonly struct LevelTime : IEquatable<LevelTime>
{
public double Seconds { get; }
@@ -13,4 +13,11 @@ public readonly struct LevelTime
public static LevelTime Of(double seconds) => double.IsFinite(seconds) && seconds > 0
? new LevelTime(seconds)
: throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be a finite positive number.");
public bool IsFasterThan(LevelTime other) => Seconds < other.Seconds;
public bool Equals(LevelTime other) => Seconds.Equals(other.Seconds);
public override bool Equals(object? obj) => obj is LevelTime other && Equals(other);
public override int GetHashCode() => Seconds.GetHashCode();
public override string ToString() => $"{Seconds:0.###}s";
}

View File

@@ -11,7 +11,7 @@ namespace Mr.BrickAdventures.Progression;
/// </remarks>
public sealed class Playthrough
{
private readonly List<ProgressionEvent> _events = new();
private readonly EventLog _events = new();
private Playthrough(PlaythroughId id, LevelId currentLevel, Lives lives)
{
@@ -114,17 +114,12 @@ public sealed class Playthrough
Lives = Lives.Decrement();
if (Lives.IsExhausted)
_events.Add(new RunEnded());
_events.Raise(new RunEnded());
}
/// <summary>
/// Takes the events raised since the last drain, clearing them. The composition root drains
/// once after each use case and republishes.
/// </summary>
public IReadOnlyList<ProgressionEvent> DrainEvents()
{
var drained = _events.ToList();
_events.Clear();
return drained;
}
public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
}

75
progression/Profile.cs Normal file
View 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;
}
}

View File

@@ -9,3 +9,9 @@ public abstract record ProgressionEvent;
/// <summary>Raised when a Playthrough loses its last life.</summary>
public sealed record RunEnded : ProgressionEvent;
/// <summary>
/// Raised the first time an Achievement is unlocked, and only then — so a subscriber that shows a
/// popup or pushes to Steam cannot fire twice however often it is told.
/// </summary>
public sealed record AchievementUnlocked(AchievementId Achievement) : ProgressionEvent;