namespace Mr.BrickAdventures.Progression; /// /// 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. /// /// /// 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. /// 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 /// 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; /// /// Whether this Level can be entered: the Campaign's first, anything cleared, or whatever follows /// something cleared. Separate from so a caller can check before starting /// a scene change it would then have to undo. /// 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(); } /// /// 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. /// 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; } /// Why a resume failed, for a caller that wants to tell absent from corrupt. public LoadOutcome PeekPlaythrough(PlaythroughId id) => _playthroughs.Load(id).Outcome; /// Adds to a lifetime statistic on the Profile. public void RecordStat(StatName stat, int amount = 1) => Profile.Record(stat, amount); /// /// Begins an Attempt at and announces it. Does not touch campaign /// position — that advances only on a First Clear, so a Replay leaves it alone. /// /// False if there is no active Playthrough, or the Level is not reachable in it. 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; } /// /// Records coins picked up during the active Attempt. They are banked only by a First Clear. /// 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 /// both the spend and the Skill, and only a First Clear makes either permanent. /// 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; } /// /// 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. /// 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(); } /// /// 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. /// /// /// 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 /// exists to refuse. /// 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; /// /// 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}")); } /// /// Takes the events raised by this service and both aggregates. The composition root drains once /// after each operation and republishes. /// public IReadOnlyList DrainEvents() { var drained = _events.Drain().ToList(); drained.AddRange(Profile.DrainEvents()); if (_playthrough is not null) drained.AddRange(_playthrough.DrainEvents()); return drained; } }