fixes the regression commit B introduced: coins and skills lived in the
Attempt ledger, but nothing discarded it, because LivesStateHandler still
called the old store and HandlePlayerDeath was never invoked. Dying kept
everything earned that level. Discard-on-death is the ledger's purpose,
so it could not be separated from it.
new AttemptFailed(remaining, runOver) event, raised after the life is
spent and the ledger discarded. DeathScreen and GameOverScreen react to
that instead of being wired to HealthComponent.Death by scene connections
which fired *before* the life was charged - on the last life game-over
saw one life left and showed nothing, then the death screen saw zero and
refused to reload. 18 scene connections deleted; the race is now
unrepresentable rather than fixed.
DeathScreen also had two read paths for lives - a signal and a direct
store read - because neither was reliable alone. Now one.
Hud reads both facts from signals; only health still polls, and that
belongs to the component rather than to progression. GameManager no
longer touches GameStateStore or SaveSystem at all.
no behaviour change - additive only.
GrantSkill: pickups are free unlocks, and collecting one already owned is
a no-op rather than a free upgrade.
RevokeSkill: for skills that consume themselves (brick shield).
AbandonAttempt: leaving a level without dying, so no life is charged.
ApplyCheat rewrites the Playthrough through its snapshot rather than via
SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is
an invitation, and the old code had two paths to exactly that. Every
value is re-validated on the way back in and a rejected cheat leaves the
Playthrough untouched, which is asserted.
73 tests.
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.
GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.
GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.
FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.
Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
reads for the UI: TotalCoins, Lives, IsRunOver, CurrentLevel,
LevelOf/IsOwned/CanAfford, HasCleared. The read half of candidate 01 -
callers get a narrow surface instead of reaching into state.
new domain events LivesChanged / TotalCoinsChanged / SkillSetChanged,
announced by comparing against the last announced value, so an operation
can neither forget to announce nor announce something unchanged.
HandlePlayerDeath now clears the spent attempt - it was still counting
towards TotalCoins after its ledger had been discarded.
republisher and EventBus extended; SkillSetChanged is what SkillManager
will reconcile against instead of the non-atomic call pairs. 65 tests.
new progression.adapters project, no engine reference. Stores take a
directory path; Godot resolves user:// with GlobalizePath and passes it
in, which is what makes them testable at all - originally I'd written
this slice off as untestable.
version lives in a file envelope, not the snapshot, so the domain only
knows the current shape. A newer-build save reports Corrupt with a
reason instead of masquerading as "no save" and getting overwritten.
atomic write via temp + rename. One file per aggregate: saving a run
leaves the profile file byte-identical, asserted.
guard rail fixed - it was matching prose in doc comments, now anchored
to using-directives plus a project.assets.json check. 63 tests.
ProgressionService holds the loaded Profile, active Playthrough and
active Attempt, so "exactly one active Attempt" has a testable home.
EnterLevel always announces LevelStarted - the reason ghost recording
and splits never worked on level 1 or after a Continue. Refuses
unreachable levels. Does not touch campaign position, so a Replay
leaves it alone.
SkillCatalogue returns null for a skill with no authored upgrades,
killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current
level from the ledger first, so one Attempt can unlock then upgrade.
HandlePlayerDeath now persists - death was free if you quit.
SkillLevel.First/Next driven out here by a real caller. 53 tests.
flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.
snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.
LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.
Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
best times only improve; achievement unlock announces on the transition
only, so popup/Steam subscribers cannot double-fire; statistics are
increase-only (IncrementStat took any int).
RelockAchievement is console-only and documented as not reaching Steam -
a Steam achievement cannot be un-earned, so the disagreement is
permanent and deliberate.
refactor: extract EventLog, composed by both aggregates now that the
duplication actually exists. 34 tests.
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.
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.
Lives (floors at zero, exhaustion observable), LevelId, SkillId,
PlaythroughId (never self-generates, keeps tests deterministic),
StatName enum, LevelTime rejects NaN/infinity.
14 tests. Arithmetic/comparison members deferred to the slices whose
behaviour needs them rather than added speculatively.
three bug-encoding invariants, one red/green cycle each:
- Coins.TrySubtract fails instead of clamping
- LevelTime rejects zero (continued-run zero best-times)
- SkillLevel has no zero (unowned is absence, not Level=1)
plan: drop signed NetCoins; add Attempt outcome as idempotency guard