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.
350 lines
13 KiB
C#
350 lines
13 KiB
C#
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; }
|
|
|
|
#region Reads — the narrow surface the UI gets instead of reaching into state
|
|
|
|
/// <summary>Spendable coins: the Bank plus this Attempt's takings, less what it has spent.</summary>
|
|
public Coins TotalCoins
|
|
{
|
|
get
|
|
{
|
|
if (_playthrough is null) return Coins.Zero;
|
|
if (_attempt is null) return _playthrough.Bank;
|
|
|
|
return _playthrough.Bank.Add(_attempt.CoinsFound)
|
|
.TrySubtract(_attempt.CoinsSpent, out var total) ? total : Coins.Zero;
|
|
}
|
|
}
|
|
|
|
public Lives Lives => _playthrough?.Lives ?? Lives.Of(0);
|
|
|
|
public bool IsRunOver => _playthrough?.IsRunOver ?? false;
|
|
|
|
public LevelId? CurrentLevel => _playthrough?.CurrentLevel;
|
|
|
|
public bool IsPlaying => _playthrough is not null;
|
|
|
|
/// <summary>
|
|
/// The level a Skill is at: what this Attempt has bought if anything, otherwise what the
|
|
/// Playthrough owns. Null when it is not owned at all — absence is what "unowned" means.
|
|
/// </summary>
|
|
public SkillLevel? LevelOf(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;
|
|
}
|
|
|
|
public bool IsOwned(SkillId skill) => LevelOf(skill) is not null;
|
|
|
|
/// <summary>Whether the next level of a Skill could be bought right now.</summary>
|
|
public bool CanAfford(SkillId skill)
|
|
{
|
|
if (_playthrough is null || _attempt is null) return false;
|
|
|
|
var next = LevelOf(skill) is { } owned ? owned.Next() : SkillLevel.First;
|
|
return _skills.CostToReach(skill, next) is { } cost && _playthrough.CanAfford(_attempt, cost);
|
|
}
|
|
|
|
public bool HasCleared(LevelId level) => _playthrough?.HasCleared(level) ?? false;
|
|
|
|
/// <summary>
|
|
/// Whether this Level can be entered: the Campaign's first, anything cleared, or whatever follows
|
|
/// something cleared. Separate from <see cref="EnterLevel"/> so a caller can check before starting
|
|
/// a scene change it would then have to undo.
|
|
/// </summary>
|
|
public bool CanEnter(LevelId level) =>
|
|
_playthrough is not null && _playthrough.ReachableLevels(_campaign).Contains(level);
|
|
|
|
#endregion
|
|
|
|
public void StartPlaythrough(PlaythroughId id)
|
|
{
|
|
_playthrough = Playthrough.StartNew(id, _campaign, _startingLives);
|
|
_attempt = null;
|
|
_playthroughs.Save(_playthrough);
|
|
AnnounceChanges();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resumes a stored Playthrough. Returns false when there is nothing to resume, leaving the
|
|
/// service exactly as it was — a failed resume must not clear an active Playthrough, and it must
|
|
/// not be mistaken for "start a new game", which is what overwrote saves before.
|
|
/// </summary>
|
|
public bool LoadPlaythrough(PlaythroughId id)
|
|
{
|
|
var result = _playthroughs.Load(id);
|
|
if (result.Outcome != LoadOutcome.Loaded) return false;
|
|
|
|
_playthrough = result.Value;
|
|
_attempt = null;
|
|
AnnounceChanges();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Why a resume failed, for a caller that wants to tell absent from corrupt.</summary>
|
|
public LoadOutcome PeekPlaythrough(PlaythroughId id) => _playthroughs.Load(id).Outcome;
|
|
|
|
/// <summary>Adds to a lifetime statistic on the Profile.</summary>
|
|
public void RecordStat(StatName stat, int amount = 1) => Profile.Record(stat, amount);
|
|
|
|
/// <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));
|
|
AnnounceChanges();
|
|
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);
|
|
AnnounceChanges();
|
|
}
|
|
|
|
/// <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 = LevelOf(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;
|
|
|
|
if (!_attempt.TryRecordPurchase(skill, next, cost)) return PurchaseOutcome.NotPlaying;
|
|
|
|
AnnounceChanges();
|
|
return PurchaseOutcome.Purchased;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grants a Skill for free, as a pickup does. No coins change hands, and collecting one already
|
|
/// owned is a no-op rather than a free upgrade.
|
|
/// </summary>
|
|
public bool GrantSkill(SkillId skill)
|
|
{
|
|
if (_playthrough is null || _attempt is null) return false;
|
|
if (IsOwned(skill)) return false;
|
|
|
|
if (!_attempt.TryRecordPurchase(skill, SkillLevel.First, Coins.Zero)) return false;
|
|
|
|
AnnounceChanges();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes a Skill away, from this Attempt's purchases and from the Playthrough alike. For Skills
|
|
/// that consume themselves rather than as a cheat.
|
|
/// </summary>
|
|
public bool RevokeSkill(SkillId skill)
|
|
{
|
|
if (_playthrough is null) return false;
|
|
|
|
var revoked = _playthrough.RevokeSkill(skill);
|
|
AnnounceChanges();
|
|
return revoked;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throws the current Attempt away without charging a life — leaving a Level rather than dying in
|
|
/// it. The ledger is discarded, so coins found and Skills bought during it are lost.
|
|
/// </summary>
|
|
public void AbandonAttempt()
|
|
{
|
|
_attempt = null;
|
|
AnnounceChanges();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Console only. Rewrites the Playthrough by transforming its snapshot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deliberately shaped this way rather than as <c>SetLives</c> / <c>AddCoins</c> mutators, which
|
|
/// ADR-0005 rules out — a bare mutator is an invitation, and the code being replaced had two paths
|
|
/// to exactly that. Going through the snapshot means every value is re-validated on the way back
|
|
/// in, and a rejected cheat leaves the Playthrough untouched.
|
|
/// </remarks>
|
|
public void ApplyCheat(Func<PlaythroughSnapshot, PlaythroughSnapshot> change)
|
|
{
|
|
if (_playthrough is null) return;
|
|
|
|
var rewritten = Playthrough.Restore(change(_playthrough.Export()));
|
|
|
|
_playthrough = rewritten;
|
|
_attempt = null;
|
|
_playthroughs.Save(_playthrough);
|
|
AnnounceChanges();
|
|
}
|
|
|
|
/// <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;
|
|
|
|
// The ledger was discarded, so it must stop contributing to TotalCoins.
|
|
_attempt = null;
|
|
_playthroughs.Save(_playthrough);
|
|
AnnounceChanges();
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="time">
|
|
/// The completion time, or null when none was measured. Null still settles the Level — it just
|
|
/// records no Best Time. That beats inventing a value: a zero-second time is exactly the bad data
|
|
/// <see cref="LevelTime"/> exists to refuse.
|
|
/// </param>
|
|
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);
|
|
if (time is { } measured) Profile.RecordTime(level, measured);
|
|
|
|
var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign);
|
|
_attempt = null;
|
|
|
|
if (wasFirstClear) _playthroughs.Save(_playthrough);
|
|
_profiles.Save(Profile);
|
|
AnnounceChanges();
|
|
}
|
|
|
|
private Coins _announcedCoins = Coins.Zero;
|
|
private int _announcedLives = -1;
|
|
private string _announcedSkills = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Announces whatever actually changed since the last announcement. Called at the end of every
|
|
/// mutating operation, so an operation cannot forget to announce; comparing against the last
|
|
/// announced value means it also cannot announce something that did not change.
|
|
/// </summary>
|
|
private void AnnounceChanges()
|
|
{
|
|
var coins = TotalCoins;
|
|
if (!coins.Equals(_announcedCoins))
|
|
{
|
|
_announcedCoins = coins;
|
|
_events.Raise(new TotalCoinsChanged(coins));
|
|
}
|
|
|
|
if (_playthrough is not null && _playthrough.Lives.Value != _announcedLives)
|
|
{
|
|
_announcedLives = _playthrough.Lives.Value;
|
|
_events.Raise(new LivesChanged(_playthrough.Lives));
|
|
}
|
|
|
|
var skills = SkillFingerprint();
|
|
if (skills != _announcedSkills)
|
|
{
|
|
_announcedSkills = skills;
|
|
_events.Raise(new SkillSetChanged());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cheap comparable summary of the effective owned-skill set, Attempt purchases included. Used only
|
|
/// to decide whether anything changed — with a handful of skills, recomputing beats bookkeeping.
|
|
/// </summary>
|
|
private string SkillFingerprint()
|
|
{
|
|
if (_playthrough is null) return string.Empty;
|
|
|
|
var effective = new SortedDictionary<string, int>();
|
|
|
|
foreach (var (skill, level) in _playthrough.OwnedSkills)
|
|
effective[skill.Value] = level.Value;
|
|
|
|
if (_attempt is not null)
|
|
foreach (var (skill, level) in _attempt.SkillsBought)
|
|
effective[skill.Value] = level.Value;
|
|
|
|
return string.Join(",", effective.Select(e => $"{e.Key}:{e.Value}"));
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|