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}"));
}
///