Files
przygody-pana-cegly/progression/ProgressionService.cs
Gabriel Kaszewski ca54bf0f82 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.
2026-07-28 19:50:05 +02:00

256 lines
9.3 KiB
C#

namespace Mr.BrickAdventures.Progression;
/// <summary>
/// The application layer for player progression: holds what is currently loaded — the Profile, the
/// active Playthrough, the active Attempt — and exposes the operations gameplay triggers.
/// </summary>
/// <remarks>
/// One class rather than a class per use case, because they all act on the same loaded state. Split
/// apart, that state would have to live in the Godot autoload, where the invariant "there is exactly
/// one active Attempt" could not be tested.
/// </remarks>
public sealed class ProgressionService
{
private readonly IPlaythroughStore _playthroughs;
private readonly IProfileStore _profiles;
private readonly Campaign _campaign;
private readonly SkillCatalogue _skills;
private readonly Lives _startingLives;
private readonly EventLog _events = new();
private Playthrough? _playthrough;
private Attempt? _attempt;
public ProgressionService(
Profile profile,
IPlaythroughStore playthroughs,
IProfileStore profiles,
Campaign campaign,
SkillCatalogue skills,
Lives startingLives)
{
Profile = profile;
_playthroughs = playthroughs;
_profiles = profiles;
_campaign = campaign;
_skills = skills;
_startingLives = startingLives;
}
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>
/// Begins an Attempt at <paramref name="level"/> and announces it. Does not touch campaign
/// position — that advances only on a First Clear, so a Replay leaves it alone.
/// </summary>
/// <returns>False if there is no active Playthrough, or the Level is not reachable in it.</returns>
public bool EnterLevel(LevelId level)
{
if (_playthrough is null) return false;
if (!_playthrough.ReachableLevels(_campaign).Contains(level)) return false;
_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);
AnnounceChanges();
}
/// <summary>
/// Buys the next level of a Skill, charging it to the active Attempt's ledger — so dying undoes
/// both the spend and the Skill, and only a First Clear makes either permanent.
/// </summary>
public PurchaseOutcome PurchaseSkill(SkillId skill)
{
if (_playthrough is null || _attempt is null) return PurchaseOutcome.NotPlaying;
if (_skills.MaxLevelOf(skill) is not { } topLevel) return PurchaseOutcome.NotPurchasable;
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;
if (!_attempt.TryRecordPurchase(skill, next, cost)) return PurchaseOutcome.NotPlaying;
AnnounceChanges();
return PurchaseOutcome.Purchased;
}
/// <summary>
/// Charges the active Attempt's death to the Playthrough and persists it. Repeated calls for one
/// Attempt cost one life — the Playthrough defers to the Attempt's terminal outcome (ADR-0005),
/// so overlapping hazards or a hazard re-firing before the scene reloads are harmless.
/// </summary>
public void HandlePlayerDeath()
{
if (_playthrough is null || _attempt is null) return;
if (!_playthrough.SettleDeath(_attempt)) return;
// The ledger was discarded, so it must stop contributing to TotalCoins.
_attempt = null;
_playthroughs.Save(_playthrough);
AnnounceChanges();
}
/// <summary>
/// Settles a completed Level. The Best Time and the completion statistic are recorded either way
/// (ADR-0003), while banking and advancing happen only on a First Clear — which the Playthrough
/// decides for itself from the Attempt's Level.
/// </summary>
public void FinishLevel(LevelTime time)
{
if (_playthrough is null || _attempt is null) return;
if (_attempt.Outcome != AttemptOutcome.InProgress) return;
var level = _attempt.Level;
// Counts completions, including Replays — deliberately a different fact from
// ClearedLevels.Count, which counts distinct Levels. Conflating the two is what made the old
// levels_completed statistic disagree with CompletedLevels.
Profile.Record(StatName.LevelsCompleted, 1);
Profile.RecordTime(level, time);
var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign);
_attempt = null;
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>
/// Takes the events raised by this service and both aggregates. The composition root drains once
/// after each operation and republishes.
/// </summary>
public IReadOnlyList<ProgressionEvent> DrainEvents()
{
var drained = _events.Drain().ToList();
drained.AddRange(Profile.DrainEvents());
if (_playthrough is not null) drained.AddRange(_playthrough.DrainEvents());
return drained;
}
}