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

@@ -0,0 +1,12 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// Where Playthroughs are kept. One per slot; the JSON adapter writes one file each, so saving a run
/// never rewrites the Profile and deleting a slot is deleting a file.
/// </summary>
public interface IPlaythroughStore
{
void Save(Playthrough playthrough);
LoadResult<Playthrough> Load(PlaythroughId id);
}

View File

@@ -0,0 +1,12 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// Where the Profile is kept. One per installation, in its own file, so unlocking an achievement
/// never rewrites a Playthrough and a corrupt slot never costs best times.
/// </summary>
public interface IProfileStore
{
void Save(Profile profile);
LoadResult<Profile> Load();
}

26
progression/LoadResult.cs Normal file
View File

@@ -0,0 +1,26 @@
namespace Mr.BrickAdventures.Progression;
public enum LoadOutcome
{
Loaded,
Absent,
Corrupt,
}
/// <summary>
/// The result of loading something from a store: it was there, it was never there, or it was there
/// and unreadable.
/// </summary>
/// <remarks>
/// Three outcomes rather than two, deliberately. The system being replaced collapsed "no save file"
/// and "version mismatch" into a single <c>false</c> from <c>LoadGame</c>, which <c>ContinueGame</c>
/// then read as "no save" — so it started a new game and overwrote the player's file. Absent means
/// offer a new game; Corrupt means tell the player something went wrong.
/// </remarks>
public sealed record LoadResult<T>(LoadOutcome Outcome, T? Value, string? Problem)
where T : class
{
public static LoadResult<T> Loaded(T value) => new(LoadOutcome.Loaded, value, null);
public static LoadResult<T> Absent() => new(LoadOutcome.Absent, null, null);
public static LoadResult<T> Corrupt(string problem) => new(LoadOutcome.Corrupt, null, problem);
}

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;
}
}

View File

@@ -0,0 +1,19 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// A Playthrough's state as plain data, for crossing the persistence seam.
/// </summary>
/// <remarks>
/// Deliberately primitives rather than value objects. A serialiser constructing <see cref="LevelId"/>
/// or <see cref="Coins"/> directly would bypass their factories and every guard in them, so instead
/// <see cref="Playthrough.Restore"/> re-runs those factories and a corrupt file fails at the domain
/// edge. Carries no version field — versioning and migration belong to the store adapter, so the
/// domain only ever knows the current shape.
/// </remarks>
public sealed record PlaythroughSnapshot(
Guid Id,
int Lives,
int Bank,
string CurrentLevel,
IReadOnlyDictionary<string, int> OwnedSkills,
IReadOnlyList<string> ClearedLevels);

View File

@@ -57,6 +57,40 @@ public sealed class Profile
/// </summary>
public IReadOnlyList<ProgressionEvent> DrainEvents() => _events.Drain();
public ProfileSnapshot Export() => new(
_bestTimes.ToDictionary(e => e.Key.Value, e => e.Value.Seconds),
_achievements.Select(a => a.Value).ToList(),
_statistics.ToDictionary(e => e.Key.ToString(), e => e.Value));
/// <summary>
/// Rebuilds a Profile from a snapshot, re-running every value object's factory.
/// </summary>
/// <remarks>
/// Writes the collections directly rather than replaying <see cref="UnlockAchievement"/>. Going
/// through the operation would raise <see cref="AchievementUnlocked"/> for every achievement on
/// every load, so a subscriber pushing to Steam or showing a popup would fire on startup.
/// </remarks>
public static Profile Restore(ProfileSnapshot snapshot)
{
var profile = new Profile();
foreach (var (level, seconds) in snapshot.BestTimes)
profile._bestTimes[LevelId.Of(level)] = LevelTime.Of(seconds);
foreach (var achievement in snapshot.Achievements)
profile._achievements.Add(AchievementId.Of(achievement));
foreach (var (stat, value) in snapshot.Statistics)
{
// A statistic this build no longer tracks is dropped. Losing one counter beats refusing
// to load the Profile and with it every achievement and best time.
if (Enum.TryParse<StatName>(stat, out var known))
profile._statistics[known] = value;
}
return profile;
}
public LevelTime? BestTimeFor(LevelId level) =>
_bestTimes.TryGetValue(level, out var time) ? time : null;

View File

@@ -0,0 +1,14 @@
namespace Mr.BrickAdventures.Progression;
/// <summary>
/// A Profile's state as plain data, for crossing the persistence seam. Primitives only, for the same
/// reasons as <see cref="PlaythroughSnapshot"/>.
/// </summary>
/// <remarks>
/// Statistics are keyed by <see cref="StatName"/>'s name rather than its numeric value, so
/// reordering the enum cannot silently reassign a player's history.
/// </remarks>
public sealed record ProfileSnapshot(
IReadOnlyDictionary<string, double> BestTimes,
IReadOnlyList<string> Achievements,
IReadOnlyDictionary<string, int> Statistics);