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.
160 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|