no behaviour change - additive only. GrantSkill: pickups are free unlocks, and collecting one already owned is a no-op rather than a free upgrade. RevokeSkill: for skills that consume themselves (brick shield). AbandonAttempt: leaving a level without dying, so no life is charged. ApplyCheat rewrites the Playthrough through its snapshot rather than via SetLives/AddCoins mutators, which ADR-0005 rules out - a bare mutator is an invitation, and the old code had two paths to exactly that. Every value is re-validated on the way back in and a rejected cheat leaves the Playthrough untouched, which is asserted. 73 tests.
327 lines
13 KiB
C#
327 lines
13 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_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);
|
|
}
|
|
}
|