feat(progression): Attempt ledger with terminal-outcome guard (slice 2)

Attempt carries its LevelId so settlement decides First Clear vs Replay
without trusting the caller. Outcome is terminal and refuses to change:
one death costs one life, one completion banks once.

Coins.Zero/Add driven out by the ledger rather than added up front.
21 tests. ADR-0005: Playthrough settles Attempts, has no LoseLife.
This commit is contained in:
2026-07-28 18:40:20 +02:00
parent 9767941c65
commit 40bd5c1d86
6 changed files with 232 additions and 4 deletions

75
progression/Attempt.cs Normal file
View File

@@ -0,0 +1,75 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// One try at a Level: a ledger of everything that happened during the try, plus how it ended.
/// Never persisted — a discarded Attempt is one that is simply thrown away.
/// </summary>
public sealed class Attempt
{
public Attempt(LevelId level) => Level = level;
/// <summary>
/// Which Level this is an attempt at. Settlement reads this rather than trusting a level
/// supplied by the caller, so finishing a Replay cannot advance campaign position.
/// </summary>
public LevelId Level { get; }
public AttemptOutcome Outcome { get; private set; } = AttemptOutcome.InProgress;
public Coins CoinsFound { get; private set; } = Coins.Zero;
/// <summary>
/// Records coins picked up during this Attempt. Silently ignored once the Attempt has ended:
/// a pickup firing after death is a real event, not an error, and it does not count.
/// </summary>
public void RecordCoinsFound(Coins amount)
{
if (Outcome != AttemptOutcome.InProgress) return;
CoinsFound = CoinsFound.Add(amount);
}
public Coins CoinsSpent { get; private set; } = Coins.Zero;
private readonly Dictionary<SkillId, SkillLevel> _skillsBought = new();
/// <summary>
/// Records a Skill bought or upgraded during this Attempt, at the level it ended up at.
/// Returns false once the Attempt has ended.
/// </summary>
/// <remarks>
/// Affordability is not checked here — it spans the Attempt and the Playthrough's Bank, so it
/// belongs to the caller that can see both. This records what was agreed, not whether it was
/// allowed.
/// </remarks>
public bool TryRecordPurchase(SkillId skill, SkillLevel resulting, Coins cost)
{
if (Outcome != AttemptOutcome.InProgress) return false;
_skillsBought[skill] = resulting;
CoinsSpent = CoinsSpent.Add(cost);
return true;
}
public IReadOnlyDictionary<SkillId, SkillLevel> SkillsBought => _skillsBought;
/// <summary>
/// Ends this Attempt in death. Returns false if it has already ended, in which case nothing
/// changes — a second death event costs no further lives.
/// </summary>
public bool TryEndInDeath() => TryEnd(AttemptOutcome.Died);
/// <summary>
/// Ends this Attempt in completion. Returns false if it has already ended, in which case
/// nothing changes — a second completion event banks nothing further.
/// </summary>
public bool TryEndInCompletion() => TryEnd(AttemptOutcome.Cleared);
private bool TryEnd(AttemptOutcome outcome)
{
if (Outcome != AttemptOutcome.InProgress) return false;
Outcome = outcome;
return true;
}
}

View File

@@ -0,0 +1,12 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// How an Attempt finished, or that it has not finished yet. An Attempt reaches a terminal outcome
/// once and refuses to change afterwards — that refusal is what guarantees one death costs one life.
/// </summary>
public enum AttemptOutcome
{
InProgress,
Died,
Cleared,
}

View File

@@ -10,10 +10,14 @@ public readonly struct Coins : IEquatable<Coins>
private Coins(int value) => Value = value;
public static readonly Coins Zero = new(0);
public static Coins Of(int value) => value < 0
? throw new ArgumentOutOfRangeException(nameof(value), value, "Coins cannot be negative.")
: new Coins(value);
public Coins Add(Coins other) => new(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.