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.
29 lines
1.9 KiB
Markdown
29 lines
1.9 KiB
Markdown
# 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.
|