diff --git a/progression.tests/ProfileTests.cs b/progression.tests/ProfileTests.cs new file mode 100644 index 0000000..5af684f --- /dev/null +++ b/progression.tests/ProfileTests.cs @@ -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()); + } + + [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(() => profile.Record(StatName.Deaths, -1)); + Assert.Throws(() => 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()); + } +} diff --git a/progression/AchievementId.cs b/progression/AchievementId.cs new file mode 100644 index 0000000..91bc08c --- /dev/null +++ b/progression/AchievementId.cs @@ -0,0 +1,21 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Stable identity of an Achievement, matching the authored AchievementResource id that Steam +/// also keys on. +/// +public readonly struct AchievementId : IEquatable +{ + 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; +} diff --git a/progression/EventLog.cs b/progression/EventLog.cs new file mode 100644 index 0000000..a31ea73 --- /dev/null +++ b/progression/EventLog.cs @@ -0,0 +1,19 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Collects the events an aggregate raises until someone drains them. Composed rather than +/// inherited — aggregates own one, they are not a kind of it. +/// +internal sealed class EventLog +{ + private readonly List _events = new(); + + public void Raise(ProgressionEvent @event) => _events.Add(@event); + + public IReadOnlyList Drain() + { + var drained = _events.ToList(); + _events.Clear(); + return drained; + } +} diff --git a/progression/LevelTime.cs b/progression/LevelTime.cs index 381dfad..43b22e4 100644 --- a/progression/LevelTime.cs +++ b/progression/LevelTime.cs @@ -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. /// -public readonly struct LevelTime +public readonly struct LevelTime : IEquatable { 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"; } diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs index 0e2174d..a02d718 100644 --- a/progression/Playthrough.cs +++ b/progression/Playthrough.cs @@ -11,7 +11,7 @@ namespace Mr.BrickAdventures.Progression; /// public sealed class Playthrough { - private readonly List _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()); } /// /// Takes the events raised since the last drain, clearing them. The composition root drains /// once after each use case and republishes. /// - public IReadOnlyList DrainEvents() - { - var drained = _events.ToList(); - _events.Clear(); - return drained; - } + public IReadOnlyList DrainEvents() => _events.Drain(); } diff --git a/progression/Profile.cs b/progression/Profile.cs new file mode 100644 index 0000000..f9aa0bc --- /dev/null +++ b/progression/Profile.cs @@ -0,0 +1,75 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Everything shared across all Playthroughs, one per installation. Never reset by starting a new +/// Playthrough — see docs/adr/0001-three-progression-scopes.md. +/// +public sealed class Profile +{ + private readonly Dictionary _bestTimes = new(); + private readonly HashSet _achievements = new(); + private readonly EventLog _events = new(); + + private Profile() { } + + public static Profile Empty() => new(); + + public bool HasAchievement(AchievementId achievement) => _achievements.Contains(achievement); + + /// + /// Unlocks an Achievement. Announces it only on the transition, so repeated calls are harmless. + /// + public void UnlockAchievement(AchievementId achievement) + { + if (!_achievements.Add(achievement)) return; + + _events.Raise(new AchievementUnlocked(achievement)); + } + + /// + /// Removes an unlocked Achievement. Exists for console commands only. + /// + /// + /// Deliberately asymmetric with : 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. + /// + public void RelockAchievement(AchievementId achievement) => _achievements.Remove(achievement); + + private readonly Dictionary _statistics = new(); + + public int ReadStat(StatName stat) => _statistics.GetValueOrDefault(stat); + + /// + /// Adds to a lifetime statistic. The amount must be positive — these only ever go up. + /// + 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; + } + + /// + /// Takes the events raised since the last drain, clearing them. + /// + public IReadOnlyList DrainEvents() => _events.Drain(); + + public LevelTime? BestTimeFor(LevelId level) => + _bestTimes.TryGetValue(level, out var time) ? time : null; + + /// + /// 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. + /// + public bool RecordTime(LevelId level, LevelTime time) + { + if (BestTimeFor(level) is { } best && !time.IsFasterThan(best)) + return false; + + _bestTimes[level] = time; + return true; + } +} diff --git a/progression/ProgressionEvent.cs b/progression/ProgressionEvent.cs index 70bb1a8..96e6dc3 100644 --- a/progression/ProgressionEvent.cs +++ b/progression/ProgressionEvent.cs @@ -9,3 +9,9 @@ public abstract record ProgressionEvent; /// Raised when a Playthrough loses its last life. public sealed record RunEnded : ProgressionEvent; + +/// +/// 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. +/// +public sealed record AchievementUnlocked(AchievementId Achievement) : ProgressionEvent;