feat(progression): use cases (slice 6)
ProgressionService holds the loaded Profile, active Playthrough and active Attempt, so "exactly one active Attempt" has a testable home. EnterLevel always announces LevelStarted - the reason ghost recording and splits never worked on level 1 or after a Continue. Refuses unreachable levels. Does not touch campaign position, so a Replay leaves it alone. SkillCatalogue returns null for a skill with no authored upgrades, killing the Upgrades[0].Cost overrun. PurchaseSkill reads the current level from the ledger first, so one Attempt can unlock then upgrade. HandlePlayerDeath now persists - death was free if you quit. SkillLevel.First/Next driven out here by a real caller. 53 tests.
This commit is contained in:
170
progression.tests/ProgressionServiceTests.cs
Normal file
170
progression.tests/ProgressionServiceTests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
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);
|
||||
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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user