feat(progression): use cases (slice 6)

ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.

EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.

SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.

SkillLevel.First/Next driven out here by a real caller. 53 tests.
This commit is contained in:
2026-07-28 19:10:06 +02:00
parent 003ec4b010
commit 330e10880c
8 changed files with 433 additions and 6 deletions

View File

@@ -81,12 +81,16 @@ public sealed class Playthrough
/// 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)
/// <returns>
/// True when this was a First Clear and the ledger was committed. False for a Replay, or when the
/// Attempt had already ended — in both cases nothing here changed.
/// </returns>
public bool SettleCompletion(Attempt attempt, Campaign campaign)
{
var isFirstClear = !HasCleared(attempt.Level);
if (!attempt.TryEndInCompletion()) return;
if (!isFirstClear) return;
if (!attempt.TryEndInCompletion()) return false;
if (!isFirstClear) return false;
if (!Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var settled))
throw new InvalidOperationException(
@@ -101,20 +105,25 @@ public sealed class Playthrough
if (campaign.Next(attempt.Level) is { } next)
CurrentLevel = next;
return true;
}
/// <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)
/// <returns>False if the Attempt had already ended, in which case nothing changed.</returns>
public bool SettleDeath(Attempt attempt)
{
if (!attempt.TryEndInDeath()) return;
if (!attempt.TryEndInDeath()) return false;
Lives = Lives.Decrement();
if (Lives.IsExhausted)
_events.Raise(new RunEnded());
return true;
}
/// <summary>