From ca54bf0f8221f2295b444502aeba9899870a72e3 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 28 Jul 2026 19:50:05 +0200 Subject: [PATCH] feat(progression): read surface and change announcements (slice 10a) reads for the UI: TotalCoins, Lives, IsRunOver, CurrentLevel, LevelOf/IsOwned/CanAfford, HasCleared. The read half of candidate 01 - callers get a narrow surface instead of reaching into state. new domain events LivesChanged / TotalCoinsChanged / SkillSetChanged, announced by comparing against the last announced value, so an operation can neither forget to announce nor announce something unchanged. HandlePlayerDeath now clears the spent attempt - it was still counting towards TotalCoins after its ledger had been discarded. republisher and EventBus extended; SkillSetChanged is what SkillManager will reconcile against instead of the non-atomic call pairs. 65 tests. --- Autoloads/EventBus.cs | 10 ++ Autoloads/ProgressionEventRepublisher.cs | 12 ++ progression.tests/ProgressionServiceTests.cs | 31 +++++ progression/ProgressionEvent.cs | 18 +++ progression/ProgressionService.cs | 138 ++++++++++++++++--- 5 files changed, 190 insertions(+), 19 deletions(-) diff --git a/Autoloads/EventBus.cs b/Autoloads/EventBus.cs index 4266885..7bcb112 100644 --- a/Autoloads/EventBus.cs +++ b/Autoloads/EventBus.cs @@ -164,6 +164,16 @@ public partial class EventBus : Node public static void EmitRunEnded() => Instance?.EmitSignal(SignalName.RunEnded); + /// + /// The set of owned skills, or the level of one of them, changed. SkillManager reconciles its live + /// components against this instead of every caller remembering to update it separately — the + /// non-atomic pair of calls that could leave a skill owned but inert. + /// + [Signal] public delegate void SkillSetChangedEventHandler(); + + public static void EmitSkillSetChanged() + => Instance?.EmitSignal(SignalName.SkillSetChanged); + public static void EmitCoinsChanged(int totalCoins) => Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins); diff --git a/Autoloads/ProgressionEventRepublisher.cs b/Autoloads/ProgressionEventRepublisher.cs index 5868093..e60f13b 100644 --- a/Autoloads/ProgressionEventRepublisher.cs +++ b/Autoloads/ProgressionEventRepublisher.cs @@ -55,6 +55,18 @@ public sealed class ProgressionEventRepublisher EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value); break; + case LivesChanged lives: + EventBus.EmitLivesChanged(lives.Remaining.Value); + break; + + case TotalCoinsChanged coins: + EventBus.EmitCoinsChanged(coins.Total.Value); + break; + + case SkillSetChanged: + EventBus.EmitSkillSetChanged(); + break; + default: GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}."); break; diff --git a/progression.tests/ProgressionServiceTests.cs b/progression.tests/ProgressionServiceTests.cs index 51a3425..6e6064f 100644 --- a/progression.tests/ProgressionServiceTests.cs +++ b/progression.tests/ProgressionServiceTests.cs @@ -21,6 +21,7 @@ public class ProgressionServiceTests startingLives: Lives.Of(3)); service.StartPlaythrough(Id); + service.DrainEvents(); // setup announces; tests assert about the operation under test return service; } @@ -84,6 +85,7 @@ public class ProgressionServiceTests TwoLevels(), Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); + service.DrainEvents(); // setup announces; tests assert about the operation under test return service; } @@ -167,4 +169,33 @@ public class ProgressionServiceTests Assert.Equal(Second, reloaded.CurrentLevel); Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First)); } + + [Fact] + public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls() + { + // CoinsChanged is emitted from four places today and subscribed by nobody, while Hud rebuilds + // three translated strings every frame in _Process. One announcement, one read path. + var service = ServiceWithSkills(); + + service.RecordCoinsFound(Coins.Of(7)); + + var announced = service.DrainEvents().OfType().Single(); + Assert.Equal(Coins.Of(7), announced.Total); + Assert.Equal(Coins.Of(7), service.TotalCoins); + } + + [Fact] + public void Dying_announces_both_the_remaining_lives_and_the_coins_reverting_to_the_bank() + { + var service = ServiceWithSkills(); + service.RecordCoinsFound(Coins.Of(40)); + + service.HandlePlayerDeath(); + + var events = service.DrainEvents(); + Assert.Equal(2, events.OfType().Single().Remaining.Value); + // The attempt is discarded, so the 40 found coins were never banked. + Assert.Equal(Coins.Zero, events.OfType().Last().Total); + Assert.Equal(Coins.Zero, service.TotalCoins); + } } diff --git a/progression/ProgressionEvent.cs b/progression/ProgressionEvent.cs index 3be32af..e973f6e 100644 --- a/progression/ProgressionEvent.cs +++ b/progression/ProgressionEvent.cs @@ -22,3 +22,21 @@ public sealed record AchievementUnlocked(AchievementId Achievement) : Progressio /// this, so an entry path that does not announce is an entry path where neither works. /// public sealed record LevelStarted(LevelId Level) : ProgressionEvent; + +/// +/// Lives remaining changed. Raised by the same operation that changed them, so no reader can observe +/// a stale value between the write and the announcement. +/// +public sealed record LivesChanged(Lives Remaining) : ProgressionEvent; + +/// +/// Spendable coins changed — the Bank plus the current Attempt's takings, less what it has spent. +/// A single fact for readers, so nothing has to know the Attempt and Bank are separate. +/// +public sealed record TotalCoinsChanged(Coins Total) : ProgressionEvent; + +/// +/// The set of owned Skills, or the level of one of them, changed. What SkillManager reconciles +/// its live components against, instead of being told separately by every caller. +/// +public sealed record SkillSetChanged : ProgressionEvent; diff --git a/progression/ProgressionService.cs b/progression/ProgressionService.cs index a992ab4..6e33deb 100644 --- a/progression/ProgressionService.cs +++ b/progression/ProgressionService.cs @@ -39,11 +39,64 @@ public sealed class ProgressionService public Profile Profile { get; } + #region Reads — the narrow surface the UI gets instead of reaching into state + + /// Spendable coins: the Bank plus this Attempt's takings, less what it has spent. + public Coins TotalCoins + { + get + { + if (_playthrough is null) return Coins.Zero; + if (_attempt is null) return _playthrough.Bank; + + return _playthrough.Bank.Add(_attempt.CoinsFound) + .TrySubtract(_attempt.CoinsSpent, out var total) ? total : Coins.Zero; + } + } + + public Lives Lives => _playthrough?.Lives ?? Lives.Of(0); + + public bool IsRunOver => _playthrough?.IsRunOver ?? false; + + public LevelId? CurrentLevel => _playthrough?.CurrentLevel; + + public bool IsPlaying => _playthrough is not null; + + /// + /// The level a Skill is at: what this Attempt has bought if anything, otherwise what the + /// Playthrough owns. Null when it is not owned at all — absence is what "unowned" means. + /// + public SkillLevel? LevelOf(SkillId skill) + { + if (_attempt is not null && _attempt.SkillsBought.TryGetValue(skill, out var bought)) + return bought; + + return _playthrough is not null && _playthrough.OwnedSkills.TryGetValue(skill, out var owned) + ? owned + : null; + } + + public bool IsOwned(SkillId skill) => LevelOf(skill) is not null; + + /// Whether the next level of a Skill could be bought right now. + public bool CanAfford(SkillId skill) + { + if (_playthrough is null || _attempt is null) return false; + + var next = LevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First; + return _skills.CostToReach(skill, next) is { } cost && _playthrough.CanAfford(_attempt, cost); + } + + public bool HasCleared(LevelId level) => _playthrough?.HasCleared(level) ?? false; + + #endregion + public void StartPlaythrough(PlaythroughId id) { _playthrough = Playthrough.StartNew(id, _campaign, _startingLives); _attempt = null; _playthroughs.Save(_playthrough); + AnnounceChanges(); } /// @@ -58,13 +111,18 @@ public sealed class ProgressionService _attempt = new Attempt(level); _events.Raise(new LevelStarted(level)); + AnnounceChanges(); return true; } /// /// Records coins picked up during the active Attempt. They are banked only by a First Clear. /// - public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount); + public void RecordCoinsFound(Coins amount) + { + _attempt?.RecordCoinsFound(amount); + AnnounceChanges(); + } /// /// Buys the next level of a Skill, charging it to the active Attempt's ledger — so dying undoes @@ -76,30 +134,16 @@ public sealed class ProgressionService if (_skills.MaxLevelOf(skill) is not { } topLevel) return PurchaseOutcome.NotPurchasable; - var next = CurrentLevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First; + var next = LevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First; if (next.Value > topLevel.Value) return PurchaseOutcome.AlreadyAtTopLevel; if (_skills.CostToReach(skill, next) is not { } cost) return PurchaseOutcome.AlreadyAtTopLevel; if (!_playthrough.CanAfford(_attempt, cost)) return PurchaseOutcome.NotAffordable; - return _attempt.TryRecordPurchase(skill, next, cost) - ? PurchaseOutcome.Purchased - : PurchaseOutcome.NotPlaying; - } + if (!_attempt.TryRecordPurchase(skill, next, cost)) return PurchaseOutcome.NotPlaying; - /// - /// The level a Skill is at right now: what this Attempt has bought if anything, otherwise what - /// the Playthrough owns. Reading only the Playthrough would let the same level be bought twice - /// within one Attempt. - /// - private SkillLevel? CurrentLevelOf(SkillId skill) - { - if (_attempt is not null && _attempt.SkillsBought.TryGetValue(skill, out var bought)) - return bought; - - return _playthrough is not null && _playthrough.OwnedSkills.TryGetValue(skill, out var owned) - ? owned - : null; + AnnounceChanges(); + return PurchaseOutcome.Purchased; } /// @@ -113,7 +157,10 @@ public sealed class ProgressionService if (!_playthrough.SettleDeath(_attempt)) return; + // The ledger was discarded, so it must stop contributing to TotalCoins. + _attempt = null; _playthroughs.Save(_playthrough); + AnnounceChanges(); } /// @@ -139,6 +186,59 @@ public sealed class ProgressionService if (wasFirstClear) _playthroughs.Save(_playthrough); _profiles.Save(Profile); + AnnounceChanges(); + } + + private Coins _announcedCoins = Coins.Zero; + private int _announcedLives = -1; + private string _announcedSkills = string.Empty; + + /// + /// Announces whatever actually changed since the last announcement. Called at the end of every + /// mutating operation, so an operation cannot forget to announce; comparing against the last + /// announced value means it also cannot announce something that did not change. + /// + private void AnnounceChanges() + { + var coins = TotalCoins; + if (!coins.Equals(_announcedCoins)) + { + _announcedCoins = coins; + _events.Raise(new TotalCoinsChanged(coins)); + } + + if (_playthrough is not null && _playthrough.Lives.Value != _announcedLives) + { + _announcedLives = _playthrough.Lives.Value; + _events.Raise(new LivesChanged(_playthrough.Lives)); + } + + var skills = SkillFingerprint(); + if (skills != _announcedSkills) + { + _announcedSkills = skills; + _events.Raise(new SkillSetChanged()); + } + } + + /// + /// Cheap comparable summary of the effective owned-skill set, Attempt purchases included. Used only + /// to decide whether anything changed — with a handful of skills, recomputing beats bookkeeping. + /// + private string SkillFingerprint() + { + if (_playthrough is null) return string.Empty; + + var effective = new SortedDictionary(); + + foreach (var (skill, level) in _playthrough.OwnedSkills) + effective[skill.Value] = level.Value; + + if (_attempt is not null) + foreach (var (skill, level) in _attempt.SkillsBought) + effective[skill.Value] = level.Value; + + return string.Join(",", effective.Select(e => $"{e.Key}:{e.Value}")); } ///