diff --git a/CONTEXT.md b/CONTEXT.md index 2568377..1f957d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,12 @@ _Avoid_: Global state, meta, persistent data, account ### Operations +**Settle**: +Resolving an ended Attempt against the Playthrough — Commit it on a First Clear, Discard it otherwise. Settling is +the only way an Attempt affects a Playthrough, and it decides First Clear versus Replay itself rather than being +told which one happened. +_Avoid_: Apply, resolve, finalise + **Commit**: Applying an Attempt's ledger to the Playthrough. Only a First Clear commits — nothing else, ever. _Avoid_: Save, persist, flush, sync @@ -59,6 +65,12 @@ One playable stage, identified by a stable `LevelId` — never by position in a name. Ids must survive inserting and reordering levels, because Playthroughs and Best Times both reference them. _Avoid_: Stage, scene, level index +**Campaign**: +The authored ordering of Levels that defines progression — which Level is first, and what follows a given one. One +Campaign is shared by every Playthrough: it is authored data, not per-Playthrough state, and it carries no scene +or presentation detail. +_Avoid_: Level list, level order, world, chapter + **Cleared Levels**: The Levels a Playthrough has completed at least once. Gates both campaign progression and level select; whether a Level is reachable is computed from this, never stored alongside it. diff --git a/docs/plans/phase-a-progression-domain.md b/docs/plans/phase-a-progression-domain.md index d453510..ca6f429 100644 --- a/docs/plans/phase-a-progression-domain.md +++ b/docs/plans/phase-a-progression-domain.md @@ -118,13 +118,30 @@ incidental and `Health` is a public `[Export]` setter that bypasses it. Do not r Aggregate root. `PlaythroughId`, `Lives`, `Bank`, `OwnedSkills : SkillId → SkillLevel`, `CurrentLevel : LevelId`, `ClearedLevels`. -Operations: `Commit(Attempt)`, `SettleDeath(Attempt)`, `SettleReplay(Attempt)`, `CanAfford(Attempt, Coins)`, -`HasCleared(LevelId)`, `ReachableLevels(order)`, `IsRunOver`. +Operations: `SettleDeath(Attempt)`, `SettleCompletion(Attempt, Campaign)`, `CanAfford(Attempt, Coins)`, +`HasCleared(LevelId)`, `ReachableLevels(Campaign)`, `IsRunOver`. **There is no `LoseLife`** — see ADR-0005. Each settle operation calls the Attempt's `TryEnd…` internally and does nothing when it returns false, so it is atomic and idempotent, and no bare mutator exists to be called from a second place. +**Two settle operations, not three.** An earlier draft had `Commit` and `SettleReplay` as separate calls, which +made the *caller* decide whether a completion was a First Clear or a Replay — precisely the ADR-0003 regression. +`SettleCompletion` branches on `HasCleared(attempt.Level)` itself, so it cannot be selected wrongly. + +**`Campaign`** is a domain value object holding the authored ordering of `LevelId`s (`First`, `Next`, `Contains`). +`ILevelCatalogue` stays on the adapter side resolving a `LevelId` to a scene handle and display name; the ordering +is pure data handed in as a `Campaign`, so the aggregate never touches a port. + +**`Commit` does not validate skill levels against the upgrade cap.** Max level comes from +`SkillData.Upgrades.Count`, which is authored data the domain cannot see. The cap is a purchase-time rule enforced +in slice 6's `UpgradeSkill`, which is also where `TryUpgradeSkill`'s current `Upgrades[skill.Level]` overrun gets a +test. `Commit` applies what the ledger recorded. + +Events leave via `IReadOnlyList DrainEvents()`. The composition root drains once after every use +case and republishes (ADR-0004), so operations stay `void` and the "forgot to drain" risk collapses to one line in +one place. + Raises: `LifeLost(remaining)`, `RunEnded`, `BankChanged(total)`, `SkillSetChanged`. This is where the report's bugs become tests. Write these first, watch them fail: diff --git a/progression.tests/PlaythroughTests.cs b/progression.tests/PlaythroughTests.cs new file mode 100644 index 0000000..5920408 --- /dev/null +++ b/progression.tests/PlaythroughTests.cs @@ -0,0 +1,159 @@ +namespace Mr.BrickAdventures.Progression.Tests; + +public class PlaythroughTests +{ + private static readonly LevelId First = LevelId.Of("village_1"); + private static readonly LevelId Second = LevelId.Of("village_2"); + private static readonly LevelId Third = LevelId.Of("village_3"); + + private static readonly PlaythroughId Id = + PlaythroughId.Of(new Guid("11111111-1111-1111-1111-111111111111")); + + private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second }); + private static Campaign ThreeLevels() => Campaign.Of(new[] { First, Second, Third }); + + private static Playthrough StartWith(Lives lives) => Playthrough.StartNew(Id, TwoLevels(), lives); + + private static Playthrough StartWith(Lives lives, Campaign campaign) => + Playthrough.StartNew(Id, campaign, lives); + + [Fact] + public void Losing_the_last_life_exhausts_lives_and_announces_that_the_run_ended() + { + // The soft-lock this replaces: DeathScreen and GameOverScreen are both wired to + // HealthComponent.Death by scene connections, which fire before PlayerController._Ready + // attaches the lambda that decrements lives. So on the last life GameOverScreen sees 1, + // shows nothing, and DeathScreen then sees 0 and refuses to reload. Settling is one + // operation, so there is no window in which a reader can see a stale value. + var playthrough = StartWith(Lives.Of(1)); + var attempt = new Attempt(playthrough.CurrentLevel); + + playthrough.SettleDeath(attempt); + + Assert.True(playthrough.Lives.IsExhausted); + Assert.True(playthrough.IsRunOver); + Assert.Contains(playthrough.DrainEvents(), e => e is RunEnded); + } + + [Fact] + public void Two_deaths_in_one_attempt_cost_one_life() + { + // A kill zone with overlapping hazards, or a hazard re-firing during the two seconds before + // the scene reloads. Lives flooring at zero cannot catch this — 3 to 1 is just as wrong. + var playthrough = StartWith(Lives.Of(3)); + var attempt = new Attempt(playthrough.CurrentLevel); + + playthrough.SettleDeath(attempt); + playthrough.SettleDeath(attempt); + + Assert.Equal(2, playthrough.Lives.Value); + } + + [Fact] + public void Dying_after_buying_a_skill_costs_neither_the_coins_nor_the_skill() + { + // Today RemoveCoins drains session coins and then debits Player.Coins immediately, while + // ResetSession throws the skill away — so buying mid-level and dying loses both. Under the + // ledger the purchase was never applied, so there is nothing to lose. + var playthrough = StartWith(Lives.Of(3)); + var attempt = new Attempt(playthrough.CurrentLevel); + attempt.RecordCoinsFound(Coins.Of(50)); + attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.Of(1), Coins.Of(40)); + + playthrough.SettleDeath(attempt); + + // Had the ledger been applied this would be 10, so zero is a real assertion rather than + // just the starting value. + Assert.Equal(Coins.Zero, playthrough.Bank); + Assert.Empty(playthrough.OwnedSkills); + } + + [Fact] + public void A_first_clear_banks_the_ledger_owns_the_skills_and_advances() + { + var brickThrow = SkillId.Of("brick_throw"); + var playthrough = StartWith(Lives.Of(3)); + var attempt = new Attempt(playthrough.CurrentLevel); + attempt.RecordCoinsFound(Coins.Of(50)); + attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(1), Coins.Of(40)); + + playthrough.SettleCompletion(attempt, TwoLevels()); + + Assert.Equal(Coins.Of(10), playthrough.Bank); + Assert.Equal(SkillLevel.Of(1), playthrough.OwnedSkills[brickThrow]); + Assert.True(playthrough.HasCleared(First)); + Assert.Equal(Second, playthrough.CurrentLevel); + } + + [Fact] + public void A_replay_banks_nothing_and_never_moves_campaign_position_backwards() + { + // ADR-0003. The dangerous version of this bug is not "no reward" — it is a completionist + // replaying level 1 while on level 3 and being sent back to level 2. + var campaign = ThreeLevels(); + var playthrough = StartWith(Lives.Of(3), campaign); + playthrough.SettleCompletion(new Attempt(First), campaign); + playthrough.SettleCompletion(new Attempt(Second), campaign); + + var replay = new Attempt(First); + replay.RecordCoinsFound(Coins.Of(99)); + playthrough.SettleCompletion(replay, campaign); + + Assert.Equal(Coins.Zero, playthrough.Bank); + Assert.Equal(Third, playthrough.CurrentLevel); + } + + [Fact] + public void Reachable_levels_are_derived_from_cleared_levels() + { + // Replaces the stored UnlockedLevels list. That list was kept in sync only by + // MarkLevelComplete while UnlockLevel stayed public, so the two could diverge — a derived + // fact stored beside its own source. + var campaign = ThreeLevels(); + var playthrough = StartWith(Lives.Of(3), campaign); + + Assert.Equal(new[] { First }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value)); + + playthrough.SettleCompletion(new Attempt(First), campaign); + + Assert.Equal(new[] { First, Second }, playthrough.ReachableLevels(campaign).OrderBy(l => l.Value)); + } + + [Fact] + public void Affordability_counts_the_bank_plus_this_attempt_minus_what_it_already_spent() + { + var campaign = TwoLevels(); + var playthrough = StartWith(Lives.Of(3)); + + var banking = new Attempt(First); + banking.RecordCoinsFound(Coins.Of(20)); + playthrough.SettleCompletion(banking, campaign); + + var attempt = new Attempt(Second); + attempt.RecordCoinsFound(Coins.Of(10)); + Assert.True(playthrough.CanAfford(attempt, Coins.Of(30))); + + attempt.TryRecordPurchase(SkillId.Of("magnetic"), SkillLevel.Of(1), Coins.Of(25)); + + Assert.True(playthrough.CanAfford(attempt, Coins.Of(5))); + Assert.False(playthrough.CanAfford(attempt, Coins.Of(10))); + } + + [Fact] + public void An_attempt_that_died_cannot_later_be_settled_as_a_completion() + { + // Both settle operations defer to the Attempt's terminal outcome, so a completion arriving + // after a death — an exit door triggering as the player dies on it — banks nothing. + var campaign = TwoLevels(); + var playthrough = StartWith(Lives.Of(3)); + var attempt = new Attempt(First); + attempt.RecordCoinsFound(Coins.Of(50)); + playthrough.SettleDeath(attempt); + + playthrough.SettleCompletion(attempt, campaign); + + Assert.Equal(Coins.Zero, playthrough.Bank); + Assert.False(playthrough.HasCleared(First)); + Assert.Equal(First, playthrough.CurrentLevel); + } +} diff --git a/progression/Campaign.cs b/progression/Campaign.cs new file mode 100644 index 0000000..53f6543 --- /dev/null +++ b/progression/Campaign.cs @@ -0,0 +1,37 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// The authored ordering of Levels that defines progression. Shared by every Playthrough — it is +/// authored data, not per-Playthrough state, and carries no scene or presentation detail. +/// +public sealed class Campaign +{ + private readonly List _levels; + + private Campaign(List levels) => _levels = levels; + + public static Campaign Of(IEnumerable levels) + { + var ordered = levels.ToList(); + + if (ordered.Count == 0) + throw new ArgumentException("A campaign needs at least one level.", nameof(levels)); + + if (ordered.Distinct().Count() != ordered.Count) + throw new ArgumentException("A campaign cannot list the same level twice.", nameof(levels)); + + return new Campaign(ordered); + } + + public LevelId First => _levels[0]; + + /// + /// The Level after , or null if it is the last one or not in this + /// Campaign at all. + /// + public LevelId? Next(LevelId level) + { + var at = _levels.IndexOf(level); + return at >= 0 && at + 1 < _levels.Count ? _levels[at + 1] : null; + } +} diff --git a/progression/Coins.cs b/progression/Coins.cs index b26c593..b0b70d0 100644 --- a/progression/Coins.cs +++ b/progression/Coins.cs @@ -18,6 +18,8 @@ public readonly struct Coins : IEquatable public Coins Add(Coins other) => new(Value + other.Value); + public bool IsAtLeast(Coins other) => Value >= other.Value; + /// /// Subtracts . Returns false when the amount exceeds what is held, /// leaving untouched rather than clamping to zero. diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs new file mode 100644 index 0000000..0e2174d --- /dev/null +++ b/progression/Playthrough.cs @@ -0,0 +1,130 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// One game from New Game until game over. Several exist at once and the player picks between them; +/// "save slot" is the word the UI uses for the same thing. +/// +/// +/// Deliberately exposes no bare mutators — no LoseLife, no AddCoins. An ended Attempt is settled, +/// and settling is atomic and idempotent because it defers to the Attempt's own terminal outcome. +/// See docs/adr/0005-playthrough-settles-attempts.md. +/// +public sealed class Playthrough +{ + private readonly List _events = new(); + + private Playthrough(PlaythroughId id, LevelId currentLevel, Lives lives) + { + Id = id; + CurrentLevel = currentLevel; + Lives = lives; + } + + public static Playthrough StartNew(PlaythroughId id, Campaign campaign, Lives startingLives) => + new(id, campaign.First, startingLives); + + public PlaythroughId Id { get; } + + public LevelId CurrentLevel { get; private set; } + + public Lives Lives { get; private set; } + + /// Coins this Playthrough holds. Only a Commit changes it — never a purchase. + public Coins Bank { get; private set; } = Coins.Zero; + + private readonly Dictionary _ownedSkills = new(); + + public IReadOnlyDictionary OwnedSkills => _ownedSkills; + + private readonly HashSet _clearedLevels = new(); + + /// + /// Levels this Playthrough has completed at least once. Reachability is derived from this and + /// never stored beside it. + /// + public IReadOnlySet ClearedLevels => _clearedLevels; + + public bool HasCleared(LevelId level) => _clearedLevels.Contains(level); + + /// + /// Levels this Playthrough can enter: the Campaign's first, everything cleared, and whatever + /// follows each cleared Level. Computed on demand — storing it beside + /// would be a second source of the same truth. + /// + public IReadOnlySet ReachableLevels(Campaign campaign) + { + var reachable = new HashSet { campaign.First }; + + foreach (var cleared in _clearedLevels) + { + reachable.Add(cleared); + if (campaign.Next(cleared) is { } next) + reachable.Add(next); + } + + return reachable; + } + + public bool IsRunOver => Lives.IsExhausted; + + /// + /// Whether can be met from the Bank plus what this Attempt has found, + /// less what it has already spent. Spans both scopes, which is why it lives here rather than on + /// the Attempt — the Attempt cannot see the Bank. + /// + public bool CanAfford(Attempt attempt, Coins cost) => + Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var available) + && available.IsAtLeast(cost); + + /// + /// Ends the given Attempt in completion, then Commits it if this is a First Clear or Discards it + /// if it is a Replay. Decides which from the Attempt's own Level, so a Replay cannot advance + /// campaign position (ADR-0003). Does nothing if the Attempt has already ended. + /// + public void SettleCompletion(Attempt attempt, Campaign campaign) + { + var isFirstClear = !HasCleared(attempt.Level); + + if (!attempt.TryEndInCompletion()) return; + if (!isFirstClear) return; + + if (!Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var settled)) + throw new InvalidOperationException( + "Settling this attempt would drive the bank negative, which means affordability was never checked."); + + Bank = settled; + + foreach (var (skill, level) in attempt.SkillsBought) + _ownedSkills[skill] = level; + + _clearedLevels.Add(attempt.Level); + + if (campaign.Next(attempt.Level) is { } next) + CurrentLevel = next; + } + + /// + /// Ends the given Attempt in death and charges one life for it. Does nothing if the Attempt has + /// already ended, so a repeated death event costs no further lives. + /// + public void SettleDeath(Attempt attempt) + { + if (!attempt.TryEndInDeath()) return; + + Lives = Lives.Decrement(); + + if (Lives.IsExhausted) + _events.Add(new RunEnded()); + } + + /// + /// Takes the events raised since the last drain, clearing them. The composition root drains + /// once after each use case and republishes. + /// + public IReadOnlyList DrainEvents() + { + var drained = _events.ToList(); + _events.Clear(); + return drained; + } +} diff --git a/progression/ProgressionEvent.cs b/progression/ProgressionEvent.cs new file mode 100644 index 0000000..70bb1a8 --- /dev/null +++ b/progression/ProgressionEvent.cs @@ -0,0 +1,11 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Something that happened to player progression, raised by an aggregate as part of the operation +/// that caused it. Plain records with no engine dependency — an adapter republishes them as Godot +/// signals (ADR-0004), which is what makes "state changed ⇒ signal fired" un-bypassable. +/// +public abstract record ProgressionEvent; + +/// Raised when a Playthrough loses its last life. +public sealed record RunEnded : ProgressionEvent;