diff --git a/progression.tests/InMemoryPlaythroughStore.cs b/progression.tests/InMemoryPlaythroughStore.cs
new file mode 100644
index 0000000..0857a62
--- /dev/null
+++ b/progression.tests/InMemoryPlaythroughStore.cs
@@ -0,0 +1,34 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+///
+/// The test-side adapter for . Its existence alongside the JSON store
+/// is what makes the port a real seam rather than a hypothetical one.
+///
+internal sealed class InMemoryPlaythroughStore : IPlaythroughStore
+{
+ private readonly Dictionary _saved = new();
+ private readonly HashSet _corrupt = new();
+
+ public void Save(Playthrough playthrough) => _saved[playthrough.Id.Value] = playthrough.Export();
+
+ /// Simulates a file that exists but cannot be read back into a valid snapshot.
+ public void Corrupt(PlaythroughId id) => _corrupt.Add(id.Value);
+
+ public LoadResult Load(PlaythroughId id)
+ {
+ if (_corrupt.Contains(id.Value))
+ return LoadResult.Corrupt("snapshot could not be read");
+
+ if (!_saved.TryGetValue(id.Value, out var snapshot))
+ return LoadResult.Absent();
+
+ try
+ {
+ return LoadResult.Loaded(Playthrough.Restore(snapshot));
+ }
+ catch (Exception e)
+ {
+ return LoadResult.Corrupt(e.Message);
+ }
+ }
+}
diff --git a/progression.tests/InMemoryProfileStore.cs b/progression.tests/InMemoryProfileStore.cs
new file mode 100644
index 0000000..0c28ac6
--- /dev/null
+++ b/progression.tests/InMemoryProfileStore.cs
@@ -0,0 +1,26 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+internal sealed class InMemoryProfileStore : IProfileStore
+{
+ private ProfileSnapshot? _saved;
+ private bool _corrupt;
+
+ public void Save(Profile profile) => _saved = profile.Export();
+
+ public void Corrupt() => _corrupt = true;
+
+ public LoadResult Load()
+ {
+ if (_corrupt) return LoadResult.Corrupt("snapshot could not be read");
+ if (_saved is null) return LoadResult.Absent();
+
+ try
+ {
+ return LoadResult.Loaded(Profile.Restore(_saved));
+ }
+ catch (Exception e)
+ {
+ return LoadResult.Corrupt(e.Message);
+ }
+ }
+}
diff --git a/progression.tests/PlaythroughSnapshotTests.cs b/progression.tests/PlaythroughSnapshotTests.cs
new file mode 100644
index 0000000..f2bb40e
--- /dev/null
+++ b/progression.tests/PlaythroughSnapshotTests.cs
@@ -0,0 +1,33 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+public class PlaythroughSnapshotTests
+{
+ private static readonly LevelId First = LevelId.Of("village_1");
+ private static readonly LevelId Second = LevelId.Of("village_2");
+
+ private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
+
+ [Fact]
+ public void A_playthrough_survives_a_round_trip_through_its_snapshot()
+ {
+ var brickThrow = SkillId.Of("brick_throw");
+ var campaign = TwoLevels();
+ var original = Playthrough.StartNew(
+ PlaythroughId.Of(new Guid("22222222-2222-2222-2222-222222222222")), campaign, Lives.Of(3));
+
+ var attempt = new Attempt(First);
+ attempt.RecordCoinsFound(Coins.Of(50));
+ attempt.TryRecordPurchase(brickThrow, SkillLevel.Of(2), Coins.Of(30));
+ original.SettleCompletion(attempt, campaign);
+ original.SettleDeath(new Attempt(Second));
+
+ var restored = Playthrough.Restore(original.Export());
+
+ Assert.Equal(original.Id, restored.Id);
+ Assert.Equal(original.Lives.Value, restored.Lives.Value);
+ Assert.Equal(original.Bank, restored.Bank);
+ Assert.Equal(original.CurrentLevel, restored.CurrentLevel);
+ Assert.Equal(SkillLevel.Of(2), restored.OwnedSkills[brickThrow]);
+ Assert.Equal(original.ClearedLevels.OrderBy(l => l.Value), restored.ClearedLevels.OrderBy(l => l.Value));
+ }
+}
diff --git a/progression.tests/PlaythroughStoreTests.cs b/progression.tests/PlaythroughStoreTests.cs
new file mode 100644
index 0000000..8e9b373
--- /dev/null
+++ b/progression.tests/PlaythroughStoreTests.cs
@@ -0,0 +1,47 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+public class PlaythroughStoreTests
+{
+ private static readonly PlaythroughId Id =
+ PlaythroughId.Of(new Guid("33333333-3333-3333-3333-333333333333"));
+
+ private static Campaign OneLevel() => Campaign.Of(new[] { LevelId.Of("village_1") });
+
+ [Fact]
+ public void A_playthrough_that_was_never_saved_is_absent_not_an_error()
+ {
+ var store = new InMemoryPlaythroughStore();
+
+ var result = store.Load(Id);
+
+ Assert.Equal(LoadOutcome.Absent, result.Outcome);
+ }
+
+ [Fact]
+ public void A_playthrough_that_cannot_be_read_is_corrupt_and_not_mistaken_for_absent()
+ {
+ // This is the version-bump bug. LoadGame returns false for both "no file" and "version
+ // mismatch", ContinueGame reads that as no save, calls StartNewGame, and overwrites the
+ // player's file. Absent means "offer a new game"; Corrupt means "say something went wrong".
+ var store = new InMemoryPlaythroughStore();
+ store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(3)));
+ store.Corrupt(Id);
+
+ var result = store.Load(Id);
+
+ Assert.Equal(LoadOutcome.Corrupt, result.Outcome);
+ Assert.NotEqual(LoadOutcome.Absent, result.Outcome);
+ }
+
+ [Fact]
+ public void A_saved_playthrough_loads_back()
+ {
+ var store = new InMemoryPlaythroughStore();
+ store.Save(Playthrough.StartNew(Id, OneLevel(), Lives.Of(2)));
+
+ var result = store.Load(Id);
+
+ Assert.Equal(LoadOutcome.Loaded, result.Outcome);
+ Assert.Equal(2, result.Value!.Lives.Value);
+ }
+}
diff --git a/progression.tests/ProfileSnapshotTests.cs b/progression.tests/ProfileSnapshotTests.cs
new file mode 100644
index 0000000..2fb3b2e
--- /dev/null
+++ b/progression.tests/ProfileSnapshotTests.cs
@@ -0,0 +1,52 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+public class ProfileSnapshotTests
+{
+ private static readonly LevelId Level = LevelId.Of("village_1");
+ private static readonly AchievementId RescueTen = AchievementId.Of("rescue_ten_kids");
+
+ [Fact]
+ public void A_profile_survives_a_round_trip_through_its_snapshot()
+ {
+ var profile = Profile.Empty();
+ profile.RecordTime(Level, LevelTime.Of(42.5));
+ profile.UnlockAchievement(RescueTen);
+ profile.Record(StatName.Deaths, 7);
+
+ var restored = Profile.Restore(profile.Export());
+
+ Assert.Equal(LevelTime.Of(42.5), restored.BestTimeFor(Level));
+ Assert.True(restored.HasAchievement(RescueTen));
+ Assert.Equal(7, restored.ReadStat(StatName.Deaths));
+ }
+
+ [Fact]
+ public void Restoring_a_profile_does_not_re_announce_its_achievements()
+ {
+ // If Restore replayed UnlockAchievement it would raise AchievementUnlocked for every
+ // achievement on every load — so the popup would fire on startup and Steam would be
+ // re-pushed each time. Loading is not earning.
+ var profile = Profile.Empty();
+ profile.UnlockAchievement(RescueTen);
+
+ var restored = Profile.Restore(profile.Export());
+
+ Assert.Empty(restored.DrainEvents());
+ }
+
+ [Fact]
+ public void An_unknown_statistic_is_dropped_rather_than_failing_the_load()
+ {
+ // A save written by a build that tracked a statistic this one no longer has. Losing one
+ // counter is acceptable; refusing to load the Profile — and with it every achievement and
+ // best time — is not.
+ var snapshot = new ProfileSnapshot(
+ BestTimes: new Dictionary(),
+ Achievements: Array.Empty(),
+ Statistics: new Dictionary { ["Deaths"] = 4, ["BricksThrown"] = 99 });
+
+ var restored = Profile.Restore(snapshot);
+
+ Assert.Equal(4, restored.ReadStat(StatName.Deaths));
+ }
+}
diff --git a/progression.tests/ProfileStoreTests.cs b/progression.tests/ProfileStoreTests.cs
new file mode 100644
index 0000000..5428a65
--- /dev/null
+++ b/progression.tests/ProfileStoreTests.cs
@@ -0,0 +1,38 @@
+namespace Mr.BrickAdventures.Progression.Tests;
+
+public class ProfileStoreTests
+{
+ private static readonly LevelId Level = LevelId.Of("village_1");
+
+ [Fact]
+ public void On_a_first_run_there_is_no_profile_yet_and_that_is_absent_not_corrupt()
+ {
+ var store = new InMemoryProfileStore();
+
+ Assert.Equal(LoadOutcome.Absent, store.Load().Outcome);
+ }
+
+ [Fact]
+ public void A_saved_profile_loads_back_with_its_best_times()
+ {
+ var store = new InMemoryProfileStore();
+ var profile = Profile.Empty();
+ profile.RecordTime(Level, LevelTime.Of(31.25));
+ store.Save(profile);
+
+ var result = store.Load();
+
+ Assert.Equal(LoadOutcome.Loaded, result.Outcome);
+ Assert.Equal(LevelTime.Of(31.25), result.Value!.BestTimeFor(Level));
+ }
+
+ [Fact]
+ public void A_profile_that_cannot_be_read_is_corrupt_so_achievements_are_not_silently_wiped()
+ {
+ var store = new InMemoryProfileStore();
+ store.Save(Profile.Empty());
+ store.Corrupt();
+
+ Assert.Equal(LoadOutcome.Corrupt, store.Load().Outcome);
+ }
+}
diff --git a/progression/IPlaythroughStore.cs b/progression/IPlaythroughStore.cs
new file mode 100644
index 0000000..3a3db90
--- /dev/null
+++ b/progression/IPlaythroughStore.cs
@@ -0,0 +1,12 @@
+namespace Mr.BrickAdventures.Progression;
+
+///
+/// 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.
+///
+public interface IPlaythroughStore
+{
+ void Save(Playthrough playthrough);
+
+ LoadResult Load(PlaythroughId id);
+}
diff --git a/progression/IProfileStore.cs b/progression/IProfileStore.cs
new file mode 100644
index 0000000..b2f39d0
--- /dev/null
+++ b/progression/IProfileStore.cs
@@ -0,0 +1,12 @@
+namespace Mr.BrickAdventures.Progression;
+
+///
+/// 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.
+///
+public interface IProfileStore
+{
+ void Save(Profile profile);
+
+ LoadResult Load();
+}
diff --git a/progression/LoadResult.cs b/progression/LoadResult.cs
new file mode 100644
index 0000000..e15fd4e
--- /dev/null
+++ b/progression/LoadResult.cs
@@ -0,0 +1,26 @@
+namespace Mr.BrickAdventures.Progression;
+
+public enum LoadOutcome
+{
+ Loaded,
+ Absent,
+ Corrupt,
+}
+
+///
+/// The result of loading something from a store: it was there, it was never there, or it was there
+/// and unreadable.
+///
+///
+/// Three outcomes rather than two, deliberately. The system being replaced collapsed "no save file"
+/// and "version mismatch" into a single false from LoadGame, which ContinueGame
+/// 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.
+///
+public sealed record LoadResult(LoadOutcome Outcome, T? Value, string? Problem)
+ where T : class
+{
+ public static LoadResult Loaded(T value) => new(LoadOutcome.Loaded, value, null);
+ public static LoadResult Absent() => new(LoadOutcome.Absent, null, null);
+ public static LoadResult Corrupt(string problem) => new(LoadOutcome.Corrupt, null, problem);
+}
diff --git a/progression/Playthrough.cs b/progression/Playthrough.cs
index a02d718..59b0aad 100644
--- a/progression/Playthrough.cs
+++ b/progression/Playthrough.cs
@@ -122,4 +122,35 @@ public sealed class Playthrough
/// once after each use case and republishes.
///
public IReadOnlyList 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());
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
}
diff --git a/progression/PlaythroughSnapshot.cs b/progression/PlaythroughSnapshot.cs
new file mode 100644
index 0000000..eae0724
--- /dev/null
+++ b/progression/PlaythroughSnapshot.cs
@@ -0,0 +1,19 @@
+namespace Mr.BrickAdventures.Progression;
+
+///
+/// A Playthrough's state as plain data, for crossing the persistence seam.
+///
+///
+/// Deliberately primitives rather than value objects. A serialiser constructing
+/// or directly would bypass their factories and every guard in them, so instead
+/// 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.
+///
+public sealed record PlaythroughSnapshot(
+ Guid Id,
+ int Lives,
+ int Bank,
+ string CurrentLevel,
+ IReadOnlyDictionary OwnedSkills,
+ IReadOnlyList ClearedLevels);
diff --git a/progression/Profile.cs b/progression/Profile.cs
index f9aa0bc..ee5a7cb 100644
--- a/progression/Profile.cs
+++ b/progression/Profile.cs
@@ -57,6 +57,40 @@ public sealed class Profile
///
public IReadOnlyList 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));
+
+ ///
+ /// Rebuilds a Profile from a snapshot, re-running every value object's factory.
+ ///
+ ///
+ /// Writes the collections directly rather than replaying . Going
+ /// through the operation would raise for every achievement on
+ /// every load, so a subscriber pushing to Steam or showing a popup would fire on startup.
+ ///
+ 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(stat, out var known))
+ profile._statistics[known] = value;
+ }
+
+ return profile;
+ }
+
public LevelTime? BestTimeFor(LevelId level) =>
_bestTimes.TryGetValue(level, out var time) ? time : null;
diff --git a/progression/ProfileSnapshot.cs b/progression/ProfileSnapshot.cs
new file mode 100644
index 0000000..70b4eba
--- /dev/null
+++ b/progression/ProfileSnapshot.cs
@@ -0,0 +1,14 @@
+namespace Mr.BrickAdventures.Progression;
+
+///
+/// A Profile's state as plain data, for crossing the persistence seam. Primitives only, for the same
+/// reasons as .
+///
+///
+/// Statistics are keyed by 's name rather than its numeric value, so
+/// reordering the enum cannot silently reassign a player's history.
+///
+public sealed record ProfileSnapshot(
+ IReadOnlyDictionary BestTimes,
+ IReadOnlyList Achievements,
+ IReadOnlyDictionary Statistics);