Files
przygody-pana-cegly/docs/adr/0005-playthrough-settles-attempts.md
Gabriel Kaszewski 40bd5c1d86 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.
2026-07-28 18:40:20 +02:00

1.9 KiB

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.