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:
155
progression/ProgressionService.cs
Normal file
155
progression/ProgressionService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
namespace Mr.BrickAdventures.Progression;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins an Attempt at <paramref name="level"/> and announces it. Does not touch campaign
|
||||
/// position — that advances only on a First Clear, so a Replay leaves it alone.
|
||||
/// </summary>
|
||||
/// <returns>False if there is no active Playthrough, or the Level is not reachable in it.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records coins picked up during the active Attempt. They are banked only by a First Clear.
|
||||
/// </summary>
|
||||
public void RecordCoinsFound(Coins amount) => _attempt?.RecordCoinsFound(amount);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void HandlePlayerDeath()
|
||||
{
|
||||
if (_playthrough is null || _attempt is null) return;
|
||||
|
||||
if (!_playthrough.SettleDeath(_attempt)) return;
|
||||
|
||||
_playthroughs.Save(_playthrough);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the events raised by this service and both aggregates. The composition root drains once
|
||||
/// after each operation and republishes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProgressionEvent> DrainEvents()
|
||||
{
|
||||
var drained = _events.Drain().ToList();
|
||||
drained.AddRange(Profile.DrainEvents());
|
||||
if (_playthrough is not null) drained.AddRange(_playthrough.DrainEvents());
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user