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.
This commit is contained in:
2026-07-28 19:50:05 +02:00
parent c1c3e7b020
commit ca54bf0f82
5 changed files with 190 additions and 19 deletions

View File

@@ -164,6 +164,16 @@ public partial class EventBus : Node
public static void EmitRunEnded() public static void EmitRunEnded()
=> Instance?.EmitSignal(SignalName.RunEnded); => Instance?.EmitSignal(SignalName.RunEnded);
/// <summary>
/// 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.
/// </summary>
[Signal] public delegate void SkillSetChangedEventHandler();
public static void EmitSkillSetChanged()
=> Instance?.EmitSignal(SignalName.SkillSetChanged);
public static void EmitCoinsChanged(int totalCoins) public static void EmitCoinsChanged(int totalCoins)
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins); => Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);

View File

@@ -55,6 +55,18 @@ public sealed class ProgressionEventRepublisher
EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value); EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
break; 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: default:
GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}."); GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}.");
break; break;

View File

@@ -21,6 +21,7 @@ public class ProgressionServiceTests
startingLives: Lives.Of(3)); startingLives: Lives.Of(3));
service.StartPlaythrough(Id); service.StartPlaythrough(Id);
service.DrainEvents(); // setup announces; tests assert about the operation under test
return service; return service;
} }
@@ -84,6 +85,7 @@ public class ProgressionServiceTests
TwoLevels(), Skills(), Lives.Of(3)); TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id); service.StartPlaythrough(Id);
service.EnterLevel(First); service.EnterLevel(First);
service.DrainEvents(); // setup announces; tests assert about the operation under test
return service; return service;
} }
@@ -167,4 +169,33 @@ public class ProgressionServiceTests
Assert.Equal(Second, reloaded.CurrentLevel); Assert.Equal(Second, reloaded.CurrentLevel);
Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First)); 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<TotalCoinsChanged>().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<LivesChanged>().Single().Remaining.Value);
// The attempt is discarded, so the 40 found coins were never banked.
Assert.Equal(Coins.Zero, events.OfType<TotalCoinsChanged>().Last().Total);
Assert.Equal(Coins.Zero, service.TotalCoins);
}
} }

View File

@@ -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. /// this, so an entry path that does not announce is an entry path where neither works.
/// </summary> /// </summary>
public sealed record LevelStarted(LevelId Level) : ProgressionEvent; public sealed record LevelStarted(LevelId Level) : ProgressionEvent;
/// <summary>
/// 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.
/// </summary>
public sealed record LivesChanged(Lives Remaining) : ProgressionEvent;
/// <summary>
/// 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.
/// </summary>
public sealed record TotalCoinsChanged(Coins Total) : ProgressionEvent;
/// <summary>
/// The set of owned Skills, or the level of one of them, changed. What <c>SkillManager</c> reconciles
/// its live components against, instead of being told separately by every caller.
/// </summary>
public sealed record SkillSetChanged : ProgressionEvent;

View File

@@ -39,11 +39,64 @@ public sealed class ProgressionService
public Profile Profile { get; } public Profile Profile { get; }
#region Reads — the narrow surface the UI gets instead of reaching into state
/// <summary>Spendable coins: the Bank plus this Attempt's takings, less what it has spent.</summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Whether the next level of a Skill could be bought right now.</summary>
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) public void StartPlaythrough(PlaythroughId id)
{ {
_playthrough = Playthrough.StartNew(id, _campaign, _startingLives); _playthrough = Playthrough.StartNew(id, _campaign, _startingLives);
_attempt = null; _attempt = null;
_playthroughs.Save(_playthrough); _playthroughs.Save(_playthrough);
AnnounceChanges();
} }
/// <summary> /// <summary>
@@ -58,13 +111,18 @@ public sealed class ProgressionService
_attempt = new Attempt(level); _attempt = new Attempt(level);
_events.Raise(new LevelStarted(level)); _events.Raise(new LevelStarted(level));
AnnounceChanges();
return true; return true;
} }
/// <summary> /// <summary>
/// Records coins picked up during the active Attempt. They are banked only by a First Clear. /// Records coins picked up during the active Attempt. They are banked only by a First Clear.
/// </summary> /// </summary>
public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount); public void RecordCoinsFound(Coins amount)
{
_attempt?.RecordCoinsFound(amount);
AnnounceChanges();
}
/// <summary> /// <summary>
/// Buys the next level of a Skill, charging it to the active Attempt's ledger — so dying undoes /// 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; 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 (next.Value > topLevel.Value) return PurchaseOutcome.AlreadyAtTopLevel;
if (_skills.CostToReach(skill, next) is not { } cost) return PurchaseOutcome.AlreadyAtTopLevel; if (_skills.CostToReach(skill, next) is not { } cost) return PurchaseOutcome.AlreadyAtTopLevel;
if (!_playthrough.CanAfford(_attempt, cost)) return PurchaseOutcome.NotAffordable; if (!_playthrough.CanAfford(_attempt, cost)) return PurchaseOutcome.NotAffordable;
return _attempt.TryRecordPurchase(skill, next, cost) if (!_attempt.TryRecordPurchase(skill, next, cost)) return PurchaseOutcome.NotPlaying;
? PurchaseOutcome.Purchased
: PurchaseOutcome.NotPlaying;
}
/// <summary> AnnounceChanges();
/// The level a Skill is at right now: what this Attempt has bought if anything, otherwise what return PurchaseOutcome.Purchased;
/// the Playthrough owns. Reading only the Playthrough would let the same level be bought twice
/// within one Attempt.
/// </summary>
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;
} }
/// <summary> /// <summary>
@@ -113,7 +157,10 @@ public sealed class ProgressionService
if (!_playthrough.SettleDeath(_attempt)) return; if (!_playthrough.SettleDeath(_attempt)) return;
// The ledger was discarded, so it must stop contributing to TotalCoins.
_attempt = null;
_playthroughs.Save(_playthrough); _playthroughs.Save(_playthrough);
AnnounceChanges();
} }
/// <summary> /// <summary>
@@ -139,6 +186,59 @@ public sealed class ProgressionService
if (wasFirstClear) _playthroughs.Save(_playthrough); if (wasFirstClear) _playthroughs.Save(_playthrough);
_profiles.Save(Profile); _profiles.Save(Profile);
AnnounceChanges();
}
private Coins _announcedCoins = Coins.Zero;
private int _announcedLives = -1;
private string _announcedSkills = string.Empty;
/// <summary>
/// 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.
/// </summary>
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());
}
}
/// <summary>
/// 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.
/// </summary>
private string SkillFingerprint()
{
if (_playthrough is null) return string.Empty;
var effective = new SortedDictionary<string, int>();
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}"));
} }
/// <summary> /// <summary>