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.
This commit is contained in:
12
CONTEXT.md
12
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.
|
||||
|
||||
@@ -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<ProgressionEvent> 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:
|
||||
|
||||
159
progression.tests/PlaythroughTests.cs
Normal file
159
progression.tests/PlaythroughTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
progression/Campaign.cs
Normal file
37
progression/Campaign.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class Campaign
|
||||
{
|
||||
private readonly List<LevelId> _levels;
|
||||
|
||||
private Campaign(List<LevelId> levels) => _levels = levels;
|
||||
|
||||
public static Campaign Of(IEnumerable<LevelId> 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];
|
||||
|
||||
/// <summary>
|
||||
/// The Level after <paramref name="level"/>, or null if it is the last one or not in this
|
||||
/// Campaign at all.
|
||||
/// </summary>
|
||||
public LevelId? Next(LevelId level)
|
||||
{
|
||||
var at = _levels.IndexOf(level);
|
||||
return at >= 0 && at + 1 < _levels.Count ? _levels[at + 1] : null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public readonly struct Coins : IEquatable<Coins>
|
||||
|
||||
public Coins Add(Coins other) => new(Value + other.Value);
|
||||
|
||||
public bool IsAtLeast(Coins other) => Value >= other.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts <paramref name="amount"/>. Returns false when the amount exceeds what is held,
|
||||
/// leaving <paramref name="remaining"/> untouched rather than clamping to zero.
|
||||
|
||||
130
progression/Playthrough.cs
Normal file
130
progression/Playthrough.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class Playthrough
|
||||
{
|
||||
private readonly List<ProgressionEvent> _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; }
|
||||
|
||||
/// <summary>Coins this Playthrough holds. Only a Commit changes it — never a purchase.</summary>
|
||||
public Coins Bank { get; private set; } = Coins.Zero;
|
||||
|
||||
private readonly Dictionary<SkillId, SkillLevel> _ownedSkills = new();
|
||||
|
||||
public IReadOnlyDictionary<SkillId, SkillLevel> OwnedSkills => _ownedSkills;
|
||||
|
||||
private readonly HashSet<LevelId> _clearedLevels = new();
|
||||
|
||||
/// <summary>
|
||||
/// Levels this Playthrough has completed at least once. Reachability is derived from this and
|
||||
/// never stored beside it.
|
||||
/// </summary>
|
||||
public IReadOnlySet<LevelId> ClearedLevels => _clearedLevels;
|
||||
|
||||
public bool HasCleared(LevelId level) => _clearedLevels.Contains(level);
|
||||
|
||||
/// <summary>
|
||||
/// Levels this Playthrough can enter: the Campaign's first, everything cleared, and whatever
|
||||
/// follows each cleared Level. Computed on demand — storing it beside <see cref="ClearedLevels"/>
|
||||
/// would be a second source of the same truth.
|
||||
/// </summary>
|
||||
public IReadOnlySet<LevelId> ReachableLevels(Campaign campaign)
|
||||
{
|
||||
var reachable = new HashSet<LevelId> { 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;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="cost"/> 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.
|
||||
/// </summary>
|
||||
public bool CanAfford(Attempt attempt, Coins cost) =>
|
||||
Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var available)
|
||||
&& available.IsAtLeast(cost);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SettleDeath(Attempt attempt)
|
||||
{
|
||||
if (!attempt.TryEndInDeath()) return;
|
||||
|
||||
Lives = Lives.Decrement();
|
||||
|
||||
if (Lives.IsExhausted)
|
||||
_events.Add(new RunEnded());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the events raised since the last drain, clearing them. The composition root drains
|
||||
/// once after each use case and republishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> DrainEvents()
|
||||
{
|
||||
var drained = _events.ToList();
|
||||
_events.Clear();
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
11
progression/ProgressionEvent.cs
Normal file
11
progression/ProgressionEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public abstract record ProgressionEvent;
|
||||
|
||||
/// <summary>Raised when a Playthrough loses its last life.</summary>
|
||||
public sealed record RunEnded : ProgressionEvent;
|
||||
Reference in New Issue
Block a user