fixes the regression commit B introduced: coins and skills lived in the Attempt ledger, but nothing discarded it, because LivesStateHandler still called the old store and HandlePlayerDeath was never invoked. Dying kept everything earned that level. Discard-on-death is the ledger's purpose, so it could not be separated from it. new AttemptFailed(remaining, runOver) event, raised after the life is spent and the ledger discarded. DeathScreen and GameOverScreen react to that instead of being wired to HealthComponent.Death by scene connections which fired *before* the life was charged - on the last life game-over saw one life left and showed nothing, then the death screen saw zero and refused to reload. 18 scene connections deleted; the race is now unrepresentable rather than fixed. DeathScreen also had two read paths for lives - a signal and a direct store read - because neither was reliable alone. Now one. Hud reads both facts from signals; only health still polls, and that belongs to the component rather than to progression. GameManager no longer touches GameStateStore or SaveSystem at all.
371 lines
14 KiB
C#
371 lines
14 KiB
C#
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");
|
|
|
|
/// <summary>brick_throw costs 10 then 25; brick_shield has no authored upgrades at all.</summary>
|
|
private static SkillCatalogue Skills() => SkillCatalogue.Of(
|
|
new Dictionary<SkillId, IReadOnlyList<Coins>>
|
|
{
|
|
[BrickThrow] = new[] { Coins.Of(10), Coins.Of(25) },
|
|
[BrickShield] = Array.Empty<Coins>(),
|
|
});
|
|
|
|
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_settled_death_announces_the_outcome_so_no_screen_has_to_read_lives_itself()
|
|
{
|
|
// DeathScreen and GameOverScreen were both wired to HealthComponent.Death by scene
|
|
// connections that fire before lives are decremented, so on the last life neither saw zero.
|
|
// Announcing the settled result means the value is already correct when they hear about it.
|
|
var service = ServiceWithSkills();
|
|
|
|
service.HandlePlayerDeath();
|
|
|
|
var failed = service.DrainEvents().OfType<AttemptFailed>().Single();
|
|
Assert.Equal(2, failed.Remaining.Value);
|
|
Assert.False(failed.RunOver);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dying_on_the_last_life_announces_the_run_is_over_in_the_same_breath()
|
|
{
|
|
var service = new ProgressionService(
|
|
Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
|
|
TwoLevels(), Skills(), Lives.Of(1));
|
|
service.StartPlaythrough(Id);
|
|
service.EnterLevel(First);
|
|
service.DrainEvents();
|
|
|
|
service.HandlePlayerDeath();
|
|
|
|
var events = service.DrainEvents();
|
|
Assert.True(events.OfType<AttemptFailed>().Single().RunOver);
|
|
Assert.Single(events.OfType<RunEnded>());
|
|
}
|
|
|
|
[Fact]
|
|
public void A_repeated_death_event_announces_nothing_further()
|
|
{
|
|
var service = ServiceWithSkills();
|
|
service.HandlePlayerDeath();
|
|
service.DrainEvents();
|
|
|
|
service.HandlePlayerDeath();
|
|
|
|
Assert.Empty(service.DrainEvents().OfType<AttemptFailed>());
|
|
}
|
|
|
|
[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<ArgumentOutOfRangeException>(
|
|
() => 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<TotalCoinsChanged>().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<LivesChanged>().Single().Remaining.Value);
|
|
// The attempt is discarded, so the 40 found coins were never banked.
|
|
Assert.Equal(Coins.Zero, events.OfType<TotalCoinsChanged>().Last().Total);
|
|
Assert.Equal(Coins.Zero, service.TotalCoins);
|
|
}
|
|
}
|