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),