Files
przygody-pana-cegly/progression/ProgressionService.cs
Gabriel Kaszewski e8d1da8f89 feat: lives and death move to the new domain (commit C of the swap)
fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.

new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.

DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.

Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
2026-07-28 20:19:35 +02:00

353 lines
13 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;
/// <summary>
/// Whether this Level can be entered: the Campaign's first, anything cleared, or whatever follows
/// something cleared. Separate from <see cref="EnterLevel"/> so a caller can check before starting
/// a scene change it would then have to undo.
/// </summary>
public bool CanEnter(LevelId level) =>
_playthrough is not null && _playthrough.ReachableLevels(_campaign).Contains(level);
#endregion
public void StartPlaythrough(PlaythroughId id)
{
_playthrough = Playthrough.StartNew(id, _campaign, _startingLives);
_attempt = null;
_playthroughs.Save(_playthrough);
AnnounceChanges();
}
/// <summary>
/// Resumes a stored Playthrough. Returns false when there is nothing to resume, leaving the
/// service exactly as it was — a failed resume must not clear an active Playthrough, and it must
/// not be mistaken for "start a new game", which is what overwrote saves before.
/// </summary>
public bool LoadPlaythrough(PlaythroughId id)
{
var result = _playthroughs.Load(id);
if (result.Outcome != LoadOutcome.Loaded) return false;
_playthrough = result.Value;
_attempt = null;
AnnounceChanges();
return true;
}
/// <summary>Why a resume failed, for a caller that wants to tell absent from corrupt.</summary>
public LoadOutcome PeekPlaythrough(PlaythroughId id) => _playthroughs.Load(id).Outcome;
/// <summary>Adds to a lifetime statistic on the Profile.</summary>
public void RecordStat(StatName stat, int amount = 1) => Profile.Record(stat, amount);
/// <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>
/// Grants a Skill for free, as a pickup does. No coins change hands, and collecting one already
/// owned is a no-op rather than a free upgrade.
/// </summary>
public bool GrantSkill(SkillId skill)
{
if (_playthrough is null || _attempt is null) return false;
if (IsOwned(skill)) return false;
if (!_attempt.TryRecordPurchase(skill, SkillLevel.First, Coins.Zero)) return false;
AnnounceChanges();
return true;
}
/// <summary>
/// Takes a Skill away, from this Attempt's purchases and from the Playthrough alike. For Skills
/// that consume themselves rather than as a cheat.
/// </summary>
public bool RevokeSkill(SkillId skill)
{
if (_playthrough is null) return false;
var revoked = _playthrough.RevokeSkill(skill);
AnnounceChanges();
return revoked;
}
/// <summary>
/// Throws the current Attempt away without charging a life — leaving a Level rather than dying in
/// it. The ledger is discarded, so coins found and Skills bought during it are lost.
/// </summary>
public void AbandonAttempt()
{
_attempt = null;
AnnounceChanges();
}
/// <summary>
/// Console only. Rewrites the Playthrough by transforming its snapshot.
/// </summary>
/// <remarks>
/// Deliberately shaped this way rather than as <c>SetLives</c> / <c>AddCoins</c> mutators, which
/// ADR-0005 rules out — a bare mutator is an invitation, and the code being replaced had two paths
/// to exactly that. Going through the snapshot means every value is re-validated on the way back
/// in, and a rejected cheat leaves the Playthrough untouched.
/// </remarks>
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change)
{
if (_playthrough is null) return;
var rewritten = Playthrough.Restore(change(_playthrough.Export()));
_playthrough = rewritten;
_attempt = null;
_playthroughs.Save(_playthrough);
AnnounceChanges();
}
/// <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 or to owned skills.
_attempt = null;
_playthroughs.Save(_playthrough);
AnnounceChanges();
// Announced last, once lives and the discard have both landed.
_events.Raise(new AttemptFailed(_playthrough.Lives, _playthrough.IsRunOver));
}
/// <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>
/// <param name="time">
/// The completion time, or null when none was measured. Null still settles the Level — it just
/// records no Best Time. That beats inventing a value: a zero-second time is exactly the bad data
/// <see cref="LevelTime"/> exists to refuse.
/// </param>
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);
if (time is { } measured) Profile.RecordTime(level, measured);
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;
}
}