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:
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
28
docs/adr/0005-playthrough-settles-attempts.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Playthrough has no LoseLife; it settles Attempts
|
||||
|
||||
Playthrough and Attempt are separate aggregate roots (ADR-0002), but "one death costs exactly one life" requires
|
||||
ending the Attempt and decrementing lives *together*. Rather than expose bare mutators and rely on the caller to
|
||||
sequence them, Playthrough exposes only what happened — `Commit(attempt)`, `SettleDeath(attempt)`,
|
||||
`SettleReplay(attempt)` — each of which calls the Attempt's own `TryEnd…` internally and does nothing when it
|
||||
returns false. Every operation is therefore atomic and idempotent, and there is no `LoseLife` to call from
|
||||
somewhere else.
|
||||
|
||||
The alternative shape, `if (attempt.TryEndInDeath()) playthrough.LoseLife();`, puts the idempotency in the domain
|
||||
but leaves the sequencing to the caller. A public `LoseLife` is an invitation: the code being replaced has two
|
||||
paths to exactly that operation — `LivesStateHandler.RemoveLife` and `ConsoleManager` writing `Player.Lives`
|
||||
directly, unclamped — because the operation existed to be called.
|
||||
|
||||
## Considered options
|
||||
|
||||
Collapsing the two into one aggregate with Attempt as an inner entity, making the atomicity structural. Rejected:
|
||||
`Commit(Attempt)` already gave the shape we needed, and passing one root into another's method is a transient
|
||||
reference rather than a stored association, so ADR-0002 stands unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Overlapping hazards in a kill zone, or a hazard re-firing during the two seconds before `DeathScreen` reloads,
|
||||
cost one life rather than two.
|
||||
- `ExitDoorComponent` and `ConsoleManager.GoToNextLevelCommand` can both request completion; only the first banks.
|
||||
- The application layer holds the active Attempt and passes it in. The Attempt carries its own `LevelId`, so
|
||||
`Commit` decides First Clear versus Replay from the Attempt itself rather than trusting a level supplied
|
||||
alongside it — finishing a Replay stops being a way to corrupt campaign position.
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user