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

View File

@@ -28,6 +28,12 @@ _Avoid_: Global state, meta, persistent data, account
### Operations
**Settle**:
Resolving an ended Attempt against the Playthrough — Commit it on a First Clear, Discard it otherwise. Settling is
the only way an Attempt affects a Playthrough, and it decides First Clear versus Replay itself rather than being
told which one happened.
_Avoid_: Apply, resolve, finalise
**Commit**:
Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever.
_Avoid_: Save, persist, flush, sync
@@ -59,6 +65,12 @@ One playable stage, identified by a stable `LevelId` — never by position in a
name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them.
_Avoid_: Stage, scene, level index
**Campaign**:
The authored ordering of Levels that defines progression — which Level is first, and what follows a given one. One
Campaign is shared by every Playthrough: it is authored data, not per-Playthrough state, and it carries no scene
or presentation detail.
_Avoid_: Level list, level order, world, chapter
**Cleared Levels**:
The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a
Level is reachable is computed from this, never stored alongside it.

View File

@@ -118,13 +118,30 @@ incidental and `Health` is a public `[Export]` setter that bypasses it. Do not r
Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`,
`ClearedLevels`.
Operations: `Commit(Attempt)`, `SettleDeath(Attempt)`, `SettleReplay(Attempt)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(order)`, `IsRunOver`.
Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `IsRunOver`.
**There is no `LoseLife`** — see ADR-0005. Each settle operation calls the Attempt's `TryEnd…` internally and does
nothing when it returns false, so it is atomic and idempotent, and no bare mutator exists to be called from a
second place.
**Two settle operations, not three.** An earlier draft had `Commit` and `SettleReplay` as separate calls, which
made the *caller* decide whether a completion was a First Clear or a Replay — precisely the ADR-0003 regression.
`SettleCompletion` branches on `HasCleared(attempt.Level)` itself, so it cannot be selected wrongly.
**`Campaign`** is a domain value object holding the authored ordering of `LevelId`s (`First`, `Next`, `Contains`).
`ILevelCatalogue` stays on the adapter side resolving a `LevelId` to a scene handle and display name; the ordering
is pure data handed in as a `Campaign`, so the aggregate never touches a port.
**`Commit` does not validate skill levels against the upgrade cap.** Max level comes from
`SkillData.Upgrades.Count`, which is authored data the domain cannot see. The cap is a purchase-time rule enforced
in slice 6's `UpgradeSkill`, which is also where `TryUpgradeSkill`'s current `Upgrades[skill.Level]` overrun gets a
test. `Commit` applies what the ledger recorded.
Events leave via `IReadOnlyList<ProgressionEvent> DrainEvents()`. The composition root drains once after every use
case and republishes (ADR-0004), so operations stay `void` and the "forgot to drain" risk collapses to one line in
one place.
Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.
This is where the report's bugs become tests. Write these first, watch them fail:

View File

@@ -0,0 +1,159 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class PlaythroughTests
{
private static readonly LevelId First = LevelId.Of("village_1");
private static readonly LevelId Second = LevelId.Of("village_2");
private static readonly LevelId Third = LevelId.Of("village_3");
private static readonly PlaythroughId Id =
PlaythroughId.Of(new Guid("11111111-1111-1111-1111-111111111111"));
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
private static Campaign ThreeLevels() => Campaign.Of(new[] { First, Second, Third });
private static Playthrough StartWith(Lives lives) => Playthrough.StartNew(Id, TwoLevels(), lives);
private static Playthrough StartWith(Lives lives, Campaign campaign) =>
Playthrough.StartNew(Id, campaign, lives);
[Fact]
public void Losing_the_last_life_exhausts_lives_and_announces_that_the_run_ended()
{
// The soft-lock this replaces: DeathScreen and GameOverScreen are both wired to
// HealthComponent.Death by scene connections, which fire before PlayerController._Ready
// attaches the lambda that decrements lives. So on the last life GameOverScreen sees 1,
// shows nothing, and DeathScreen then sees 0 and refuses to reload. Settling is one
// operation, so there is no window in which a reader can see a stale value.
var playthrough = StartWith(Lives.Of(1));
var attempt = new Attempt(playthrough.CurrentLevel);
playthrough.SettleDeath(attempt);
Assert.True(playthrough.Lives.IsExhausted);
Assert.True(playthrough.IsRunOver);
Assert.Contains(playthrough.DrainEvents(), e => e is RunEnded);
}
[Fact]
public void Two_deaths_in_one_attempt_cost_one_life()
{
// A kill zone with overlapping hazards, or a hazard re-firing during the two seconds before
// the scene reloads. Lives flooring at zero cannot catch this — 3 to 1 is just as wrong.
var playthrough = StartWith(Lives.Of(3));
var attempt = new Attempt(playthrough.CurrentLevel);
playthrough.SettleDeath(attempt);
playthrough.SettleDeath(attempt);
Assert.Equal(2, playthrough.Lives.Value);
}
[Fact]
public void Dying_after_buying_a_skill_costs_neither_the_coins_nor_the_skill()
{
// Today RemoveCoins drains session coins and then debits Player.Coins immediately, while
// ResetSession throws the skill away — so buying mid-level and dying loses both. Under the
// ledger the purchase was never applied, so there is nothing to lose.
var playthrough = StartWith(Lives.Of(3));
var attempt = new Attempt(playthrough.CurrentLevel);
attempt.RecordCoinsFound(Coins.Of(50));
attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(40));
playthrough.SettleDeath(attempt);
// Had the ledger been applied this would be 10, so zero is a real assertion rather than
// just the starting value.
Assert.Equal(Coins.Zero, playthrough.Bank);
Assert.Empty(playthrough.OwnedSkills);
}
[Fact]
public void A_first_clear_banks_the_ledger_owns_the_skills_and_advances()
{
var brickThrow = SkillId.Of("brick_throw");
var playthrough = StartWith(Lives.Of(3));
var attempt = new Attempt(playthrough.CurrentLevel);
attempt.RecordCoinsFound(Coins.Of(50));
attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(40));
playthrough.SettleCompletion(attempt, TwoLevels());
Assert.Equal(Coins.Of(10), playthrough.Bank);
Assert.Equal(SkillLevel.Of(1), playthrough.OwnedSkills[brickThrow]);
Assert.True(playthrough.HasCleared(First));
Assert.Equal(Second, playthrough.CurrentLevel);
}
[Fact]
public void A_replay_banks_nothing_and_never_moves_campaign_position_backwards()
{
// ADR-0003. The dangerous version of this bug is not "no reward" — it is a completionist
// replaying level 1 while on level 3 and being sent back to level 2.
var campaign = ThreeLevels();
var playthrough = StartWith(Lives.Of(3), campaign);
playthrough.SettleCompletion(new Attempt(First), campaign);
playthrough.SettleCompletion(new Attempt(Second), campaign);
var replay = new Attempt(First);
replay.RecordCoinsFound(Coins.Of(99));
playthrough.SettleCompletion(replay, campaign);
Assert.Equal(Coins.Zero, playthrough.Bank);
Assert.Equal(Third, playthrough.CurrentLevel);
}
[Fact]
public void Reachable_levels_are_derived_from_cleared_levels()
{
// Replaces the stored UnlockedLevels list. That list was kept in sync only by
// MarkLevelComplete while UnlockLevel stayed public, so the two could diverge — a derived
// fact stored beside its own source.
var campaign = ThreeLevels();
var playthrough = StartWith(Lives.Of(3), campaign);
Assert.Equal(new[] { First }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value));
playthrough.SettleCompletion(new Attempt(First), campaign);
Assert.Equal(new[] { First, Second }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value));
}
[Fact]
public void Affordability_counts_the_bank_plus_this_attempt_minus_what_it_already_spent()
{
var campaign = TwoLevels();
var playthrough = StartWith(Lives.Of(3));
var banking = new Attempt(First);
banking.RecordCoinsFound(Coins.Of(20));
playthrough.SettleCompletion(banking, campaign);
var attempt = new Attempt(Second);
attempt.RecordCoinsFound(Coins.Of(10));
Assert.True(playthrough.CanAfford(attempt, Coins.Of(30)));
attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(25));
Assert.True(playthrough.CanAfford(attempt, Coins.Of(5)));
Assert.False(playthrough.CanAfford(attempt, Coins.Of(10)));
}
[Fact]
public void An_attempt_that_died_cannot_later_be_settled_as_a_completion()
{
// Both settle operations defer to the Attempt's terminal outcome, so a completion arriving
// after a death — an exit door triggering as the player dies on it — banks nothing.
var campaign = TwoLevels();
var playthrough = StartWith(Lives.Of(3));
var attempt = new Attempt(First);
attempt.RecordCoinsFound(Coins.Of(50));
playthrough.SettleDeath(attempt);
playthrough.SettleCompletion(attempt, campaign);
Assert.Equal(Coins.Zero, playthrough.Bank);
Assert.False(playthrough.HasCleared(First));
Assert.Equal(First, playthrough.CurrentLevel);
}
}

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;
}
}

View File

@@ -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
View 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;
}
}

View 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;