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); } [Fact] public void Committing_an_attempt_that_overspent_the_bank_is_an_impossible_state() { // PurchaseSkill's affordability check makes this unreachable through the application layer. // Asserted directly so the guard is covered rather than being dead code nobody has run — if // it ever fires, the bug is a missing affordability check, not this line. var campaign = TwoLevels(); var playthrough = StartWith(Lives.Of(3)); var attempt = new Attempt(First); attempt.TryRecordPurchase(SkillId.Of("brick_throw"), SkillLevel.First, Coins.Of(999)); Assert.Throws(() => playthrough.SettleCompletion(attempt, campaign)); } }