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:
@@ -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
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_playthrough = Playthrough.StartNew(id, _campaign, _startingLives);
|
||||
_attempt = null;
|
||||
_playthroughs.Save(_playthrough);
|
||||
AnnounceChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -58,13 +111,18 @@ public sealed class ProgressionService
|
||||
|
||||
_attempt = new Attempt(level);
|
||||
_events.Raise(new LevelStarted(level));
|
||||
AnnounceChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records coins picked up during the active Attempt. They are banked only by a First Clear.
|
||||
/// </summary>
|
||||
public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount);
|
||||
public void RecordCoinsFound(Coins amount)
|
||||
{
|
||||
_attempt?.RecordCoinsFound(amount);
|
||||
AnnounceChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </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;
|
||||
AnnounceChanges();
|
||||
return PurchaseOutcome.Purchased;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
/// <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>
|
||||
|
||||
Reference in New Issue
Block a user