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:
2026-07-28 18:52:20 +02:00
parent 40bd5c1d86
commit ffa2b986ae
7 changed files with 370 additions and 2 deletions

37
progression/Campaign.cs Normal file
View 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;
}
}