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:
21
progression/AchievementId.cs
Normal file
21
progression/AchievementId.cs
Normal 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
19
progression/EventLog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user