namespace Mr.BrickAdventures.Progression; /// /// One game from New Game until game over. Several exist at once and the player picks between them; /// "save slot" is the word the UI uses for the same thing. /// /// /// Deliberately exposes no bare mutators — no LoseLife, no AddCoins. An ended Attempt is settled, /// and settling is atomic and idempotent because it defers to the Attempt's own terminal outcome. /// See docs/adr/0005-playthrough-settles-attempts.md. /// public sealed class Playthrough { private readonly EventLog _events = new(); private Playthrough(PlaythroughId id, LevelId currentLevel, Lives lives) { Id = id; CurrentLevel = currentLevel; Lives = lives; } public static Playthrough StartNew(PlaythroughId id, Campaign campaign, Lives startingLives) => new(id, campaign.First, startingLives); public PlaythroughId Id { get; } public LevelId CurrentLevel { get; private set; } public Lives Lives { get; private set; } /// Coins this Playthrough holds. Only a Commit changes it — never a purchase. public Coins Bank { get; private set; } = Coins.Zero; private readonly Dictionary _ownedSkills = new(); public IReadOnlyDictionary OwnedSkills => _ownedSkills; private readonly HashSet _clearedLevels = new(); /// /// Levels this Playthrough has completed at least once. Reachability is derived from this and /// never stored beside it. /// public IReadOnlySet ClearedLevels => _clearedLevels; public bool HasCleared(LevelId level) => _clearedLevels.Contains(level); /// /// Levels this Playthrough can enter: the Campaign's first, everything cleared, and whatever /// follows each cleared Level. Computed on demand — storing it beside /// would be a second source of the same truth. /// public IReadOnlySet ReachableLevels(Campaign campaign) { var reachable = new HashSet { campaign.First }; foreach (var cleared in _clearedLevels) { reachable.Add(cleared); if (campaign.Next(cleared) is { } next) reachable.Add(next); } return reachable; } public bool IsRunOver => Lives.IsExhausted; /// /// Whether can be met from the Bank plus what this Attempt has found, /// less what it has already spent. Spans both scopes, which is why it lives here rather than on /// the Attempt — the Attempt cannot see the Bank. /// public bool CanAfford(Attempt attempt, Coins cost) => Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var available) && available.IsAtLeast(cost); /// /// Ends the given Attempt in completion, then Commits it if this is a First Clear or Discards it /// if it is a Replay. Decides which from the Attempt's own Level, so a Replay cannot advance /// campaign position (ADR-0003). Does nothing if the Attempt has already ended. /// /// /// True when this was a First Clear and the ledger was committed. False for a Replay, or when the /// Attempt had already ended — in both cases nothing here changed. /// public bool SettleCompletion(Attempt attempt, Campaign campaign) { var isFirstClear = !HasCleared(attempt.Level); if (!attempt.TryEndInCompletion()) return false; if (!isFirstClear) return false; if (!Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var settled)) throw new InvalidOperationException( "Settling this attempt would drive the bank negative, which means affordability was never checked."); Bank = settled; foreach (var (skill, level) in attempt.SkillsBought) _ownedSkills[skill] = level; _clearedLevels.Add(attempt.Level); if (campaign.Next(attempt.Level) is { } next) CurrentLevel = next; return true; } /// /// Ends the given Attempt in death and charges one life for it. Does nothing if the Attempt has /// already ended, so a repeated death event costs no further lives. /// /// False if the Attempt had already ended, in which case nothing changed. public bool SettleDeath(Attempt attempt) { if (!attempt.TryEndInDeath()) return false; Lives = Lives.Decrement(); if (Lives.IsExhausted) _events.Raise(new RunEnded()); return true; } /// /// Takes the events raised since the last drain, clearing them. The composition root drains /// once after each use case and republishes. /// /// /// Forgets an owned Skill. Exists for Skills that consume themselves — the brick shield is spent /// when it breaks — so this is gameplay, not a cheat. /// public bool RevokeSkill(SkillId skill) => _ownedSkills.Remove(skill); public IReadOnlyList DrainEvents() => _events.Drain(); public PlaythroughSnapshot Export() => new( Id.Value, Lives.Value, Bank.Value, CurrentLevel.Value, _ownedSkills.ToDictionary(e => e.Key.Value, e => e.Value.Value), _clearedLevels.Select(l => l.Value).ToList()); /// /// Rebuilds a Playthrough from a snapshot, re-running every value object's factory so an invalid /// snapshot is rejected here rather than becoming corrupt in-memory state. /// public static Playthrough Restore(PlaythroughSnapshot snapshot) { var playthrough = new Playthrough( PlaythroughId.Of(snapshot.Id), LevelId.Of(snapshot.CurrentLevel), Lives.Of(snapshot.Lives)) { Bank = Coins.Of(snapshot.Bank), }; foreach (var (skill, level) in snapshot.OwnedSkills) playthrough._ownedSkills[SkillId.Of(skill)] = SkillLevel.Of(level); foreach (var level in snapshot.ClearedLevels) playthrough._clearedLevels.Add(LevelId.Of(level)); return playthrough; } }