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.
This commit is contained in:
2026-07-28 19:57:01 +02:00
parent ca54bf0f82
commit a1c9042652
6 changed files with 260 additions and 61 deletions

View File

@@ -89,6 +89,14 @@ public sealed class ProgressionService
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)
@@ -99,6 +107,28 @@ public sealed class ProgressionService
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.
@@ -168,7 +198,12 @@ public sealed class ProgressionService
/// (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)
/// <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;
@@ -179,7 +214,7 @@ public sealed class ProgressionService
// 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);
if (time is { } measured) Profile.RecordTime(level, measured);
var wasFirstClear = _playthrough.SettleCompletion(_attempt, _campaign);
_attempt = null;