Files
przygody-pana-cegly/progression/ProgressionService.cs
Gabriel Kaszewski a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
2026-07-28 19:57:01 +02:00

291 lines
11 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>
/// 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;
}
}