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,74 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class ProfileTests
{
private static readonly LevelId Level = LevelId.Of("village_1");
[Fact]
public void A_slower_run_does_not_replace_a_better_time()
{
var profile = Profile.Empty();
Assert.True(profile.RecordTime(Level, LevelTime.Of(42.5)));
Assert.False(profile.RecordTime(Level, LevelTime.Of(51.0)));
Assert.Equal(LevelTime.Of(42.5), profile.BestTimeFor(Level));
}
[Fact]
public void Unlocking_the_same_achievement_twice_announces_it_once()
{
// AchievementManager shows a popup and pushes to Steam on every unlock call, guarded only by
// a Contains check on a list it reached into. Announcing only on the transition means
// subscribers cannot double-fire regardless of how often they are told.
var rescueTen = AchievementId.Of("rescue_ten_kids");
var profile = Profile.Empty();
profile.UnlockAchievement(rescueTen);
profile.UnlockAchievement(rescueTen);
Assert.True(profile.HasAchievement(rescueTen));
Assert.Single(profile.DrainEvents().OfType<AchievementUnlocked>());
}
[Fact]
public void Statistics_accumulate_and_an_unrecorded_one_reads_as_zero()
{
var profile = Profile.Empty();
profile.Record(StatName.CoinsCollected, 5);
profile.Record(StatName.CoinsCollected, 3);
Assert.Equal(8, profile.ReadStat(StatName.CoinsCollected));
Assert.Equal(0, profile.ReadStat(StatName.Deaths));
}
[Fact]
public void A_statistic_cannot_be_decremented()
{
// IncrementStat currently takes any int. A lifetime counter that can go down is not a
// lifetime counter, and nothing in the game has a reason to reduce one.
var profile = Profile.Empty();
Assert.Throws<ArgumentOutOfRangeException>(() => profile.Record(StatName.Deaths, -1));
Assert.Throws<ArgumentOutOfRangeException>(() => profile.Record(StatName.Deaths, 0));
}
[Fact]
public void Relocking_an_achievement_removes_it_and_lets_it_announce_again()
{
// Console-only. Note the asymmetry: unlocking is mirrored to Steam by a subscriber, but
// relocking cannot be, because a Steam achievement cannot be un-earned.
var rescueTen = AchievementId.Of("rescue_ten_kids");
var profile = Profile.Empty();
profile.UnlockAchievement(rescueTen);
profile.DrainEvents();
profile.RelockAchievement(rescueTen);
Assert.False(profile.HasAchievement(rescueTen));
profile.UnlockAchievement(rescueTen);
Assert.Single(profile.DrainEvents().OfType<AchievementUnlocked>());
}
}

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 /// 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. /// running, which is a defect rather than a fast run.
/// </summary> /// </summary>
public readonly struct LevelTime public readonly struct LevelTime : IEquatable<LevelTime>
{ {
public double Seconds { get; } public double Seconds { get; }
@@ -13,4 +13,11 @@ public readonly struct LevelTime
public static LevelTime Of(double seconds) => double.IsFinite(seconds) && seconds > 0 public static LevelTime Of(double seconds) => double.IsFinite(seconds) && seconds > 0
? new LevelTime(seconds) ? new LevelTime(seconds)
: throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "A completion time must be a finite positive number."); : 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> /// </remarks>
public sealed class Playthrough public sealed class Playthrough
{ {
private readonly List<ProgressionEvent> _events = new(); private readonly EventLog _events = new();
private Playthrough(PlaythroughId id, LevelId currentLevel, Lives lives) private Playthrough(PlaythroughId id, LevelId currentLevel, Lives lives)
{ {
@@ -114,17 +114,12 @@ public sealed class Playthrough
Lives = Lives.Decrement(); Lives = Lives.Decrement();
if (Lives.IsExhausted) if (Lives.IsExhausted)
_events.Add(new RunEnded()); _events.Raise(new RunEnded());
} }
/// <summary> /// <summary>
/// Takes the events raised since the last drain, clearing them. The composition root drains /// Takes the events raised since the last drain, clearing them. The composition root drains
/// once after each use case and republishes. /// once after each use case and republishes.
/// </summary> /// </summary>
public IReadOnlyList<ProgressionEvent> DrainEvents() public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
{
var drained = _events.ToList();
_events.Clear();
return drained;
}
} }

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> /// <summary>Raised when a Playthrough loses its last life.</summary>
public sealed record RunEnded : ProgressionEvent; 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;