diff --git a/progression.tests/PlaythroughTests.cs b/progression.tests/PlaythroughTests.cs index 5920408..6902ca1 100644 --- a/progression.tests/PlaythroughTests.cs +++ b/progression.tests/PlaythroughTests.cs @@ -156,4 +156,18 @@ public class PlaythroughTests 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)); + } } diff --git a/progression.tests/ProgressionServiceTests.cs b/progression.tests/ProgressionServiceTests.cs new file mode 100644 index 0000000..51a3425 --- /dev/null +++ b/progression.tests/ProgressionServiceTests.cs @@ -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"); + + /// 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); + 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)); + } +} diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs index 59b0aad..45653e6 100644 --- a/progression/Playthrough.cs +++ b/progression/Playthrough.cs @@ -81,12 +81,16 @@ public sealed class Playthrough /// if it is a Replay. Decides which from the Attempt's own Level, so a Replay cannot advance /// campaign position (ADR-0003). Does nothing if the Attempt has already ended. /// - public void SettleCompletion(Attempt attempt, Campaign campaign) + /// + /// True when this was a First Clear and the ledger was committed. False for a Replay, or when the + /// Attempt had already ended — in both cases nothing here changed. + /// + public bool SettleCompletion(Attempt attempt, Campaign campaign) { var isFirstClear = !HasCleared(attempt.Level); - if (!attempt.TryEndInCompletion()) return; - if (!isFirstClear) return; + if (!attempt.TryEndInCompletion()) return false; + if (!isFirstClear) return false; if (!Bank.Add(attempt.CoinsFound).TrySubtract(attempt.CoinsSpent, out var settled)) throw new InvalidOperationException( @@ -101,20 +105,25 @@ public sealed class Playthrough if (campaign.Next(attempt.Level) is { } next) CurrentLevel = next; + + return true; } /// /// Ends the given Attempt in death and charges one life for it. Does nothing if the Attempt has /// already ended, so a repeated death event costs no further lives. /// - public void SettleDeath(Attempt attempt) + /// False if the Attempt had already ended, in which case nothing changed. + public bool SettleDeath(Attempt attempt) { - if (!attempt.TryEndInDeath()) return; + if (!attempt.TryEndInDeath()) return false; Lives = Lives.Decrement(); if (Lives.IsExhausted) _events.Raise(new RunEnded()); + + return true; } /// diff --git a/progression/ProgressionEvent.cs b/progression/ProgressionEvent.cs index 96e6dc3..3be32af 100644 --- a/progression/ProgressionEvent.cs +++ b/progression/ProgressionEvent.cs @@ -15,3 +15,10 @@ public sealed record RunEnded : ProgressionEvent; /// popup or pushes to Steam cannot fire twice however often it is told. /// public sealed record AchievementUnlocked(AchievementId Achievement) : ProgressionEvent; + +/// +/// Raised whenever a Level is entered — every time, including the first Level of a new Playthrough +/// and the first Level after resuming one. Ghost recording and the speedrun timer both start from +/// this, so an entry path that does not announce is an entry path where neither works. +/// +public sealed record LevelStarted(LevelId Level) : ProgressionEvent; diff --git a/progression/ProgressionService.cs b/progression/ProgressionService.cs new file mode 100644 index 0000000..a992ab4 --- /dev/null +++ b/progression/ProgressionService.cs @@ -0,0 +1,155 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// The application layer for player progression: holds what is currently loaded — the Profile, the +/// active Playthrough, the active Attempt — and exposes the operations gameplay triggers. +/// +/// +/// One class rather than a class per use case, because they all act on the same loaded state. Split +/// apart, that state would have to live in the Godot autoload, where the invariant "there is exactly +/// one active Attempt" could not be tested. +/// +public sealed class ProgressionService +{ + private readonly IPlaythroughStore _playthroughs; + private readonly IProfileStore _profiles; + private readonly Campaign _campaign; + private readonly SkillCatalogue _skills; + private readonly Lives _startingLives; + private readonly EventLog _events = new(); + + private Playthrough? _playthrough; + private Attempt? _attempt; + + public ProgressionService( + Profile profile, + IPlaythroughStore playthroughs, + IProfileStore profiles, + Campaign campaign, + SkillCatalogue skills, + Lives startingLives) + { + Profile = profile; + _playthroughs = playthroughs; + _profiles = profiles; + _campaign = campaign; + _skills = skills; + _startingLives = startingLives; + } + + public Profile Profile { get; } + + public void StartPlaythrough(PlaythroughId id) + { + _playthrough = Playthrough.StartNew(id, _campaign, _startingLives); + _attempt = null; + _playthroughs.Save(_playthrough); + } + + /// + /// Begins an Attempt at and announces it. Does not touch campaign + /// position — that advances only on a First Clear, so a Replay leaves it alone. + /// + /// False if there is no active Playthrough, or the Level is not reachable in it. + public bool EnterLevel(LevelId level) + { + if (_playthrough is null) return false; + if (!_playthrough.ReachableLevels(_campaign).Contains(level)) return false; + + _attempt = new Attempt(level); + _events.Raise(new LevelStarted(level)); + return true; + } + + /// + /// Records coins picked up during the active Attempt. They are banked only by a First Clear. + /// + public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount); + + /// + /// Buys the next level of a Skill, charging it to the active Attempt's ledger — so dying undoes + /// both the spend and the Skill, and only a First Clear makes either permanent. + /// + public PurchaseOutcome PurchaseSkill(SkillId skill) + { + if (_playthrough is null || _attempt is null) return PurchaseOutcome.NotPlaying; + + if (_skills.MaxLevelOf(skill) is not { } topLevel) return PurchaseOutcome.NotPurchasable; + + var next = CurrentLevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First; + if (next.Value > topLevel.Value) return PurchaseOutcome.AlreadyAtTopLevel; + + if (_skills.CostToReach(skill, next) is not { } cost) return PurchaseOutcome.AlreadyAtTopLevel; + if (!_playthrough.CanAfford(_attempt, cost)) return PurchaseOutcome.NotAffordable; + + return _attempt.TryRecordPurchase(skill, next, cost) + ? PurchaseOutcome.Purchased + : PurchaseOutcome.NotPlaying; + } + + /// + /// The level a Skill is at right now: what this Attempt has bought if anything, otherwise what + /// the Playthrough owns. Reading only the Playthrough would let the same level be bought twice + /// within one Attempt. + /// + private SkillLevel? CurrentLevelOf(SkillId skill) + { + if (_attempt is not null && _attempt.SkillsBought.TryGetValue(skill, out var bought)) + return bought; + + return _playthrough is not null && _playthrough.OwnedSkills.TryGetValue(skill, out var owned) + ? owned + : null; + } + + /// + /// Charges the active Attempt's death to the Playthrough and persists it. Repeated calls for one + /// Attempt cost one life — the Playthrough defers to the Attempt's terminal outcome (ADR-0005), + /// so overlapping hazards or a hazard re-firing before the scene reloads are harmless. + /// + public void HandlePlayerDeath() + { + if (_playthrough is null || _attempt is null) return; + + if (!_playthrough.SettleDeath(_attempt)) return; + + _playthroughs.Save(_playthrough); + } + + /// + /// Settles a completed Level. The Best Time and the completion statistic are recorded either way + /// (ADR-0003), while banking and advancing happen only on a First Clear — which the Playthrough + /// decides for itself from the Attempt's Level. + /// + public void FinishLevel(LevelTime time) + { + if (_playthrough is null || _attempt is null) return; + if (_attempt.Outcome != AttemptOutcome.InProgress) return; + + var level = _attempt.Level; + + // Counts completions, including Replays — deliberately a different fact from + // ClearedLevels.Count, which counts distinct Levels. Conflating the two is what made the old + // levels_completed statistic disagree with CompletedLevels. + Profile.Record(StatName.LevelsCompleted, 1); + Profile.RecordTime(level, time); + + var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign); + _attempt = null; + + if (wasFirstClear) _playthroughs.Save(_playthrough); + _profiles.Save(Profile); + } + + /// + /// Takes the events raised by this service and both aggregates. The composition root drains once + /// after each operation and republishes. + /// + public IReadOnlyList DrainEvents() + { + var drained = _events.Drain().ToList(); + drained.AddRange(Profile.DrainEvents()); + if (_playthrough is not null) drained.AddRange(_playthrough.DrainEvents()); + return drained; + } +} diff --git a/progression/PurchaseOutcome.cs b/progression/PurchaseOutcome.cs new file mode 100644 index 0000000..8ddc123 --- /dev/null +++ b/progression/PurchaseOutcome.cs @@ -0,0 +1,22 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// Why a Skill purchase did or did not happen. Named outcomes rather than a bool, because the +/// marketplace wants to say something different for each. +/// +public enum PurchaseOutcome +{ + Purchased, + + /// The Bank plus this Attempt's coins, less what it has already spent, is not enough. + NotAffordable, + + /// The Skill is already at the top level its authored upgrade table defines. + AlreadyAtTopLevel, + + /// The Skill has no authored upgrades, so it cannot be bought at any price. + NotPurchasable, + + /// There is no Playthrough loaded, or no Attempt in progress to charge it to. + NotPlaying, +} diff --git a/progression/SkillCatalogue.cs b/progression/SkillCatalogue.cs new file mode 100644 index 0000000..d14f40e --- /dev/null +++ b/progression/SkillCatalogue.cs @@ -0,0 +1,40 @@ +namespace Mr.BrickAdventures.Progression; + +/// +/// The authored upgrade table for every Skill: what each level costs to reach. Built from +/// SkillData resources by an adapter — same pattern as , so no port is +/// needed and a test can construct one directly. +/// +public sealed class SkillCatalogue +{ + private readonly Dictionary> _costs; + + private SkillCatalogue(Dictionary> costs) => _costs = costs; + + public static SkillCatalogue Empty() => new(new Dictionary>()); + + /// + /// Builds a catalogue. Each Skill's list is the cost to reach level 1, level 2, and so on, so a + /// Skill with an empty list cannot be bought at all. + /// + public static SkillCatalogue Of(IReadOnlyDictionary> costsByLevel) => + new(costsByLevel.ToDictionary(e => e.Key, e => e.Value)); + + /// + /// The highest level this Skill can reach, or null when it has no authored upgrades and so + /// cannot be bought. Returning null is what stops Upgrades[0].Cost from throwing on the + /// five Skills that currently have no upgrade entries. + /// + public SkillLevel? MaxLevelOf(SkillId skill) => + _costs.TryGetValue(skill, out var costs) && costs.Count > 0 + ? SkillLevel.Of(costs.Count) + : null; + + /// + /// What it costs to reach , or null if this Skill does not go that high. + /// + public Coins? CostToReach(SkillId skill, SkillLevel level) => + _costs.TryGetValue(skill, out var costs) && level.Value <= costs.Count + ? costs[level.Value - 1] + : null; +} diff --git a/progression/SkillLevel.cs b/progression/SkillLevel.cs index 56f83d6..34ef250 100644 --- a/progression/SkillLevel.cs +++ b/progression/SkillLevel.cs @@ -4,12 +4,22 @@ namespace Mr.BrickAdventures.Progression; /// How far a Skill has been upgraded. Starts at one; there is no zero, because a Playthrough /// records only the Skills it owns and absence from that record is what "unowned" means. /// -public readonly struct SkillLevel +public readonly struct SkillLevel : IEquatable { public int Value { get; } private SkillLevel(int value) => Value = value; + /// The level a Skill is at the moment it is first bought. + public static readonly SkillLevel First = new(1); + + public SkillLevel Next() => new(Value + 1); + + public bool Equals(SkillLevel other) => Value == other.Value; + public override bool Equals(object? obj) => obj is SkillLevel other && Equals(other); + public override int GetHashCode() => Value; + public override string ToString() => Value.ToString(); + public static SkillLevel Of(int value) => value < 1 ? throw new ArgumentOutOfRangeException(nameof(value), value, "A skill level starts at 1; unowned is absence, not level 0.") : new SkillLevel(value);