feat(progression): Playthrough aggregate (slice 3)

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.
This commit is contained in:
2026-07-28 18:52:20 +02:00
parent 40bd5c1d86
commit ffa2b986ae
7 changed files with 370 additions and 2 deletions

View File

@@ -118,13 +118,30 @@ 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: `Commit(Attempt)`, `SettleDeath(Attempt)`, `SettleReplay(Attempt)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(order)`, `IsRunOver`.
Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`,
`HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `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.
**Two settle operations, not three.** An earlier draft had `Commit` and `SettleReplay` as separate calls, which
made the *caller* decide whether a completion was a First Clear or a Replay — precisely the ADR-0003 regression.
`SettleCompletion` branches on `HasCleared(attempt.Level)` itself, so it cannot be selected wrongly.
**`Campaign`** is a domain value object holding the authored ordering of `LevelId`s (`First`, `Next`, `Contains`).
`ILevelCatalogue` stays on the adapter side resolving a `LevelId` to a scene handle and display name; the ordering
is pure data handed in as a `Campaign`, so the aggregate never touches a port.
**`Commit` does not validate skill levels against the upgrade cap.** Max level comes from
`SkillData.Upgrades.Count`, which is authored data the domain cannot see. The cap is a purchase-time rule enforced
in slice 6's `UpgradeSkill`, which is also where `TryUpgradeSkill`'s current `Upgrades[skill.Level]` overrun gets a
test. `Commit` applies what the ledger recorded.
Events leave via `IReadOnlyList<ProgressionEvent> DrainEvents()`. The composition root drains once after every use
case and republishes (ADR-0004), so operations stay `void` and the "forgot to drain" risk collapses to one line in
one place.
Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`.
This is where the report's bugs become tests. Write these first, watch them fail: