From b6a8448743cd27b4f53acbe5c7a496b2517fa52e Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 28 Jul 2026 20:07:47 +0200 Subject: [PATCH] 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. --- Autoloads/ProgressionRoot.cs | 31 ++++++++++ progression.tests/ProgressionServiceTests.cs | 65 ++++++++++++++++++++ progression/Playthrough.cs | 6 ++ progression/ProgressionService.cs | 59 ++++++++++++++++++ 4 files changed, 161 insertions(+) diff --git a/Autoloads/ProgressionRoot.cs b/Autoloads/ProgressionRoot.cs index 1aa9ccc..af83ad3 100644 --- a/Autoloads/ProgressionRoot.cs +++ b/Autoloads/ProgressionRoot.cs @@ -174,6 +174,37 @@ public partial class ProgressionRoot : Node return outcome; } + /// Free unlock, as a pickup gives. Returns false if it was already owned. + public bool GrantSkill(SkillId skill) + { + if (_service == null) return false; + + var granted = _service.GrantSkill(skill); + Publish(); + return granted; + } + + /// Takes a Skill away — for Skills that consume themselves. + public bool RevokeSkill(SkillId skill) + { + if (_service == null) return false; + + var revoked = _service.RevokeSkill(skill); + Publish(); + return revoked; + } + + /// Discards the current Attempt without charging a life — leaving a Level, not dying. + public void AbandonAttempt() => Run(() => _service.AbandonAttempt()); + + /// Console only — see . + public void ApplyCheat(Func change) => + Run(() => _service.ApplyCheat(change)); + + /// Whether there is a resumable Playthrough, for enabling the Continue button. + public bool HasSavedPlaythrough => + PeekPlaythrough(SoleSlot) == LoadOutcome.Loaded; + private void Run(System.Action operation) { if (_service == null) return; diff --git a/progression.tests/ProgressionServiceTests.cs b/progression.tests/ProgressionServiceTests.cs index 9cfacd6..63f89a2 100644 --- a/progression.tests/ProgressionServiceTests.cs +++ b/progression.tests/ProgressionServiceTests.cs @@ -230,6 +230,71 @@ public class ProgressionServiceTests Assert.False(service.CanEnter(Second)); } + [Fact] + public void A_collected_skill_is_granted_free_and_only_once() + { + // Skill pickups are not purchases — no coins change hands. Collecting one you already own is + // a no-op rather than a free upgrade. + var service = ServiceWithSkills(); + service.RecordCoinsFound(Coins.Of(10)); + + Assert.True(service.GrantSkill(BrickThrow)); + Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow)); + Assert.Equal(Coins.Of(10), service.TotalCoins); + + Assert.False(service.GrantSkill(BrickThrow)); + Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow)); + } + + [Fact] + public void A_revoked_skill_is_gone_from_both_the_attempt_and_the_playthrough() + { + // BrickShieldSkillComponent consumes itself when broken, so this is gameplay rather than a cheat. + var campaign = TwoLevels(); + var store = new InMemoryPlaythroughStore(); + var service = new ProgressionService( + Profile.Empty(), store, new InMemoryProfileStore(), campaign, Skills(), Lives.Of(3)); + service.StartPlaythrough(Id); + service.EnterLevel(First); + service.GrantSkill(BrickThrow); + service.FinishLevel(LevelTime.Of(10.0)); // commits it to the Playthrough + service.EnterLevel(Second); + + service.RevokeSkill(BrickThrow); + + Assert.False(service.IsOwned(BrickThrow)); + } + + [Fact] + public void Abandoning_an_attempt_discards_it_without_costing_a_life() + { + var service = ServiceWithSkills(); + service.RecordCoinsFound(Coins.Of(30)); + + service.AbandonAttempt(); + + Assert.Equal(3, service.Lives.Value); + Assert.Equal(Coins.Zero, service.TotalCoins); + } + + [Fact] + public void A_cheat_rewrites_the_save_shaped_state_and_is_still_validated() + { + // Cheats go through the snapshot rather than bare mutators, so ADR-0005 holds: there is still + // no SetLives or AddCoins for gameplay code to reach for. + var service = ServiceWithSkills(); + + service.ApplyCheat(snapshot => snapshot with { Bank = 500, Lives = 9 }); + + Assert.Equal(Coins.Of(500), service.TotalCoins); + Assert.Equal(9, service.Lives.Value); + + // Still validated on the way back in: a negative bank is refused, state left alone. + Assert.Throws( + () => service.ApplyCheat(snapshot => snapshot with { Bank = -1 })); + Assert.Equal(Coins.Of(500), service.TotalCoins); + } + [Fact] public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls() { diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs index 45653e6..77d6490 100644 --- a/progression/Playthrough.cs +++ b/progression/Playthrough.cs @@ -130,6 +130,12 @@ public sealed class Playthrough /// Takes the events raised since the last drain, clearing them. The composition root drains /// once after each use case and republishes. /// + /// + /// Forgets an owned Skill. Exists for Skills that consume themselves — the brick shield is spent + /// when it breaks — so this is gameplay, not a cheat. + /// + public bool RevokeSkill(SkillId skill) => _ownedSkills.Remove(skill); + public IReadOnlyList DrainEvents() => _events.Drain(); public PlaythroughSnapshot Export() => new( diff --git a/progression/ProgressionService.cs b/progression/ProgressionService.cs index 1e76a86..59bc293 100644 --- a/progression/ProgressionService.cs +++ b/progression/ProgressionService.cs @@ -176,6 +176,65 @@ public sealed class ProgressionService return PurchaseOutcome.Purchased; } + /// + /// Grants a Skill for free, as a pickup does. No coins change hands, and collecting one already + /// owned is a no-op rather than a free upgrade. + /// + public bool GrantSkill(SkillId skill) + { + if (_playthrough is null || _attempt is null) return false; + if (IsOwned(skill)) return false; + + if (!_attempt.TryRecordPurchase(skill, SkillLevel.First, Coins.Zero)) return false; + + AnnounceChanges(); + return true; + } + + /// + /// Takes a Skill away, from this Attempt's purchases and from the Playthrough alike. For Skills + /// that consume themselves rather than as a cheat. + /// + public bool RevokeSkill(SkillId skill) + { + if (_playthrough is null) return false; + + var revoked = _playthrough.RevokeSkill(skill); + AnnounceChanges(); + return revoked; + } + + /// + /// Throws the current Attempt away without charging a life — leaving a Level rather than dying in + /// it. The ledger is discarded, so coins found and Skills bought during it are lost. + /// + public void AbandonAttempt() + { + _attempt = null; + AnnounceChanges(); + } + + /// + /// Console only. Rewrites the Playthrough by transforming its snapshot. + /// + /// + /// Deliberately shaped this way rather than as SetLives / AddCoins mutators, which + /// ADR-0005 rules out — a bare mutator is an invitation, and the code being replaced had two paths + /// to exactly that. Going through the snapshot means every value is re-validated on the way back + /// in, and a rejected cheat leaves the Playthrough untouched. + /// + public void ApplyCheat(Func change) + { + if (_playthrough is null) return; + + var rewritten = Playthrough.Restore(change(_playthrough.Export())); + + _playthrough = rewritten; + _attempt = null; + _playthroughs.Save(_playthrough); + AnnounceChanges(); + } + /// /// Charges the active Attempt's death to the Playthrough and persists it. Repeated calls for one /// Attempt cost one life — the Playthrough defers to the Attempt's terminal outcome (ADR-0005),