Commit Graph

12 Commits

Author SHA1 Message Date
e8d1da8f89 feat: lives and death move to the new domain (commit C of the swap)
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.
2026-07-28 20:19:35 +02:00
b6a8448743 feat(progression): capabilities the adapters need (commit A of the swap)
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.
2026-07-28 20:07:47 +02:00
a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
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.
2026-07-28 19:57:01 +02:00
ca54bf0f82 feat(progression): read surface and change announcements (slice 10a)
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.
2026-07-28 19:50:05 +02:00
330e10880c feat(progression): use cases (slice 6)
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.
2026-07-28 19:10:06 +02:00
003ec4b010 feat(progression): snapshots, ports, in-memory adapters (slice 5)
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.
2026-07-28 19:02:41 +02:00
05149bb4a9 feat(progression): Profile aggregate (slice 4)
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.
2026-07-28 18:56:58 +02:00
ffa2b986ae 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.
2026-07-28 18:52:20 +02:00
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
9767941c65 feat(progression): remaining slice 1 value objects
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.
2026-07-28 18:27:26 +02:00
313a5315b1 feat(progression): Coins, LevelTime, SkillLevel core invariants (slice 1)
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
2026-07-28 18:22:46 +02:00
c3bbf3282a chore: scaffold progression domain + test projects (slice 0)
net8.0 classlib, no engine ref, EnableDynamicLoading; xunit test project
referencing domain only; excluded from Godot's **/*.cs glob; .gdignore both
2026-07-28 18:14:24 +02:00