Files
przygody-pana-cegly/progression.tests/PlaythroughTests.cs
Gabriel Kaszewski ffa2b986ae 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.
2026-07-28 18:52:20 +02:00

160 lines
6.6 KiB
C#

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);
}
}