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.
This commit is contained in:
@@ -174,6 +174,37 @@ public partial class ProgressionRoot : Node
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Free unlock, as a pickup gives. Returns false if it was already owned.</summary>
|
||||||
|
public bool GrantSkill(SkillId skill)
|
||||||
|
{
|
||||||
|
if (_service == null) return false;
|
||||||
|
|
||||||
|
var granted = _service.GrantSkill(skill);
|
||||||
|
Publish();
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes a Skill away — for Skills that consume themselves.</summary>
|
||||||
|
public bool RevokeSkill(SkillId skill)
|
||||||
|
{
|
||||||
|
if (_service == null) return false;
|
||||||
|
|
||||||
|
var revoked = _service.RevokeSkill(skill);
|
||||||
|
Publish();
|
||||||
|
return revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Discards the current Attempt without charging a life — leaving a Level, not dying.</summary>
|
||||||
|
public void AbandonAttempt() => Run(() => _service.AbandonAttempt());
|
||||||
|
|
||||||
|
/// <summary>Console only — see <see cref="ProgressionService.ApplyCheat"/>.</summary>
|
||||||
|
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change) =>
|
||||||
|
Run(() => _service.ApplyCheat(change));
|
||||||
|
|
||||||
|
/// <summary>Whether there is a resumable Playthrough, for enabling the Continue button.</summary>
|
||||||
|
public bool HasSavedPlaythrough =>
|
||||||
|
PeekPlaythrough(SoleSlot) == LoadOutcome.Loaded;
|
||||||
|
|
||||||
private void Run(System.Action operation)
|
private void Run(System.Action operation)
|
||||||
{
|
{
|
||||||
if (_service == null) return;
|
if (_service == null) return;
|
||||||
|
|||||||
@@ -230,6 +230,71 @@ public class ProgressionServiceTests
|
|||||||
Assert.False(service.CanEnter(Second));
|
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<ArgumentOutOfRangeException>(
|
||||||
|
() => service.ApplyCheat(snapshot => snapshot with { Bank = -1 }));
|
||||||
|
Assert.Equal(Coins.Of(500), service.TotalCoins);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls()
|
public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ public sealed class Playthrough
|
|||||||
/// Takes the events raised since the last drain, clearing them. The composition root drains
|
/// Takes the events raised since the last drain, clearing them. The composition root drains
|
||||||
/// once after each use case and republishes.
|
/// once after each use case and republishes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool RevokeSkill(SkillId skill) => _ownedSkills.Remove(skill);
|
||||||
|
|
||||||
public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
|
public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
|
||||||
|
|
||||||
public PlaythroughSnapshot Export() => new(
|
public PlaythroughSnapshot Export() => new(
|
||||||
|
|||||||
@@ -176,6 +176,65 @@ public sealed class ProgressionService
|
|||||||
return PurchaseOutcome.Purchased;
|
return PurchaseOutcome.Purchased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes a Skill away, from this Attempt's purchases and from the Playthrough alike. For Skills
|
||||||
|
/// that consume themselves rather than as a cheat.
|
||||||
|
/// </summary>
|
||||||
|
public bool RevokeSkill(SkillId skill)
|
||||||
|
{
|
||||||
|
if (_playthrough is null) return false;
|
||||||
|
|
||||||
|
var revoked = _playthrough.RevokeSkill(skill);
|
||||||
|
AnnounceChanges();
|
||||||
|
return revoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public void AbandonAttempt()
|
||||||
|
{
|
||||||
|
_attempt = null;
|
||||||
|
AnnounceChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Console only. Rewrites the Playthrough by transforming its snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately shaped this way rather than as <c>SetLives</c> / <c>AddCoins</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change)
|
||||||
|
{
|
||||||
|
if (_playthrough is null) return;
|
||||||
|
|
||||||
|
var rewritten = Playthrough.Restore(change(_playthrough.Export()));
|
||||||
|
|
||||||
|
_playthrough = rewritten;
|
||||||
|
_attempt = null;
|
||||||
|
_playthroughs.Save(_playthrough);
|
||||||
|
AnnounceChanges();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Charges the active Attempt's death to the Playthrough and persists it. Repeated calls for one
|
/// 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),
|
/// Attempt cost one life — the Playthrough defers to the Attempt's terminal outcome (ADR-0005),
|
||||||
|
|||||||
Reference in New Issue
Block a user