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.
best times only improve; achievement unlock announces on the transition
only, so popup/Steam subscribers cannot double-fire; statistics are
increase-only (IncrementStat took any int).
RelockAchievement is console-only and documented as not reaching Steam -
a Steam achievement cannot be un-earned, so the disagreement is
permanent and deliberate.
refactor: extract EventLog, composed by both aggregates now that the
duplication actually exists. 34 tests.
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.