feat(progression): Playthrough aggregate (slice 3)
no LoseLife (ADR-0005). SettleDeath / SettleCompletion defer to the Attempt's terminal outcome, so both are atomic and idempotent. SettleCompletion branches on HasCleared itself - a Replay cannot bank or move campaign position backwards (ADR-0003). Campaign value object holds authored level order; ReachableLevels is derived, replacing the stored UnlockedLevels. Coins.IsAtLeast driven out by cross-scope affordability. 29 tests. CONTEXT.md: Campaign, Settle.
This commit is contained in:
37
progression/Campaign.cs
Normal file
37
progression/Campaign.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// The authored ordering of Levels that defines progression. Shared by every Playthrough — it is
|
||||
/// authored data, not per-Playthrough state, and carries no scene or presentation detail.
|
||||
/// </summary>
|
||||
public sealed class Campaign
|
||||
{
|
||||
private readonly List<LevelId> _levels;
|
||||
|
||||
private Campaign(List<LevelId> levels) => _levels = levels;
|
||||
|
||||
public static Campaign Of(IEnumerable<LevelId> levels)
|
||||
{
|
||||
var ordered = levels.ToList();
|
||||
|
||||
if (ordered.Count == 0)
|
||||
throw new ArgumentException("A campaign needs at least one level.", nameof(levels));
|
||||
|
||||
if (ordered.Distinct().Count() != ordered.Count)
|
||||
throw new ArgumentException("A campaign cannot list the same level twice.", nameof(levels));
|
||||
|
||||
return new Campaign(ordered);
|
||||
}
|
||||
|
||||
public LevelId First => _levels[0];
|
||||
|
||||
/// <summary>
|
||||
/// The Level after <paramref name="level"/>, or null if it is the last one or not in this
|
||||
/// Campaign at all.
|
||||
/// </summary>
|
||||
public LevelId? Next(LevelId level)
|
||||
{
|
||||
var at = _levels.IndexOf(level);
|
||||
return at >= 0 && at + 1 < _levels.Count ? _levels[at + 1] : null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public readonly struct Coins : IEquatable<Coins>
|
||||
|
||||
public Coins Add(Coins other) => new(Value + other.Value);
|
||||
|
||||
public bool IsAtLeast(Coins other) => Value >= other.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held,
|
||||
/// leaving <paramref name="remaining"/> untouched rather than clamping to zero.
|
||||
|
||||
130
progression/Playthrough.cs
Normal file
130
progression/Playthrough.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
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 List<ProgressionEvent> _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>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public void SettleDeath(Attempt attempt)
|
||||
{
|
||||
if (!attempt.TryEndInDeath()) return;
|
||||
|
||||
Lives = Lives.Decrement();
|
||||
|
||||
if (Lives.IsExhausted)
|
||||
_events.Add(new RunEnded());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the events raised since the last drain, clearing them. The composition root drains
|
||||
/// once after each use case and republishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> DrainEvents()
|
||||
{
|
||||
var drained = _events.ToList();
|
||||
_events.Clear();
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
11
progression/ProgressionEvent.cs
Normal file
11
progression/ProgressionEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// Something that happened to player progression, raised by an aggregate as part of the operation
|
||||
/// that caused it. Plain records with no engine dependency — an adapter republishes them as Godot
|
||||
/// signals (ADR-0004), which is what makes "state changed ⇒ signal fired" un-bypassable.
|
||||
/// </summary>
|
||||
public abstract record ProgressionEvent;
|
||||
|
||||
/// <summary>Raised when a Playthrough loses its last life.</summary>
|
||||
public sealed record RunEnded : ProgressionEvent;
|
||||
Reference in New Issue
Block a user