namespace Mr.BrickAdventures.Progression.Tests; public class ProgressionServiceTests { private static readonly LevelId First = LevelId.Of("village_1"); private static readonly LevelId Second = LevelId.Of("village_2"); private static readonly PlaythroughId Id = PlaythroughId.Of(new Guid("44444444-4444-4444-4444-444444444444")); private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second }); private static ProgressionService Service() { var service = new ProgressionService( Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(), TwoLevels(), SkillCatalogue.Empty(), startingLives: Lives.Of(3)); service.StartPlaythrough(Id); service.DrainEvents(); // setup announces; tests assert about the operation under test return service; } [Fact] public void Entering_a_level_always_announces_that_it_started() { // Today LevelStarted is emitted only by TryToGoToNextLevel, so it never fires for the first // level of a new game or for anything after a Continue. GhostManager.StartRecording and the // speedrun timer both hang off it, which is why neither works except mid-campaign. var service = Service(); service.EnterLevel(First); Assert.Contains(service.DrainEvents(), e => e is LevelStarted started && started.Level.Equals(First)); } [Fact] public void Entering_a_level_the_playthrough_cannot_reach_is_refused() { var service = Service(); Assert.False(service.EnterLevel(Second)); Assert.Empty(service.DrainEvents()); } [Fact] public void Two_death_events_in_one_attempt_cost_one_life_and_persist_the_result() { // End to end this time. Also note the save: today nothing persists on death, so quitting // after dying gives the lives back. var store = new InMemoryPlaythroughStore(); var service = new ProgressionService( Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), SkillCatalogue.Empty(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.HandlePlayerDeath(); service.HandlePlayerDeath(); var reloaded = store.Load(Id); Assert.Equal(LoadOutcome.Loaded, reloaded.Outcome); Assert.Equal(2, reloaded.Value!.Lives.Value); } private static readonly SkillId BrickThrow = SkillId.Of("brick_throw"); private static readonly SkillId BrickShield = SkillId.Of("brick_shield"); /// brick_throw costs 10 then 25; brick_shield has no authored upgrades at all. private static SkillCatalogue Skills() => SkillCatalogue.Of( new Dictionary> { [BrickThrow] = new[] { Coins.Of(10), Coins.Of(25) }, [BrickShield] = Array.Empty(), }); private static ProgressionService ServiceWithSkills(IPlaythroughStore? store = null) { var service = new ProgressionService( Profile.Empty(), store ?? new InMemoryPlaythroughStore(), new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.DrainEvents(); // setup announces; tests assert about the operation under test return service; } [Fact] public void A_skill_that_cannot_be_afforded_is_refused_and_records_nothing() { // The bank is empty and the attempt has found nothing, so there is no way to pay. Refusing // here is what keeps SettleCompletion's negative-bank assertion unreachable. var service = ServiceWithSkills(); Assert.Equal(PurchaseOutcome.NotAffordable, service.PurchaseSkill(BrickThrow)); } [Fact] public void A_skill_with_no_authored_upgrades_cannot_be_bought() { // Five skills currently have no upgrades/ entries. TryUnlockSkill reads Upgrades[0].Cost on // them, which would throw — it is unreachable today only because MarketplaceButton hides // them. Here it is a stated outcome instead of a hidden landmine. var service = ServiceWithSkills(); Assert.Equal(PurchaseOutcome.NotPurchasable, service.PurchaseSkill(BrickShield)); } [Fact] public void A_skill_already_at_its_top_level_cannot_be_upgraded_further() { // TryUpgradeSkill guards with Level >= MaxLevel but then indexes Upgrades[skill.Level], and // BrickArmorSkillComponent indexes Upgrades[Level - 1] unguarded. var service = ServiceWithSkills(); service.RecordCoinsFound(Coins.Of(35)); // Unlock to level 1 for 10, upgrade to level 2 for 25 — both within one Attempt, so this // also covers reading the current level from the ledger rather than only from the Playthrough. Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow)); Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow)); Assert.Equal(PurchaseOutcome.AlreadyAtTopLevel, service.PurchaseSkill(BrickThrow)); } [Fact] public void Finishing_a_level_for_the_first_time_banks_advances_and_persists_both_stores() { var playthroughStore = new InMemoryPlaythroughStore(); var profileStore = new InMemoryProfileStore(); var service = new ProgressionService( Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.RecordCoinsFound(Coins.Of(50)); service.FinishLevel(LevelTime.Of(31.5)); var reloaded = playthroughStore.Load(Id).Value!; Assert.Equal(Coins.Of(50), reloaded.Bank); Assert.Equal(Second, reloaded.CurrentLevel); Assert.True(reloaded.HasCleared(First)); Assert.Equal(LevelTime.Of(31.5), profileStore.Load().Value!.BestTimeFor(First)); } [Fact] public void Replaying_a_cleared_level_improves_the_best_time_but_banks_nothing() { var playthroughStore = new InMemoryPlaythroughStore(); var profileStore = new InMemoryProfileStore(); var service = new ProgressionService( Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.RecordCoinsFound(Coins.Of(50)); service.FinishLevel(LevelTime.Of(40.0)); // Back for a faster run, picking up coins along the way. service.EnterLevel(First); service.RecordCoinsFound(Coins.Of(99)); service.FinishLevel(LevelTime.Of(30.0)); var reloaded = playthroughStore.Load(Id).Value!; Assert.Equal(Coins.Of(50), reloaded.Bank); Assert.Equal(Second, reloaded.CurrentLevel); Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First)); } [Fact] public void Resuming_a_saved_playthrough_restores_where_it_had_got_to() { var store = new InMemoryPlaythroughStore(); var first = new ProgressionService( Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3)); first.StartPlaythrough(Id); first.EnterLevel(First); first.FinishLevel(LevelTime.Of(20.0)); // A fresh service, as though the game had been restarted. var resumed = new ProgressionService( Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3)); Assert.True(resumed.LoadPlaythrough(Id)); Assert.Equal(Second, resumed.CurrentLevel); Assert.True(resumed.HasCleared(First)); Assert.True(resumed.CanEnter(First)); Assert.True(resumed.CanEnter(Second)); } [Fact] public void Resuming_when_there_is_no_save_leaves_the_service_not_playing() { var service = new ProgressionService( Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3)); Assert.False(service.LoadPlaythrough(Id)); Assert.False(service.IsPlaying); } [Fact] public void Finishing_without_a_measured_time_still_settles_the_level_but_records_no_best_time() { // GetCurrentLevelTime returns 0 when the speedrun timer was never started. Passing null says // so honestly rather than banking a zero-second record. var profileStore = new InMemoryProfileStore(); var service = new ProgressionService( Profile.Empty(), new InMemoryPlaythroughStore(), profileStore, TwoLevels(), Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.FinishLevel(null); Assert.True(service.HasCleared(First)); Assert.Equal(Second, service.CurrentLevel); Assert.Null(profileStore.Load().Value!.BestTimeFor(First)); } [Fact] public void An_unreachable_level_cannot_be_entered_even_though_it_is_in_the_campaign() { var service = Service(); Assert.True(service.CanEnter(First)); Assert.False(service.CanEnter(Second)); } [Fact] public void A_collected_skill_is_granted_free_and_only_once() { // Skill pickups are not purchases — no coins change hands. Collecting one you already own is // a no-op rather than a free upgrade. var service = ServiceWithSkills(); service.RecordCoinsFound(Coins.Of(10)); Assert.True(service.GrantSkill(BrickThrow)); Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow)); Assert.Equal(Coins.Of(10), service.TotalCoins); Assert.False(service.GrantSkill(BrickThrow)); Assert.Equal(SkillLevel.First, service.LevelOf(BrickThrow)); } [Fact] public void A_revoked_skill_is_gone_from_both_the_attempt_and_the_playthrough() { // BrickShieldSkillComponent consumes itself when broken, so this is gameplay rather than a cheat. var campaign = TwoLevels(); var store = new InMemoryPlaythroughStore(); var service = new ProgressionService( Profile.Empty(), store, new InMemoryProfileStore(), campaign, Skills(), Lives.Of(3)); service.StartPlaythrough(Id); service.EnterLevel(First); service.GrantSkill(BrickThrow); service.FinishLevel(LevelTime.Of(10.0)); // commits it to the Playthrough service.EnterLevel(Second); service.RevokeSkill(BrickThrow); Assert.False(service.IsOwned(BrickThrow)); } [Fact] public void Abandoning_an_attempt_discards_it_without_costing_a_life() { var service = ServiceWithSkills(); service.RecordCoinsFound(Coins.Of(30)); service.AbandonAttempt(); Assert.Equal(3, service.Lives.Value); Assert.Equal(Coins.Zero, service.TotalCoins); } [Fact] public void A_cheat_rewrites_the_save_shaped_state_and_is_still_validated() { // Cheats go through the snapshot rather than bare mutators, so ADR-0005 holds: there is still // no SetLives or AddCoins for gameplay code to reach for. var service = ServiceWithSkills(); service.ApplyCheat(snapshot => snapshot with { Bank = 500, Lives = 9 }); Assert.Equal(Coins.Of(500), service.TotalCoins); Assert.Equal(9, service.Lives.Value); // Still validated on the way back in: a negative bank is refused, state left alone. Assert.Throws( () => service.ApplyCheat(snapshot => snapshot with { Bank = -1 })); Assert.Equal(Coins.Of(500), service.TotalCoins); } [Fact] public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls() { // CoinsChanged is emitted from four places today and subscribed by nobody, while Hud rebuilds // three translated strings every frame in _Process. One announcement, one read path. var service = ServiceWithSkills(); service.RecordCoinsFound(Coins.Of(7)); var announced = service.DrainEvents().OfType().Single(); Assert.Equal(Coins.Of(7), announced.Total); Assert.Equal(Coins.Of(7), service.TotalCoins); } [Fact] public void Dying_announces_both_the_remaining_lives_and_the_coins_reverting_to_the_bank() { var service = ServiceWithSkills(); service.RecordCoinsFound(Coins.Of(40)); service.HandlePlayerDeath(); var events = service.DrainEvents(); Assert.Equal(2, events.OfType().Single().Remaining.Value); // The attempt is discarded, so the 40 found coins were never banked. Assert.Equal(Coins.Zero, events.OfType().Last().Total); Assert.Equal(Coins.Zero, service.TotalCoins); } }