feat(progression): snapshots, ports, in-memory adapters (slice 5)

flat snapshot record per aggregate - separate files mean saving a run
doesn't rewrite the Profile, deleting a slot is deleting a file, and a
corrupt slot doesn't cost achievements or best times.

snapshots hold primitives so Restore re-runs the value-object factories;
a serialiser building LevelId directly would bypass every guard.

LoadResult has three outcomes: Absent != Corrupt. Collapsing them is the
bug where a version bump made ContinueGame overwrite the save.

Profile.Restore writes collections directly - replaying UnlockAchievement
would re-fire popups and Steam pushes on every load. 44 tests.
This commit is contained in:
2026-07-28 19:02:41 +02:00
parent 05149bb4a9
commit 003ec4b010
13 changed files with 378 additions and 0 deletions

View File

@@ -122,4 +122,35 @@ public sealed class Playthrough
/// once after each use case and republishes.
/// </summary>
public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
public PlaythroughSnapshot Export() => new(
Id.Value,
Lives.Value,
Bank.Value,
CurrentLevel.Value,
_ownedSkills.ToDictionary(e => e.Key.Value, e => e.Value.Value),
_clearedLevels.Select(l => l.Value).ToList());
/// <summary>
/// Rebuilds a Playthrough from a snapshot, re-running every value object's factory so an invalid
/// snapshot is rejected here rather than becoming corrupt in-memory state.
/// </summary>
public static Playthrough Restore(PlaythroughSnapshot snapshot)
{
var playthrough = new Playthrough(
PlaythroughId.Of(snapshot.Id),
LevelId.Of(snapshot.CurrentLevel),
Lives.Of(snapshot.Lives))
{
Bank = Coins.Of(snapshot.Bank),
};
foreach (var (skill, level) in snapshot.OwnedSkills)
playthrough._ownedSkills[SkillId.Of(skill)] = SkillLevel.Of(level);
foreach (var level in snapshot.ClearedLevels)
playthrough._clearedLevels.Add(LevelId.Of(level));
return playthrough;
}
}