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.
///
public void SettleCompletion(Attempt attempt, Campaign campaign)
{
var isFirstClear = !HasCleared(attempt.Level);
if (!attempt.TryEndInCompletion()) return;
if (!isFirstClear) return;
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;
}
///
/// 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.
///
public void SettleDeath(Attempt attempt)
{
if (!attempt.TryEndInDeath()) return;
Lives = Lives.Decrement();
if (Lives.IsExhausted)
_events.Raise(new RunEnded());
}
///
/// Takes the events raised since the last drain, clearing them. The composition root drains
/// once after each use case and republishes.
///
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;
}
}