no behaviour change - additive only. GrantSkill: pickups are free unlocks, and collecting one already owned is a no-op rather than a free upgrade. RevokeSkill: for skills that consume themselves (brick shield). AbandonAttempt: leaving a level without dying, so no life is charged. ApplyCheat rewrites the Playthrough through its snapshot rather than via SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is an invitation, and the old code had two paths to exactly that. Every value is re-validated on the way back in and a rejected cheat leaves the Playthrough untouched, which is asserted. 73 tests.
172 lines
6.3 KiB
C#
172 lines
6.3 KiB
C#
namespace Mr.BrickAdventures.Progression;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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; }
|
|
|
|
/// <summary>Coins this Playthrough holds. Only a Commit changes it — never a purchase.</summary>
|
|
public Coins Bank { get; private set; } = Coins.Zero;
|
|
|
|
private readonly Dictionary<SkillId, SkillLevel> _ownedSkills = new();
|
|
|
|
public IReadOnlyDictionary<SkillId, SkillLevel> OwnedSkills => _ownedSkills;
|
|
|
|
private readonly HashSet<LevelId> _clearedLevels = new();
|
|
|
|
/// <summary>
|
|
/// Levels this Playthrough has completed at least once. Reachability is derived from this and
|
|
/// never stored beside it.
|
|
/// </summary>
|
|
public IReadOnlySet<LevelId> ClearedLevels => _clearedLevels;
|
|
|
|
public bool HasCleared(LevelId level) => _clearedLevels.Contains(level);
|
|
|
|
/// <summary>
|
|
/// Levels this Playthrough can enter: the Campaign's first, everything cleared, and whatever
|
|
/// follows each cleared Level. Computed on demand — storing it beside <see cref="ClearedLevels"/>
|
|
/// would be a second source of the same truth.
|
|
/// </summary>
|
|
public IReadOnlySet<LevelId> ReachableLevels(Campaign campaign)
|
|
{
|
|
var reachable = new HashSet<LevelId> { 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;
|
|
|
|
/// <summary>
|
|
/// Whether <paramref name="cost"/> 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.
|
|
/// </summary>
|
|
public bool CanAfford(Attempt attempt, Coins cost) =>
|
|
Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var available)
|
|
&& available.IsAtLeast(cost);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// 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.
|
|
/// </returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>False if the Attempt had already ended, in which case nothing changed.</returns>
|
|
public bool SettleDeath(Attempt attempt)
|
|
{
|
|
if (!attempt.TryEndInDeath()) return false;
|
|
|
|
Lives = Lives.Decrement();
|
|
|
|
if (Lives.IsExhausted)
|
|
_events.Raise(new RunEnded());
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the events raised since the last drain, clearing them. The composition root drains
|
|
/// once after each use case and republishes.
|
|
/// </summary>
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool RevokeSkill(SkillId skill) => _ownedSkills.Remove(skill);
|
|
|
|
public IReadOnlyList<ProgressionEvent> 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());
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|