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:
75
progression/Attempt.cs
Normal file
75
progression/Attempt.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
progression/AttemptOutcome.cs
Normal file
12
progression/AttemptOutcome.cs
Normal 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,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user