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

View File

@@ -73,12 +73,19 @@ Invariants to drive out with tests:
The ledger from ADR-0002: `CoinsFound`, `CoinsSpent`, `SkillsBought`, plus an outcome.
- `RecordCoinsFound(Coins)`
- `RecordPurchase(SkillId, Coins cost)`
- `Attempt(LevelId level)` and `LevelId Level` — the Attempt knows which Level it is an attempt at, so
`Commit` decides First Clear versus Replay from the Attempt rather than from a level the caller passes alongside
- `Outcome` — `InProgress | Died | Cleared`
- `void RecordCoinsFound(Coins)` — silently ignores late coins once ended
- `bool TryRecordPurchase(SkillId, SkillLevel resulting, Coins cost)` — records where the skill ended up, since
one Attempt can both unlock and upgrade the same skill
- `bool TryEndInDeath()` / `bool TryEndInCompletion()` — return false if the Attempt already ended
- No `Discard` method: discarding is throwing the object away.
The recording methods are deliberately asymmetric. A coin pickup firing after death is a real event — the player
node survives for two seconds before `DeathScreen` reloads — and its caller has nothing useful to do with a
`false`, so it returns nothing. A purchase's caller does care.
`CoinsFound` and `CoinsSpent` are both non-negative. There is no signed net value — settlement is
`bank.Add(found).TrySubtract(spent)` in `Playthrough.Commit`, which in that order cannot go negative given
affordability was checked at purchase time.
@@ -111,8 +118,12 @@ incidental and `Health` is a public `[Export]` setter that bypasses it. Do not r
Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`,
`ClearedLevels`.
Operations: `LoseLife`, `Commit(Attempt)`, `CanAfford(Attempt, Coins)`, `HasCleared(LevelId)`,
`ReachableLevels(order)`, `IsRunOver`.
Operations: `Commit(Attempt)`, `SettleDeath(Attempt)`, `SettleReplay(Attempt)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(order)`, `IsRunOver`.
**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
second place.
Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.