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 ### 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**: **Commit**:
Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever. Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever.
_Avoid_: Save, persist, flush, sync _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. name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them.
_Avoid_: Stage, scene, level index _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**: **Cleared Levels**:
The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a 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. 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`, Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`,
`ClearedLevels`. `ClearedLevels`.
Operations: `Commit(Attempt)`, `SettleDeath(Attempt)`, `SettleReplay(Attempt)`, `CanAfford(Attempt, Coins)`, Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(order)`, `IsRunOver`. `HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `IsRunOver`.
**There is no `LoseLife`** — see ADR-0005. Each settle operation calls the Attempt's `TryEnd…` internally and does **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 nothing when it returns false, so it is atomic and idempotent, and no bare mutator exists to be called from a
second place. 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`. Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.
This is where the report's bugs become tests. Write these first, watch them fail: 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 Coins Add(Coins other) => new(Value + other.Value);
public bool IsAtLeast(Coins other) => Value >= other.Value;
/// <summary> /// <summary>
/// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held, /// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held,
/// leaving <paramref name="remaining"/> untouched rather than clamping to zero. /// 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;