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; } public void StartPlaythrough(PlaythroughId id) { _playthrough = Playthrough.StartNew(id, _campaign, _startingLives); _attempt = null; _playthroughs.Save(_playthrough); } /// /// 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)); 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); /// /// 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 = CurrentLevelOf(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; } /// /// 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. /// 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; } /// /// 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; _playthroughs.Save(_playthrough); } /// /// 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. /// 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); } /// /// 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; } }